@aooth/auth 0.1.49 → 0.1.51

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.
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_clock = require("./clock-Bl-H3eqE.cjs");
3
- const require_payload = require("./payload-BJjvj8AH.cjs");
3
+ const require_payload = require("./payload-HPgyPiek.cjs");
4
4
  const require_dynamic_client_store = require("./dynamic-client-store-DykM9QOT.cjs");
5
5
  let node_crypto = require("node:crypto");
6
6
  //#region src/atscript-db/authz-stores.ts
@@ -324,6 +324,7 @@ function stateToRow(state, token, expiresAt, metadataField) {
324
324
  kind: state.kind,
325
325
  parentCredentialId: state.parentCredentialId,
326
326
  rotatedAt: state.rotatedAt,
327
+ successor: state.successor,
327
328
  sessionId: state.sessionId,
328
329
  lastSeenAt: state.lastSeenAt
329
330
  };
@@ -351,6 +352,7 @@ function rowToState(row, metadataField) {
351
352
  if (row.kind === "access" || row.kind === "refresh") state.kind = row.kind;
352
353
  if (row.parentCredentialId !== void 0) state.parentCredentialId = row.parentCredentialId;
353
354
  if (row.rotatedAt !== void 0) state.rotatedAt = row.rotatedAt;
355
+ if (row.successor !== void 0 && row.successor !== null) state.successor = row.successor;
354
356
  if (row.sessionId !== void 0) state.sessionId = row.sessionId;
355
357
  if (row.lastSeenAt !== void 0) state.lastSeenAt = row.lastSeenAt;
356
358
  return state;
@@ -1,5 +1,5 @@
1
- import { r as CredentialStore, s as CredentialState, t as Clock } from "./clock-DJ_eroHW.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-DbiSLOj0.cjs";
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-Cujxec8n.cjs";
3
3
 
4
4
  //#region src/atscript-db/authz-stores.d.ts
5
5
  /**
@@ -225,6 +225,7 @@ type AuthCredentialRow<TPayload extends object = object> = {
225
225
  kind?: string;
226
226
  parentCredentialId?: string;
227
227
  rotatedAt?: number;
228
+ successor?: CredentialSuccessorRef;
228
229
  sessionId?: string;
229
230
  lastSeenAt?: number;
230
231
  } & TPayload;
@@ -1,5 +1,5 @@
1
- import { r as CredentialStore, s as CredentialState, t as Clock } from "./clock-DJ_eroHW.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-DJgtMjPw.mjs";
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-DEGFMiTK.mjs";
3
3
 
4
4
  //#region src/atscript-db/authz-stores.d.ts
5
5
  /**
@@ -225,6 +225,7 @@ type AuthCredentialRow<TPayload extends object = object> = {
225
225
  kind?: string;
226
226
  parentCredentialId?: string;
227
227
  rotatedAt?: number;
228
+ successor?: CredentialSuccessorRef;
228
229
  sessionId?: string;
229
230
  lastSeenAt?: number;
230
231
  } & TPayload;
@@ -1,5 +1,5 @@
1
1
  import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
2
- import { t as credentialPayloadOf } from "./payload-D-DzH5-J.mjs";
2
+ import { t as credentialPayloadOf } from "./payload-DP5zRiXT.mjs";
3
3
  import { r as AuthCodeStore, s as PendingAuthorizationStore, t as DynamicClientStore } from "./dynamic-client-store-B1JwFHdP.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  //#region src/atscript-db/authz-stores.ts
@@ -323,6 +323,7 @@ function stateToRow(state, token, expiresAt, metadataField) {
323
323
  kind: state.kind,
324
324
  parentCredentialId: state.parentCredentialId,
325
325
  rotatedAt: state.rotatedAt,
326
+ successor: state.successor,
326
327
  sessionId: state.sessionId,
327
328
  lastSeenAt: state.lastSeenAt
328
329
  };
@@ -350,6 +351,7 @@ function rowToState(row, metadataField) {
350
351
  if (row.kind === "access" || row.kind === "refresh") state.kind = row.kind;
351
352
  if (row.parentCredentialId !== void 0) state.parentCredentialId = row.parentCredentialId;
352
353
  if (row.rotatedAt !== void 0) state.rotatedAt = row.rotatedAt;
354
+ if (row.successor !== void 0 && row.successor !== null) state.successor = row.successor;
353
355
  if (row.sessionId !== void 0) state.sessionId = row.sessionId;
354
356
  if (row.lastSeenAt !== void 0) state.lastSeenAt = row.lastSeenAt;
355
357
  return state;
@@ -1,4 +1,4 @@
1
- import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.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-C0O0FCCN.cjs";
2
2
 
3
3
  //#region src/credential/auth-credential.d.ts
4
4
  interface AuthCredentialOptions<TPayload extends object = object> {
@@ -84,16 +84,21 @@ type IssueOptions<TPayload extends object = object> = TPayload & {
84
84
  * - `false` — mint NO refresh token even when the instance config exists
85
85
  * (e.g. the authz token endpoint suppressing the paired refresh for a
86
86
  * policy that didn't opt in — no orphaned refresh row).
87
- * - `{ ttl? }` — mint a refresh token for this credential; `ttl` (ms)
88
- * overrides the instance `refresh.ttl`. Works WITHOUT an instance config
89
- * (then `ttl` is required). Per-mint families ALWAYS redeem with
87
+ * - `{ ttl?, graceMs? }` — mint a refresh token for this credential; `ttl`
88
+ * (ms) overrides the instance `refresh.ttl`. Works WITHOUT an instance
89
+ * config (then `ttl` is required). Per-mint families ALWAYS redeem with
90
90
  * fixed-ceiling (`'always'`) rotation semantics — the family is stamped
91
91
  * `metadata.refreshRotation: "always"` at mint, which `refresh()` honors
92
92
  * over the instance rotation, so rotation never extends the family's
93
93
  * lifetime even on an instance whose sessions rotate `'sliding'`.
94
+ * `graceMs` (ms, ≥ 0) fixes the family's rotation grace window the same
95
+ * mint-time-authority way (stamped `metadata.rotationGraceMs`, honored
96
+ * over the instance `rotationGraceMs`); `0` is strict single-use. Omit to
97
+ * inherit the instance window (or its 30 s default).
94
98
  */
95
99
  refresh?: false | {
96
100
  ttl?: number;
101
+ graceMs?: number;
97
102
  };
98
103
  };
99
104
  /** Options for {@link AuthCredential.refresh}. */
@@ -168,11 +173,18 @@ declare class AuthCredential<TPayload extends object = object> {
168
173
  private refreshAlways;
169
174
  /**
170
175
  * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
171
- * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
172
- * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
173
- * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
174
- * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
175
- * (`sliding`) expiry for the new refresh token.
176
+ * stores. The first rotation keeps the old refresh valid and stamps
177
+ * `rotatedAt` + the {@link CredentialState.successor} pair on it; within the
178
+ * grace window a re-presentation **re-delivers that same pair verbatim** —
179
+ * idempotent, minting nothing, with no liveness side effects (no expiry
180
+ * slide, no `lastSeenAt`) — so a captured stale token teaches an attacker
181
+ * nothing beyond capturing the original rotation response, and a multi-tab
182
+ * race converges on one successor. Beyond grace — or once the successor has
183
+ * itself been rotated or revoked (superseded) — the re-presentation is
184
+ * treated as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs
185
+ * sliding (`sliding`) expiry for the new refresh token; the family's
186
+ * mint-time `metadata.rotationGraceMs` (when stamped) wins over the instance
187
+ * window, and a window of `0` is strict single-use.
176
188
  */
177
189
  private rotateWithGrace;
178
190
  revoke(token: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.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-C0O0FCCN.mjs";
2
2
 
3
3
  //#region src/credential/auth-credential.d.ts
4
4
  interface AuthCredentialOptions<TPayload extends object = object> {
@@ -84,16 +84,21 @@ type IssueOptions<TPayload extends object = object> = TPayload & {
84
84
  * - `false` — mint NO refresh token even when the instance config exists
85
85
  * (e.g. the authz token endpoint suppressing the paired refresh for a
86
86
  * policy that didn't opt in — no orphaned refresh row).
87
- * - `{ ttl? }` — mint a refresh token for this credential; `ttl` (ms)
88
- * overrides the instance `refresh.ttl`. Works WITHOUT an instance config
89
- * (then `ttl` is required). Per-mint families ALWAYS redeem with
87
+ * - `{ ttl?, graceMs? }` — mint a refresh token for this credential; `ttl`
88
+ * (ms) overrides the instance `refresh.ttl`. Works WITHOUT an instance
89
+ * config (then `ttl` is required). Per-mint families ALWAYS redeem with
90
90
  * fixed-ceiling (`'always'`) rotation semantics — the family is stamped
91
91
  * `metadata.refreshRotation: "always"` at mint, which `refresh()` honors
92
92
  * over the instance rotation, so rotation never extends the family's
93
93
  * lifetime even on an instance whose sessions rotate `'sliding'`.
94
+ * `graceMs` (ms, ≥ 0) fixes the family's rotation grace window the same
95
+ * mint-time-authority way (stamped `metadata.rotationGraceMs`, honored
96
+ * over the instance `rotationGraceMs`); `0` is strict single-use. Omit to
97
+ * inherit the instance window (or its 30 s default).
94
98
  */
95
99
  refresh?: false | {
96
100
  ttl?: number;
101
+ graceMs?: number;
97
102
  };
98
103
  };
99
104
  /** Options for {@link AuthCredential.refresh}. */
@@ -168,11 +173,18 @@ declare class AuthCredential<TPayload extends object = object> {
168
173
  private refreshAlways;
169
174
  /**
170
175
  * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
171
- * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
172
- * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
173
- * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
174
- * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
175
- * (`sliding`) expiry for the new refresh token.
176
+ * stores. The first rotation keeps the old refresh valid and stamps
177
+ * `rotatedAt` + the {@link CredentialState.successor} pair on it; within the
178
+ * grace window a re-presentation **re-delivers that same pair verbatim** —
179
+ * idempotent, minting nothing, with no liveness side effects (no expiry
180
+ * slide, no `lastSeenAt`) — so a captured stale token teaches an attacker
181
+ * nothing beyond capturing the original rotation response, and a multi-tab
182
+ * race converges on one successor. Beyond grace — or once the successor has
183
+ * itself been rotated or revoked (superseded) — the re-presentation is
184
+ * treated as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs
185
+ * sliding (`sliding`) expiry for the new refresh token; the family's
186
+ * mint-time `metadata.rotationGraceMs` (when stamped) wins over the instance
187
+ * window, and a window of `0` is strict single-use.
176
188
  */
177
189
  private rotateWithGrace;
178
190
  revoke(token: string): Promise<void>;
package/dist/authz.cjs CHANGED
@@ -28,7 +28,10 @@ function tokenPolicyToIssueOptions(policy, clientId) {
28
28
  ...policy.payload,
29
29
  ...policy.kind !== void 0 && { kind: policy.kind },
30
30
  ...policy.ttl !== void 0 && { ttl: policy.ttl },
31
- refresh: withRefresh ? { ttl: policy.refresh?.ttl ?? 2592e6 } : false,
31
+ refresh: withRefresh ? {
32
+ ttl: policy.refresh?.ttl ?? 2592e6,
33
+ ...policy.refresh?.graceMs !== void 0 && { graceMs: policy.refresh.graceMs }
34
+ } : false,
32
35
  ...withRefresh && { metadata: { authzClientId: clientId } }
33
36
  };
34
37
  }
package/dist/authz.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-DJ_eroHW.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-DbiSLOj0.cjs";
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-Cujxec8n.cjs";
3
3
  import { JWK } from "jose";
4
4
 
5
5
  //#region src/authz/authz-errors.d.ts
package/dist/authz.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-DJ_eroHW.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-DJgtMjPw.mjs";
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-DEGFMiTK.mjs";
3
3
  import { JWK } from "jose";
4
4
 
5
5
  //#region src/authz/authz-errors.d.ts
package/dist/authz.mjs CHANGED
@@ -27,7 +27,10 @@ function tokenPolicyToIssueOptions(policy, clientId) {
27
27
  ...policy.payload,
28
28
  ...policy.kind !== void 0 && { kind: policy.kind },
29
29
  ...policy.ttl !== void 0 && { ttl: policy.ttl },
30
- refresh: withRefresh ? { ttl: policy.refresh?.ttl ?? 2592e6 } : false,
30
+ refresh: withRefresh ? {
31
+ ttl: policy.refresh?.ttl ?? 2592e6,
32
+ ...policy.refresh?.graceMs !== void 0 && { graceMs: policy.refresh.graceMs }
33
+ } : false,
31
34
  ...withRefresh && { metadata: { authzClientId: clientId } }
32
35
  };
33
36
  }
@@ -78,6 +78,29 @@ interface CredentialMetadata {
78
78
  * sessions are unaffected).
79
79
  */
80
80
  refreshRotation?: "none" | "always" | "sliding";
81
+ /**
82
+ * Per-family rotation grace window in ms — stamped by `issue()` when a
83
+ * refresh token is minted with a per-mint `graceMs` (e.g. an authz grant's
84
+ * `TokenPolicy.refresh.graceMs`), and honored by `refresh()` OVER the
85
+ * instance {@link RefreshConfig.rotationGraceMs}. Same mint-time-authority
86
+ * mechanism as {@link accessTtl} / {@link refreshRotation}. Absent ⇒ the
87
+ * instance window (or its 30 s default) applies.
88
+ */
89
+ rotationGraceMs?: number;
90
+ }
91
+ /**
92
+ * The successor pair persisted on a rotated refresh row (see
93
+ * {@link CredentialState.successor}) so a within-grace re-presentation of the
94
+ * consumed token re-delivers the SAME tokens the original rotation produced.
95
+ * Raw token material in a store column matches the store contract's existing
96
+ * posture: stateful stores already key rows on the raw token, and
97
+ * {@link CredentialState.parentCredentialId} already carries one.
98
+ */
99
+ interface CredentialSuccessorRef {
100
+ accessToken: string;
101
+ accessExpiresAt: number;
102
+ refreshToken: string;
103
+ refreshExpiresAt: number;
81
104
  }
82
105
  /**
83
106
  * Persisted state of a credential — the fixed **envelope**. A consumer's
@@ -99,8 +122,17 @@ interface CredentialState {
99
122
  kind?: "access" | "refresh";
100
123
  /** For rotated refresh tokens — id of the parent credential this one replaced */
101
124
  parentCredentialId?: string;
102
- /** Timestamp of rotation; used by sliding rotation grace period */
125
+ /** Timestamp of rotation; used by the sliding/always rotation grace window */
103
126
  rotatedAt?: number;
127
+ /**
128
+ * Set alongside {@link rotatedAt} on the OLD refresh row when it is rotated:
129
+ * the successor pair the rotation produced, re-delivered VERBATIM on a
130
+ * within-grace re-presentation of this token (idempotent re-delivery — a
131
+ * grace hit mints nothing, so it grants an attacker no capability beyond
132
+ * capturing the original rotation response). Dies with the row; only the
133
+ * family's latest rotation carries a live grace record.
134
+ */
135
+ successor?: CredentialSuccessorRef;
104
136
  /**
105
137
  * Stable id of the session (token family) this credential belongs to. Minted
106
138
  * once by `issue()` and copied forward onto every rotation (access + refresh),
@@ -200,11 +232,17 @@ interface RefreshConfig {
200
232
  * activity.
201
233
  *
202
234
  * Both `'sliding'` and `'always'` are **grace-tolerant** on stateful stores:
203
- * a benign concurrent refresh (multi-tab / parallel requests presenting the
204
- * just-rotated token) within {@link rotationGraceMs} re-issues a fresh pair
205
- * instead of being mistaken for token theft. Because the grace window is
206
- * tracked in the store (via {@link CredentialState.rotatedAt}), it is correct
207
- * across multiple app instances.
235
+ * a benign re-presentation of the just-rotated token (multi-tab race, a
236
+ * rotation response lost to a rolling deploy) within {@link rotationGraceMs}
237
+ * **re-delivers the SAME successor pair the rotation produced** — idempotent,
238
+ * minting nothing — instead of being mistaken for token theft. A grace hit
239
+ * has no liveness side effects: it never slides the refresh expiry and never
240
+ * stamps `lastSeenAt`, so a captured stale token cannot be used as a
241
+ * keep-alive. Because the grace window is tracked in the store (via
242
+ * {@link CredentialState.rotatedAt} + {@link CredentialState.successor}), it
243
+ * is correct across multiple app instances — and the moment the successor is
244
+ * itself rotated (or revoked), the old token is dead regardless of the
245
+ * window (superseded ⇒ the strict theft path).
208
246
  *
209
247
  * Note: the grace window needs a stateful store (one with `listForUser`).
210
248
  * Stateless stores (JWT, Encapsulated) cannot mutate an issued token in place;
@@ -213,7 +251,14 @@ interface RefreshConfig {
213
251
  * with a process-local reuse signal (no cross-instance grace).
214
252
  */
215
253
  rotation?: "none" | "always" | "sliding";
216
- /** Grace period for sliding/always rotation, in milliseconds. Defaults to 30_000. */
254
+ /**
255
+ * Grace window for sliding/always rotation, in milliseconds. Defaults to
256
+ * 30_000. Size it to survive a rolling-deploy drain + client retry (30–60 s);
257
+ * it stays meaningless for offline token theft. An explicit `0` is strict
258
+ * mode: every re-presentation of a rotated token is treated as theft.
259
+ * Overridden per family by a per-mint `graceMs`
260
+ * ({@link CredentialMetadata.rotationGraceMs}).
261
+ */
217
262
  rotationGraceMs?: number;
218
263
  /**
219
264
  * Revocation scope when refresh-token reuse is detected (replay after grace,
@@ -228,6 +273,16 @@ interface RefreshConfig {
228
273
  reuseResponse?: "session" | "user";
229
274
  /** Theft detection hook — invoked when a previously-rotated refresh is reused. */
230
275
  onRotationReuse?: (state: CredentialState) => void;
276
+ /**
277
+ * Audit hook for within-grace re-presentations — invoked with the OLD
278
+ * (rotated) refresh credential's envelope just before its successor pair is
279
+ * re-delivered. The benign twin of {@link onRotationReuse}: a trickle of
280
+ * grace hits correlated with deploys is expected (a rotation response died
281
+ * with a drained task and the client retried); a spike is someone replaying
282
+ * tokens. Wire it to the same sink as `onRotationReuse` (e.g. a
283
+ * `refresh-grace-hit` audit event) to tell the two apart.
284
+ */
285
+ onRotationGraceHit?: (state: CredentialState) => void;
231
286
  }
232
287
  //#endregion
233
288
  //#region src/stores/store.d.ts
@@ -309,4 +364,4 @@ interface Clock {
309
364
  }
310
365
  declare const defaultClock: Clock;
311
366
  //#endregion
312
- export { AuthContext as a, EnrichedSession as c, RefreshResult as d, SessionEnricher as f, DenylistStore as i, IssueResult as l, defaultClock as n, CredentialMetadata as o, SessionInfo as p, CredentialStore as r, CredentialState as s, Clock as t, RefreshConfig as u };
367
+ export { AuthContext as a, CredentialSuccessorRef as c, RefreshConfig as d, RefreshResult as f, DenylistStore as i, EnrichedSession as l, SessionInfo as m, defaultClock as n, CredentialMetadata as o, SessionEnricher as p, CredentialStore as r, CredentialState as s, Clock as t, IssueResult as u };
@@ -78,6 +78,29 @@ interface CredentialMetadata {
78
78
  * sessions are unaffected).
79
79
  */
80
80
  refreshRotation?: "none" | "always" | "sliding";
81
+ /**
82
+ * Per-family rotation grace window in ms — stamped by `issue()` when a
83
+ * refresh token is minted with a per-mint `graceMs` (e.g. an authz grant's
84
+ * `TokenPolicy.refresh.graceMs`), and honored by `refresh()` OVER the
85
+ * instance {@link RefreshConfig.rotationGraceMs}. Same mint-time-authority
86
+ * mechanism as {@link accessTtl} / {@link refreshRotation}. Absent ⇒ the
87
+ * instance window (or its 30 s default) applies.
88
+ */
89
+ rotationGraceMs?: number;
90
+ }
91
+ /**
92
+ * The successor pair persisted on a rotated refresh row (see
93
+ * {@link CredentialState.successor}) so a within-grace re-presentation of the
94
+ * consumed token re-delivers the SAME tokens the original rotation produced.
95
+ * Raw token material in a store column matches the store contract's existing
96
+ * posture: stateful stores already key rows on the raw token, and
97
+ * {@link CredentialState.parentCredentialId} already carries one.
98
+ */
99
+ interface CredentialSuccessorRef {
100
+ accessToken: string;
101
+ accessExpiresAt: number;
102
+ refreshToken: string;
103
+ refreshExpiresAt: number;
81
104
  }
82
105
  /**
83
106
  * Persisted state of a credential — the fixed **envelope**. A consumer's
@@ -99,8 +122,17 @@ interface CredentialState {
99
122
  kind?: "access" | "refresh";
100
123
  /** For rotated refresh tokens — id of the parent credential this one replaced */
101
124
  parentCredentialId?: string;
102
- /** Timestamp of rotation; used by sliding rotation grace period */
125
+ /** Timestamp of rotation; used by the sliding/always rotation grace window */
103
126
  rotatedAt?: number;
127
+ /**
128
+ * Set alongside {@link rotatedAt} on the OLD refresh row when it is rotated:
129
+ * the successor pair the rotation produced, re-delivered VERBATIM on a
130
+ * within-grace re-presentation of this token (idempotent re-delivery — a
131
+ * grace hit mints nothing, so it grants an attacker no capability beyond
132
+ * capturing the original rotation response). Dies with the row; only the
133
+ * family's latest rotation carries a live grace record.
134
+ */
135
+ successor?: CredentialSuccessorRef;
104
136
  /**
105
137
  * Stable id of the session (token family) this credential belongs to. Minted
106
138
  * once by `issue()` and copied forward onto every rotation (access + refresh),
@@ -200,11 +232,17 @@ interface RefreshConfig {
200
232
  * activity.
201
233
  *
202
234
  * Both `'sliding'` and `'always'` are **grace-tolerant** on stateful stores:
203
- * a benign concurrent refresh (multi-tab / parallel requests presenting the
204
- * just-rotated token) within {@link rotationGraceMs} re-issues a fresh pair
205
- * instead of being mistaken for token theft. Because the grace window is
206
- * tracked in the store (via {@link CredentialState.rotatedAt}), it is correct
207
- * across multiple app instances.
235
+ * a benign re-presentation of the just-rotated token (multi-tab race, a
236
+ * rotation response lost to a rolling deploy) within {@link rotationGraceMs}
237
+ * **re-delivers the SAME successor pair the rotation produced** — idempotent,
238
+ * minting nothing — instead of being mistaken for token theft. A grace hit
239
+ * has no liveness side effects: it never slides the refresh expiry and never
240
+ * stamps `lastSeenAt`, so a captured stale token cannot be used as a
241
+ * keep-alive. Because the grace window is tracked in the store (via
242
+ * {@link CredentialState.rotatedAt} + {@link CredentialState.successor}), it
243
+ * is correct across multiple app instances — and the moment the successor is
244
+ * itself rotated (or revoked), the old token is dead regardless of the
245
+ * window (superseded ⇒ the strict theft path).
208
246
  *
209
247
  * Note: the grace window needs a stateful store (one with `listForUser`).
210
248
  * Stateless stores (JWT, Encapsulated) cannot mutate an issued token in place;
@@ -213,7 +251,14 @@ interface RefreshConfig {
213
251
  * with a process-local reuse signal (no cross-instance grace).
214
252
  */
215
253
  rotation?: "none" | "always" | "sliding";
216
- /** Grace period for sliding/always rotation, in milliseconds. Defaults to 30_000. */
254
+ /**
255
+ * Grace window for sliding/always rotation, in milliseconds. Defaults to
256
+ * 30_000. Size it to survive a rolling-deploy drain + client retry (30–60 s);
257
+ * it stays meaningless for offline token theft. An explicit `0` is strict
258
+ * mode: every re-presentation of a rotated token is treated as theft.
259
+ * Overridden per family by a per-mint `graceMs`
260
+ * ({@link CredentialMetadata.rotationGraceMs}).
261
+ */
217
262
  rotationGraceMs?: number;
218
263
  /**
219
264
  * Revocation scope when refresh-token reuse is detected (replay after grace,
@@ -228,6 +273,16 @@ interface RefreshConfig {
228
273
  reuseResponse?: "session" | "user";
229
274
  /** Theft detection hook — invoked when a previously-rotated refresh is reused. */
230
275
  onRotationReuse?: (state: CredentialState) => void;
276
+ /**
277
+ * Audit hook for within-grace re-presentations — invoked with the OLD
278
+ * (rotated) refresh credential's envelope just before its successor pair is
279
+ * re-delivered. The benign twin of {@link onRotationReuse}: a trickle of
280
+ * grace hits correlated with deploys is expected (a rotation response died
281
+ * with a drained task and the client retried); a spike is someone replaying
282
+ * tokens. Wire it to the same sink as `onRotationReuse` (e.g. a
283
+ * `refresh-grace-hit` audit event) to tell the two apart.
284
+ */
285
+ onRotationGraceHit?: (state: CredentialState) => void;
231
286
  }
232
287
  //#endregion
233
288
  //#region src/stores/store.d.ts
@@ -309,4 +364,4 @@ interface Clock {
309
364
  }
310
365
  declare const defaultClock: Clock;
311
366
  //#endregion
312
- export { AuthContext as a, EnrichedSession as c, RefreshResult as d, SessionEnricher as f, DenylistStore as i, IssueResult as l, defaultClock as n, CredentialMetadata as o, SessionInfo as p, CredentialStore as r, CredentialState as s, Clock as t, RefreshConfig as u };
367
+ export { AuthContext as a, CredentialSuccessorRef as c, RefreshConfig as d, RefreshResult as f, DenylistStore as i, EnrichedSession as l, SessionInfo as m, defaultClock as n, CredentialMetadata as o, SessionEnricher as p, CredentialStore as r, CredentialState as s, Clock as t, IssueResult as u };
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-DJ_eroHW.mjs";
2
- import { r as IssueOptions } from "./auth-credential-C3qebKuy.mjs";
1
+ import { t as Clock } from "./clock-C0O0FCCN.cjs";
2
+ import { r as IssueOptions } from "./auth-credential-C5zD8b0J.cjs";
3
3
 
4
4
  //#region src/authz/token-policy.d.ts
5
5
  /**
@@ -37,9 +37,19 @@ interface TokenPolicy {
37
37
  * dynamic clients) — the family is stamped with `metadata.authzClientId` and
38
38
  * a refresh token redeems only for that client. A clientless (Tier-1
39
39
  * loopback) grant has nothing to bind to, so the flag is IGNORED there.
40
+ *
41
+ * `graceMs` (ms, ≥ 0) fixes the family's rotation grace window — the window
42
+ * within which re-presenting a just-rotated refresh token idempotently
43
+ * re-delivers the same successor pair instead of tripping theft detection
44
+ * (a connector refreshing against a redeploying server survives the lost
45
+ * response instead of bricking until re-consent). Stamped at mint
46
+ * (`metadata.rotationGraceMs`) and honored over the credential instance's
47
+ * `rotationGraceMs`; `0` is strict single-use. Absent ⇒ the instance window
48
+ * (or its 30 s default).
40
49
  */
41
50
  refresh?: {
42
51
  ttl?: number;
52
+ graceMs?: number;
43
53
  };
44
54
  }
45
55
  /**
@@ -1,5 +1,5 @@
1
- import { t as Clock } from "./clock-DJ_eroHW.cjs";
2
- import { r as IssueOptions } from "./auth-credential-CEijxoGU.cjs";
1
+ import { t as Clock } from "./clock-C0O0FCCN.mjs";
2
+ import { r as IssueOptions } from "./auth-credential-C9xy4X_k.mjs";
3
3
 
4
4
  //#region src/authz/token-policy.d.ts
5
5
  /**
@@ -37,9 +37,19 @@ interface TokenPolicy {
37
37
  * dynamic clients) — the family is stamped with `metadata.authzClientId` and
38
38
  * a refresh token redeems only for that client. A clientless (Tier-1
39
39
  * loopback) grant has nothing to bind to, so the flag is IGNORED there.
40
+ *
41
+ * `graceMs` (ms, ≥ 0) fixes the family's rotation grace window — the window
42
+ * within which re-presenting a just-rotated refresh token idempotently
43
+ * re-delivers the same successor pair instead of tripping theft detection
44
+ * (a connector refreshing against a redeploying server survives the lost
45
+ * response instead of bricking until re-consent). Stamped at mint
46
+ * (`metadata.rotationGraceMs`) and honored over the credential instance's
47
+ * `rotationGraceMs`; `0` is strict single-use. Absent ⇒ the instance window
48
+ * (or its 30 s default).
40
49
  */
41
50
  refresh?: {
42
51
  ttl?: number;
52
+ graceMs?: number;
43
53
  };
44
54
  }
45
55
  /**
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_clock = require("./clock-Bl-H3eqE.cjs");
3
- const require_payload = require("./payload-BJjvj8AH.cjs");
3
+ const require_payload = require("./payload-HPgyPiek.cjs");
4
4
  const require_opaque_token = require("./opaque-token-CaZgD2i6.cjs");
5
5
  let node_crypto = require("node:crypto");
6
6
  let jose = require("jose");
@@ -583,6 +583,8 @@ var AuthCredential = class {
583
583
  this.clock = opts.clock ?? require_clock.defaultClock;
584
584
  if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
585
585
  if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
586
+ const graceMs = this.refreshConfig?.rotationGraceMs;
587
+ if (graceMs !== void 0 && graceMs < 0) throw new AuthError("INVALID_CONFIG", `refresh.rotationGraceMs must be >= 0 (got ${graceMs})`);
586
588
  }
587
589
  async issue(userId, options) {
588
590
  if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
@@ -594,6 +596,7 @@ var AuthCredential = class {
594
596
  refreshTtl = refresh.ttl ?? this.refreshConfig?.ttl;
595
597
  if (refreshTtl === void 0) throw new AuthError("INVALID_CONFIG", "issue: per-mint refresh needs a ttl when no instance refresh config exists");
596
598
  if (refreshTtl <= 0) throw new AuthError("INVALID_CONFIG", `issue: refresh.ttl must be > 0 (got ${refreshTtl})`);
599
+ if (refresh.graceMs !== void 0 && refresh.graceMs < 0) throw new AuthError("INVALID_CONFIG", `issue: refresh.graceMs must be >= 0 (got ${refresh.graceMs})`);
597
600
  }
598
601
  const perMintRefresh = refresh !== void 0 && refresh !== false;
599
602
  const stampAccessTtl = refreshTtl !== void 0 && ttl !== void 0;
@@ -601,7 +604,8 @@ var AuthCredential = class {
601
604
  ...metadata,
602
605
  ...kind !== void 0 && { credentialKind: kind },
603
606
  ...stampAccessTtl && { accessTtl: ttl },
604
- ...perMintRefresh && { refreshRotation: "always" }
607
+ ...perMintRefresh && { refreshRotation: "always" },
608
+ ...perMintRefresh && refresh.graceMs !== void 0 && { rotationGraceMs: refresh.graceMs }
605
609
  } : metadata;
606
610
  if (ttl !== void 0 && expiresAt !== void 0) throw new AuthError("INVALID_CONFIG", "issue: pass either `ttl` or `expiresAt`, not both");
607
611
  if (ttl !== void 0 && ttl <= 0) throw new AuthError("INVALID_CONFIG", `issue: ttl must be > 0 (got ${ttl})`);
@@ -715,23 +719,48 @@ var AuthCredential = class {
715
719
  }
716
720
  /**
717
721
  * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
718
- * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
719
- * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
720
- * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
721
- * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
722
- * (`sliding`) expiry for the new refresh token.
722
+ * stores. The first rotation keeps the old refresh valid and stamps
723
+ * `rotatedAt` + the {@link CredentialState.successor} pair on it; within the
724
+ * grace window a re-presentation **re-delivers that same pair verbatim** —
725
+ * idempotent, minting nothing, with no liveness side effects (no expiry
726
+ * slide, no `lastSeenAt`) — so a captured stale token teaches an attacker
727
+ * nothing beyond capturing the original rotation response, and a multi-tab
728
+ * race converges on one successor. Beyond grace — or once the successor has
729
+ * itself been rotated or revoked (superseded) — the re-presentation is
730
+ * treated as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs
731
+ * sliding (`sliding`) expiry for the new refresh token; the family's
732
+ * mint-time `metadata.rotationGraceMs` (when stamped) wins over the instance
733
+ * window, and a window of `0` is strict single-use.
723
734
  */
724
735
  async rotateWithGrace(oldState, refreshToken, now, preserveExpiry) {
725
- const graceMs = this.refreshConfig?.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
736
+ const graceMs = oldState.metadata?.rotationGraceMs ?? this.refreshConfig?.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
726
737
  if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now, preserveExpiry);
727
- if (now - oldState.rotatedAt > graceMs) await this.respondToRefreshReuse({
738
+ const reuse = {
728
739
  userId: oldState.userId,
729
740
  sessionId: oldState.sessionId,
730
741
  issuedAt: oldState.issuedAt,
731
742
  expiresAt: oldState.expiresAt,
732
743
  rotatedAt: oldState.rotatedAt
744
+ };
745
+ if (graceMs <= 0 || now - oldState.rotatedAt > graceMs) return await this.respondToRefreshReuse(reuse);
746
+ const successor = oldState.successor;
747
+ const successorState = successor === void 0 ? null : await this.store.retrieve(successor.refreshToken);
748
+ if (successor === void 0 || successorState === null || typeof successorState.rotatedAt === "number") return await this.respondToRefreshReuse(reuse);
749
+ this.refreshConfig?.onRotationGraceHit?.({
750
+ userId: oldState.userId,
751
+ issuedAt: oldState.issuedAt,
752
+ expiresAt: oldState.expiresAt,
753
+ kind: "refresh",
754
+ rotatedAt: oldState.rotatedAt,
755
+ ...oldState.sessionId !== void 0 && { sessionId: oldState.sessionId }
733
756
  });
734
- return await this.issueRotatedPair(oldState, refreshToken, false, now, preserveExpiry);
757
+ return {
758
+ accessToken: successor.accessToken,
759
+ accessExpiresAt: successor.accessExpiresAt,
760
+ refreshToken: successor.refreshToken,
761
+ refreshExpiresAt: successor.refreshExpiresAt,
762
+ userId: oldState.userId
763
+ };
735
764
  }
736
765
  async revoke(token) {
737
766
  await this.store.revoke(token);
@@ -909,7 +938,13 @@ var AuthCredential = class {
909
938
  if (rotateOld) {
910
939
  const rotatedState = {
911
940
  ...oldRefreshState,
912
- rotatedAt: now
941
+ rotatedAt: now,
942
+ successor: {
943
+ accessToken: access.token,
944
+ accessExpiresAt: access.expiresAt,
945
+ refreshToken: newRefreshToken,
946
+ refreshExpiresAt
947
+ }
913
948
  };
914
949
  await this.store.update(oldRefreshToken, rotatedState);
915
950
  }
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, n as defaultClock, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.cjs";
2
- import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-CEijxoGU.cjs";
3
- import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-BYnpA7VK.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-C0O0FCCN.cjs";
2
+ import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-C5zD8b0J.cjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-C6DU6VZk.cjs";
4
4
  import { CryptoKey } from "jose";
5
5
 
6
6
  //#region src/errors.d.ts
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as AuthContext, c as EnrichedSession, d as RefreshResult, f as SessionEnricher, i as DenylistStore, l as IssueResult, n as defaultClock, o as CredentialMetadata, p as SessionInfo, r as CredentialStore, s as CredentialState, t as Clock, u as RefreshConfig } from "./clock-DJ_eroHW.mjs";
2
- import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-C3qebKuy.mjs";
3
- import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-pKh0ZaGG.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-C0O0FCCN.mjs";
2
+ import { i as RefreshCallOptions, n as AuthCredentialOptions, r as IssueOptions, t as AuthCredential } from "./auth-credential-C9xy4X_k.mjs";
3
+ import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-y7K7z1EF.mjs";
4
4
  import { CryptoKey } from "jose";
5
5
 
6
6
  //#region src/errors.d.ts
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as defaultClock } from "./clock-Bdsep_1j.mjs";
2
- import { t as credentialPayloadOf } from "./payload-D-DzH5-J.mjs";
2
+ import { t as credentialPayloadOf } from "./payload-DP5zRiXT.mjs";
3
3
  import { t as generateOpaqueToken } from "./opaque-token-BaMOrC1A.mjs";
4
4
  import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID, scryptSync } from "node:crypto";
5
5
  import { SignJWT, jwtVerify } from "jose";
@@ -582,6 +582,8 @@ var AuthCredential = class {
582
582
  this.clock = opts.clock ?? defaultClock;
583
583
  if (this.accessTtl <= 0) throw new AuthError("INVALID_CONFIG", `accessTtl must be > 0 (got ${this.accessTtl})`);
584
584
  if (this.refreshConfig && this.refreshConfig.ttl <= 0) throw new AuthError("INVALID_CONFIG", `refresh.ttl must be > 0 (got ${this.refreshConfig.ttl})`);
585
+ const graceMs = this.refreshConfig?.rotationGraceMs;
586
+ if (graceMs !== void 0 && graceMs < 0) throw new AuthError("INVALID_CONFIG", `refresh.rotationGraceMs must be >= 0 (got ${graceMs})`);
585
587
  }
586
588
  async issue(userId, options) {
587
589
  if (this.maxConcurrent !== void 0 && this.store.listForUser) await this.enforceConcurrencyLimit(userId);
@@ -593,6 +595,7 @@ var AuthCredential = class {
593
595
  refreshTtl = refresh.ttl ?? this.refreshConfig?.ttl;
594
596
  if (refreshTtl === void 0) throw new AuthError("INVALID_CONFIG", "issue: per-mint refresh needs a ttl when no instance refresh config exists");
595
597
  if (refreshTtl <= 0) throw new AuthError("INVALID_CONFIG", `issue: refresh.ttl must be > 0 (got ${refreshTtl})`);
598
+ if (refresh.graceMs !== void 0 && refresh.graceMs < 0) throw new AuthError("INVALID_CONFIG", `issue: refresh.graceMs must be >= 0 (got ${refresh.graceMs})`);
596
599
  }
597
600
  const perMintRefresh = refresh !== void 0 && refresh !== false;
598
601
  const stampAccessTtl = refreshTtl !== void 0 && ttl !== void 0;
@@ -600,7 +603,8 @@ var AuthCredential = class {
600
603
  ...metadata,
601
604
  ...kind !== void 0 && { credentialKind: kind },
602
605
  ...stampAccessTtl && { accessTtl: ttl },
603
- ...perMintRefresh && { refreshRotation: "always" }
606
+ ...perMintRefresh && { refreshRotation: "always" },
607
+ ...perMintRefresh && refresh.graceMs !== void 0 && { rotationGraceMs: refresh.graceMs }
604
608
  } : metadata;
605
609
  if (ttl !== void 0 && expiresAt !== void 0) throw new AuthError("INVALID_CONFIG", "issue: pass either `ttl` or `expiresAt`, not both");
606
610
  if (ttl !== void 0 && ttl <= 0) throw new AuthError("INVALID_CONFIG", `issue: ttl must be > 0 (got ${ttl})`);
@@ -714,23 +718,48 @@ var AuthCredential = class {
714
718
  }
715
719
  /**
716
720
  * Shared rotation-with-grace mechanism for `sliding` and `always` on stateful
717
- * stores. Keeps the old refresh valid + stamps `rotatedAt` on first rotation;
718
- * within `rotationGraceMs` of that stamp it re-issues a fresh pair WITHOUT
719
- * re-rotating (replay-tolerant); beyond grace it treats the re-presentation
720
- * as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs sliding
721
- * (`sliding`) expiry for the new refresh token.
721
+ * stores. The first rotation keeps the old refresh valid and stamps
722
+ * `rotatedAt` + the {@link CredentialState.successor} pair on it; within the
723
+ * grace window a re-presentation **re-delivers that same pair verbatim** —
724
+ * idempotent, minting nothing, with no liveness side effects (no expiry
725
+ * slide, no `lastSeenAt`) — so a captured stale token teaches an attacker
726
+ * nothing beyond capturing the original rotation response, and a multi-tab
727
+ * race converges on one successor. Beyond grace — or once the successor has
728
+ * itself been rotated or revoked (superseded) — the re-presentation is
729
+ * treated as theft. `preserveExpiry` selects fixed-ceiling (`always`) vs
730
+ * sliding (`sliding`) expiry for the new refresh token; the family's
731
+ * mint-time `metadata.rotationGraceMs` (when stamped) wins over the instance
732
+ * window, and a window of `0` is strict single-use.
722
733
  */
723
734
  async rotateWithGrace(oldState, refreshToken, now, preserveExpiry) {
724
- const graceMs = this.refreshConfig?.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
735
+ const graceMs = oldState.metadata?.rotationGraceMs ?? this.refreshConfig?.rotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS;
725
736
  if (typeof oldState.rotatedAt !== "number") return await this.issueRotatedPair(oldState, refreshToken, true, now, preserveExpiry);
726
- if (now - oldState.rotatedAt > graceMs) await this.respondToRefreshReuse({
737
+ const reuse = {
727
738
  userId: oldState.userId,
728
739
  sessionId: oldState.sessionId,
729
740
  issuedAt: oldState.issuedAt,
730
741
  expiresAt: oldState.expiresAt,
731
742
  rotatedAt: oldState.rotatedAt
743
+ };
744
+ if (graceMs <= 0 || now - oldState.rotatedAt > graceMs) return await this.respondToRefreshReuse(reuse);
745
+ const successor = oldState.successor;
746
+ const successorState = successor === void 0 ? null : await this.store.retrieve(successor.refreshToken);
747
+ if (successor === void 0 || successorState === null || typeof successorState.rotatedAt === "number") return await this.respondToRefreshReuse(reuse);
748
+ this.refreshConfig?.onRotationGraceHit?.({
749
+ userId: oldState.userId,
750
+ issuedAt: oldState.issuedAt,
751
+ expiresAt: oldState.expiresAt,
752
+ kind: "refresh",
753
+ rotatedAt: oldState.rotatedAt,
754
+ ...oldState.sessionId !== void 0 && { sessionId: oldState.sessionId }
732
755
  });
733
- return await this.issueRotatedPair(oldState, refreshToken, false, now, preserveExpiry);
756
+ return {
757
+ accessToken: successor.accessToken,
758
+ accessExpiresAt: successor.accessExpiresAt,
759
+ refreshToken: successor.refreshToken,
760
+ refreshExpiresAt: successor.refreshExpiresAt,
761
+ userId: oldState.userId
762
+ };
734
763
  }
735
764
  async revoke(token) {
736
765
  await this.store.revoke(token);
@@ -908,7 +937,13 @@ var AuthCredential = class {
908
937
  if (rotateOld) {
909
938
  const rotatedState = {
910
939
  ...oldRefreshState,
911
- rotatedAt: now
940
+ rotatedAt: now,
941
+ successor: {
942
+ accessToken: access.token,
943
+ accessExpiresAt: access.expiresAt,
944
+ refreshToken: newRefreshToken,
945
+ refreshExpiresAt
946
+ }
912
947
  };
913
948
  await this.store.update(oldRefreshToken, rotatedState);
914
949
  }
@@ -7,6 +7,7 @@ const ENVELOPE_KEYS = new Set(Object.keys({
7
7
  kind: true,
8
8
  parentCredentialId: true,
9
9
  rotatedAt: true,
10
+ successor: true,
10
11
  sessionId: true,
11
12
  lastSeenAt: true,
12
13
  token: true
@@ -7,6 +7,7 @@ const ENVELOPE_KEYS = new Set(Object.keys({
7
7
  kind: true,
8
8
  parentCredentialId: true,
9
9
  rotatedAt: true,
10
+ successor: true,
10
11
  sessionId: true,
11
12
  lastSeenAt: true,
12
13
  token: true
package/dist/redis.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-DJ_eroHW.cjs";
2
- import { t as RateLimitStore } from "./store-BYnpA7VK.cjs";
1
+ import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-C0O0FCCN.cjs";
2
+ import { t as RateLimitStore } from "./store-C6DU6VZk.cjs";
3
3
 
4
4
  //#region src/redis/index.d.ts
5
5
  /**
package/dist/redis.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-DJ_eroHW.mjs";
2
- import { t as RateLimitStore } from "./store-pKh0ZaGG.mjs";
1
+ import { i as DenylistStore, r as CredentialStore, s as CredentialState } from "./clock-C0O0FCCN.mjs";
2
+ import { t as RateLimitStore } from "./store-y7K7z1EF.mjs";
3
3
 
4
4
  //#region src/redis/index.d.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { t as Clock } from "./clock-DJ_eroHW.cjs";
1
+ import { t as Clock } from "./clock-C0O0FCCN.cjs";
2
2
 
3
3
  //#region src/rate-limit/store.d.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as Clock } from "./clock-DJ_eroHW.mjs";
1
+ import { t as Clock } from "./clock-C0O0FCCN.mjs";
2
2
 
3
3
  //#region src/rate-limit/store.d.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "Auth method layer for aoothjs (sessions, tokens, password reset, MFA primitives)",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -104,18 +104,18 @@
104
104
  },
105
105
  "dependencies": {
106
106
  "jose": "^6.2.3",
107
- "@aooth/user": "0.1.49"
107
+ "@aooth/user": "0.1.51"
108
108
  },
109
109
  "devDependencies": {
110
- "@atscript/core": "^0.1.82",
111
- "@atscript/db": "^0.1.115",
112
- "@atscript/db-sql-tools": "^0.1.115",
113
- "@atscript/db-sqlite": "^0.1.115",
114
- "@atscript/typescript": "^0.1.82",
110
+ "@atscript/core": "^0.1.83",
111
+ "@atscript/db": "^0.1.116",
112
+ "@atscript/db-sql-tools": "^0.1.116",
113
+ "@atscript/db-sqlite": "^0.1.116",
114
+ "@atscript/typescript": "^0.1.83",
115
115
  "@types/better-sqlite3": "^7.6.13",
116
116
  "better-sqlite3": "^12.6.2",
117
117
  "ioredis": "^5.11.1",
118
- "unplugin-atscript": "^0.1.82"
118
+ "unplugin-atscript": "^0.1.83"
119
119
  },
120
120
  "peerDependencies": {
121
121
  "@atscript/db": ">=0.1.79"
@@ -45,6 +45,12 @@ export type AoothCredentialMetadataBase = {
45
45
  * slides. Stored as a plain string (same portability rule as `kind`).
46
46
  */
47
47
  refreshRotation?: string
48
+ /**
49
+ * Per-family rotation grace window (ms) — stamped when a refresh token
50
+ * is minted with a per-mint `graceMs` (e.g. `TokenPolicy.refresh.graceMs`),
51
+ * honored over the instance `rotationGraceMs`. 0 = strict single-use.
52
+ */
53
+ rotationGraceMs?: number
48
54
  }
49
55
 
50
56
  @db.table 'aooth_credentials'
@@ -84,6 +90,19 @@ export interface AoothAuthCredential {
84
90
  parentCredentialId?: string
85
91
  rotatedAt?: number.timestamp
86
92
 
93
+ /**
94
+ * Set alongside `rotatedAt` on a rotated refresh row: the successor pair
95
+ * the rotation produced, re-delivered verbatim on a within-grace
96
+ * re-presentation of this token (idempotent grace hits mint nothing).
97
+ */
98
+ @db.json
99
+ successor?: {
100
+ accessToken: string
101
+ accessExpiresAt: number.timestamp
102
+ refreshToken: string
103
+ refreshExpiresAt: number.timestamp
104
+ }
105
+
87
106
  /**
88
107
  * Stable session-family id. Minted once at login, copied forward on every
89
108
  * rotation. Indexed so a store could group/revoke a family natively (the
@@ -22,6 +22,7 @@ export type AoothCredentialMetadataBase = {
22
22
  authzClientId?: string
23
23
  accessTtl?: number
24
24
  refreshRotation?: string
25
+ rotationGraceMs?: number
25
26
  }
26
27
  declare namespace AoothCredentialMetadataBase {
27
28
  const __is_atscript_annotated_type: true
@@ -36,7 +37,7 @@ declare namespace AoothCredentialMetadataBase {
36
37
 
37
38
  /**
38
39
  * Atscript interface **AoothAuthCredential**
39
- * @see {@link ./auth-credential.as:52:18}
40
+ * @see {@link ./auth-credential.as:58:18}
40
41
  */
41
42
  export declare class AoothAuthCredential {
42
43
  token: string
@@ -46,6 +47,12 @@ export declare class AoothAuthCredential {
46
47
  kind?: string
47
48
  parentCredentialId?: string
48
49
  rotatedAt?: number /* timestamp */
50
+ successor?: {
51
+ accessToken: string
52
+ accessExpiresAt: number /* timestamp */
53
+ refreshToken: string
54
+ refreshExpiresAt: number /* timestamp */
55
+ }
49
56
  sessionId?: string
50
57
  lastSeenAt?: number /* timestamp */
51
58
  static __is_atscript_annotated_type: true
@@ -64,6 +71,7 @@ export declare class AoothAuthCredential {
64
71
  "kind"?: string
65
72
  "parentCredentialId"?: string
66
73
  "rotatedAt"?: number /* timestamp */
74
+ "successor"?: string
67
75
  "sessionId"?: string
68
76
  "lastSeenAt"?: number /* timestamp */
69
77
  }
@@ -75,6 +83,7 @@ export declare class AoothAuthCredential {
75
83
  "kind"?: string
76
84
  "parentCredentialId"?: string
77
85
  "rotatedAt"?: number /* timestamp */
86
+ "successor"?: string
78
87
  "sessionId"?: string
79
88
  "lastSeenAt"?: number /* timestamp */
80
89
  }