@aooth/auth 0.1.6 → 0.1.8

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