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