@12-apps/mcp 3.16.0 → 3.17.0

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,5 +1,11 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
 
3
+ import {
4
+ DEFAULT_ROTATION_GRACE_MS,
5
+ openSuccessor,
6
+ sealSuccessor,
7
+ } from "./rotation-grace";
8
+ import { revokeLineage } from "./refresh-lineage";
3
9
  import type { NewRefreshToken, RefreshTokenStore, StoredRefreshToken } from "./stores";
4
10
 
5
11
  /**
@@ -16,14 +22,28 @@ import type { NewRefreshToken, RefreshTokenStore, StoredRefreshToken } from "./s
16
22
  * + scopes;
17
23
  * - {@link rotateRefreshToken} consumes a token: it issues a NEW token chained
18
24
  * via `rotatedFrom` and revokes the parent, so a token is single-use;
19
- * - reuse of an already-rotated/revoked token is a REPLAY: rejected, AND the
20
- * whole lineage (every ancestor + descendant reachable through `rotatedFrom`)
21
- * is revoked — the OAuth 2.1 refresh-token replay rule;
22
- * - CONCURRENT reuse is the same event and gets the same answer. The store's
23
- * `rotate` is a claim-once write, so of two simultaneous rotations of one
24
- * parent exactly one is issued a successor and the other is treated as the
25
- * replay it is. Without that, replay protection would be bypassable by
26
- * WINNING a race instead of arriving second (see `RefreshTokenStore.rotate`);
25
+ * - reuse of an already-rotated/revoked token OUTSIDE the grace window is a
26
+ * REPLAY: rejected, AND the whole lineage (every ancestor + descendant
27
+ * reachable through `rotatedFrom`) is revoked — the OAuth 2.1 refresh-token
28
+ * replay rule;
29
+ * - reuse INSIDE the window is a RETRY, and answers with the successor that
30
+ * rotation already minted rather than a second one (`./rotation-grace.ts`).
31
+ * A lost response and two concurrent refreshes are the routine reasons one
32
+ * client uses one token twice, and punishing them as theft is what cost a
33
+ * connected user their session and sent them back through the whole
34
+ * authorization flow;
35
+ * - CONCURRENT reuse takes that same retry path. The store's `rotate` is a
36
+ * claim-once write, so of two simultaneous rotations of one parent exactly
37
+ * one successor is ever WRITTEN — that invariant is untouched, and without it
38
+ * replay protection would be bypassable by WINNING a race instead of arriving
39
+ * second (see `RefreshTokenStore.rotate`). The loser is now handed the
40
+ * winner's token instead of destroying it;
41
+ * - the cost is real and is NOT a one-rotation deferral: two parties left
42
+ * holding one successor take the retry path again at every subsequent
43
+ * rotation, so they stay in lockstep for as long as their uses keep falling
44
+ * inside the window. What survives is: a collision is detected only when the
45
+ * two uses fall more than `graceMs` apart. `./rotation-grace.ts` argues why
46
+ * that is the accepted trade and `graceMs: 0` is the way back out;
27
47
  * - rotate may only NARROW scope (new ⊆ original); broadening is rejected and
28
48
  * nothing new is stored.
29
49
  */
@@ -74,6 +94,21 @@ export interface RefreshTokenContext {
74
94
  store: RefreshTokenStore;
75
95
  /** Lifetime of a newly stored token. Default 30 days. */
76
96
  ttlMs?: number;
97
+ /**
98
+ * How long a just-rotated token keeps answering with the successor it minted,
99
+ * instead of being treated as a replay. Default
100
+ * {@link DEFAULT_ROTATION_GRACE_MS}; `0` restores the strict rule.
101
+ *
102
+ * This is what makes a rotation RETRYABLE. See `./rotation-grace.ts` for why the
103
+ * window returns the same successor rather than minting a second one, and why
104
+ * that keeps replay detection intact.
105
+ */
106
+ graceMs?: number;
107
+ }
108
+
109
+ /** The configured grace window, in milliseconds. `<= 0` disables it. */
110
+ function graceWindowMs(context: RefreshTokenContext): number {
111
+ return context.graceMs ?? DEFAULT_ROTATION_GRACE_MS;
77
112
  }
78
113
 
79
114
  function expiryOf(context: RefreshTokenContext): Date {
@@ -102,84 +137,115 @@ export async function issueRefreshToken(
102
137
  return { refreshToken, scopes: binding.scopes };
103
138
  }
104
139
 
105
- /**
106
- * A pre-built O(1)-lookup index of one `(userEmail, clientId)` token family:
107
- * `byHash` resolves a hash to its row (to walk ancestors via `rotatedFrom`), and
108
- * `childrenOf` is the reverse index mapping a parent hash to its direct successor
109
- * hashes (to walk descendants). Both are built in a single pass so the lineage
110
- * traversal never re-scans the family (no O() inner loop).
111
- */
112
- interface LineageIndex {
113
- byHash: Map<string, StoredRefreshToken>;
114
- childrenOf: Map<string, string[]>;
140
+ /** Reject any requested scope not already on the token (narrow-only). */
141
+ function narrowedScopes(current: StoredRefreshToken, requested?: string[]): string[] {
142
+ const scopes = requested ?? current.scopes;
143
+ const original = new Set(current.scopes);
144
+ for (const scope of scopes) {
145
+ if (!original.has(scope)) {
146
+ throw new RefreshTokenError(
147
+ "invalid_scope",
148
+ `scope '${scope}' broadens the refresh token grant`,
149
+ );
150
+ }
151
+ }
152
+ return scopes;
115
153
  }
116
154
 
117
- function buildLineageIndex(family: StoredRefreshToken[]): LineageIndex {
118
- const byHash = new Map<string, StoredRefreshToken>();
119
- const childrenOf = new Map<string, string[]>();
120
- for (const row of family) {
121
- byHash.set(row.tokenHash, row);
122
- if (!row.rotatedFrom) continue;
123
- const siblings = childrenOf.get(row.rotatedFrom) ?? [];
124
- siblings.push(row.tokenHash);
125
- childrenOf.set(row.rotatedFrom, siblings);
155
+ /** Set equality over scope lists, which are unordered and may repeat. */
156
+ function sameScopes(left: readonly string[], right: readonly string[]): boolean {
157
+ const wanted = new Set(left);
158
+ const held = new Set(right);
159
+ if (wanted.size !== held.size) return false;
160
+ for (const scope of wanted) {
161
+ if (!held.has(scope)) return false;
126
162
  }
127
- return { byHash, childrenOf };
163
+ return true;
164
+ }
165
+
166
+ /** The one successor a retry may be answered with: its seal and what it grants. */
167
+ interface RetryTarget {
168
+ seal: string;
169
+ scopes: string[];
128
170
  }
129
171
 
130
172
  /**
131
- * Collect every token hash reachable from `seedHash` its ancestors (via
132
- * `rotatedFrom`) and its descendants (via the reverse index) — by a BFS over the
133
- * pre-built index. Each neighbour lookup is O(1), so the walk is linear in the
134
- * family size.
173
+ * Pick the successor a retry is entitled to, or `null` to fall through to the
174
+ * replay rule.
175
+ *
176
+ * FILTER, not find. Two rows sharing one `rotatedFrom` cannot happen while
177
+ * `rotate` honours its claim-once contract — but the lineage walk in
178
+ * `./refresh-lineage.ts` already treats multiple children as possible, and
179
+ * serving an arbitrary one of them would be the quiet half of a broken store, so
180
+ * an ambiguous family fails closed.
181
+ *
182
+ * A REVOKED successor means the lineage already died to a real replay, and grace
183
+ * must never resurrect it; an expired one is past its own TTL. Neither is a
184
+ * retry the window was opened to forgive.
135
185
  */
136
- function collectLineage(index: LineageIndex, seedHash: string): Set<string> {
137
- const lineage = new Set<string>();
138
- const queue = [seedHash];
139
- while (queue.length > 0) {
140
- const hash = queue.shift();
141
- if (!hash || lineage.has(hash)) continue;
142
- lineage.add(hash);
143
-
144
- const parent = index.byHash.get(hash)?.rotatedFrom ?? null;
145
- if (parent && !lineage.has(parent)) queue.push(parent);
146
-
147
- const children = (index.childrenOf.get(hash) ?? []).filter((child) => !lineage.has(child));
148
- queue.push(...children);
149
- }
150
- return lineage;
186
+ function retryableSuccessor(
187
+ family: StoredRefreshToken[],
188
+ tokenHash: string,
189
+ now: number,
190
+ ): RetryTarget | null {
191
+ const successors = family.filter((row) => row.rotatedFrom === tokenHash);
192
+ if (successors.length !== 1) return null;
193
+ const [successor] = successors;
194
+ if (!successor?.graceSeal || successor.revokedAt) return null;
195
+ if (successor.expiresAt.getTime() <= now) return null;
196
+ return { seal: successor.graceSeal, scopes: successor.scopes };
151
197
  }
152
198
 
153
199
  /**
154
- * Walk a token's rotation lineage (both directions) and revoke every token in it.
155
- * Called on replay detection, so a leaked refresh token — once reused —
156
- * invalidates the entire chain it belongs to.
200
+ * The grace path: a token that was already consumed is being presented again.
201
+ *
202
+ * Returns the successor that consumption minted — the SAME one, recovered by
203
+ * opening the seal with the parent the caller just presented — when every
204
+ * condition for a retry holds, and `null` when any of them does not, in which
205
+ * case the caller falls through to the replay rule unchanged.
206
+ *
207
+ * The conditions are the security argument, so each is checked rather than
208
+ * assumed:
209
+ *
210
+ * - exactly one unrevoked, unexpired successor exists ({@link retryableSuccessor});
211
+ * - the seal opens with THIS parent, which is what proves the caller held the
212
+ * token it claims to be retrying rather than merely knowing its hash;
213
+ * - the sealed deadline has not passed. It rides inside the AEAD blob, so it
214
+ * cannot be extended by editing the row;
215
+ * - the request asks for the same scopes. A retry repeats its original
216
+ * request; a different scope set is a NEW decision, and answering it with a
217
+ * token minted for the old one would silently ignore what was asked.
157
218
  */
158
- async function revokeLineage(
219
+ async function graceReissue(
159
220
  context: RefreshTokenContext,
160
- scopedTo: Pick<StoredRefreshToken, "userEmail" | "clientId">,
161
- seedHash: string,
162
- ): Promise<void> {
163
- // The lineage is confined to one (userEmail, clientId) pair, so load that set
164
- // once and walk the `rotatedFrom` links in memory — a small, bounded chain.
165
- const family = await context.store.listFamily(scopedTo.userEmail, scopedTo.clientId);
166
- const lineage = collectLineage(buildLineageIndex(family), seedHash);
167
- await context.store.revokeHashes([...lineage], new Date());
168
- }
221
+ current: StoredRefreshToken,
222
+ tokenHash: string,
223
+ parentPlaintext: string,
224
+ requestedScopes?: string[],
225
+ ): Promise<IssuedRefreshToken | null> {
226
+ if (graceWindowMs(context) <= 0) return null;
169
227
 
170
- /** Reject any requested scope not already on the token (narrow-only). */
171
- function narrowedScopes(current: StoredRefreshToken, requested?: string[]): string[] {
172
- const scopes = requested ?? current.scopes;
173
- const original = new Set(current.scopes);
174
- for (const scope of scopes) {
175
- if (!original.has(scope)) {
176
- throw new RefreshTokenError(
177
- "invalid_scope",
178
- `scope '${scope}' broadens the refresh token grant`,
179
- );
180
- }
228
+ const now = Date.now();
229
+ const family = await context.store.listFamily(current.userEmail, current.clientId);
230
+ const target = retryableSuccessor(family, tokenHash, now);
231
+ if (!target) return null;
232
+
233
+ const opened = openSuccessor(parentPlaintext, target.seal);
234
+ if (!opened || opened.graceUntil <= now) return null;
235
+
236
+ // Inside the window and the seal opened, so this IS the retry it looks like —
237
+ // but it asks for something else. Refusing is right; refusing as a REPLAY is
238
+ // not, because that revokes the whole lineage and destroys a live session for
239
+ // the innocent double-use this window exists to forgive. Say `invalid_scope`
240
+ // and leave the family alone.
241
+ if (requestedScopes && !sameScopes(requestedScopes, target.scopes)) {
242
+ throw new RefreshTokenError(
243
+ "invalid_scope",
244
+ "a retry inside the rotation grace window cannot change scope",
245
+ );
181
246
  }
182
- return scopes;
247
+
248
+ return { refreshToken: opened.successor, scopes: target.scopes };
183
249
  }
184
250
 
185
251
  /**
@@ -216,14 +282,19 @@ export async function rotateRefreshToken(
216
282
  if (current.expiresAt.getTime() <= Date.now()) {
217
283
  throw new RefreshTokenError("invalid_grant", "refresh token expired");
218
284
  }
219
- // Already revoked OR already used as the parent of a rotation REPLAY. Revoke
220
- // the whole lineage and reject.
285
+ // Already revoked OR already used as the parent of a rotation. Inside the grace
286
+ // window this is a RETRY and answers with the successor that consumption
287
+ // already minted; outside it, it is the replay it looks like — rejected, with
288
+ // the whole lineage revoked.
221
289
  if (current.revokedAt || (await context.store.hasSuccessor(tokenHash))) {
290
+ const retried = await graceReissue(context, current, tokenHash, plaintext, newScopes);
291
+ if (retried) return retried;
222
292
  await replay(context, current, tokenHash);
223
293
  }
224
294
 
225
295
  const scopes = narrowedScopes(current, newScopes);
226
296
  const successorPlaintext = generateToken();
297
+ const grace = graceWindowMs(context);
227
298
  const claimed = await context.store.rotate(
228
299
  {
229
300
  tokenHash: hashToken(successorPlaintext),
@@ -233,19 +304,35 @@ export async function rotateRefreshToken(
233
304
  scopes,
234
305
  expiresAt: expiryOf(context),
235
306
  rotatedFrom: tokenHash,
307
+ // Sealed under the PARENT the caller just presented, so a retry of this
308
+ // very rotation can be answered with this same token and nothing else can
309
+ // read it. Omitted entirely when the window is off, so the strict rule
310
+ // stores nothing extra.
311
+ graceSeal:
312
+ grace > 0 ? sealSuccessor(plaintext, successorPlaintext, Date.now() + grace) : null,
236
313
  },
237
314
  tokenHash,
238
315
  new Date(),
239
316
  );
240
317
  // The checks above are a READ, so a concurrent rotation of the same parent can
241
318
  // pass them too; `rotate` is the serialization point and it hands the claim to
242
- // exactly one caller. Losing it is the SAME event as the replay branch above —
243
- // one token used twice — so it gets the same answer, deliberately: reject, and
244
- // revoke the lineage including the winner's fresh successor. Rejecting without
245
- // revoking would leave a race-winning attacker holding a live family, which is
246
- // the whole attack; and a client that legitimately double-submits already loses
247
- // its family in the sequential case, so this is consistent rather than harsher.
248
- if (!claimed) await replay(context, current, tokenHash);
319
+ // exactly one caller. Exactly one successor is therefore ever written that
320
+ // part is unchanged, and it is the invariant replay protection rests on.
321
+ //
322
+ // What the loser is TOLD changed. It used to be the replay answer: reject, and
323
+ // revoke the lineage including the successor just handed to the winner. That
324
+ // is correct against an attacker racing the client, and catastrophic for the
325
+ // far more common case of one client refreshing twice — it destroyed a working
326
+ // session and forced a human back through the authorization flow. So the loser
327
+ // now takes the same grace path as a sequential retry and receives the WINNER's
328
+ // token: one successor, two callers holding it, no second family. What that
329
+ // costs is stated honestly in `./rotation-grace.ts` — not a one-rotation
330
+ // deferral, but detection only once two uses fall more than the window apart.
331
+ if (!claimed) {
332
+ const retried = await graceReissue(context, current, tokenHash, plaintext, newScopes);
333
+ if (retried) return retried;
334
+ await replay(context, current, tokenHash);
335
+ }
249
336
 
250
337
  return { refreshToken: successorPlaintext, scopes };
251
338
  }
@@ -256,7 +343,7 @@ async function replay(
256
343
  current: StoredRefreshToken,
257
344
  tokenHash: string,
258
345
  ): Promise<never> {
259
- await revokeLineage(context, current, tokenHash);
346
+ await revokeLineage(context.store, current, tokenHash);
260
347
  throw new RefreshTokenError(
261
348
  "invalid_grant",
262
349
  "refresh token already used (replay) — lineage revoked",
@@ -0,0 +1,216 @@
1
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * The sealed successor that makes refresh rotation IDEMPOTENT for the length of a
5
+ * grace window — the half of `refresh.ts` that lets a legitimate client survive a
6
+ * lost response or a concurrent refresh without losing its session.
7
+ *
8
+ * ## The problem this exists to solve
9
+ *
10
+ * Rotation-on-use plus replay revocation is the OAuth 2.1 rule, and it is right:
11
+ * a stolen refresh token is detected the moment BOTH the thief and the rightful
12
+ * client use it, and the whole lineage dies. What that rule cannot tell apart is
13
+ * a thief from a client that used its token twice for an innocent reason, and
14
+ * there are two of those, both routine:
15
+ *
16
+ * - **the lost response.** The client rotates, the 200 never arrives (a proxy
17
+ * timeout, a dropped connection), and it retries with the only token it
18
+ * still has — the one the server already consumed.
19
+ * - **the concurrent refresh.** Two of the client's own sessions notice an
20
+ * expired access token at the same moment and both refresh.
21
+ *
22
+ * Without a grace window both are punished as theft: the lineage is revoked,
23
+ * INCLUDING the successor just handed to whoever won, and the connection is dead
24
+ * until a human re-runs the whole authorization flow. That is the failure this
25
+ * module removes.
26
+ *
27
+ * ## Why a SEALED successor rather than a second one
28
+ *
29
+ * The obvious shortcut — mint a fresh successor for every in-window reuse — is
30
+ * the one thing that must not happen. It leaves one parent with two live
31
+ * successors and two independently rotating families, which is precisely the
32
+ * state replay protection exists to prevent: an attacker holding a stolen token
33
+ * would only have to fire it alongside the real client to walk away with a
34
+ * family of its own. So the window returns *the same* successor to every caller
35
+ * that presents the parent. Reuse becomes idempotent instead of forgiven, and
36
+ * exactly one successor is ever written.
37
+ *
38
+ * ## What detection this actually costs — stated plainly
39
+ *
40
+ * It would be convenient to say detection is merely DEFERRED by one rotation.
41
+ * It is not, and the code does not provide that. Two parties left holding one
42
+ * successor take this same path again at the next rotation, and again after
43
+ * that: whichever of them arrives second is inside a fresh window each time, so
44
+ * they stay in lockstep indefinitely. The honest guarantee is narrower:
45
+ *
46
+ * **a collision is detected only when the two uses fall more than the window
47
+ * apart.**
48
+ *
49
+ * A thief who replays a freshly stolen token within the window of the real
50
+ * client's rotation is handed a live token and raises no signal — and that
51
+ * timing is precisely what the window exists to forgive, so it cannot be
52
+ * distinguished. This is the accepted cost, and it is why the window is short
53
+ * by default, why it is configurable, and why `0` restores the strict rule for
54
+ * a deployment that would rather pay in re-authentications.
55
+ *
56
+ * ## Why the key is derived from the parent, and nothing is stored in the clear
57
+ *
58
+ * Returning the same successor means recovering its plaintext, and the plaintext
59
+ * is exactly what `refresh.ts` promises never to persist. So it is not persisted:
60
+ * it is sealed under a key derived by HKDF from the PARENT's own plaintext, and
61
+ * only the sealed blob reaches the store. The consequences are the point:
62
+ *
63
+ * - the database alone cannot open it. The parent's plaintext is never stored
64
+ * either, so a dump of the tokens table yields ciphertext and no key — the
65
+ * "hashed, never plaintext" invariant is unchanged;
66
+ * - the only party that CAN open it is a caller presenting the parent, which is
67
+ * the caller we mean to serve. It grants no capability that party lacks: it
68
+ * already held the parent, and the parent is what mints the successor;
69
+ * - the grace deadline is sealed INSIDE the blob rather than kept in a column,
70
+ * so an attacker with write access to the row cannot extend the window
71
+ * without also being able to forge the AES-GCM tag.
72
+ *
73
+ * One honest limit on that last point. The deadline is enforced by the server
74
+ * when it opens a seal, not by the ciphertext, and a seal is cleared when its
75
+ * token is consumed or revoked — not when its window lapses. So the ONE hop an
76
+ * attacker holding a spent parent plaintext plus a table read can take is bounded
77
+ * by when the successor is next used, which for an idle connection is the refresh
78
+ * token's TTL rather than `graceMs`. Bounded to one hop either way, because every
79
+ * consume and every revoke clears the parent's seal; sweeping lapsed seals would
80
+ * tighten it to the window itself.
81
+ */
82
+
83
+ /** AEAD, so a tampered blob fails to open rather than decrypting to garbage. */
84
+ const ALGORITHM = "aes-256-gcm";
85
+
86
+ /** 96-bit nonce — the size AES-GCM is specified for. */
87
+ const IV_BYTES = 12;
88
+
89
+ /** AES-256. */
90
+ const KEY_BYTES = 32;
91
+
92
+ /** GCM authentication tag length in bytes. */
93
+ const TAG_BYTES = 16;
94
+
95
+ /** Domain separation for the HKDF expansion, so this key is only ever this key. */
96
+ const HKDF_INFO = "12-apps/mcp:refresh-rotation-grace:v1";
97
+
98
+ /** Version prefix, so a future format change is recognisable rather than corrupt. */
99
+ const SEAL_VERSION = "v1";
100
+
101
+ /** How long a just-rotated token keeps answering with its successor. */
102
+ export const DEFAULT_ROTATION_GRACE_MS = 30_000;
103
+
104
+ /**
105
+ * What a successfully opened seal yields.
106
+ *
107
+ * Not exported: `refresh.ts` is the only caller and reads it through inference,
108
+ * so exporting it would only widen the package's public surface with a name
109
+ * nobody imports.
110
+ */
111
+ interface OpenedSuccessor {
112
+ /** The successor's opaque plaintext — the token to hand back. */
113
+ successor: string;
114
+ /** Epoch milliseconds after which the seal must be refused. */
115
+ graceUntil: number;
116
+ }
117
+
118
+ /**
119
+ * Derive the sealing key from the parent's plaintext.
120
+ *
121
+ * No salt: an opaque refresh token is already 256 bits of CSPRNG output, so HKDF
122
+ * is used here for domain separation and length adjustment rather than to
123
+ * concentrate entropy that is not there.
124
+ */
125
+ function sealingKey(parentPlaintext: string): Buffer {
126
+ const derived = hkdfSync(
127
+ "sha256",
128
+ Buffer.from(parentPlaintext, "utf8"),
129
+ Buffer.alloc(0),
130
+ Buffer.from(HKDF_INFO, "utf8"),
131
+ KEY_BYTES,
132
+ );
133
+ return Buffer.from(derived);
134
+ }
135
+
136
+ /** base64url without padding, so the blob is safe in any column or URL. */
137
+ function encode(value: Buffer): string {
138
+ return value.toString("base64url");
139
+ }
140
+
141
+ /**
142
+ * Seal `successorPlaintext` so that only a caller holding `parentPlaintext` can
143
+ * recover it, carrying `graceUntil` inside the sealed blob.
144
+ *
145
+ * The deadline is DATA here, not enforcement: {@link openSuccessor} returns it
146
+ * rather than acting on it, and the caller (`refresh.ts`) is what refuses a
147
+ * lapsed one. Sealing it inside the AEAD blob is what stops it being edited in
148
+ * the row; it is not a claim that the ciphertext stops opening on its own. A
149
+ * seal therefore stays openable-by-its-parent until the row is consumed or
150
+ * revoked, which for an idle connection is the token's TTL rather than the
151
+ * window — see the note in the module docblock.
152
+ */
153
+ export function sealSuccessor(
154
+ parentPlaintext: string,
155
+ successorPlaintext: string,
156
+ graceUntil: number,
157
+ ): string {
158
+ const iv = randomBytes(IV_BYTES);
159
+ const cipher = createCipheriv(ALGORITHM, sealingKey(parentPlaintext), iv);
160
+ const payload = JSON.stringify({ successor: successorPlaintext, graceUntil });
161
+ const sealed = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
162
+ return [SEAL_VERSION, encode(iv), encode(cipher.getAuthTag()), encode(sealed)].join(".");
163
+ }
164
+
165
+ /** Parse the four-part wire form, or `null` when it is not one. */
166
+ function parts(seal: string): { iv: Buffer; tag: Buffer; body: Buffer } | null {
167
+ const segments = seal.split(".");
168
+ if (segments.length !== 4) return null;
169
+ const [version, iv, tag, body] = segments;
170
+ if (version !== SEAL_VERSION) return null;
171
+
172
+ const decoded = {
173
+ iv: Buffer.from(iv ?? "", "base64url"),
174
+ tag: Buffer.from(tag ?? "", "base64url"),
175
+ body: Buffer.from(body ?? "", "base64url"),
176
+ };
177
+ // Lengths are fixed by the algorithm; a wrong one is a malformed blob, and
178
+ // `createDecipheriv` would throw on it rather than return.
179
+ if (decoded.iv.length !== IV_BYTES || decoded.tag.length !== TAG_BYTES) return null;
180
+ return decoded;
181
+ }
182
+
183
+ /**
184
+ * Open a seal with the parent's plaintext.
185
+ *
186
+ * `null` for every failure — a wrong parent, a tampered or truncated blob, an
187
+ * unknown version, a payload that is not the expected shape. The caller treats
188
+ * `null` as "no grace applies" and falls through to the replay rule, so a
189
+ * failure here is never the difference between secure and insecure; it only
190
+ * costs the client its retry.
191
+ */
192
+ export function openSuccessor(parentPlaintext: string, seal: string): OpenedSuccessor | null {
193
+ const parsed = parts(seal);
194
+ if (!parsed) return null;
195
+
196
+ try {
197
+ const decipher = createDecipheriv(ALGORITHM, sealingKey(parentPlaintext), parsed.iv);
198
+ decipher.setAuthTag(parsed.tag);
199
+ const opened = Buffer.concat([decipher.update(parsed.body), decipher.final()]);
200
+ const payload: unknown = JSON.parse(opened.toString("utf8"));
201
+ return readPayload(payload);
202
+ } catch {
203
+ // A wrong key fails the GCM tag check, which throws. That is the expected
204
+ // path for "this is not the parent that sealed it", not an error to report.
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /** Narrow the decrypted JSON to {@link OpenedSuccessor}, or `null`. */
210
+ function readPayload(payload: unknown): OpenedSuccessor | null {
211
+ if (payload === null || typeof payload !== "object") return null;
212
+ const { successor, graceUntil } = payload as Record<string, unknown>;
213
+ if (typeof successor !== "string" || successor === "") return null;
214
+ if (typeof graceUntil !== "number" || !Number.isFinite(graceUntil)) return null;
215
+ return { successor, graceUntil };
216
+ }
@@ -58,6 +58,38 @@ export interface StoredRefreshToken {
58
58
  /** The prior token's hash — the rotation lineage. `null` for a root token. */
59
59
  rotatedFrom: string | null;
60
60
  revokedAt: Date | null;
61
+ /**
62
+ * This token's own plaintext, SEALED under a key derived from the plaintext of
63
+ * the token it was rotated from (`./rotation-grace.ts`), and readable only by a
64
+ * caller presenting that parent.
65
+ *
66
+ * It is what lets a rotation be retried: within the grace window, re-presenting
67
+ * the consumed parent returns THIS successor again instead of destroying the
68
+ * lineage, so a lost response or two concurrent refreshes no longer force the
69
+ * user through the whole authorization flow again.
70
+ *
71
+ * REQUIRED of a store from this version on, even though the type is optional
72
+ * for the root token that has no parent to seal under. `rotate` writes it on
73
+ * every successor and CLEARS it on every parent it consumes.
74
+ *
75
+ * A PRISMA host that raises the version without the column fails loudly, on
76
+ * every rotation, because the delegate rejects the unknown key — a dead token
77
+ * endpoint rather than a degraded one, and the reason to land the migration in
78
+ * the SAME change as the version raise. A hand-written store has no such
79
+ * backstop: drop the field there and the window silently never applies, so
80
+ * implementing this field and its clearing is part of meeting the port, not an
81
+ * optional extra. `harness/backend/src/mcp-oauth-db.ts` is the worked example.
82
+ *
83
+ * Clearing it on consumption is what bounds the exposure, and it is the whole
84
+ * reason the field is safe to store at all: a seal is openable only by the
85
+ * plaintext of the token it was rotated from, so leaving spent seals in place
86
+ * would let anyone holding ONE historical plaintext plus a copy of this table
87
+ * walk the chain forward offline — hop by hop, with no server call and so no
88
+ * replay detection — all the way to the live token. With the parent's seal
89
+ * cleared as it is consumed, at most one hop is ever open, and only while the
90
+ * successor it points at is still the live token.
91
+ */
92
+ graceSeal?: string | null;
61
93
  }
62
94
 
63
95
  /** A token about to be stored (the plaintext never is). */
@@ -70,7 +102,13 @@ export interface RefreshTokenStore {
70
102
  hasSuccessor(tokenHash: string): Promise<boolean>;
71
103
  /** Every token of one `(userEmail, clientId)` family — the lineage walk's input. */
72
104
  listFamily(userEmail: string, clientId: string): Promise<StoredRefreshToken[]>;
73
- /** Revoke exactly these hashes (idempotent). */
105
+ /**
106
+ * Revoke exactly these hashes (idempotent), CLEARING each row's `graceSeal`.
107
+ *
108
+ * A revoked lineage must leave nothing openable behind it — otherwise the
109
+ * revocation that replay detection exists to perform would still leave the
110
+ * chain readable to anyone holding one of its plaintexts.
111
+ */
74
112
  revokeHashes(tokenHashes: readonly string[], at: Date): Promise<void>;
75
113
  /**
76
114
  * CLAIM the parent and store the successor, atomically. The whole of OAuth 2.1
@@ -97,6 +135,12 @@ export interface RefreshTokenStore {
97
135
  * live parent AND a live child) but it is not sufficient, and it is the easier
98
136
  * half to satisfy by accident.
99
137
  */
138
+ /**
139
+ * The claim MUST also clear the parent's own `graceSeal`. It is not tidiness:
140
+ * an uncleared seal is permanently openable by the plaintext it was sealed
141
+ * under, so a chain of them is an offline path from any historical token to
142
+ * the live one. Clearing on consumption keeps at most one hop readable.
143
+ */
100
144
  rotate(successor: NewRefreshToken, parentHash: string, at: Date): Promise<boolean>;
101
145
  /**
102
146
  * Revoke every LIVE token a user holds for one client; returns how many were
@@ -32,7 +32,9 @@ import {
32
32
  * the stored hash, else `invalid_client` (401).
33
33
  * - **Bound `redirect_uri`:** it must equal the one the code was minted with
34
34
  * (RFC 6749 §4.1.3).
35
- * - **Refresh rotation:** client-bound, replay-revoking, narrow-only scope.
35
+ * - **Refresh rotation:** client-bound, replay-revoking, narrow-only scope
36
+ * with a grace window in which re-presenting a just-consumed token is a
37
+ * RETRY answered with the same successor, not a replay (`./rotation-grace.ts`).
36
38
  */
37
39
 
38
40
  /** Throttle default: don't rewrite liveness on every grant. */
@@ -247,6 +249,7 @@ async function handleRefreshToken(
247
249
  const refreshContext = {
248
250
  store: context.stores.refreshTokens,
249
251
  ttlMs: context.refreshTokenTtlMs,
252
+ graceMs: context.refreshRotationGraceMs,
250
253
  };
251
254
 
252
255
  // Rotation enforces client binding (the token's stored clientId must equal the