@aooth/auth 0.1.7 → 0.1.9

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 CHANGED
@@ -1,4 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_clock = require("./clock-Bl-H3eqE.cjs");
3
+ const require_payload = require("./payload-BJjvj8AH.cjs");
2
4
  let node_crypto = require("node:crypto");
3
5
  let jose = require("jose");
4
6
  //#region src/errors.ts
@@ -12,6 +14,8 @@ const defaultMessages = {
12
14
  INVALID_CONFIG: "Invalid auth configuration"
13
15
  };
14
16
  var AuthError = class extends Error {
17
+ type;
18
+ details;
15
19
  name = "AuthError";
16
20
  constructor(type, message, details) {
17
21
  super(message ?? defaultMessages[type]);
@@ -20,9 +24,6 @@ var AuthError = class extends Error {
20
24
  }
21
25
  };
22
26
  //#endregion
23
- //#region src/utils/clock.ts
24
- const defaultClock = { now: () => Date.now() };
25
- //#endregion
26
27
  //#region src/stores/memory.ts
27
28
  /**
28
29
  * In-memory implementation of CredentialStore using random opaque token IDs.
@@ -37,7 +38,7 @@ var CredentialStoreMemory = class {
37
38
  byUser = /* @__PURE__ */ new Map();
38
39
  clock;
39
40
  constructor(opts) {
40
- this.clock = opts?.clock ?? defaultClock;
41
+ this.clock = opts?.clock ?? require_clock.defaultClock;
41
42
  }
42
43
  async persist(state, ttl) {
43
44
  const token = (0, node_crypto.randomUUID)();
@@ -79,6 +80,11 @@ var CredentialStoreMemory = class {
79
80
  this.states.delete(token);
80
81
  this.indexRemove(state.userId, token);
81
82
  }
83
+ async touch(token, at) {
84
+ const state = this.states.get(token);
85
+ if (!state) return;
86
+ state.lastSeenAt = at;
87
+ }
82
88
  async revokeAllForUser(userId) {
83
89
  const tokens = this.byUser.get(userId);
84
90
  if (!tokens || tokens.size === 0) return 0;
@@ -144,7 +150,7 @@ var DenylistStoreMemory = class {
144
150
  entries = /* @__PURE__ */ new Map();
145
151
  clock;
146
152
  constructor(opts) {
147
- this.clock = opts?.clock ?? defaultClock;
153
+ this.clock = opts?.clock ?? require_clock.defaultClock;
148
154
  }
149
155
  async add(jti, expiresAt) {
150
156
  this.entries.set(jti, expiresAt);
@@ -169,6 +175,13 @@ var DenylistStoreMemory = class {
169
175
  }
170
176
  };
171
177
  //#endregion
178
+ //#region src/stores/derive-subkey.ts
179
+ const SUBKEY_SALT = Buffer.from("aooth/credential-store/subkey-v1");
180
+ /** HKDF-SHA256 a stable 32-byte subkey from symmetric key material + a label. */
181
+ function hkdfSubkey(ikm, label) {
182
+ return Buffer.from((0, node_crypto.hkdfSync)("sha256", ikm, SUBKEY_SALT, Buffer.from(label, "utf8"), 32));
183
+ }
184
+ //#endregion
172
185
  //#region src/stores/jwt.ts
173
186
  const HS_ALGS = new Set([
174
187
  "HS256",
@@ -206,7 +219,7 @@ var CredentialStoreJwt = class {
206
219
  this.issuer = opts.issuer;
207
220
  this.audience = opts.audience;
208
221
  this.denylist = opts.denylist;
209
- this.clock = opts.clock ?? defaultClock;
222
+ this.clock = opts.clock ?? require_clock.defaultClock;
210
223
  if (HS_ALGS.has(this.algorithm)) {
211
224
  if (opts.secret == null) throw new AuthError("INVALID_CONFIG", `Algorithm ${this.algorithm} requires a 'secret' option`);
212
225
  const key = normalizeSecret(opts.secret);
@@ -227,7 +240,8 @@ var CredentialStoreJwt = class {
227
240
  expMs: expiresAtMs
228
241
  };
229
242
  if (state.kind !== void 0) stateClaim.kind = state.kind;
230
- if (state.claims !== void 0) stateClaim.claims = state.claims;
243
+ const payload = require_payload.credentialPayloadOf(state);
244
+ if (Object.keys(payload).length > 0) stateClaim.payload = payload;
231
245
  if (state.metadata !== void 0) stateClaim.metadata = state.metadata;
232
246
  if (state.parentCredentialId !== void 0) stateClaim.parentCredentialId = state.parentCredentialId;
233
247
  if (state.rotatedAt !== void 0) stateClaim.rotatedAt = state.rotatedAt;
@@ -273,6 +287,10 @@ var CredentialStoreJwt = class {
273
287
  this.epochs.set(userId, this.clock.now());
274
288
  return 1;
275
289
  }
290
+ deriveSubkey(label) {
291
+ if (this.signingKey instanceof Uint8Array) return hkdfSubkey(this.signingKey, label);
292
+ throw new AuthError("INVALID_CONFIG", "deriveSubkey requires a symmetric (HS*) secret; this JWT store uses an asymmetric key — provide an explicit wfStateSecret");
293
+ }
276
294
  requireDenylist(op) {
277
295
  if (!this.denylist) throw new AuthError("STATELESS_OPERATION_UNSUPPORTED", `${op} requires a denylist on stateless JWT store`);
278
296
  return this.denylist;
@@ -318,10 +336,10 @@ var CredentialStoreJwt = class {
318
336
  expiresAt
319
337
  };
320
338
  if (stateClaim.kind !== void 0) out.kind = stateClaim.kind;
321
- if (stateClaim.claims !== void 0) out.claims = stateClaim.claims;
322
339
  if (stateClaim.metadata !== void 0) out.metadata = stateClaim.metadata;
323
340
  if (stateClaim.parentCredentialId !== void 0) out.parentCredentialId = stateClaim.parentCredentialId;
324
341
  if (stateClaim.rotatedAt !== void 0) out.rotatedAt = stateClaim.rotatedAt;
342
+ if (stateClaim.payload && typeof stateClaim.payload === "object") Object.assign(out, stateClaim.payload);
325
343
  return out;
326
344
  }
327
345
  };
@@ -377,7 +395,7 @@ var CredentialStoreEncapsulated = class {
377
395
  if (opts.secret == null) throw new AuthError("INVALID_CONFIG", "CredentialStoreEncapsulated requires a 'secret'");
378
396
  this.key = deriveKey(opts.secret);
379
397
  this.denylist = opts.denylist;
380
- this.clock = opts.clock ?? defaultClock;
398
+ this.clock = opts.clock ?? require_clock.defaultClock;
381
399
  }
382
400
  async persist(state, ttl) {
383
401
  const jti = (0, node_crypto.randomUUID)();
@@ -432,6 +450,9 @@ var CredentialStoreEncapsulated = class {
432
450
  this.epochs.set(userId, this.clock.now());
433
451
  return 1;
434
452
  }
453
+ deriveSubkey(label) {
454
+ return hkdfSubkey(this.key, label);
455
+ }
435
456
  /**
436
457
  * Reject decrypted payloads whose `issuedAt` predates the user's revocation
437
458
  * epoch. Same-ms mints (`issuedAt === epoch`) are accepted so a workflow can
@@ -449,7 +470,7 @@ var CredentialStoreEncapsulated = class {
449
470
  decrypt(token) {
450
471
  try {
451
472
  const blob = Buffer.from(token, "base64url");
452
- if (blob.length < IV_LEN + 1 + TAG_LEN) return null;
473
+ if (blob.length < 29) return null;
453
474
  const iv = blob.subarray(0, IV_LEN);
454
475
  const authTag = blob.subarray(blob.length - TAG_LEN);
455
476
  const ciphertext = blob.subarray(IV_LEN, blob.length - TAG_LEN);
@@ -489,6 +510,30 @@ function fingerprint(token) {
489
510
  return (0, node_crypto.createHash)("sha256").update(token).digest("hex");
490
511
  }
491
512
  /**
513
+ * Merge a credential's typed payload under the fixed {@link CredentialState}
514
+ * envelope, asserting the `CredentialState & TPayload` shape in ONE place. The
515
+ * payload is spread first so an envelope field always wins a name clash. Every
516
+ * issue / rotation state builder routes through here so the spread-order
517
+ * invariant and the (irreducible, generic-spread) boundary cast live once.
518
+ */
519
+ function stateWithPayload(payload, envelope) {
520
+ return {
521
+ ...payload,
522
+ ...envelope
523
+ };
524
+ }
525
+ /**
526
+ * Match a session family's semantic kind against a `listSessions({ kind })`
527
+ * filter. No filter ⇒ only ordinary interactive sessions (`"session"`), keeping
528
+ * `cli-session` / `pat` credentials out of the default "active sessions" view.
529
+ * A `"*"` value (a bare string or anywhere in the array) matches every kind.
530
+ */
531
+ function matchesSessionKind(kind, filter) {
532
+ if (filter === void 0) return kind === "session";
533
+ const wanted = Array.isArray(filter) ? filter : [filter];
534
+ return wanted.includes("*") || wanted.includes(kind);
535
+ }
536
+ /**
492
537
  * Orchestrates credential issuance, validation, refresh, and revocation
493
538
  * on top of a pluggable {@link CredentialStore}.
494
539
  *
@@ -501,9 +546,10 @@ function fingerprint(token) {
501
546
  * tokens stored side-by-side in the same store.
502
547
  * - `listForUser` and `maxConcurrent` consider only access-kind credentials,
503
548
  * 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.
549
+ * - On detected refresh-reuse, the orchestrator best-effort revokes the
550
+ * compromised token family (the OAuth-best-practice theft response). Set
551
+ * `refresh.reuseResponse: 'user'` to escalate to revoking ALL of the user's
552
+ * sessions. See {@link RefreshConfig.reuseResponse}.
507
553
  */
508
554
  var AuthCredential = class {
509
555
  store;
@@ -513,6 +559,7 @@ var AuthCredential = class {
513
559
  denylist;
514
560
  maxConcurrent;
515
561
  onLimit;
562
+ trackLastSeen;
516
563
  clock;
517
564
  /**
518
565
  * Recently-consumed refresh tokens, keyed by the raw refresh token string.
@@ -531,40 +578,51 @@ var AuthCredential = class {
531
578
  this.denylist = opts.denylist;
532
579
  this.maxConcurrent = opts.maxConcurrent;
533
580
  this.onLimit = opts.onLimit ?? "reject";
534
- this.clock = opts.clock ?? defaultClock;
581
+ this.trackLastSeen = opts.trackLastSeen ?? false;
582
+ this.clock = opts.clock ?? require_clock.defaultClock;
535
583
  if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
536
584
  if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
537
585
  }
538
586
  async issue(userId, options) {
539
587
  if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
540
588
  const now = this.clock.now();
541
- const accessState = {
589
+ const { metadata, sessionId: providedSessionId, ttl, expiresAt, kind, ...payload } = options ?? {};
590
+ const effectiveMetadata = kind !== void 0 ? {
591
+ ...metadata,
592
+ credentialKind: kind
593
+ } : metadata;
594
+ if (ttl !== void 0 && expiresAt !== void 0) throw new AuthError("INVALID_CONFIG", "issue: pass either `ttl` or `expiresAt`, not both");
595
+ if (ttl !== void 0 && ttl <= 0) throw new AuthError("INVALID_CONFIG", `issue: ttl must be > 0 (got ${ttl})`);
596
+ const accessExpiresAt = expiresAt ?? now + (ttl ?? this.accessTtl);
597
+ const accessStoreTtl = accessExpiresAt - now;
598
+ const sessionId = providedSessionId ?? (0, node_crypto.randomUUID)();
599
+ const accessState = stateWithPayload(payload, {
542
600
  userId,
543
601
  issuedAt: now,
544
- expiresAt: now + this.accessTtl,
602
+ expiresAt: accessExpiresAt,
545
603
  kind: "access",
546
- claims: options?.claims,
547
- metadata: options?.metadata
548
- };
549
- const accessToken = await this.store.persist(accessState, this.accessTtl);
604
+ metadata: effectiveMetadata,
605
+ sessionId
606
+ });
607
+ const accessToken = await this.store.persist(accessState, accessStoreTtl);
550
608
  let refreshToken;
551
609
  let refreshExpiresAt;
552
610
  if (this.refreshConfig) {
553
- const refreshState = {
611
+ const refreshState = stateWithPayload(payload, {
554
612
  userId,
555
613
  issuedAt: now,
556
614
  expiresAt: now + this.refreshConfig.ttl,
557
615
  kind: "refresh",
558
- claims: options?.claims,
559
- metadata: options?.metadata
560
- };
616
+ metadata: effectiveMetadata,
617
+ sessionId
618
+ });
561
619
  refreshToken = await this.store.persist(refreshState, this.refreshConfig.ttl);
562
620
  refreshExpiresAt = now + this.refreshConfig.ttl;
563
621
  }
564
622
  return {
565
623
  accessToken,
566
624
  refreshToken,
567
- accessExpiresAt: now + this.accessTtl,
625
+ accessExpiresAt,
568
626
  refreshExpiresAt
569
627
  };
570
628
  }
@@ -574,12 +632,14 @@ var AuthCredential = class {
574
632
  if (!state) return null;
575
633
  if (state.expiresAt <= this.clock.now()) return null;
576
634
  if (state.kind === "refresh") return null;
635
+ if (this.trackLastSeen === "validate" && this.store.touch) await this.store.touch(accessToken, this.clock.now());
577
636
  return {
637
+ ...require_payload.credentialPayloadOf(state),
578
638
  userId: state.userId,
579
639
  method: this.method,
580
640
  credentialId: fingerprint(accessToken),
581
- expiresAt: state.expiresAt,
582
- claims: state.claims
641
+ sessionId: this.sessionIdOf(state.sessionId, accessToken),
642
+ expiresAt: state.expiresAt
583
643
  };
584
644
  }
585
645
  async refresh(refreshToken) {
@@ -595,14 +655,7 @@ var AuthCredential = class {
595
655
  if (oldState.kind !== "refresh") throw new AuthError("INVALID_TOKEN", "Token is not a refresh credential");
596
656
  switch (rotation) {
597
657
  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);
658
+ case "always": return await this.refreshAlways(oldState, refreshToken, now);
606
659
  case "sliding": return await this.refreshSliding(oldState, refreshToken, now);
607
660
  default: throw new AuthError("INVALID_CONFIG", `Unknown rotation: ${String(rotation)}`);
608
661
  }
@@ -616,19 +669,57 @@ var AuthCredential = class {
616
669
  refreshExpiresAt: oldState.expiresAt
617
670
  };
618
671
  }
672
+ /**
673
+ * `sliding` rotation: rotate on every use and slide the refresh expiry
674
+ * forward (rolling session). Grace-tolerant via the shared store-backed
675
+ * window.
676
+ */
619
677
  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, {
678
+ return await this.rotateWithGrace(oldState, refreshToken, now, false);
679
+ }
680
+ /**
681
+ * `always` rotation: rotate on every use but keep a FIXED session ceiling —
682
+ * each rotated token inherits the family's original `expiresAt` (no sliding).
683
+ *
684
+ * On a stateful store this reuses the same store-backed grace window as
685
+ * `sliding` (so a benign concurrent refresh within grace is NOT mistaken for
686
+ * theft, even across instances). On a stateless store the old token cannot be
687
+ * kept valid (`update` re-issues), so it falls back to single-use semantics
688
+ * with a process-local reuse signal — the only mechanism possible there.
689
+ */
690
+ async refreshAlways(oldState, refreshToken, now) {
691
+ if (!this.store.listForUser) {
692
+ await this.store.consume(refreshToken);
693
+ this.consumedRefreshes.set(refreshToken, {
627
694
  userId: oldState.userId,
628
- rotatedAt: oldState.rotatedAt
695
+ iat: oldState.issuedAt,
696
+ exp: oldState.expiresAt,
697
+ ...oldState.sessionId !== void 0 && { sessionId: oldState.sessionId }
629
698
  });
699
+ return await this.issueRotatedPair(oldState, refreshToken, false, now, true);
630
700
  }
631
- return await this.issueRotatedPair(oldState, refreshToken, false, now);
701
+ return await this.rotateWithGrace(oldState, refreshToken, now, true);
702
+ }
703
+ /**
704
+ * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
705
+ * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
706
+ * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
707
+ * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
708
+ * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
709
+ * (`sliding`) expiry for the new refresh token.
710
+ */
711
+ async rotateWithGrace(oldState, refreshToken, now, preserveExpiry) {
712
+ if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
713
+ const graceMs = this.refreshConfig.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
714
+ if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now, preserveExpiry);
715
+ if (now - oldState.rotatedAt > graceMs) await this.respondToRefreshReuse({
716
+ userId: oldState.userId,
717
+ sessionId: oldState.sessionId,
718
+ issuedAt: oldState.issuedAt,
719
+ expiresAt: oldState.expiresAt,
720
+ rotatedAt: oldState.rotatedAt
721
+ });
722
+ return await this.issueRotatedPair(oldState, refreshToken, false, now, preserveExpiry);
632
723
  }
633
724
  async revoke(token) {
634
725
  await this.store.revoke(token);
@@ -639,13 +730,117 @@ var AuthCredential = class {
639
730
  async listForUser(userId) {
640
731
  if (!this.store.listForUser) return [];
641
732
  return (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh").map((entry) => ({
733
+ ...require_payload.credentialPayloadOf(entry),
642
734
  userId: entry.userId,
643
735
  method: this.method,
644
736
  credentialId: fingerprint(entry.token),
645
- expiresAt: entry.expiresAt,
646
- claims: entry.claims
737
+ sessionId: this.sessionKeyOf(entry),
738
+ expiresAt: entry.expiresAt
647
739
  }));
648
740
  }
741
+ /**
742
+ * The session id for a credential: its stored `sessionId`, or the token
743
+ * fingerprint for legacy rows predating sessionId (so they surface as
744
+ * singleton sessions). The ONE place this fallback rule lives — shared by
745
+ * `validate()`, `listForUser()`, and the session-family methods so "this
746
+ * device" matching stays consistent across all of them.
747
+ */
748
+ sessionIdOf(sessionId, token) {
749
+ return sessionId ?? fingerprint(token);
750
+ }
751
+ /** Session-grouping key for a stored credential entry (token attached). */
752
+ sessionKeyOf(entry) {
753
+ return this.sessionIdOf(entry.sessionId, entry.token);
754
+ }
755
+ /**
756
+ * List the user's active sessions, one row per token family (access +
757
+ * refresh + every rotation collapsed by `sessionId`). Newest first by
758
+ * `lastSeenAt` (or `createdAt` when activity isn't tracked). Returns `[]` for
759
+ * stores that can't enumerate (stateless JWT/encapsulated). Pass `enrich` to
760
+ * map each row through a {@link SessionEnricher} (device/location labels).
761
+ */
762
+ async listSessions(userId, opts) {
763
+ if (!this.store.listForUser) return [];
764
+ const all = await this.store.listForUser(userId);
765
+ const families = /* @__PURE__ */ new Map();
766
+ for (const entry of all) {
767
+ const key = this.sessionKeyOf(entry);
768
+ const bucket = families.get(key);
769
+ if (bucket) bucket.push(entry);
770
+ else families.set(key, [entry]);
771
+ }
772
+ const sessions = [];
773
+ for (const [sessionId, entries] of families) {
774
+ let createdAt = Infinity;
775
+ let lastSeenAt;
776
+ let refreshExpiresAt;
777
+ let accessExpiresAt;
778
+ let metadata;
779
+ for (const e of entries) {
780
+ if (e.issuedAt < createdAt) createdAt = e.issuedAt;
781
+ if (typeof e.lastSeenAt === "number" && (lastSeenAt === void 0 || e.lastSeenAt > lastSeenAt)) lastSeenAt = e.lastSeenAt;
782
+ if (e.kind === "refresh") {
783
+ if (refreshExpiresAt === void 0 || e.expiresAt > refreshExpiresAt) refreshExpiresAt = e.expiresAt;
784
+ } else if (accessExpiresAt === void 0 || e.expiresAt > accessExpiresAt) accessExpiresAt = e.expiresAt;
785
+ if (!metadata && e.metadata) metadata = e.metadata;
786
+ }
787
+ const credentialKind = metadata?.credentialKind;
788
+ if (!matchesSessionKind(credentialKind ?? "session", opts?.kind)) continue;
789
+ sessions.push({
790
+ sessionId,
791
+ userId,
792
+ createdAt: createdAt === Infinity ? this.clock.now() : createdAt,
793
+ expiresAt: refreshExpiresAt ?? accessExpiresAt ?? 0,
794
+ ...lastSeenAt !== void 0 && { lastSeenAt },
795
+ ...metadata && { metadata },
796
+ ...credentialKind !== void 0 && { kind: credentialKind }
797
+ });
798
+ }
799
+ sessions.sort((a, b) => (b.lastSeenAt ?? b.createdAt) - (a.lastSeenAt ?? a.createdAt));
800
+ if (!opts?.enrich) return sessions;
801
+ const enrich = opts.enrich;
802
+ return Promise.all(sessions.map((s) => Promise.resolve(enrich(s))));
803
+ }
804
+ /**
805
+ * Revoke a single session — every token in its family (access + refresh +
806
+ * rotations). No-op for stores that can't enumerate. Other sessions keep
807
+ * validating.
808
+ */
809
+ async revokeSession(userId, sessionId) {
810
+ if (!this.store.listForUser) return;
811
+ const all = await this.store.listForUser(userId);
812
+ await Promise.all(all.filter((entry) => this.sessionKeyOf(entry) === sessionId).map((entry) => this.store.revoke(entry.token)));
813
+ }
814
+ /**
815
+ * Revoke every session for the user EXCEPT `keepSessionId` ("log out
816
+ * everywhere else"). Returns the number of distinct sessions revoked. No-op
817
+ * (returns 0) for stores that can't enumerate.
818
+ */
819
+ async revokeOtherSessions(userId, keepSessionId) {
820
+ if (!this.store.listForUser) return 0;
821
+ const all = await this.store.listForUser(userId);
822
+ const revokedSessions = /* @__PURE__ */ new Set();
823
+ const revokes = [];
824
+ for (const entry of all) {
825
+ const key = this.sessionKeyOf(entry);
826
+ if (key === keepSessionId) continue;
827
+ revokedSessions.add(key);
828
+ revokes.push(this.store.revoke(entry.token));
829
+ }
830
+ await Promise.all(revokes);
831
+ return revokedSessions.size;
832
+ }
833
+ /**
834
+ * Derive a stable 32-byte key from this credential's underlying store secret,
835
+ * domain-separated by `label`. Lets adjacent subsystems (e.g. workflow-state
836
+ * encryption) reuse the auth secret without managing a second one. Throws if
837
+ * the store has no reusable symmetric secret (stateful/asymmetric) — callers
838
+ * should then require an explicit secret.
839
+ */
840
+ deriveStateKey(label = "wf-state") {
841
+ if (!this.store.deriveSubkey) throw new AuthError("INVALID_CONFIG", "credential store has no reusable secret; provide an explicit wfStateSecret");
842
+ return this.store.deriveSubkey(label);
843
+ }
649
844
  async enforceConcurrencyLimit(userId) {
650
845
  if (!this.store.listForUser || this.maxConcurrent === void 0) return;
651
846
  const accessOnly = (await this.store.listForUser(userId)).filter((entry) => entry.kind !== "refresh");
@@ -660,32 +855,43 @@ var AuthCredential = class {
660
855
  for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
661
856
  }
662
857
  async issueAccessFromRefresh(refreshState, now) {
663
- const accessState = {
858
+ const accessState = stateWithPayload(require_payload.credentialPayloadOf(refreshState), {
664
859
  userId: refreshState.userId,
665
860
  issuedAt: now,
666
861
  expiresAt: now + this.accessTtl,
667
862
  kind: "access",
668
- claims: refreshState.claims,
669
- metadata: refreshState.metadata
670
- };
863
+ metadata: refreshState.metadata,
864
+ sessionId: refreshState.sessionId,
865
+ ...this.trackLastSeen === "refresh" && { lastSeenAt: now }
866
+ });
671
867
  return {
672
868
  token: await this.store.persist(accessState, this.accessTtl),
673
869
  expiresAt: now + this.accessTtl
674
870
  };
675
871
  }
676
- async issueRotatedPair(oldRefreshState, oldRefreshToken, rotateOld, now) {
872
+ /**
873
+ * Issue a fresh access + refresh pair off an existing refresh credential.
874
+ * `preserveExpiry` keeps the family's original refresh `expiresAt` (a fixed
875
+ * session ceiling, used by `always`); otherwise the new refresh slides to
876
+ * `now + ttl` (used by `sliding`). `rotateOld` stamps the old refresh as
877
+ * rotated (keeping it valid through the grace window) instead of consuming it.
878
+ */
879
+ async issueRotatedPair(oldRefreshState, oldRefreshToken, rotateOld, now, preserveExpiry = false) {
677
880
  if (!this.refreshConfig) throw new AuthError("INVALID_CONFIG", "Refresh not enabled");
678
881
  const access = await this.issueAccessFromRefresh(oldRefreshState, now);
679
- const newRefreshState = {
882
+ const refreshExpiresAt = preserveExpiry ? oldRefreshState.expiresAt : now + this.refreshConfig.ttl;
883
+ const refreshTtl = preserveExpiry ? Math.max(0, oldRefreshState.expiresAt - now) : this.refreshConfig.ttl;
884
+ const newRefreshState = stateWithPayload(require_payload.credentialPayloadOf(oldRefreshState), {
680
885
  userId: oldRefreshState.userId,
681
886
  issuedAt: now,
682
- expiresAt: now + this.refreshConfig.ttl,
887
+ expiresAt: refreshExpiresAt,
683
888
  kind: "refresh",
684
- claims: oldRefreshState.claims,
685
889
  metadata: oldRefreshState.metadata,
686
- parentCredentialId: oldRefreshToken
687
- };
688
- const newRefreshToken = await this.store.persist(newRefreshState, this.refreshConfig.ttl);
890
+ parentCredentialId: oldRefreshToken,
891
+ sessionId: oldRefreshState.sessionId,
892
+ ...this.trackLastSeen === "refresh" && { lastSeenAt: now }
893
+ });
894
+ const newRefreshToken = await this.store.persist(newRefreshState, refreshTtl);
689
895
  if (rotateOld) {
690
896
  const rotatedState = {
691
897
  ...oldRefreshState,
@@ -697,7 +903,7 @@ var AuthCredential = class {
697
903
  accessToken: access.token,
698
904
  accessExpiresAt: access.expiresAt,
699
905
  refreshToken: newRefreshToken,
700
- refreshExpiresAt: now + this.refreshConfig.ttl
906
+ refreshExpiresAt
701
907
  };
702
908
  }
703
909
  lookupConsumedRefresh(token) {
@@ -710,15 +916,38 @@ var AuthCredential = class {
710
916
  return entry;
711
917
  }
712
918
  async fireRefreshReuseTheftResponse(consumed, refreshToken) {
713
- this.refreshConfig?.onRotationReuse?.({
919
+ this.consumedRefreshes.delete(refreshToken);
920
+ await this.respondToRefreshReuse({
714
921
  userId: consumed.userId,
922
+ sessionId: consumed.sessionId,
715
923
  issuedAt: consumed.iat,
716
- expiresAt: consumed.exp,
717
- kind: "refresh"
924
+ expiresAt: consumed.exp
925
+ });
926
+ }
927
+ /**
928
+ * Best-effort theft response for a detected refresh-token reuse. Fires the
929
+ * `onRotationReuse` hook, then revokes per {@link RefreshConfig.reuseResponse}:
930
+ * the compromised token family (`'session'`, default) or every session for
931
+ * the user (`'user'`). Falls back to user-wide revocation when the session
932
+ * can't be targeted (no `sessionId`, or a store that can't enumerate
933
+ * sessions). Always throws `REFRESH_REUSE_DETECTED`.
934
+ */
935
+ async respondToRefreshReuse(reuse) {
936
+ this.refreshConfig?.onRotationReuse?.({
937
+ userId: reuse.userId,
938
+ issuedAt: reuse.issuedAt,
939
+ expiresAt: reuse.expiresAt,
940
+ kind: "refresh",
941
+ ...reuse.sessionId !== void 0 && { sessionId: reuse.sessionId },
942
+ ...reuse.rotatedAt !== void 0 && { rotatedAt: reuse.rotatedAt }
943
+ });
944
+ if ((this.refreshConfig?.reuseResponse ?? "session") === "session" && reuse.sessionId !== void 0 && this.store.listForUser) await this.revokeSession(reuse.userId, reuse.sessionId);
945
+ else await this.store.revokeAllForUser(reuse.userId);
946
+ throw new AuthError("REFRESH_REUSE_DETECTED", void 0, {
947
+ userId: reuse.userId,
948
+ ...reuse.sessionId !== void 0 && { sessionId: reuse.sessionId },
949
+ ...reuse.rotatedAt !== void 0 && { rotatedAt: reuse.rotatedAt }
718
950
  });
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
951
  }
723
952
  };
724
953
  //#endregion
@@ -737,5 +966,5 @@ exports.CredentialStoreEncapsulated = CredentialStoreEncapsulated;
737
966
  exports.CredentialStoreJwt = CredentialStoreJwt;
738
967
  exports.CredentialStoreMemory = CredentialStoreMemory;
739
968
  exports.DenylistStoreMemory = DenylistStoreMemory;
740
- exports.defaultClock = defaultClock;
969
+ exports.defaultClock = require_clock.defaultClock;
741
970
  exports.generateMagicLinkToken = generateMagicLinkToken;