@aooth/auth 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,741 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_crypto = require("node:crypto");
3
+ let jose = require("jose");
4
+ //#region src/errors.ts
5
+ const defaultMessages = {
6
+ INVALID_TOKEN: "Invalid token",
7
+ TOKEN_EXPIRED: "Token has expired",
8
+ TOKEN_REVOKED: "Token has been revoked",
9
+ REFRESH_REUSE_DETECTED: "Refresh token reuse detected",
10
+ STATELESS_OPERATION_UNSUPPORTED: "Operation is not supported by stateless credential store",
11
+ MAX_CONCURRENT_REACHED: "Maximum concurrent credentials reached for user",
12
+ INVALID_CONFIG: "Invalid auth configuration"
13
+ };
14
+ var AuthError = class extends Error {
15
+ name = "AuthError";
16
+ constructor(type, message, details) {
17
+ super(message ?? defaultMessages[type]);
18
+ this.type = type;
19
+ this.details = details;
20
+ }
21
+ };
22
+ //#endregion
23
+ //#region src/utils/clock.ts
24
+ const defaultClock = { now: () => Date.now() };
25
+ //#endregion
26
+ //#region src/stores/memory.ts
27
+ /**
28
+ * In-memory implementation of CredentialStore using random opaque token IDs.
29
+ *
30
+ * - `Map<token, state>` is the primary index.
31
+ * - `Map<userId, Set<token>>` is a secondary index for `revokeAllForUser` /
32
+ * `listForUser` without scanning the full primary map.
33
+ * - Both maps are kept in sync on every mutation.
34
+ */
35
+ var CredentialStoreMemory = class {
36
+ states = /* @__PURE__ */ new Map();
37
+ byUser = /* @__PURE__ */ new Map();
38
+ clock;
39
+ constructor(opts) {
40
+ this.clock = opts?.clock ?? defaultClock;
41
+ }
42
+ async persist(state, ttl) {
43
+ const token = (0, node_crypto.randomUUID)();
44
+ const stored = { ...state };
45
+ if (typeof ttl === "number") stored.expiresAt = this.clock.now() + ttl;
46
+ this.states.set(token, stored);
47
+ this.indexAdd(stored.userId, token);
48
+ return token;
49
+ }
50
+ async retrieve(token) {
51
+ const state = this.states.get(token);
52
+ if (!state) return null;
53
+ if (state.expiresAt <= this.clock.now()) {
54
+ this.states.delete(token);
55
+ this.indexRemove(state.userId, token);
56
+ return null;
57
+ }
58
+ return { ...state };
59
+ }
60
+ async consume(token) {
61
+ const state = await this.retrieve(token);
62
+ if (!state) return null;
63
+ await this.revoke(token);
64
+ return state;
65
+ }
66
+ async update(token, state) {
67
+ const existing = this.states.get(token);
68
+ if (!existing) return token;
69
+ if (existing.userId !== state.userId) {
70
+ this.indexRemove(existing.userId, token);
71
+ this.indexAdd(state.userId, token);
72
+ }
73
+ this.states.set(token, { ...state });
74
+ return token;
75
+ }
76
+ async revoke(token) {
77
+ const state = this.states.get(token);
78
+ if (!state) return;
79
+ this.states.delete(token);
80
+ this.indexRemove(state.userId, token);
81
+ }
82
+ async revokeAllForUser(userId) {
83
+ const tokens = this.byUser.get(userId);
84
+ if (!tokens || tokens.size === 0) return 0;
85
+ let count = 0;
86
+ for (const token of tokens) if (this.states.delete(token)) count++;
87
+ this.byUser.delete(userId);
88
+ return count;
89
+ }
90
+ async listForUser(userId) {
91
+ const tokens = this.byUser.get(userId);
92
+ if (!tokens || tokens.size === 0) return [];
93
+ const now = this.clock.now();
94
+ const out = [];
95
+ const expired = [];
96
+ for (const token of tokens) {
97
+ const state = this.states.get(token);
98
+ if (!state) {
99
+ expired.push(token);
100
+ continue;
101
+ }
102
+ if (state.expiresAt <= now) {
103
+ expired.push(token);
104
+ continue;
105
+ }
106
+ out.push({
107
+ ...state,
108
+ token
109
+ });
110
+ }
111
+ for (const token of expired) {
112
+ this.states.delete(token);
113
+ tokens.delete(token);
114
+ }
115
+ if (tokens.size === 0) this.byUser.delete(userId);
116
+ return out;
117
+ }
118
+ indexAdd(userId, token) {
119
+ let set = this.byUser.get(userId);
120
+ if (!set) {
121
+ set = /* @__PURE__ */ new Set();
122
+ this.byUser.set(userId, set);
123
+ }
124
+ set.add(token);
125
+ }
126
+ indexRemove(userId, token) {
127
+ const set = this.byUser.get(userId);
128
+ if (!set) return;
129
+ set.delete(token);
130
+ if (set.size === 0) this.byUser.delete(userId);
131
+ }
132
+ };
133
+ //#endregion
134
+ //#region src/stores/denylist-memory.ts
135
+ /**
136
+ * In-memory denylist for stateless revocation.
137
+ *
138
+ * Stores a map from JTI (or any opaque identifier) to absolute expiry timestamp.
139
+ * `has` returns false for entries whose TTL has elapsed and lazily removes them.
140
+ * `cleanup` performs a sweep across the map and returns the number of entries
141
+ * removed, useful for periodic compaction in long-lived processes.
142
+ */
143
+ var DenylistStoreMemory = class {
144
+ entries = /* @__PURE__ */ new Map();
145
+ clock;
146
+ constructor(opts) {
147
+ this.clock = opts?.clock ?? defaultClock;
148
+ }
149
+ async add(jti, expiresAt) {
150
+ this.entries.set(jti, expiresAt);
151
+ }
152
+ async has(jti) {
153
+ const expiresAt = this.entries.get(jti);
154
+ if (expiresAt === void 0) return false;
155
+ if (expiresAt <= this.clock.now()) {
156
+ this.entries.delete(jti);
157
+ return false;
158
+ }
159
+ return true;
160
+ }
161
+ async cleanup() {
162
+ const now = this.clock.now();
163
+ let removed = 0;
164
+ for (const [jti, expiresAt] of this.entries) if (expiresAt <= now) {
165
+ this.entries.delete(jti);
166
+ removed++;
167
+ }
168
+ return removed;
169
+ }
170
+ };
171
+ //#endregion
172
+ //#region src/stores/jwt.ts
173
+ const HS_ALGS = new Set([
174
+ "HS256",
175
+ "HS384",
176
+ "HS512"
177
+ ]);
178
+ /**
179
+ * Stateless credential store that signs the credential state into a JWT.
180
+ *
181
+ * The token IS the state — there is no internal map. State is reconstructed
182
+ * by verifying signature/claims on every retrieve. Revocation requires a
183
+ * `denylist`; without one, `revoke`/`update`/`consume` throw
184
+ * STATELESS_OPERATION_UNSUPPORTED.
185
+ */
186
+ var CredentialStoreJwt = class {
187
+ algorithm;
188
+ signingKey;
189
+ verifyingKey;
190
+ issuer;
191
+ audience;
192
+ denylist;
193
+ clock;
194
+ /**
195
+ * Per-user revocation epoch (ms). `revokeAllForUser` sets this to
196
+ * `clock.now()`; `retrieve` rejects any token whose `iatMs` predates the
197
+ * user's epoch (same-ms mints are accepted so recovery/invite flows can
198
+ * revoke and re-issue in one tick). Compensates for JWT statelessness so
199
+ * password-change cascades invalidate tokens minted before the change.
200
+ * In-memory: resets on process restart (a known JWT limitation — production
201
+ * deployments needing durability should back this with an external store).
202
+ */
203
+ epochs = /* @__PURE__ */ new Map();
204
+ constructor(opts) {
205
+ this.algorithm = opts.algorithm ?? "HS256";
206
+ this.issuer = opts.issuer;
207
+ this.audience = opts.audience;
208
+ this.denylist = opts.denylist;
209
+ this.clock = opts.clock ?? defaultClock;
210
+ if (HS_ALGS.has(this.algorithm)) {
211
+ if (opts.secret == null) throw new AuthError("INVALID_CONFIG", `Algorithm ${this.algorithm} requires a 'secret' option`);
212
+ const key = normalizeSecret(opts.secret);
213
+ this.signingKey = key;
214
+ this.verifyingKey = key;
215
+ } else {
216
+ if (opts.privateKey == null || opts.publicKey == null) throw new AuthError("INVALID_CONFIG", `Algorithm ${this.algorithm} requires both 'privateKey' and 'publicKey' options`);
217
+ this.signingKey = opts.privateKey;
218
+ this.verifyingKey = opts.publicKey;
219
+ }
220
+ }
221
+ async persist(state, ttl) {
222
+ const jti = (0, node_crypto.randomUUID)();
223
+ const now = this.clock.now();
224
+ const expiresAtMs = typeof ttl === "number" ? now + ttl : state.expiresAt;
225
+ const stateClaim = {
226
+ iatMs: state.issuedAt,
227
+ expMs: expiresAtMs
228
+ };
229
+ if (state.kind !== void 0) stateClaim.kind = state.kind;
230
+ if (state.claims !== void 0) stateClaim.claims = state.claims;
231
+ if (state.metadata !== void 0) stateClaim.metadata = state.metadata;
232
+ if (state.parentCredentialId !== void 0) stateClaim.parentCredentialId = state.parentCredentialId;
233
+ if (state.rotatedAt !== void 0) stateClaim.rotatedAt = state.rotatedAt;
234
+ const builder = new jose.SignJWT({ state: stateClaim }).setProtectedHeader({ alg: this.algorithm }).setSubject(state.userId).setIssuedAt(Math.floor(state.issuedAt / 1e3)).setExpirationTime(Math.floor(expiresAtMs / 1e3)).setJti(jti);
235
+ if (this.issuer) builder.setIssuer(this.issuer);
236
+ if (this.audience) builder.setAudience(this.audience);
237
+ return builder.sign(this.signingKey);
238
+ }
239
+ async retrieve(token) {
240
+ const verified = await this.verify(token);
241
+ if (!verified) return null;
242
+ if (this.denylist && verified.payload.jti) {
243
+ if (await this.denylist.has(verified.payload.jti)) return null;
244
+ }
245
+ if (!this.passesEpoch(verified.payload)) return null;
246
+ return this.payloadToState(verified.payload);
247
+ }
248
+ async consume(token) {
249
+ const denylist = this.requireDenylist("consume");
250
+ const verified = await this.verify(token);
251
+ if (!verified) return null;
252
+ if (!this.passesEpoch(verified.payload)) return null;
253
+ const jti = verified.payload.jti;
254
+ if (jti) {
255
+ if (await denylist.has(jti)) return null;
256
+ await denylist.add(jti, this.payloadExpMs(verified.payload));
257
+ }
258
+ return this.payloadToState(verified.payload);
259
+ }
260
+ async update(token, state) {
261
+ const denylist = this.requireDenylist("update");
262
+ const verified = await this.verify(token);
263
+ if (verified?.payload.jti) await denylist.add(verified.payload.jti, this.payloadExpMs(verified.payload));
264
+ return this.persist(state);
265
+ }
266
+ async revoke(token) {
267
+ const denylist = this.requireDenylist("revoke");
268
+ const verified = await this.verify(token);
269
+ if (!verified?.payload.jti) return;
270
+ await denylist.add(verified.payload.jti, this.payloadExpMs(verified.payload));
271
+ }
272
+ async revokeAllForUser(userId) {
273
+ this.epochs.set(userId, this.clock.now());
274
+ return 1;
275
+ }
276
+ requireDenylist(op) {
277
+ if (!this.denylist) throw new AuthError("STATELESS_OPERATION_UNSUPPORTED", `${op} requires a denylist on stateless JWT store`);
278
+ return this.denylist;
279
+ }
280
+ /** Convert payload `exp` (seconds) to ms; fall back to a 60s window. */
281
+ payloadExpMs(payload) {
282
+ return typeof payload.exp === "number" ? payload.exp * 1e3 : this.clock.now() + 6e4;
283
+ }
284
+ /**
285
+ * Reject tokens minted before the user's revocation epoch. Same-ms mints
286
+ * (`iatMs === epoch`) are accepted so a workflow can revoke and re-issue in
287
+ * one tick. `iatMs` mirrors `iat` at ms precision; we fall back to
288
+ * `iat * 1000` for tokens that predate the mirror field.
289
+ */
290
+ passesEpoch(payload) {
291
+ if (typeof payload.sub !== "string") return true;
292
+ const epoch = this.epochs.get(payload.sub);
293
+ if (epoch === void 0) return true;
294
+ const stateClaim = payload.state ?? {};
295
+ return (typeof stateClaim.iatMs === "number" ? stateClaim.iatMs : typeof payload.iat === "number" ? payload.iat * 1e3 : 0) >= epoch;
296
+ }
297
+ async verify(token) {
298
+ try {
299
+ return { payload: (await (0, jose.jwtVerify)(token, this.verifyingKey, {
300
+ algorithms: [this.algorithm],
301
+ issuer: this.issuer,
302
+ audience: this.audience,
303
+ currentDate: new Date(this.clock.now())
304
+ })).payload };
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+ payloadToState(payload) {
310
+ if (typeof payload.sub !== "string") return null;
311
+ if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
312
+ const stateClaim = payload.state ?? {};
313
+ const issuedAt = typeof stateClaim.iatMs === "number" ? stateClaim.iatMs : payload.iat * 1e3;
314
+ const expiresAt = typeof stateClaim.expMs === "number" ? stateClaim.expMs : payload.exp * 1e3;
315
+ const out = {
316
+ userId: payload.sub,
317
+ issuedAt,
318
+ expiresAt
319
+ };
320
+ if (stateClaim.kind !== void 0) out.kind = stateClaim.kind;
321
+ if (stateClaim.claims !== void 0) out.claims = stateClaim.claims;
322
+ if (stateClaim.metadata !== void 0) out.metadata = stateClaim.metadata;
323
+ if (stateClaim.parentCredentialId !== void 0) out.parentCredentialId = stateClaim.parentCredentialId;
324
+ if (stateClaim.rotatedAt !== void 0) out.rotatedAt = stateClaim.rotatedAt;
325
+ return out;
326
+ }
327
+ };
328
+ function normalizeSecret(secret) {
329
+ if (typeof secret === "string") return new TextEncoder().encode(secret);
330
+ return secret;
331
+ }
332
+ //#endregion
333
+ //#region src/stores/encapsulated.ts
334
+ const KEY_LEN = 32;
335
+ const IV_LEN = 12;
336
+ const TAG_LEN = 16;
337
+ const KDF_SALT = Buffer.from("aoothjs-auth-encapsulated-v1", "utf8");
338
+ /**
339
+ * Stateless credential store that encrypts the credential state with
340
+ * AES-256-GCM. The token IS the encrypted blob, base64url-encoded as
341
+ * `iv (12B) || ciphertext || authTag (16B)`.
342
+ *
343
+ * Same denylist semantics as {@link CredentialStoreJwt}: revocation /
344
+ * single-use consume / update require a denylist; otherwise these operations
345
+ * throw STATELESS_OPERATION_UNSUPPORTED.
346
+ *
347
+ * Key derivation:
348
+ * - A 32-byte `Buffer`/`Uint8Array` `secret` is used as the AES key directly
349
+ * (no KDF).
350
+ * - Any other secret (string, shorter/longer buffer) is run through scrypt
351
+ * with a fixed library-scoped salt to produce a 32-byte key. The salt is
352
+ * intentionally fixed so keys remain stable across process restarts; if it
353
+ * were random, every previously issued token would become undecryptable.
354
+ * For maximum protection against rainbow tables on weak passphrases,
355
+ * provide a 32-byte random buffer as `secret` (the KDF path is skipped).
356
+ *
357
+ * Revocation caveat: `revokeAllForUser` uses an in-memory per-user epoch map.
358
+ * It does not survive process restart and does not sync across instances —
359
+ * the same limitation as `CredentialStoreJwt`. Use `CredentialStoreRedis` or
360
+ * `CredentialStoreAtscriptDb` when you need native enumeration with durable
361
+ * cross-instance cascade semantics.
362
+ */
363
+ var CredentialStoreEncapsulated = class {
364
+ key;
365
+ denylist;
366
+ clock;
367
+ /**
368
+ * Per-user revocation epoch (ms). `revokeAllForUser` sets this to
369
+ * `clock.now()`; `retrieve`/`consume` reject any decrypted state whose
370
+ * `issuedAt` is strictly less than the user's epoch (same-ms mints are
371
+ * accepted so recovery/invite flows can revoke and re-issue in the same
372
+ * tick). Mirrors the JWT store pattern; see class JSDoc for durability
373
+ * caveats.
374
+ */
375
+ epochs = /* @__PURE__ */ new Map();
376
+ constructor(opts) {
377
+ if (opts.secret == null) throw new AuthError("INVALID_CONFIG", "CredentialStoreEncapsulated requires a 'secret'");
378
+ this.key = deriveKey(opts.secret);
379
+ this.denylist = opts.denylist;
380
+ this.clock = opts.clock ?? defaultClock;
381
+ }
382
+ async persist(state, ttl) {
383
+ const jti = (0, node_crypto.randomUUID)();
384
+ const expiresAt = typeof ttl === "number" ? this.clock.now() + ttl : state.expiresAt;
385
+ const payload = {
386
+ ...state,
387
+ expiresAt,
388
+ jti
389
+ };
390
+ const iv = (0, node_crypto.randomBytes)(IV_LEN);
391
+ const cipher = (0, node_crypto.createCipheriv)("aes-256-gcm", this.key, iv);
392
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
393
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
394
+ const authTag = cipher.getAuthTag();
395
+ return Buffer.concat([
396
+ iv,
397
+ ciphertext,
398
+ authTag
399
+ ]).toString("base64url");
400
+ }
401
+ async retrieve(token) {
402
+ const decrypted = this.decrypt(token);
403
+ if (!decrypted) return null;
404
+ if (decrypted.expiresAt <= this.clock.now()) return null;
405
+ if (!this.passesEpoch(decrypted)) return null;
406
+ if (this.denylist && await this.denylist.has(decrypted.jti)) return null;
407
+ return stripJti(decrypted);
408
+ }
409
+ async consume(token) {
410
+ const denylist = this.requireDenylist("consume");
411
+ const decrypted = this.decrypt(token);
412
+ if (!decrypted) return null;
413
+ if (decrypted.expiresAt <= this.clock.now()) return null;
414
+ if (!this.passesEpoch(decrypted)) return null;
415
+ if (await denylist.has(decrypted.jti)) return null;
416
+ await denylist.add(decrypted.jti, decrypted.expiresAt);
417
+ return stripJti(decrypted);
418
+ }
419
+ async update(token, state) {
420
+ const denylist = this.requireDenylist("update");
421
+ const decrypted = this.decrypt(token);
422
+ if (decrypted) await denylist.add(decrypted.jti, decrypted.expiresAt);
423
+ return this.persist(state);
424
+ }
425
+ async revoke(token) {
426
+ const denylist = this.requireDenylist("revoke");
427
+ const decrypted = this.decrypt(token);
428
+ if (!decrypted) return;
429
+ await denylist.add(decrypted.jti, decrypted.expiresAt);
430
+ }
431
+ async revokeAllForUser(userId) {
432
+ this.epochs.set(userId, this.clock.now());
433
+ return 1;
434
+ }
435
+ /**
436
+ * Reject decrypted payloads whose `issuedAt` predates the user's revocation
437
+ * epoch. Same-ms mints (`issuedAt === epoch`) are accepted so a workflow can
438
+ * revoke and re-issue in one tick. Mirrors `CredentialStoreJwt.passesEpoch`.
439
+ */
440
+ passesEpoch(payload) {
441
+ const epoch = this.epochs.get(payload.userId);
442
+ if (epoch === void 0) return true;
443
+ return payload.issuedAt >= epoch;
444
+ }
445
+ requireDenylist(op) {
446
+ if (!this.denylist) throw new AuthError("STATELESS_OPERATION_UNSUPPORTED", `${op} requires a denylist on stateless encapsulated store`);
447
+ return this.denylist;
448
+ }
449
+ decrypt(token) {
450
+ try {
451
+ const blob = Buffer.from(token, "base64url");
452
+ if (blob.length < IV_LEN + 1 + TAG_LEN) return null;
453
+ const iv = blob.subarray(0, IV_LEN);
454
+ const authTag = blob.subarray(blob.length - TAG_LEN);
455
+ const ciphertext = blob.subarray(IV_LEN, blob.length - TAG_LEN);
456
+ const decipher = (0, node_crypto.createDecipheriv)("aes-256-gcm", this.key, iv);
457
+ decipher.setAuthTag(authTag);
458
+ const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
459
+ const parsed = JSON.parse(plain.toString("utf8"));
460
+ if (typeof parsed?.userId !== "string" || typeof parsed.jti !== "string" || typeof parsed.issuedAt !== "number" || typeof parsed.expiresAt !== "number") return null;
461
+ return parsed;
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+ };
467
+ function deriveKey(secret) {
468
+ if (typeof secret !== "string") {
469
+ const buf = Buffer.isBuffer(secret) ? secret : Buffer.from(secret);
470
+ if (buf.length === KEY_LEN) return buf;
471
+ return (0, node_crypto.scryptSync)(buf, KDF_SALT, KEY_LEN);
472
+ }
473
+ return (0, node_crypto.scryptSync)(secret, KDF_SALT, KEY_LEN);
474
+ }
475
+ function stripJti(payload) {
476
+ const { jti: _jti, ...rest } = payload;
477
+ return rest;
478
+ }
479
+ //#endregion
480
+ //#region src/credential/auth-credential.ts
481
+ const DEFAULT_ACCESS_TTL = 3600 * 1e3;
482
+ const DEFAULT_ROTATION_GRACE_MS = 3e4;
483
+ /**
484
+ * Stable, non-reversible fingerprint of a token used as `credentialId` in
485
+ * {@link AuthContext}. Hashing avoids leaking a live bearer token whenever
486
+ * downstream consumers log or persist `ctx.credentialId`.
487
+ */
488
+ function fingerprint(token) {
489
+ return (0, node_crypto.createHash)("sha256").update(token).digest("hex");
490
+ }
491
+ /**
492
+ * Orchestrates credential issuance, validation, refresh, and revocation
493
+ * on top of a pluggable {@link CredentialStore}.
494
+ *
495
+ * Design notes:
496
+ * - `credentialId` returned in {@link AuthContext} is a SHA-256 fingerprint of
497
+ * the access token, never the token itself. The fingerprint is stable
498
+ * per-token, safe to log/persist, and cannot be replayed against the API.
499
+ * M3 will switch to a `jti` claim for JWT/encapsulated stores.
500
+ * - `kind: 'access' | 'refresh'` on {@link CredentialState} discriminates
501
+ * tokens stored side-by-side in the same store.
502
+ * - `listForUser` and `maxConcurrent` consider only access-kind credentials,
503
+ * matching how callers typically display "active sessions".
504
+ * - On detected refresh-reuse-after-grace, the orchestrator best-effort
505
+ * revokes ALL credentials for the affected user (access + refresh).
506
+ * This is OAuth-best-practice theft response; documented and intentional.
507
+ */
508
+ var AuthCredential = class {
509
+ store;
510
+ method;
511
+ accessTtl;
512
+ refreshConfig;
513
+ denylist;
514
+ maxConcurrent;
515
+ onLimit;
516
+ clock;
517
+ /**
518
+ * Recently-consumed refresh tokens, keyed by the raw refresh token string.
519
+ * Lets `'always'` rotation detect reuse: stateful stores forget the token
520
+ * after `consume`, and stateless (JWT) stores hide it behind a denylist hit
521
+ * on `retrieve`. Without this map the orchestrator can no longer distinguish
522
+ * "fake token" from "previously valid token replayed". Pruned lazily on
523
+ * access; bounded by refresh TTL.
524
+ */
525
+ consumedRefreshes = /* @__PURE__ */ new Map();
526
+ constructor(opts) {
527
+ this.store = opts.store;
528
+ this.method = opts.method ?? "token";
529
+ this.accessTtl = opts.accessTtl ?? DEFAULT_ACCESS_TTL;
530
+ this.refreshConfig = opts.refresh;
531
+ this.denylist = opts.denylist;
532
+ this.maxConcurrent = opts.maxConcurrent;
533
+ this.onLimit = opts.onLimit ?? "reject";
534
+ this.clock = opts.clock ?? defaultClock;
535
+ if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
536
+ if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
537
+ }
538
+ async issue(userId, options) {
539
+ if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
540
+ const now = this.clock.now();
541
+ const accessState = {
542
+ userId,
543
+ issuedAt: now,
544
+ expiresAt: now + this.accessTtl,
545
+ kind: "access",
546
+ claims: options?.claims,
547
+ metadata: options?.metadata
548
+ };
549
+ const accessToken = await this.store.persist(accessState, this.accessTtl);
550
+ let refreshToken;
551
+ let refreshExpiresAt;
552
+ if (this.refreshConfig) {
553
+ const refreshState = {
554
+ userId,
555
+ issuedAt: now,
556
+ expiresAt: now + this.refreshConfig.ttl,
557
+ kind: "refresh",
558
+ claims: options?.claims,
559
+ metadata: options?.metadata
560
+ };
561
+ refreshToken = await this.store.persist(refreshState, this.refreshConfig.ttl);
562
+ refreshExpiresAt = now + this.refreshConfig.ttl;
563
+ }
564
+ return {
565
+ accessToken,
566
+ refreshToken,
567
+ accessExpiresAt: now + this.accessTtl,
568
+ refreshExpiresAt
569
+ };
570
+ }
571
+ async validate(accessToken) {
572
+ if (this.denylist && await this.denylist.has(accessToken)) return null;
573
+ const state = await this.store.retrieve(accessToken);
574
+ if (!state) return null;
575
+ if (state.expiresAt <= this.clock.now()) return null;
576
+ if (state.kind === "refresh") return null;
577
+ return {
578
+ userId: state.userId,
579
+ method: this.method,
580
+ credentialId: fingerprint(accessToken),
581
+ expiresAt: state.expiresAt,
582
+ claims: state.claims
583
+ };
584
+ }
585
+ async refresh(refreshToken) {
586
+ if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
587
+ const rotation = this.refreshConfig.rotation ?? "sliding";
588
+ const now = this.clock.now();
589
+ const oldState = await this.store.retrieve(refreshToken);
590
+ if (!oldState) {
591
+ const consumed = this.lookupConsumedRefresh(refreshToken);
592
+ if (consumed) await this.fireRefreshReuseTheftResponse(consumed, refreshToken);
593
+ throw new AuthError("INVALID_TOKEN");
594
+ }
595
+ if (oldState.kind !== "refresh") throw new AuthError("INVALID_TOKEN", "Token is not a refresh credential");
596
+ switch (rotation) {
597
+ case "none": return await this.refreshNone(oldState, refreshToken, now);
598
+ case "always":
599
+ await this.store.consume(refreshToken);
600
+ this.consumedRefreshes.set(refreshToken, {
601
+ userId: oldState.userId,
602
+ iat: oldState.issuedAt,
603
+ exp: oldState.expiresAt
604
+ });
605
+ return await this.issueRotatedPair(oldState, refreshToken, false, now);
606
+ case "sliding": return await this.refreshSliding(oldState, refreshToken, now);
607
+ default: throw new AuthError("INVALID_CONFIG", `Unknown rotation: ${String(rotation)}`);
608
+ }
609
+ }
610
+ async refreshNone(oldState, refreshToken, now) {
611
+ const newAccess = await this.issueAccessFromRefresh(oldState, now);
612
+ return {
613
+ accessToken: newAccess.token,
614
+ accessExpiresAt: newAccess.expiresAt,
615
+ refreshToken,
616
+ refreshExpiresAt: oldState.expiresAt
617
+ };
618
+ }
619
+ async refreshSliding(oldState, refreshToken, now) {
620
+ if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
621
+ const graceMs = this.refreshConfig.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
622
+ if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now);
623
+ if (now - oldState.rotatedAt > graceMs) {
624
+ this.refreshConfig.onRotationReuse?.(oldState);
625
+ await this.store.revokeAllForUser(oldState.userId);
626
+ throw new AuthError("REFRESH_REUSE_DETECTED", void 0, {
627
+ userId: oldState.userId,
628
+ rotatedAt: oldState.rotatedAt
629
+ });
630
+ }
631
+ return await this.issueRotatedPair(oldState, refreshToken, false, now);
632
+ }
633
+ async revoke(token) {
634
+ await this.store.revoke(token);
635
+ }
636
+ async revokeAllForUser(userId) {
637
+ return await this.store.revokeAllForUser(userId);
638
+ }
639
+ async listForUser(userId) {
640
+ if (!this.store.listForUser) return [];
641
+ return (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh").map((entry) => ({
642
+ userId: entry.userId,
643
+ method: this.method,
644
+ credentialId: fingerprint(entry.token),
645
+ expiresAt: entry.expiresAt,
646
+ claims: entry.claims
647
+ }));
648
+ }
649
+ async enforceConcurrencyLimit(userId) {
650
+ if (!this.store.listForUser || this.maxConcurrent === void 0) return;
651
+ const accessOnly = (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh");
652
+ if (accessOnly.length < this.maxConcurrent) return;
653
+ if (this.onLimit === "reject") throw new AuthError("MAX_CONCURRENT_REACHED", void 0, {
654
+ userId,
655
+ limit: this.maxConcurrent,
656
+ active: accessOnly.length
657
+ });
658
+ const sorted = accessOnly.toSorted((a, b) => a.issuedAt - b.issuedAt);
659
+ const toEvict = accessOnly.length - this.maxConcurrent + 1;
660
+ for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
661
+ }
662
+ async issueAccessFromRefresh(refreshState, now) {
663
+ const accessState = {
664
+ userId: refreshState.userId,
665
+ issuedAt: now,
666
+ expiresAt: now + this.accessTtl,
667
+ kind: "access",
668
+ claims: refreshState.claims,
669
+ metadata: refreshState.metadata
670
+ };
671
+ return {
672
+ token: await this.store.persist(accessState, this.accessTtl),
673
+ expiresAt: now + this.accessTtl
674
+ };
675
+ }
676
+ async issueRotatedPair(oldRefreshState, oldRefreshToken, rotateOld, now) {
677
+ if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
678
+ const access = await this.issueAccessFromRefresh(oldRefreshState, now);
679
+ const newRefreshState = {
680
+ userId: oldRefreshState.userId,
681
+ issuedAt: now,
682
+ expiresAt: now + this.refreshConfig.ttl,
683
+ kind: "refresh",
684
+ claims: oldRefreshState.claims,
685
+ metadata: oldRefreshState.metadata,
686
+ parentCredentialId: oldRefreshToken
687
+ };
688
+ const newRefreshToken = await this.store.persist(newRefreshState, this.refreshConfig.ttl);
689
+ if (rotateOld) {
690
+ const rotatedState = {
691
+ ...oldRefreshState,
692
+ rotatedAt: now
693
+ };
694
+ await this.store.update(oldRefreshToken, rotatedState);
695
+ }
696
+ return {
697
+ accessToken: access.token,
698
+ accessExpiresAt: access.expiresAt,
699
+ refreshToken: newRefreshToken,
700
+ refreshExpiresAt: now + this.refreshConfig.ttl
701
+ };
702
+ }
703
+ lookupConsumedRefresh(token) {
704
+ const entry = this.consumedRefreshes.get(token);
705
+ if (!entry) return null;
706
+ if (entry.exp <= this.clock.now()) {
707
+ this.consumedRefreshes.delete(token);
708
+ return null;
709
+ }
710
+ return entry;
711
+ }
712
+ async fireRefreshReuseTheftResponse(consumed, refreshToken) {
713
+ this.refreshConfig?.onRotationReuse?.({
714
+ userId: consumed.userId,
715
+ issuedAt: consumed.iat,
716
+ expiresAt: consumed.exp,
717
+ kind: "refresh"
718
+ });
719
+ await this.store.revokeAllForUser(consumed.userId);
720
+ this.consumedRefreshes.delete(refreshToken);
721
+ throw new AuthError("REFRESH_REUSE_DETECTED", void 0, { userId: consumed.userId });
722
+ }
723
+ };
724
+ //#endregion
725
+ //#region src/magic-link.ts
726
+ /**
727
+ * 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
728
+ * URL-safe. Strong enough to survive short TTLs against online guessing.
729
+ */
730
+ function generateMagicLinkToken() {
731
+ return (0, node_crypto.randomBytes)(32).toString("base64url");
732
+ }
733
+ //#endregion
734
+ exports.AuthCredential = AuthCredential;
735
+ exports.AuthError = AuthError;
736
+ exports.CredentialStoreEncapsulated = CredentialStoreEncapsulated;
737
+ exports.CredentialStoreJwt = CredentialStoreJwt;
738
+ exports.CredentialStoreMemory = CredentialStoreMemory;
739
+ exports.DenylistStoreMemory = DenylistStoreMemory;
740
+ exports.defaultClock = defaultClock;
741
+ exports.generateMagicLinkToken = generateMagicLinkToken;