@aooth/auth 0.1.77 → 0.1.78

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.
@@ -273,7 +273,15 @@ var CredentialStoreAtscriptDb = class {
273
273
  async revoke(token) {
274
274
  await this.table.deleteOne(token);
275
275
  }
276
- async touch(token, at) {
276
+ async touch(token, at, state) {
277
+ if (state && this.table.updateOne) {
278
+ if (state.expiresAt <= Date.now()) return;
279
+ await this.table.updateOne({
280
+ token,
281
+ lastSeenAt: at
282
+ });
283
+ return;
284
+ }
277
285
  const row = await this.table.findOne({ filter: { token } });
278
286
  if (!row) return;
279
287
  if (row.expiresAt <= Date.now()) return;
@@ -1,5 +1,5 @@
1
- import { c as CredentialSuccessorRef, r as CredentialStore, s as CredentialState, t as Clock } from "./clock-C0O0FCCN.cjs";
2
- import { c as AuthCodeStore, d as NewAuthCode, h as PendingAuthorizationStore, m as PendingAuthorization, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient } from "./dynamic-client-store-CafYRhI4.cjs";
1
+ import { c as CredentialSuccessorRef, r as CredentialStore, s as CredentialState, t as Clock } from "./clock-CyjqzLxc.cjs";
2
+ import { c as AuthCodeStore, d as NewAuthCode, h as PendingAuthorizationStore, m as PendingAuthorization, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient } from "./dynamic-client-store-HLfcl2FJ.cjs";
3
3
  //#region src/atscript-db/authz-stores.d.ts
4
4
  /**
5
5
  * atscript-db-backed durable implementations of the two short-lived
@@ -255,6 +255,14 @@ interface AuthCredentialTable<TPayload extends object = object> {
255
255
  deleteMany(filter: Record<string, unknown>): Promise<{
256
256
  deletedCount: number;
257
257
  }>;
258
+ /**
259
+ * Optional PK-addressed patch (atscript-db's `updateOne`): lets `touch`
260
+ * stamp `lastSeenAt` without re-reading the row. Never inserts.
261
+ */
262
+ updateOne?(patch: {
263
+ token: string;
264
+ lastSeenAt: number;
265
+ }): Promise<unknown>;
258
266
  }
259
267
  interface CredentialStoreAtscriptDbOptions<TPayload extends object> {
260
268
  table: AuthCredentialTable<TPayload>;
@@ -290,7 +298,7 @@ declare class CredentialStoreAtscriptDb<TPayload extends object = object> implem
290
298
  consume(token: string): Promise<(CredentialState & TPayload) | null>;
291
299
  update(token: string, state: CredentialState & TPayload): Promise<string>;
292
300
  revoke(token: string): Promise<void>;
293
- touch(token: string, at: number): Promise<void>;
301
+ touch(token: string, at: number, state?: CredentialState & TPayload): Promise<void>;
294
302
  revokeAllForUser(userId: string): Promise<number>;
295
303
  listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
296
304
  token: string;
@@ -1,5 +1,5 @@
1
- import { c as CredentialSuccessorRef, r as CredentialStore, s as CredentialState, t as Clock } from "./clock-C0O0FCCN.mjs";
2
- import { c as AuthCodeStore, d as NewAuthCode, h as PendingAuthorizationStore, m as PendingAuthorization, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient } from "./dynamic-client-store-BCAqzG0Y.mjs";
1
+ import { c as CredentialSuccessorRef, r as CredentialStore, s as CredentialState, t as Clock } from "./clock-CyjqzLxc.mjs";
2
+ import { c as AuthCodeStore, d as NewAuthCode, h as PendingAuthorizationStore, m as PendingAuthorization, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient } from "./dynamic-client-store-BF4nXMK2.mjs";
3
3
  //#region src/atscript-db/authz-stores.d.ts
4
4
  /**
5
5
  * atscript-db-backed durable implementations of the two short-lived
@@ -255,6 +255,14 @@ interface AuthCredentialTable<TPayload extends object = object> {
255
255
  deleteMany(filter: Record<string, unknown>): Promise<{
256
256
  deletedCount: number;
257
257
  }>;
258
+ /**
259
+ * Optional PK-addressed patch (atscript-db's `updateOne`): lets `touch`
260
+ * stamp `lastSeenAt` without re-reading the row. Never inserts.
261
+ */
262
+ updateOne?(patch: {
263
+ token: string;
264
+ lastSeenAt: number;
265
+ }): Promise<unknown>;
258
266
  }
259
267
  interface CredentialStoreAtscriptDbOptions<TPayload extends object> {
260
268
  table: AuthCredentialTable<TPayload>;
@@ -290,7 +298,7 @@ declare class CredentialStoreAtscriptDb<TPayload extends object = object> implem
290
298
  consume(token: string): Promise<(CredentialState & TPayload) | null>;
291
299
  update(token: string, state: CredentialState & TPayload): Promise<string>;
292
300
  revoke(token: string): Promise<void>;
293
- touch(token: string, at: number): Promise<void>;
301
+ touch(token: string, at: number, state?: CredentialState & TPayload): Promise<void>;
294
302
  revokeAllForUser(userId: string): Promise<number>;
295
303
  listForUser(userId: string): Promise<Array<CredentialState & TPayload & {
296
304
  token: string;
@@ -272,7 +272,15 @@ var CredentialStoreAtscriptDb = class {
272
272
  async revoke(token) {
273
273
  await this.table.deleteOne(token);
274
274
  }
275
- async touch(token, at) {
275
+ async touch(token, at, state) {
276
+ if (state && this.table.updateOne) {
277
+ if (state.expiresAt <= Date.now()) return;
278
+ await this.table.updateOne({
279
+ token,
280
+ lastSeenAt: at
281
+ });
282
+ return;
283
+ }
276
284
  const row = await this.table.findOne({ filter: { token } });
277
285
  if (!row) return;
278
286
  if (row.expiresAt <= Date.now()) return;
@@ -1,4 +1,4 @@
1
- import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-C0O0FCCN.mjs";
1
+ import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-CyjqzLxc.mjs";
2
2
  //#region src/credential/auth-credential.d.ts
3
3
  interface AuthCredentialOptions<TPayload extends object = object> {
4
4
  /** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
@@ -32,6 +32,22 @@ interface AuthCredentialOptions<TPayload extends object = object> {
32
32
  * store that implements `touch`; a no-op on stores that don't.
33
33
  */
34
34
  trackLastSeen?: "refresh" | "validate" | false;
35
+ /**
36
+ * OPT-IN process-local cache of validated access-token state (token →
37
+ * state), reused by `validate()` for `ttlMs` instead of a store / denylist
38
+ * round trip. Off by default. A hit still re-checks expiry and kind; this
39
+ * instance's `revoke`, `revokeAllForUser`, `revokeSession`,
40
+ * `revokeOtherSessions`, the concurrency eviction and refresh-reuse
41
+ * responses evict their tokens at once. A revocation made elsewhere (another
42
+ * process / instance, or a direct store / denylist write) is seen within
43
+ * `ttlMs` — keep it short (seconds).
44
+ *
45
+ * @since 0.1.78
46
+ */
47
+ validateCache?: {
48
+ ttlMs: number;
49
+ max?: number;
50
+ };
35
51
  /** Optional clock for testability. */
36
52
  clock?: Clock;
37
53
  }
@@ -148,9 +164,18 @@ declare class AuthCredential<TPayload extends object = object> {
148
164
  * access; bounded by refresh TTL.
149
165
  */
150
166
  private readonly consumedRefreshes;
167
+ /** Map size at which `consumedRefreshes` next sweeps its expired entries. */
168
+ private consumedSweepAt;
169
+ /** Opt-in validated-state cache (`validateCache`). */
170
+ private readonly validated?;
151
171
  constructor(opts: AuthCredentialOptions<TPayload>);
152
172
  issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
153
173
  validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
174
+ /**
175
+ * The stored state of `accessToken` unless the orchestrator's denylist has
176
+ * it — both looked up concurrently (one round trip on remote stores).
177
+ */
178
+ private retrieveUnlessDenied;
154
179
  refresh(refreshToken: string, opts?: RefreshCallOptions<TPayload>): Promise<RefreshResult>;
155
180
  private refreshNone;
156
181
  /**
@@ -240,6 +265,8 @@ declare class AuthCredential<TPayload extends object = object> {
240
265
  * rotated (keeping it valid through the grace window) instead of consuming it.
241
266
  */
242
267
  private issueRotatedPair;
268
+ /** Amortized sweep of expired `consumedRefreshes` entries (each size doubling). */
269
+ private sweepConsumedRefreshes;
243
270
  private lookupConsumedRefresh;
244
271
  private fireRefreshReuseTheftResponse;
245
272
  /**
@@ -1,4 +1,4 @@
1
- import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-C0O0FCCN.cjs";
1
+ import { a as AuthContext, d as RefreshConfig, f as RefreshResult, i as DenylistStore, l as EnrichedSession, m as SessionInfo, o as CredentialMetadata, p as SessionEnricher, r as CredentialStore, s as CredentialState, t as Clock, u as IssueResult } from "./clock-CyjqzLxc.cjs";
2
2
  //#region src/credential/auth-credential.d.ts
3
3
  interface AuthCredentialOptions<TPayload extends object = object> {
4
4
  /** Pluggable credential store (Memory, JWT, Encapsulated, ...). */
@@ -32,6 +32,22 @@ interface AuthCredentialOptions<TPayload extends object = object> {
32
32
  * store that implements `touch`; a no-op on stores that don't.
33
33
  */
34
34
  trackLastSeen?: "refresh" | "validate" | false;
35
+ /**
36
+ * OPT-IN process-local cache of validated access-token state (token →
37
+ * state), reused by `validate()` for `ttlMs` instead of a store / denylist
38
+ * round trip. Off by default. A hit still re-checks expiry and kind; this
39
+ * instance's `revoke`, `revokeAllForUser`, `revokeSession`,
40
+ * `revokeOtherSessions`, the concurrency eviction and refresh-reuse
41
+ * responses evict their tokens at once. A revocation made elsewhere (another
42
+ * process / instance, or a direct store / denylist write) is seen within
43
+ * `ttlMs` — keep it short (seconds).
44
+ *
45
+ * @since 0.1.78
46
+ */
47
+ validateCache?: {
48
+ ttlMs: number;
49
+ max?: number;
50
+ };
35
51
  /** Optional clock for testability. */
36
52
  clock?: Clock;
37
53
  }
@@ -148,9 +164,18 @@ declare class AuthCredential<TPayload extends object = object> {
148
164
  * access; bounded by refresh TTL.
149
165
  */
150
166
  private readonly consumedRefreshes;
167
+ /** Map size at which `consumedRefreshes` next sweeps its expired entries. */
168
+ private consumedSweepAt;
169
+ /** Opt-in validated-state cache (`validateCache`). */
170
+ private readonly validated?;
151
171
  constructor(opts: AuthCredentialOptions<TPayload>);
152
172
  issue(userId: string, options?: IssueOptions<TPayload>): Promise<IssueResult>;
153
173
  validate(accessToken: string): Promise<AuthContext<TPayload> | null>;
174
+ /**
175
+ * The stored state of `accessToken` unless the orchestrator's denylist has
176
+ * it — both looked up concurrently (one round trip on remote stores).
177
+ */
178
+ private retrieveUnlessDenied;
154
179
  refresh(refreshToken: string, opts?: RefreshCallOptions<TPayload>): Promise<RefreshResult>;
155
180
  private refreshNone;
156
181
  /**
@@ -240,6 +265,8 @@ declare class AuthCredential<TPayload extends object = object> {
240
265
  * rotated (keeping it valid through the grace window) instead of consuming it.
241
266
  */
242
267
  private issueRotatedPair;
268
+ /** Amortized sweep of expired `consumedRefreshes` entries (each size doubling). */
269
+ private sweepConsumedRefreshes;
243
270
  private lookupConsumedRefresh;
244
271
  private fireRefreshReuseTheftResponse;
245
272
  /**
package/dist/authz.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-C0O0FCCN.cjs";
2
- import { _ as PendingAuthorizationStoreMemoryOptions, a as DynamicClientStoreMemoryOptions, b as tokenPolicyToIssueOptions, c as AuthCodeStore, d as NewAuthCode, f as DEFAULT_PENDING_TTL_MS, g as PendingAuthorizationStoreMemory, h as PendingAuthorizationStore, i as DynamicClientStoreMemory, l as AuthCodeStoreMemory, m as PendingAuthorization, n as DynamicClientAuthMethod, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient, u as AuthCodeStoreMemoryOptions, v as DEFAULT_AUTHZ_REFRESH_TTL_MS, y as TokenPolicy } from "./dynamic-client-store-CafYRhI4.cjs";
1
+ import { t as Clock } from "./clock-CyjqzLxc.cjs";
2
+ import { _ as PendingAuthorizationStoreMemoryOptions, a as DynamicClientStoreMemoryOptions, b as tokenPolicyToIssueOptions, c as AuthCodeStore, d as NewAuthCode, f as DEFAULT_PENDING_TTL_MS, g as PendingAuthorizationStoreMemory, h as PendingAuthorizationStore, i as DynamicClientStoreMemory, l as AuthCodeStoreMemory, m as PendingAuthorization, n as DynamicClientAuthMethod, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient, u as AuthCodeStoreMemoryOptions, v as DEFAULT_AUTHZ_REFRESH_TTL_MS, y as TokenPolicy } from "./dynamic-client-store-HLfcl2FJ.cjs";
3
3
  import { JWK } from "jose";
4
4
  //#region src/authz/authz-errors.d.ts
5
5
  /**
package/dist/authz.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-C0O0FCCN.mjs";
2
- import { _ as PendingAuthorizationStoreMemoryOptions, a as DynamicClientStoreMemoryOptions, b as tokenPolicyToIssueOptions, c as AuthCodeStore, d as NewAuthCode, f as DEFAULT_PENDING_TTL_MS, g as PendingAuthorizationStoreMemory, h as PendingAuthorizationStore, i as DynamicClientStoreMemory, l as AuthCodeStoreMemory, m as PendingAuthorization, n as DynamicClientAuthMethod, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient, u as AuthCodeStoreMemoryOptions, v as DEFAULT_AUTHZ_REFRESH_TTL_MS, y as TokenPolicy } from "./dynamic-client-store-BCAqzG0Y.mjs";
1
+ import { t as Clock } from "./clock-CyjqzLxc.mjs";
2
+ import { _ as PendingAuthorizationStoreMemoryOptions, a as DynamicClientStoreMemoryOptions, b as tokenPolicyToIssueOptions, c as AuthCodeStore, d as NewAuthCode, f as DEFAULT_PENDING_TTL_MS, g as PendingAuthorizationStoreMemory, h as PendingAuthorizationStore, i as DynamicClientStoreMemory, l as AuthCodeStoreMemory, m as PendingAuthorization, n as DynamicClientAuthMethod, o as NewDynamicClient, p as NewPendingAuthorization, r as DynamicClientStore, s as AuthCode, t as DynamicClient, u as AuthCodeStoreMemoryOptions, v as DEFAULT_AUTHZ_REFRESH_TTL_MS, y as TokenPolicy } from "./dynamic-client-store-BF4nXMK2.mjs";
3
3
  import { JWK } from "jose";
4
4
  //#region src/authz/authz-errors.d.ts
5
5
  /**
@@ -328,9 +328,10 @@ interface CredentialStore<TPayload extends object = object> {
328
328
  * Optional last-activity stamp. Set `state.lastSeenAt = at` for the token if
329
329
  * it exists; no-op otherwise. Backs `AuthCredential`'s
330
330
  * `trackLastSeen: 'validate'` mode. Stateless stores omit it (the token is
331
- * immutable once issued).
331
+ * immutable once issued). `state` (since 0.1.78) is the state `retrieve`
332
+ * just returned for the token — a store may use it instead of re-reading.
332
333
  */
333
- touch?(token: string, at: number): Promise<void>;
334
+ touch?(token: string, at: number, state?: CredentialState & TPayload): Promise<void>;
334
335
  /**
335
336
  * Derive a stable, domain-separated 32-byte subkey from this store's
336
337
  * symmetric secret via HKDF-SHA256. Used so other subsystems (e.g. the
@@ -328,9 +328,10 @@ interface CredentialStore<TPayload extends object = object> {
328
328
  * Optional last-activity stamp. Set `state.lastSeenAt = at` for the token if
329
329
  * it exists; no-op otherwise. Backs `AuthCredential`'s
330
330
  * `trackLastSeen: 'validate'` mode. Stateless stores omit it (the token is
331
- * immutable once issued).
331
+ * immutable once issued). `state` (since 0.1.78) is the state `retrieve`
332
+ * just returned for the token — a store may use it instead of re-reading.
332
333
  */
333
- touch?(token: string, at: number): Promise<void>;
334
+ touch?(token: string, at: number, state?: CredentialState & TPayload): Promise<void>;
334
335
  /**
335
336
  * Derive a stable, domain-separated 32-byte subkey from this store's
336
337
  * symmetric secret via HKDF-SHA256. Used so other subsystems (e.g. the
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-C0O0FCCN.mjs";
2
- import { r as IssueOptions } from "./auth-credential-C5DiSbb2.mjs";
1
+ import { t as Clock } from "./clock-CyjqzLxc.mjs";
2
+ import { r as IssueOptions } from "./auth-credential-CLAQgjCS.mjs";
3
3
  //#region src/authz/token-policy.d.ts
4
4
  /**
5
5
  * What the authorization server mints for a completed grant — forwarded verbatim
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-C0O0FCCN.cjs";
2
- import { r as IssueOptions } from "./auth-credential-CU4vsKhu.cjs";
1
+ import { t as Clock } from "./clock-CyjqzLxc.cjs";
2
+ import { r as IssueOptions } from "./auth-credential-GRAK230V.cjs";
3
3
  //#region src/authz/token-policy.d.ts
4
4
  /**
5
5
  * What the authorization server mints for a completed grant — forwarded verbatim
package/dist/index.cjs CHANGED
@@ -81,7 +81,7 @@ var CredentialStoreMemory = class {
81
81
  this.states.delete(token);
82
82
  this.indexRemove(state.userId, token);
83
83
  }
84
- async touch(token, at) {
84
+ async touch(token, at, _state) {
85
85
  const state = this.states.get(token);
86
86
  if (!state) return;
87
87
  state.lastSeenAt = at;
@@ -143,17 +143,24 @@ var CredentialStoreMemory = class {
143
143
  * In-memory denylist for stateless revocation.
144
144
  *
145
145
  * Stores a map from JTI (or any opaque identifier) to absolute expiry timestamp.
146
- * `has` returns false for entries whose TTL has elapsed and lazily removes them.
147
- * `cleanup` performs a sweep across the map and returns the number of entries
148
- * removed, useful for periodic compaction in long-lived processes.
146
+ * `has` returns false for entries whose TTL has elapsed and lazily removes them;
147
+ * `add` sweeps expired entries as the map grows (amortized, each time it
148
+ * doubles). `cleanup` performs a sweep on demand and returns the number of
149
+ * entries removed.
149
150
  */
150
151
  var DenylistStoreMemory = class {
151
152
  entries = /* @__PURE__ */ new Map();
152
153
  clock;
154
+ /** Map size at which `add` next sweeps expired entries (amortized; doubles). */
155
+ sweepAt = MIN_SWEEP_SIZE$2;
153
156
  constructor(opts) {
154
157
  this.clock = opts?.clock ?? require_clock.defaultClock;
155
158
  }
156
159
  async add(jti, expiresAt) {
160
+ if (this.entries.size >= this.sweepAt && !this.entries.has(jti)) {
161
+ this.sweep(this.clock.now());
162
+ this.sweepAt = Math.max(MIN_SWEEP_SIZE$2, this.entries.size * 2);
163
+ }
157
164
  this.entries.set(jti, expiresAt);
158
165
  }
159
166
  async has(jti) {
@@ -166,7 +173,9 @@ var DenylistStoreMemory = class {
166
173
  return true;
167
174
  }
168
175
  async cleanup() {
169
- const now = this.clock.now();
176
+ return this.sweep(this.clock.now());
177
+ }
178
+ sweep(now) {
170
179
  let removed = 0;
171
180
  for (const [jti, expiresAt] of this.entries) if (expiresAt <= now) {
172
181
  this.entries.delete(jti);
@@ -175,6 +184,8 @@ var DenylistStoreMemory = class {
175
184
  return removed;
176
185
  }
177
186
  };
187
+ /** Smallest map size an amortized expiry sweep runs at. */
188
+ const MIN_SWEEP_SIZE$2 = 1024;
178
189
  //#endregion
179
190
  //#region src/stores/derive-subkey.ts
180
191
  const SUBKEY_SALT = Buffer.from("aooth/credential-store/subkey-v1");
@@ -201,6 +212,12 @@ var CredentialStoreJwt = class {
201
212
  algorithm;
202
213
  signingKey;
203
214
  verifyingKey;
215
+ /**
216
+ * HS* with raw secret bytes: the HMAC `CryptoKey` imported once (lazily) and
217
+ * reused for every sign / verify — jose would otherwise re-import the raw
218
+ * bytes per call. `signingKey` keeps the bytes for `deriveSubkey`.
219
+ */
220
+ hmacKey;
204
221
  issuer;
205
222
  audience;
206
223
  denylist;
@@ -249,7 +266,7 @@ var CredentialStoreJwt = class {
249
266
  const builder = new jose.SignJWT({ state: stateClaim }).setProtectedHeader({ alg: this.algorithm }).setSubject(state.userId).setIssuedAt(Math.floor(state.issuedAt / 1e3)).setExpirationTime(Math.floor(expiresAtMs / 1e3)).setJti(jti);
250
267
  if (this.issuer) builder.setIssuer(this.issuer);
251
268
  if (this.audience) builder.setAudience(this.audience);
252
- return builder.sign(this.signingKey);
269
+ return builder.sign(await this.keyFor(this.signingKey));
253
270
  }
254
271
  async retrieve(token) {
255
272
  const verified = await this.verify(token);
@@ -315,7 +332,7 @@ var CredentialStoreJwt = class {
315
332
  }
316
333
  async verify(token) {
317
334
  try {
318
- return { payload: (await (0, jose.jwtVerify)(token, this.verifyingKey, {
335
+ return { payload: (await (0, jose.jwtVerify)(token, await this.keyFor(this.verifyingKey), {
319
336
  algorithms: [this.algorithm],
320
337
  issuer: this.issuer,
321
338
  audience: this.audience,
@@ -325,6 +342,19 @@ var CredentialStoreJwt = class {
325
342
  return null;
326
343
  }
327
344
  }
345
+ /**
346
+ * The key jose gets: raw HS* secret bytes as their HMAC `CryptoKey`
347
+ * (imported once — usable for both sign and verify, its hash pinned to the
348
+ * configured algorithm), anything else as given.
349
+ */
350
+ keyFor(key) {
351
+ if (!(key instanceof Uint8Array) || !HS_ALGS.has(this.algorithm)) return key;
352
+ this.hmacKey ??= crypto.subtle.importKey("raw", key, {
353
+ name: "HMAC",
354
+ hash: `SHA-${this.algorithm.slice(2)}`
355
+ }, false, ["sign", "verify"]);
356
+ return this.hmacKey;
357
+ }
328
358
  payloadToState(payload) {
329
359
  if (typeof payload.sub !== "string") return null;
330
360
  if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
@@ -511,6 +541,63 @@ function fingerprint(token) {
511
541
  return (0, node_crypto.createHash)("sha256").update(token).digest("hex");
512
542
  }
513
543
  /**
544
+ * Turn `ctx.credentialId` (already in place, so key order is kept) into a
545
+ * lazily computed, memoized, still writable property.
546
+ */
547
+ function lazyCredentialId(ctx, token) {
548
+ let value;
549
+ Object.defineProperty(ctx, "credentialId", {
550
+ enumerable: true,
551
+ configurable: true,
552
+ get: () => value ??= fingerprint(token),
553
+ set: (next) => {
554
+ value = next;
555
+ }
556
+ });
557
+ }
558
+ /** Smallest map size an amortized expiry sweep runs at. */
559
+ const MIN_SWEEP_SIZE$1 = 1024;
560
+ /**
561
+ * The opt-in `validateCache`: token → validated state for `ttlMs`, bounded
562
+ * (oldest dropped first). `epoch` moves on every eviction so a validation
563
+ * racing a revocation never re-caches the revoked token.
564
+ */
565
+ var ValidateCache = class {
566
+ ttlMs;
567
+ max;
568
+ entries = /* @__PURE__ */ new Map();
569
+ epoch = 0;
570
+ constructor(ttlMs, max) {
571
+ this.ttlMs = ttlMs;
572
+ this.max = max;
573
+ }
574
+ get(token, now) {
575
+ const entry = this.entries.get(token);
576
+ if (!entry) return void 0;
577
+ if (entry.until > now) return entry.state;
578
+ this.entries.delete(token);
579
+ }
580
+ set(token, state, now) {
581
+ this.entries.delete(token);
582
+ if (this.entries.size >= this.max) {
583
+ const oldest = this.entries.keys().next();
584
+ if (!oldest.done) this.entries.delete(oldest.value);
585
+ }
586
+ this.entries.set(token, {
587
+ state,
588
+ until: now + this.ttlMs
589
+ });
590
+ }
591
+ delete(token) {
592
+ this.epoch++;
593
+ this.entries.delete(token);
594
+ }
595
+ deleteUser(userId) {
596
+ this.epoch++;
597
+ for (const [token, entry] of this.entries) if (entry.state.userId === userId) this.entries.delete(token);
598
+ }
599
+ };
600
+ /**
514
601
  * Merge a credential's typed payload under the fixed {@link CredentialState}
515
602
  * envelope, asserting the `CredentialState & TPayload` shape in ONE place. The
516
603
  * payload is spread first so an envelope field always wins a name clash. Every
@@ -571,6 +658,10 @@ var AuthCredential = class {
571
658
  * access; bounded by refresh TTL.
572
659
  */
573
660
  consumedRefreshes = /* @__PURE__ */ new Map();
661
+ /** Map size at which `consumedRefreshes` next sweeps its expired entries. */
662
+ consumedSweepAt = MIN_SWEEP_SIZE$1;
663
+ /** Opt-in validated-state cache (`validateCache`). */
664
+ validated;
574
665
  constructor(opts) {
575
666
  this.store = opts.store;
576
667
  this.method = opts.method ?? "token";
@@ -581,6 +672,10 @@ var AuthCredential = class {
581
672
  this.onLimit = opts.onLimit ?? "reject";
582
673
  this.trackLastSeen = opts.trackLastSeen ?? false;
583
674
  this.clock = opts.clock ?? require_clock.defaultClock;
675
+ if (opts.validateCache) {
676
+ if (!(opts.validateCache.ttlMs > 0)) throw new AuthError("INVALID_CONFIG", `validateCache.ttlMs must be > 0 (got ${opts.validateCache.ttlMs})`);
677
+ this.validated = new ValidateCache(opts.validateCache.ttlMs, opts.validateCache.max ?? 1e4);
678
+ }
584
679
  if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
585
680
  if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
586
681
  const graceMs = this.refreshConfig?.rotationGraceMs;
@@ -643,20 +738,42 @@ var AuthCredential = class {
643
738
  };
644
739
  }
645
740
  async validate(accessToken) {
646
- if (this.denylist && await this.denylist.has(accessToken)) return null;
647
- const state = await this.store.retrieve(accessToken);
648
- if (!state) return null;
649
- if (state.expiresAt <= this.clock.now()) return null;
650
- if (state.kind === "refresh") return null;
651
- if (this.trackLastSeen === "validate" && this.store.touch) await this.store.touch(accessToken, this.clock.now());
652
- return {
741
+ let state = this.validated?.get(accessToken, this.clock.now());
742
+ let fresh = false;
743
+ if (!state) {
744
+ fresh = true;
745
+ const epoch = this.validated?.epoch;
746
+ state = await this.retrieveUnlessDenied(accessToken);
747
+ if (!state) return null;
748
+ if (state.expiresAt <= this.clock.now()) return null;
749
+ if (state.kind === "refresh") return null;
750
+ if (this.validated && this.validated.epoch === epoch) this.validated.set(accessToken, state, this.clock.now());
751
+ } else if (state.expiresAt <= this.clock.now()) {
752
+ this.validated.delete(accessToken);
753
+ return null;
754
+ }
755
+ if (this.trackLastSeen === "validate" && this.store.touch) await this.store.touch(accessToken, this.clock.now(), fresh ? state : void 0);
756
+ const ctx = {
653
757
  ...require_payload.credentialPayloadOf(state),
654
758
  userId: state.userId,
655
759
  method: this.method,
656
- credentialId: fingerprint(accessToken),
760
+ credentialId: "",
657
761
  sessionId: this.sessionIdOf(state.sessionId, accessToken),
658
762
  expiresAt: state.expiresAt
659
763
  };
764
+ lazyCredentialId(ctx, accessToken);
765
+ return ctx;
766
+ }
767
+ /**
768
+ * The stored state of `accessToken` unless the orchestrator's denylist has
769
+ * it — both looked up concurrently (one round trip on remote stores).
770
+ */
771
+ async retrieveUnlessDenied(accessToken) {
772
+ const retrieving = this.store.retrieve(accessToken);
773
+ if (!this.denylist) return await retrieving;
774
+ retrieving.catch(() => {});
775
+ if (await this.denylist.has(accessToken)) return null;
776
+ return await retrieving;
660
777
  }
661
778
  async refresh(refreshToken, opts) {
662
779
  const now = this.clock.now();
@@ -707,6 +824,7 @@ var AuthCredential = class {
707
824
  async refreshAlways(oldState, refreshToken, now) {
708
825
  if (!this.store.listForUser) {
709
826
  await this.store.consume(refreshToken);
827
+ this.sweepConsumedRefreshes();
710
828
  this.consumedRefreshes.set(refreshToken, {
711
829
  userId: oldState.userId,
712
830
  iat: oldState.issuedAt,
@@ -763,10 +881,20 @@ var AuthCredential = class {
763
881
  };
764
882
  }
765
883
  async revoke(token) {
766
- await this.store.revoke(token);
884
+ this.validated?.delete(token);
885
+ try {
886
+ await this.store.revoke(token);
887
+ } finally {
888
+ this.validated?.delete(token);
889
+ }
767
890
  }
768
891
  async revokeAllForUser(userId) {
769
- return await this.store.revokeAllForUser(userId);
892
+ this.validated?.deleteUser(userId);
893
+ try {
894
+ return await this.store.revokeAllForUser(userId);
895
+ } finally {
896
+ this.validated?.deleteUser(userId);
897
+ }
770
898
  }
771
899
  async listForUser(userId) {
772
900
  if (!this.store.listForUser) return [];
@@ -850,7 +978,7 @@ var AuthCredential = class {
850
978
  async revokeSession(userId, sessionId) {
851
979
  if (!this.store.listForUser) return;
852
980
  const all = await this.store.listForUser(userId);
853
- await Promise.all(all.filter((entry) => this.sessionKeyOf(entry) === sessionId).map((entry) => this.store.revoke(entry.token)));
981
+ await Promise.all(all.filter((entry) => this.sessionKeyOf(entry) === sessionId).map((entry) => this.revoke(entry.token)));
854
982
  }
855
983
  /**
856
984
  * Revoke every session for the user EXCEPT `keepSessionId` ("log out
@@ -866,7 +994,7 @@ var AuthCredential = class {
866
994
  const key = this.sessionKeyOf(entry);
867
995
  if (key === keepSessionId) continue;
868
996
  revokedSessions.add(key);
869
- revokes.push(this.store.revoke(entry.token));
997
+ revokes.push(this.revoke(entry.token));
870
998
  }
871
999
  await Promise.all(revokes);
872
1000
  return revokedSessions.size;
@@ -893,7 +1021,7 @@ var AuthCredential = class {
893
1021
  });
894
1022
  const sorted = accessOnly.toSorted((a, b) => a.issuedAt - b.issuedAt);
895
1023
  const toEvict = accessOnly.length - this.maxConcurrent + 1;
896
- for (let i = 0; i < toEvict && i < sorted.length; i++) await this.store.revoke(sorted[i].token);
1024
+ for (let i = 0; i < toEvict && i < sorted.length; i++) await this.revoke(sorted[i].token);
897
1025
  }
898
1026
  async issueAccessFromRefresh(refreshState, now) {
899
1027
  const accessTtl = refreshState.metadata?.accessTtl ?? this.accessTtl;
@@ -956,6 +1084,13 @@ var AuthCredential = class {
956
1084
  userId: oldRefreshState.userId
957
1085
  };
958
1086
  }
1087
+ /** Amortized sweep of expired `consumedRefreshes` entries (each size doubling). */
1088
+ sweepConsumedRefreshes() {
1089
+ if (this.consumedRefreshes.size < this.consumedSweepAt) return;
1090
+ const now = this.clock.now();
1091
+ for (const [token, entry] of this.consumedRefreshes) if (entry.exp <= now) this.consumedRefreshes.delete(token);
1092
+ this.consumedSweepAt = Math.max(MIN_SWEEP_SIZE$1, this.consumedRefreshes.size * 2);
1093
+ }
959
1094
  lookupConsumedRefresh(token) {
960
1095
  const entry = this.consumedRefreshes.get(token);
961
1096
  if (!entry) return null;
@@ -992,7 +1127,7 @@ var AuthCredential = class {
992
1127
  ...reuse.rotatedAt !== void 0 && { rotatedAt: reuse.rotatedAt }
993
1128
  });
994
1129
  if ((this.refreshConfig?.reuseResponse ?? "session") === "session" && reuse.sessionId !== void 0 && this.store.listForUser) await this.revokeSession(reuse.userId, reuse.sessionId);
995
- else await this.store.revokeAllForUser(reuse.userId);
1130
+ else await this.revokeAllForUser(reuse.userId);
996
1131
  throw new AuthError("REFRESH_REUSE_DETECTED", void 0, {
997
1132
  userId: reuse.userId,
998
1133
  ...reuse.sessionId !== void 0 && { sessionId: reuse.sessionId },
@@ -1082,13 +1217,15 @@ function renderRateLimitMessage(template, rule, retryAfterMs) {
1082
1217
  //#region src/rate-limit/store.ts
1083
1218
  /**
1084
1219
  * In-memory `RateLimitStore` — the zero-config default (single process only).
1085
- * Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
1086
- * periodic compaction in long-lived processes (same pattern as
1087
- * `DenylistStoreMemory`).
1220
+ * Lazy-evicts expired counters on access, and sweeps expired counters itself
1221
+ * as the map grows (amortized, each time it doubles); `cleanup()` sweeps the
1222
+ * whole map on demand (same pattern as `DenylistStoreMemory`).
1088
1223
  */
1089
1224
  var RateLimitStoreMemory = class {
1090
1225
  entries = /* @__PURE__ */ new Map();
1091
1226
  clock;
1227
+ /** Map size at which `hit` next sweeps expired counters (amortized; doubles). */
1228
+ sweepAt = MIN_SWEEP_SIZE;
1092
1229
  constructor(opts) {
1093
1230
  this.clock = opts?.clock ?? require_clock.defaultClock;
1094
1231
  }
@@ -1096,6 +1233,10 @@ var RateLimitStoreMemory = class {
1096
1233
  const now = this.clock.now();
1097
1234
  const entry = this.entries.get(key);
1098
1235
  if (!entry || entry.expiresAt <= now) {
1236
+ if (!entry && this.entries.size >= this.sweepAt) {
1237
+ this.sweep(now);
1238
+ this.sweepAt = Math.max(MIN_SWEEP_SIZE, this.entries.size * 2);
1239
+ }
1099
1240
  this.entries.set(key, {
1100
1241
  count: 1,
1101
1242
  expiresAt: now + ttlMs
@@ -1113,7 +1254,9 @@ var RateLimitStoreMemory = class {
1113
1254
  this.entries.clear();
1114
1255
  }
1115
1256
  async cleanup() {
1116
- const now = this.clock.now();
1257
+ return this.sweep(this.clock.now());
1258
+ }
1259
+ sweep(now) {
1117
1260
  let removed = 0;
1118
1261
  for (const [key, entry] of this.entries) if (entry.expiresAt <= now) {
1119
1262
  this.entries.delete(key);
@@ -1122,6 +1265,8 @@ var RateLimitStoreMemory = class {
1122
1265
  return removed;
1123
1266
  }
1124
1267
  };
1268
+ /** Smallest map size an amortized expiry sweep runs at. */
1269
+ const MIN_SWEEP_SIZE = 1024;
1125
1270
  //#endregion
1126
1271
  //#region src/rate-limit/rate-limiter.ts
1127
1272
  /**