@aooth/auth 0.1.77 → 0.1.79

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.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, n as defaultClock, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-C0O0FCCN.cjs";
2
- import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-CU4vsKhu.cjs";
3
- import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-3YN2hfss.cjs";
1
+ import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, n as defaultClock, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-CyjqzLxc.cjs";
2
+ import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-GRAK230V.cjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-ClQnA8yh.cjs";
4
4
  import { CryptoKey } from "jose";
5
5
  //#region src/errors.d.ts
6
6
  type AuthErrorType = "INVALID_TOKEN" | "TOKEN_EXPIRED" | "TOKEN_REVOKED" | "REFRESH_REUSE_DETECTED" | "STATELESS_OPERATION_UNSUPPORTED" | "MAX_CONCURRENT_REACHED" | "INVALID_CONFIG";
@@ -32,7 +32,7 @@ declare class CredentialStoreMemory<TPayload extends object = object> implements
32
32
  consume(token: string): Promise<(CredentialState & TPayload) | null>;
33
33
  update(token: string, state: CredentialState & TPayload): Promise<string>;
34
34
  revoke(token: string): Promise<void>;
35
- touch(token: string, at: number): Promise<void>;
35
+ touch(token: string, at: number, _state?: CredentialState & TPayload): Promise<void>;
36
36
  revokeAllForUser(userId: string): Promise<number>;
37
37
  listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
38
38
  token: string;
@@ -46,19 +46,23 @@ declare class CredentialStoreMemory<TPayload extends object = object> implements
46
46
  * In-memory denylist for stateless revocation.
47
47
  *
48
48
  * Stores a map from JTI (or any opaque identifier) to absolute expiry timestamp.
49
- * `has` returns false for entries whose TTL has elapsed and lazily removes them.
50
- * `cleanup` performs a sweep across the map and returns the number of entries
51
- * removed, useful for periodic compaction in long-lived processes.
49
+ * `has` returns false for entries whose TTL has elapsed and lazily removes them;
50
+ * `add` sweeps expired entries as the map grows (amortized, each time it
51
+ * doubles). `cleanup` performs a sweep on demand and returns the number of
52
+ * entries removed.
52
53
  */
53
54
  declare class DenylistStoreMemory implements DenylistStore {
54
55
  private readonly entries;
55
56
  private readonly clock;
57
+ /** Map size at which `add` next sweeps expired entries (amortized; doubles). */
58
+ private sweepAt;
56
59
  constructor(opts?: {
57
60
  clock?: Clock;
58
61
  });
59
62
  add(jti: string, expiresAt: number): Promise<void>;
60
63
  has(jti: string): Promise<boolean>;
61
64
  cleanup(): Promise<number>;
65
+ private sweep;
62
66
  }
63
67
  //#endregion
64
68
  //#region src/stores/jwt.d.ts
@@ -98,6 +102,12 @@ declare class CredentialStoreJwt<TPayload extends object = object> implements Cr
98
102
  private readonly algorithm;
99
103
  private readonly signingKey;
100
104
  private readonly verifyingKey;
105
+ /**
106
+ * HS* with raw secret bytes: the HMAC `CryptoKey` imported once (lazily) and
107
+ * reused for every sign / verify — jose would otherwise re-import the raw
108
+ * bytes per call. `signingKey` keeps the bytes for `deriveSubkey`.
109
+ */
110
+ private hmacKey?;
101
111
  private readonly issuer?;
102
112
  private readonly audience?;
103
113
  private readonly denylist?;
@@ -131,6 +141,12 @@ declare class CredentialStoreJwt<TPayload extends object = object> implements Cr
131
141
  */
132
142
  private passesEpoch;
133
143
  private verify;
144
+ /**
145
+ * The key jose gets: raw HS* secret bytes as their HMAC `CryptoKey`
146
+ * (imported once — usable for both sign and verify, its hash pinned to the
147
+ * configured algorithm), anything else as given.
148
+ */
149
+ private keyFor;
134
150
  private payloadToState;
135
151
  }
136
152
  //#endregion
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, n as defaultClock, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-C0O0FCCN.mjs";
2
- import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-C5DiSbb2.mjs";
3
- import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-BUIC0iCK.mjs";
1
+ import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, n as defaultClock, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-CyjqzLxc.mjs";
2
+ import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-CLAQgjCS.mjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-CVD1nd2j.mjs";
4
4
  import { CryptoKey } from "jose";
5
5
  //#region src/errors.d.ts
6
6
  type AuthErrorType = "INVALID_TOKEN" | "TOKEN_EXPIRED" | "TOKEN_REVOKED" | "REFRESH_REUSE_DETECTED" | "STATELESS_OPERATION_UNSUPPORTED" | "MAX_CONCURRENT_REACHED" | "INVALID_CONFIG";
@@ -32,7 +32,7 @@ declare class CredentialStoreMemory<TPayload extends object = object> implements
32
32
  consume(token: string): Promise<(CredentialState & TPayload) | null>;
33
33
  update(token: string, state: CredentialState & TPayload): Promise<string>;
34
34
  revoke(token: string): Promise<void>;
35
- touch(token: string, at: number): Promise<void>;
35
+ touch(token: string, at: number, _state?: CredentialState & TPayload): Promise<void>;
36
36
  revokeAllForUser(userId: string): Promise<number>;
37
37
  listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
38
38
  token: string;
@@ -46,19 +46,23 @@ declare class CredentialStoreMemory<TPayload extends object = object> implements
46
46
  * In-memory denylist for stateless revocation.
47
47
  *
48
48
  * Stores a map from JTI (or any opaque identifier) to absolute expiry timestamp.
49
- * `has` returns false for entries whose TTL has elapsed and lazily removes them.
50
- * `cleanup` performs a sweep across the map and returns the number of entries
51
- * removed, useful for periodic compaction in long-lived processes.
49
+ * `has` returns false for entries whose TTL has elapsed and lazily removes them;
50
+ * `add` sweeps expired entries as the map grows (amortized, each time it
51
+ * doubles). `cleanup` performs a sweep on demand and returns the number of
52
+ * entries removed.
52
53
  */
53
54
  declare class DenylistStoreMemory implements DenylistStore {
54
55
  private readonly entries;
55
56
  private readonly clock;
57
+ /** Map size at which `add` next sweeps expired entries (amortized; doubles). */
58
+ private sweepAt;
56
59
  constructor(opts?: {
57
60
  clock?: Clock;
58
61
  });
59
62
  add(jti: string, expiresAt: number): Promise<void>;
60
63
  has(jti: string): Promise<boolean>;
61
64
  cleanup(): Promise<number>;
65
+ private sweep;
62
66
  }
63
67
  //#endregion
64
68
  //#region src/stores/jwt.d.ts
@@ -98,6 +102,12 @@ declare class CredentialStoreJwt<TPayload extends object = object> implements Cr
98
102
  private readonly algorithm;
99
103
  private readonly signingKey;
100
104
  private readonly verifyingKey;
105
+ /**
106
+ * HS* with raw secret bytes: the HMAC `CryptoKey` imported once (lazily) and
107
+ * reused for every sign / verify — jose would otherwise re-import the raw
108
+ * bytes per call. `signingKey` keeps the bytes for `deriveSubkey`.
109
+ */
110
+ private hmacKey?;
101
111
  private readonly issuer?;
102
112
  private readonly audience?;
103
113
  private readonly denylist?;
@@ -131,6 +141,12 @@ declare class CredentialStoreJwt<TPayload extends object = object> implements Cr
131
141
  */
132
142
  private passesEpoch;
133
143
  private verify;
144
+ /**
145
+ * The key jose gets: raw HS* secret bytes as their HMAC `CryptoKey`
146
+ * (imported once — usable for both sign and verify, its hash pinned to the
147
+ * configured algorithm), anything else as given.
148
+ */
149
+ private keyFor;
134
150
  private payloadToState;
135
151
  }
136
152
  //#endregion
package/dist/index.mjs CHANGED
@@ -80,7 +80,7 @@ var CredentialStoreMemory = class {
80
80
  this.states.delete(token);
81
81
  this.indexRemove(state.userId, token);
82
82
  }
83
- async touch(token, at) {
83
+ async touch(token, at, _state) {
84
84
  const state = this.states.get(token);
85
85
  if (!state) return;
86
86
  state.lastSeenAt = at;
@@ -142,17 +142,24 @@ var CredentialStoreMemory = class {
142
142
  * In-memory denylist for stateless revocation.
143
143
  *
144
144
  * Stores a map from JTI (or any opaque identifier) to absolute expiry timestamp.
145
- * `has` returns false for entries whose TTL has elapsed and lazily removes them.
146
- * `cleanup` performs a sweep across the map and returns the number of entries
147
- * removed, useful for periodic compaction in long-lived processes.
145
+ * `has` returns false for entries whose TTL has elapsed and lazily removes them;
146
+ * `add` sweeps expired entries as the map grows (amortized, each time it
147
+ * doubles). `cleanup` performs a sweep on demand and returns the number of
148
+ * entries removed.
148
149
  */
149
150
  var DenylistStoreMemory = class {
150
151
  entries = /* @__PURE__ */ new Map();
151
152
  clock;
153
+ /** Map size at which `add` next sweeps expired entries (amortized; doubles). */
154
+ sweepAt = MIN_SWEEP_SIZE$2;
152
155
  constructor(opts) {
153
156
  this.clock = opts?.clock ?? defaultClock;
154
157
  }
155
158
  async add(jti, expiresAt) {
159
+ if (this.entries.size >= this.sweepAt && !this.entries.has(jti)) {
160
+ this.sweep(this.clock.now());
161
+ this.sweepAt = Math.max(MIN_SWEEP_SIZE$2, this.entries.size * 2);
162
+ }
156
163
  this.entries.set(jti, expiresAt);
157
164
  }
158
165
  async has(jti) {
@@ -165,7 +172,9 @@ var DenylistStoreMemory = class {
165
172
  return true;
166
173
  }
167
174
  async cleanup() {
168
- const now = this.clock.now();
175
+ return this.sweep(this.clock.now());
176
+ }
177
+ sweep(now) {
169
178
  let removed = 0;
170
179
  for (const [jti, expiresAt] of this.entries) if (expiresAt <= now) {
171
180
  this.entries.delete(jti);
@@ -174,6 +183,8 @@ var DenylistStoreMemory = class {
174
183
  return removed;
175
184
  }
176
185
  };
186
+ /** Smallest map size an amortized expiry sweep runs at. */
187
+ const MIN_SWEEP_SIZE$2 = 1024;
177
188
  //#endregion
178
189
  //#region src/stores/derive-subkey.ts
179
190
  const SUBKEY_SALT = Buffer.from("aooth/credential-store/subkey-v1");
@@ -200,6 +211,12 @@ var CredentialStoreJwt = class {
200
211
  algorithm;
201
212
  signingKey;
202
213
  verifyingKey;
214
+ /**
215
+ * HS* with raw secret bytes: the HMAC `CryptoKey` imported once (lazily) and
216
+ * reused for every sign / verify — jose would otherwise re-import the raw
217
+ * bytes per call. `signingKey` keeps the bytes for `deriveSubkey`.
218
+ */
219
+ hmacKey;
203
220
  issuer;
204
221
  audience;
205
222
  denylist;
@@ -248,7 +265,7 @@ var CredentialStoreJwt = class {
248
265
  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);
249
266
  if (this.issuer) builder.setIssuer(this.issuer);
250
267
  if (this.audience) builder.setAudience(this.audience);
251
- return builder.sign(this.signingKey);
268
+ return builder.sign(await this.keyFor(this.signingKey));
252
269
  }
253
270
  async retrieve(token) {
254
271
  const verified = await this.verify(token);
@@ -314,7 +331,7 @@ var CredentialStoreJwt = class {
314
331
  }
315
332
  async verify(token) {
316
333
  try {
317
- return { payload: (await jwtVerify(token, this.verifyingKey, {
334
+ return { payload: (await jwtVerify(token, await this.keyFor(this.verifyingKey), {
318
335
  algorithms: [this.algorithm],
319
336
  issuer: this.issuer,
320
337
  audience: this.audience,
@@ -324,6 +341,19 @@ var CredentialStoreJwt = class {
324
341
  return null;
325
342
  }
326
343
  }
344
+ /**
345
+ * The key jose gets: raw HS* secret bytes as their HMAC `CryptoKey`
346
+ * (imported once — usable for both sign and verify, its hash pinned to the
347
+ * configured algorithm), anything else as given.
348
+ */
349
+ keyFor(key) {
350
+ if (!(key instanceof Uint8Array) || !HS_ALGS.has(this.algorithm)) return key;
351
+ this.hmacKey ??= crypto.subtle.importKey("raw", key, {
352
+ name: "HMAC",
353
+ hash: `SHA-${this.algorithm.slice(2)}`
354
+ }, false, ["sign", "verify"]);
355
+ return this.hmacKey;
356
+ }
327
357
  payloadToState(payload) {
328
358
  if (typeof payload.sub !== "string") return null;
329
359
  if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
@@ -510,6 +540,63 @@ function fingerprint(token) {
510
540
  return createHash("sha256").update(token).digest("hex");
511
541
  }
512
542
  /**
543
+ * Turn `ctx.credentialId` (already in place, so key order is kept) into a
544
+ * lazily computed, memoized, still writable property.
545
+ */
546
+ function lazyCredentialId(ctx, token) {
547
+ let value;
548
+ Object.defineProperty(ctx, "credentialId", {
549
+ enumerable: true,
550
+ configurable: true,
551
+ get: () => value ??= fingerprint(token),
552
+ set: (next) => {
553
+ value = next;
554
+ }
555
+ });
556
+ }
557
+ /** Smallest map size an amortized expiry sweep runs at. */
558
+ const MIN_SWEEP_SIZE$1 = 1024;
559
+ /**
560
+ * The opt-in `validateCache`: token → validated state for `ttlMs`, bounded
561
+ * (oldest dropped first). `epoch` moves on every eviction so a validation
562
+ * racing a revocation never re-caches the revoked token.
563
+ */
564
+ var ValidateCache = class {
565
+ ttlMs;
566
+ max;
567
+ entries = /* @__PURE__ */ new Map();
568
+ epoch = 0;
569
+ constructor(ttlMs, max) {
570
+ this.ttlMs = ttlMs;
571
+ this.max = max;
572
+ }
573
+ get(token, now) {
574
+ const entry = this.entries.get(token);
575
+ if (!entry) return void 0;
576
+ if (entry.until > now) return entry.state;
577
+ this.entries.delete(token);
578
+ }
579
+ set(token, state, now) {
580
+ this.entries.delete(token);
581
+ if (this.entries.size >= this.max) {
582
+ const oldest = this.entries.keys().next();
583
+ if (!oldest.done) this.entries.delete(oldest.value);
584
+ }
585
+ this.entries.set(token, {
586
+ state,
587
+ until: now + this.ttlMs
588
+ });
589
+ }
590
+ delete(token) {
591
+ this.epoch++;
592
+ this.entries.delete(token);
593
+ }
594
+ deleteUser(userId) {
595
+ this.epoch++;
596
+ for (const [token, entry] of this.entries) if (entry.state.userId === userId) this.entries.delete(token);
597
+ }
598
+ };
599
+ /**
513
600
  * Merge a credential's typed payload under the fixed {@link CredentialState}
514
601
  * envelope, asserting the `CredentialState & TPayload` shape in ONE place. The
515
602
  * payload is spread first so an envelope field always wins a name clash. Every
@@ -570,6 +657,10 @@ var AuthCredential = class {
570
657
  * access; bounded by refresh TTL.
571
658
  */
572
659
  consumedRefreshes = /* @__PURE__ */ new Map();
660
+ /** Map size at which `consumedRefreshes` next sweeps its expired entries. */
661
+ consumedSweepAt = MIN_SWEEP_SIZE$1;
662
+ /** Opt-in validated-state cache (`validateCache`). */
663
+ validated;
573
664
  constructor(opts) {
574
665
  this.store = opts.store;
575
666
  this.method = opts.method ?? "token";
@@ -580,6 +671,10 @@ var AuthCredential = class {
580
671
  this.onLimit = opts.onLimit ?? "reject";
581
672
  this.trackLastSeen = opts.trackLastSeen ?? false;
582
673
  this.clock = opts.clock ?? defaultClock;
674
+ if (opts.validateCache) {
675
+ if (!(opts.validateCache.ttlMs > 0)) throw new AuthError("INVALID_CONFIG", `validateCache.ttlMs must be > 0 (got ${opts.validateCache.ttlMs})`);
676
+ this.validated = new ValidateCache(opts.validateCache.ttlMs, opts.validateCache.max ?? 1e4);
677
+ }
583
678
  if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
584
679
  if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
585
680
  const graceMs = this.refreshConfig?.rotationGraceMs;
@@ -642,20 +737,42 @@ var AuthCredential = class {
642
737
  };
643
738
  }
644
739
  async validate(accessToken) {
645
- if (this.denylist && await this.denylist.has(accessToken)) return null;
646
- const state = await this.store.retrieve(accessToken);
647
- if (!state) return null;
648
- if (state.expiresAt <= this.clock.now()) return null;
649
- if (state.kind === "refresh") return null;
650
- if (this.trackLastSeen === "validate" && this.store.touch) await this.store.touch(accessToken, this.clock.now());
651
- return {
740
+ let state = this.validated?.get(accessToken, this.clock.now());
741
+ let fresh = false;
742
+ if (!state) {
743
+ fresh = true;
744
+ const epoch = this.validated?.epoch;
745
+ state = await this.retrieveUnlessDenied(accessToken);
746
+ if (!state) return null;
747
+ if (state.expiresAt <= this.clock.now()) return null;
748
+ if (state.kind === "refresh") return null;
749
+ if (this.validated && this.validated.epoch === epoch) this.validated.set(accessToken, state, this.clock.now());
750
+ } else if (state.expiresAt <= this.clock.now()) {
751
+ this.validated.delete(accessToken);
752
+ return null;
753
+ }
754
+ if (this.trackLastSeen === "validate" && this.store.touch) await this.store.touch(accessToken, this.clock.now(), fresh ? state : void 0);
755
+ const ctx = {
652
756
  ...credentialPayloadOf(state),
653
757
  userId: state.userId,
654
758
  method: this.method,
655
- credentialId: fingerprint(accessToken),
759
+ credentialId: "",
656
760
  sessionId: this.sessionIdOf(state.sessionId, accessToken),
657
761
  expiresAt: state.expiresAt
658
762
  };
763
+ lazyCredentialId(ctx, accessToken);
764
+ return ctx;
765
+ }
766
+ /**
767
+ * The stored state of `accessToken` unless the orchestrator's denylist has
768
+ * it — both looked up concurrently (one round trip on remote stores).
769
+ */
770
+ async retrieveUnlessDenied(accessToken) {
771
+ const retrieving = this.store.retrieve(accessToken);
772
+ if (!this.denylist) return await retrieving;
773
+ retrieving.catch(() => {});
774
+ if (await this.denylist.has(accessToken)) return null;
775
+ return await retrieving;
659
776
  }
660
777
  async refresh(refreshToken, opts) {
661
778
  const now = this.clock.now();
@@ -706,6 +823,7 @@ var AuthCredential = class {
706
823
  async refreshAlways(oldState, refreshToken, now) {
707
824
  if (!this.store.listForUser) {
708
825
  await this.store.consume(refreshToken);
826
+ this.sweepConsumedRefreshes();
709
827
  this.consumedRefreshes.set(refreshToken, {
710
828
  userId: oldState.userId,
711
829
  iat: oldState.issuedAt,
@@ -762,10 +880,20 @@ var AuthCredential = class {
762
880
  };
763
881
  }
764
882
  async revoke(token) {
765
- await this.store.revoke(token);
883
+ this.validated?.delete(token);
884
+ try {
885
+ await this.store.revoke(token);
886
+ } finally {
887
+ this.validated?.delete(token);
888
+ }
766
889
  }
767
890
  async revokeAllForUser(userId) {
768
- return await this.store.revokeAllForUser(userId);
891
+ this.validated?.deleteUser(userId);
892
+ try {
893
+ return await this.store.revokeAllForUser(userId);
894
+ } finally {
895
+ this.validated?.deleteUser(userId);
896
+ }
769
897
  }
770
898
  async listForUser(userId) {
771
899
  if (!this.store.listForUser) return [];
@@ -849,7 +977,7 @@ var AuthCredential = class {
849
977
  async revokeSession(userId, sessionId) {
850
978
  if (!this.store.listForUser) return;
851
979
  const all = await this.store.listForUser(userId);
852
- await Promise.all(all.filter((entry) => this.sessionKeyOf(entry) === sessionId).map((entry) => this.store.revoke(entry.token)));
980
+ await Promise.all(all.filter((entry) => this.sessionKeyOf(entry) === sessionId).map((entry) => this.revoke(entry.token)));
853
981
  }
854
982
  /**
855
983
  * Revoke every session for the user EXCEPT `keepSessionId` ("log out
@@ -865,7 +993,7 @@ var AuthCredential = class {
865
993
  const key = this.sessionKeyOf(entry);
866
994
  if (key === keepSessionId) continue;
867
995
  revokedSessions.add(key);
868
- revokes.push(this.store.revoke(entry.token));
996
+ revokes.push(this.revoke(entry.token));
869
997
  }
870
998
  await Promise.all(revokes);
871
999
  return revokedSessions.size;
@@ -892,7 +1020,7 @@ var AuthCredential = class {
892
1020
  });
893
1021
  const sorted = accessOnly.toSorted((a, b) => a.issuedAt - b.issuedAt);
894
1022
  const toEvict = accessOnly.length - this.maxConcurrent + 1;
895
- for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
1023
+ for (let i = 0; i < toEvict && i < sorted.length; i++) await this.revoke(sorted[i].token);
896
1024
  }
897
1025
  async issueAccessFromRefresh(refreshState, now) {
898
1026
  const accessTtl = refreshState.metadata?.accessTtl ?? this.accessTtl;
@@ -955,6 +1083,13 @@ var AuthCredential = class {
955
1083
  userId: oldRefreshState.userId
956
1084
  };
957
1085
  }
1086
+ /** Amortized sweep of expired `consumedRefreshes` entries (each size doubling). */
1087
+ sweepConsumedRefreshes() {
1088
+ if (this.consumedRefreshes.size < this.consumedSweepAt) return;
1089
+ const now = this.clock.now();
1090
+ for (const [token, entry] of this.consumedRefreshes) if (entry.exp <= now) this.consumedRefreshes.delete(token);
1091
+ this.consumedSweepAt = Math.max(MIN_SWEEP_SIZE$1, this.consumedRefreshes.size * 2);
1092
+ }
958
1093
  lookupConsumedRefresh(token) {
959
1094
  const entry = this.consumedRefreshes.get(token);
960
1095
  if (!entry) return null;
@@ -991,7 +1126,7 @@ var AuthCredential = class {
991
1126
  ...reuse.rotatedAt !== void 0 && { rotatedAt: reuse.rotatedAt }
992
1127
  });
993
1128
  if ((this.refreshConfig?.reuseResponse ?? "session") === "session" && reuse.sessionId !== void 0 && this.store.listForUser) await this.revokeSession(reuse.userId, reuse.sessionId);
994
- else await this.store.revokeAllForUser(reuse.userId);
1129
+ else await this.revokeAllForUser(reuse.userId);
995
1130
  throw new AuthError("REFRESH_REUSE_DETECTED", void 0, {
996
1131
  userId: reuse.userId,
997
1132
  ...reuse.sessionId !== void 0 && { sessionId: reuse.sessionId },
@@ -1081,13 +1216,15 @@ function renderRateLimitMessage(template, rule, retryAfterMs) {
1081
1216
  //#region src/rate-limit/store.ts
1082
1217
  /**
1083
1218
  * In-memory `RateLimitStore` — the zero-config default (single process only).
1084
- * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
1085
- * periodic compaction in long-lived processes (same pattern as
1086
- * `DenylistStoreMemory`).
1219
+ * Lazy-evicts expired counters on access, and sweeps expired counters itself
1220
+ * as the map grows (amortized, each time it doubles); `cleanup()` sweeps the
1221
+ * whole map on demand (same pattern as `DenylistStoreMemory`).
1087
1222
  */
1088
1223
  var RateLimitStoreMemory = class {
1089
1224
  entries = /* @__PURE__ */ new Map();
1090
1225
  clock;
1226
+ /** Map size at which `hit` next sweeps expired counters (amortized; doubles). */
1227
+ sweepAt = MIN_SWEEP_SIZE;
1091
1228
  constructor(opts) {
1092
1229
  this.clock = opts?.clock ?? defaultClock;
1093
1230
  }
@@ -1095,6 +1232,10 @@ var RateLimitStoreMemory = class {
1095
1232
  const now = this.clock.now();
1096
1233
  const entry = this.entries.get(key);
1097
1234
  if (!entry || entry.expiresAt <= now) {
1235
+ if (!entry && this.entries.size >= this.sweepAt) {
1236
+ this.sweep(now);
1237
+ this.sweepAt = Math.max(MIN_SWEEP_SIZE, this.entries.size * 2);
1238
+ }
1098
1239
  this.entries.set(key, {
1099
1240
  count: 1,
1100
1241
  expiresAt: now + ttlMs
@@ -1112,7 +1253,9 @@ var RateLimitStoreMemory = class {
1112
1253
  this.entries.clear();
1113
1254
  }
1114
1255
  async cleanup() {
1115
- const now = this.clock.now();
1256
+ return this.sweep(this.clock.now());
1257
+ }
1258
+ sweep(now) {
1116
1259
  let removed = 0;
1117
1260
  for (const [key, entry] of this.entries) if (entry.expiresAt <= now) {
1118
1261
  this.entries.delete(key);
@@ -1121,6 +1264,8 @@ var RateLimitStoreMemory = class {
1121
1264
  return removed;
1122
1265
  }
1123
1266
  };
1267
+ /** Smallest map size an amortized expiry sweep runs at. */
1268
+ const MIN_SWEEP_SIZE = 1024;
1124
1269
  //#endregion
1125
1270
  //#region src/rate-limit/rate-limiter.ts
1126
1271
  /**
package/dist/redis.cjs CHANGED
@@ -74,10 +74,14 @@ 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);
77
+ async touch(token, at, current) {
78
+ let state;
79
+ if (current) state = { ...current };
80
+ else {
81
+ const raw = await this.redis.get(this.tokenKey(token));
82
+ if (raw === null) return;
83
+ state = JSON.parse(raw);
84
+ }
81
85
  state.lastSeenAt = at;
82
86
  const ttlMs = Math.max(0, state.expiresAt - Date.now());
83
87
  if (ttlMs <= 0) return;
package/dist/redis.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-C0O0FCCN.cjs";
2
- import { t as RateLimitStore } from "./store-3YN2hfss.cjs";
1
+ import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-CyjqzLxc.cjs";
2
+ import { t as RateLimitStore } from "./store-ClQnA8yh.cjs";
3
3
  //#region src/redis/index.d.ts
4
4
  /**
5
5
  * Structural Redis client. Covers the exact set of commands used by
@@ -101,7 +101,7 @@ declare class CredentialStoreRedis<TPayload extends object = object> implements
101
101
  consume(token: string): Promise<(CredentialState & TPayload) | null>;
102
102
  update(token: string, state: CredentialState & TPayload): Promise<string>;
103
103
  revoke(token: string): Promise<void>;
104
- touch(token: string, at: number): Promise<void>;
104
+ touch(token: string, at: number, current?: CredentialState & TPayload): Promise<void>;
105
105
  revokeAllForUser(userId: string): Promise<number>;
106
106
  listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
107
107
  token: string;
package/dist/redis.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-C0O0FCCN.mjs";
2
- import { t as RateLimitStore } from "./store-BUIC0iCK.mjs";
1
+ import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-CyjqzLxc.mjs";
2
+ import { t as RateLimitStore } from "./store-CVD1nd2j.mjs";
3
3
  //#region src/redis/index.d.ts
4
4
  /**
5
5
  * Structural Redis client. Covers the exact set of commands used by
@@ -101,7 +101,7 @@ declare class CredentialStoreRedis<TPayload extends object = object> implements
101
101
  consume(token: string): Promise<(CredentialState & TPayload) | null>;
102
102
  update(token: string, state: CredentialState & TPayload): Promise<string>;
103
103
  revoke(token: string): Promise<void>;
104
- touch(token: string, at: number): Promise<void>;
104
+ touch(token: string, at: number, current?: CredentialState & TPayload): Promise<void>;
105
105
  revokeAllForUser(userId: string): Promise<number>;
106
106
  listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
107
107
  token: string;
package/dist/redis.mjs CHANGED
@@ -73,10 +73,14 @@ var CredentialStoreRedis = class {
73
73
  await this.redis.del(this.tokenKey(token));
74
74
  await this.redis.srem(this.userKey(state.userId), token);
75
75
  }
76
- async touch(token, at) {
77
- const raw = await this.redis.get(this.tokenKey(token));
78
- if (raw === null) return;
79
- const state = JSON.parse(raw);
76
+ async touch(token, at, current) {
77
+ let state;
78
+ if (current) state = { ...current };
79
+ else {
80
+ const raw = await this.redis.get(this.tokenKey(token));
81
+ if (raw === null) return;
82
+ state = JSON.parse(raw);
83
+ }
80
84
  state.lastSeenAt = at;
81
85
  const ttlMs = Math.max(0, state.expiresAt - Date.now());
82
86
  if (ttlMs <= 0) return;
@@ -1,4 +1,4 @@
1
- import { t as Clock } from "./clock-C0O0FCCN.cjs";
1
+ import { t as Clock } from "./clock-CyjqzLxc.mjs";
2
2
  //#region src/rate-limit/store.d.ts
3
3
  /**
4
4
  * Storage contract for the fixed-window limiter (RL.spec.md §4.3).
@@ -18,13 +18,15 @@ interface RateLimitStore {
18
18
  }
19
19
  /**
20
20
  * In-memory `RateLimitStore` — the zero-config default (single process only).
21
- * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
22
- * periodic compaction in long-lived processes (same pattern as
23
- * `DenylistStoreMemory`).
21
+ * Lazy-evicts expired counters on access, and sweeps expired counters itself
22
+ * as the map grows (amortized, each time it doubles); `cleanup()` sweeps the
23
+ * whole map on demand (same pattern as `DenylistStoreMemory`).
24
24
  */
25
25
  declare class RateLimitStoreMemory implements RateLimitStore {
26
26
  private readonly entries;
27
27
  private readonly clock;
28
+ /** Map size at which `hit` next sweeps expired counters (amortized; doubles). */
29
+ private sweepAt;
28
30
  constructor(opts?: {
29
31
  clock?: Clock;
30
32
  });
@@ -33,6 +35,7 @@ declare class RateLimitStoreMemory implements RateLimitStore {
33
35
  /** Drop every counter — test-harness reset between cases. */
34
36
  clear(): void;
35
37
  cleanup(): Promise<number>;
38
+ private sweep;
36
39
  }
37
40
  //#endregion
38
41
  export { RateLimitStoreMemory as n, RateLimitStore as t };
@@ -1,4 +1,4 @@
1
- import { t as Clock } from "./clock-C0O0FCCN.mjs";
1
+ import { t as Clock } from "./clock-CyjqzLxc.cjs";
2
2
  //#region src/rate-limit/store.d.ts
3
3
  /**
4
4
  * Storage contract for the fixed-window limiter (RL.spec.md §4.3).
@@ -18,13 +18,15 @@ interface RateLimitStore {
18
18
  }
19
19
  /**
20
20
  * In-memory `RateLimitStore` — the zero-config default (single process only).
21
- * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
22
- * periodic compaction in long-lived processes (same pattern as
23
- * `DenylistStoreMemory`).
21
+ * Lazy-evicts expired counters on access, and sweeps expired counters itself
22
+ * as the map grows (amortized, each time it doubles); `cleanup()` sweeps the
23
+ * whole map on demand (same pattern as `DenylistStoreMemory`).
24
24
  */
25
25
  declare class RateLimitStoreMemory implements RateLimitStore {
26
26
  private readonly entries;
27
27
  private readonly clock;
28
+ /** Map size at which `hit` next sweeps expired counters (amortized; doubles). */
29
+ private sweepAt;
28
30
  constructor(opts?: {
29
31
  clock?: Clock;
30
32
  });
@@ -33,6 +35,7 @@ declare class RateLimitStoreMemory implements RateLimitStore {
33
35
  /** Drop every counter — test-harness reset between cases. */
34
36
  clear(): void;
35
37
  cleanup(): Promise<number>;
38
+ private sweep;
36
39
  }
37
40
  //#endregion
38
41
  export { RateLimitStoreMemory as n, RateLimitStore as t };