@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.
- package/ADOPTING.md +41 -0
- package/README.md +1 -1
- package/dist/{chunk-VDD4YRNP.js → chunk-EANHJLDH.js} +13 -4
- package/dist/chunk-EANHJLDH.js.map +1 -0
- package/dist/{chunk-UIILEGAC.js → chunk-WCZC4TPX.js} +193 -48
- package/dist/chunk-WCZC4TPX.js.map +1 -0
- package/dist/{create-api-mcp-oauth-CsC0jlH7.d.ts → create-api-mcp-oauth-BEvYLRBV.d.ts} +96 -4
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/index.d.ts +100 -2
- package/dist/index.js +71 -6
- package/dist/index.js.map +1 -1
- package/dist/manifest/server.d.ts +1 -1
- package/dist/manifest/server.js +2 -2
- package/dist/oauth/index.d.ts +18 -3
- package/dist/oauth/index.js +4 -2
- package/package.json +5 -5
- package/prisma/mcp.prisma +10 -0
- package/prisma/migrations/20260910120000_add_refresh_grace_seal/migration.sql +37 -0
- package/src/index.ts +11 -0
- package/src/oauth/access-token.ts +72 -10
- package/src/oauth/context.ts +20 -0
- package/src/oauth/index.ts +2 -0
- package/src/oauth/prisma-stores.ts +16 -5
- package/src/oauth/refresh-lineage.ts +77 -0
- package/src/oauth/refresh.ts +169 -82
- package/src/oauth/rotation-grace.ts +216 -0
- package/src/oauth/stores.ts +45 -1
- package/src/oauth/token-grants.ts +4 -1
- package/src/server/auth-failure.ts +145 -0
- package/src/server/jsonrpc.ts +44 -5
- package/dist/chunk-UIILEGAC.js.map +0 -1
- package/dist/chunk-VDD4YRNP.js.map +0 -1
package/src/oauth/refresh.ts
CHANGED
|
@@ -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
|
|
20
|
-
* whole lineage (every ancestor + descendant
|
|
21
|
-
* is revoked — the OAuth 2.1 refresh-token
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
219
|
+
async function graceReissue(
|
|
159
220
|
context: RefreshTokenContext,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
//
|
|
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.
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// the
|
|
247
|
-
//
|
|
248
|
-
|
|
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
|
+
}
|
package/src/oauth/stores.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|