@12-apps/mcp 3.15.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-F3LMK6OL.js +25 -0
- package/dist/chunk-F3LMK6OL.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/e2e/index.d.ts +109 -0
- package/dist/e2e/index.js +27 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.js +79 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/{guide-KQNcXlMG.d.ts → guide-CrzdsdNf.d.ts} +1 -1
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/index.d.ts +102 -4
- package/dist/index.js +71 -6
- package/dist/index.js.map +1 -1
- package/dist/{locales-eKE_OJw4.d.ts → locales-Cv0Pecvu.d.ts} +1 -1
- package/dist/manifest/index.d.ts +29 -7
- package/dist/manifest/index.js +2 -1
- package/dist/manifest/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 +19 -4
- package/dist/oauth/index.js +4 -2
- package/dist/react/index.d.ts +3 -3
- package/features/ai-connect.feature +46 -0
- package/package.json +25 -8
- package/prisma/mcp.prisma +10 -0
- package/prisma/migrations/20260910120000_add_refresh_grace_seal/migration.sql +37 -0
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +136 -0
- package/src/e2e/world.ts +84 -0
- package/src/index.ts +11 -0
- package/src/manifest/index.ts +24 -7
- 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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { RefreshTokenStore, StoredRefreshToken } from "./stores";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The walk over `rotatedFrom`, and the revocation the replay rule spends it on.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `./refresh.ts` because it is the one part of that file with no
|
|
7
|
+
* opinion about tokens: it takes a family of rows, follows the links between
|
|
8
|
+
* them, and revokes what it reaches. It knows nothing about grace windows,
|
|
9
|
+
* scopes, error codes or the request being served — which is also why it takes a
|
|
10
|
+
* {@link RefreshTokenStore} rather than the refresh context, keeping the
|
|
11
|
+
* dependency pointing one way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A pre-built O(1)-lookup index of one `(userEmail, clientId)` token family:
|
|
16
|
+
* `byHash` resolves a hash to its row (to walk ancestors via `rotatedFrom`), and
|
|
17
|
+
* `childrenOf` is the reverse index mapping a parent hash to its direct successor
|
|
18
|
+
* hashes (to walk descendants). Both are built in a single pass so the lineage
|
|
19
|
+
* traversal never re-scans the family (no O(n²) inner loop).
|
|
20
|
+
*/
|
|
21
|
+
interface LineageIndex {
|
|
22
|
+
byHash: Map<string, StoredRefreshToken>;
|
|
23
|
+
childrenOf: Map<string, string[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildLineageIndex(family: StoredRefreshToken[]): LineageIndex {
|
|
27
|
+
const byHash = new Map<string, StoredRefreshToken>();
|
|
28
|
+
const childrenOf = new Map<string, string[]>();
|
|
29
|
+
for (const row of family) {
|
|
30
|
+
byHash.set(row.tokenHash, row);
|
|
31
|
+
if (!row.rotatedFrom) continue;
|
|
32
|
+
const siblings = childrenOf.get(row.rotatedFrom) ?? [];
|
|
33
|
+
siblings.push(row.tokenHash);
|
|
34
|
+
childrenOf.set(row.rotatedFrom, siblings);
|
|
35
|
+
}
|
|
36
|
+
return { byHash, childrenOf };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect every token hash reachable from `seedHash` — its ancestors (via
|
|
41
|
+
* `rotatedFrom`) and its descendants (via the reverse index) — by a BFS over the
|
|
42
|
+
* pre-built index. Each neighbour lookup is O(1), so the walk is linear in the
|
|
43
|
+
* family size.
|
|
44
|
+
*/
|
|
45
|
+
function collectLineage(index: LineageIndex, seedHash: string): Set<string> {
|
|
46
|
+
const lineage = new Set<string>();
|
|
47
|
+
const queue = [seedHash];
|
|
48
|
+
while (queue.length > 0) {
|
|
49
|
+
const hash = queue.shift();
|
|
50
|
+
if (!hash || lineage.has(hash)) continue;
|
|
51
|
+
lineage.add(hash);
|
|
52
|
+
|
|
53
|
+
const parent = index.byHash.get(hash)?.rotatedFrom ?? null;
|
|
54
|
+
if (parent && !lineage.has(parent)) queue.push(parent);
|
|
55
|
+
|
|
56
|
+
const children = (index.childrenOf.get(hash) ?? []).filter((child) => !lineage.has(child));
|
|
57
|
+
queue.push(...children);
|
|
58
|
+
}
|
|
59
|
+
return lineage;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk a token's rotation lineage (both directions) and revoke every token in it.
|
|
64
|
+
* Called on replay detection, so a leaked refresh token — once reused —
|
|
65
|
+
* invalidates the entire chain it belongs to.
|
|
66
|
+
*/
|
|
67
|
+
export async function revokeLineage(
|
|
68
|
+
store: RefreshTokenStore,
|
|
69
|
+
scopedTo: Pick<StoredRefreshToken, "userEmail" | "clientId">,
|
|
70
|
+
seedHash: string,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
// The lineage is confined to one (userEmail, clientId) pair, so load that set
|
|
73
|
+
// once and walk the `rotatedFrom` links in memory — a small, bounded chain.
|
|
74
|
+
const family = await store.listFamily(scopedTo.userEmail, scopedTo.clientId);
|
|
75
|
+
const lineage = collectLineage(buildLineageIndex(family), seedHash);
|
|
76
|
+
await store.revokeHashes([...lineage], new Date());
|
|
77
|
+
}
|
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
|
+
}
|