@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.
@@ -0,0 +1,37 @@
1
+ -- @12-apps/mcp: the sealed successor that makes refresh rotation RETRYABLE.
2
+ --
3
+ -- `oauth_refresh_tokens.grace_seal` holds a token's own plaintext, encrypted
4
+ -- (AES-256-GCM) under a key derived by HKDF from the plaintext of the token it
5
+ -- was rotated FROM, with its grace deadline sealed inside the same blob.
6
+ --
7
+ -- WHY THE COLUMN EXISTS. Rotation-on-use with replay revocation cannot tell a
8
+ -- thief from a client that used one token twice for an innocent reason, and
9
+ -- there are two routine innocent reasons: a response lost to a proxy timeout,
10
+ -- and two of the client's own sessions refreshing at the same moment. Both were
11
+ -- punished as theft — the whole lineage revoked, including the successor just
12
+ -- handed to whoever won — which killed a live connection and sent a human back
13
+ -- through the full authorization flow. With this column, re-presenting a
14
+ -- just-consumed token inside the window returns THAT SAME successor instead, so
15
+ -- one successor is still all that ever exists and no second family is created.
16
+ --
17
+ -- WHY IT IS NOT A PLAINTEXT COLUMN. The key is never stored: it is derived from
18
+ -- the parent, which is itself only ever stored hashed. A dump of this table
19
+ -- therefore yields ciphertext and nothing that opens it, so the package's
20
+ -- "hashed, never plaintext" invariant is unchanged. The only party that can open
21
+ -- a seal is one presenting the parent — which is the party being served, and
22
+ -- which already held the token that mints the successor.
23
+ --
24
+ -- The seal is CLEARED whenever a token is consumed or revoked, and that is the
25
+ -- bound on what it costs: a seal opens only under the plaintext it was rotated
26
+ -- from, so spent seals left in place would chain — one historical plaintext plus
27
+ -- a copy of this table would walk forward to the live token offline, with no
28
+ -- server call and therefore no replay detection.
29
+ --
30
+ -- Nullable, but NOT optional: the package writes this field on every rotation,
31
+ -- so a deployment that raises the package version without applying this
32
+ -- migration gets a runtime failure on every refresh, not a quietly disabled
33
+ -- window. Apply it in the same change as the version raise. Guarded with
34
+ -- IF NOT EXISTS like every other statement this package ships, so a host that
35
+ -- already added the column adopts the migration as a no-op.
36
+ ALTER TABLE "oauth_refresh_tokens"
37
+ ADD COLUMN IF NOT EXISTS "grace_seal" TEXT;
package/src/index.ts CHANGED
@@ -73,6 +73,17 @@ export {
73
73
  type McpJsonRpcOptions,
74
74
  type McpServerInfo,
75
75
  } from "./server/jsonrpc";
76
+ // WHY a call was refused, and what a client should do about it. The RFC gives a
77
+ // resource server three challenge codes, which is not enough to tell a lapsed
78
+ // connection from a misconfigured deployment — so the reason travels alongside.
79
+ export {
80
+ authFailureData,
81
+ describeAuthFailure,
82
+ type McpAuthFailure,
83
+ type McpAuthFailureData,
84
+ type McpAuthFailureReason,
85
+ type McpAuthRecovery,
86
+ } from "./server/auth-failure";
76
87
  export {
77
88
  buildManifest,
78
89
  serializeManifest,
@@ -35,14 +35,48 @@ export interface VerifiedAccessToken {
35
35
  /** Distinct verification failure reasons the caller maps to OAuth challenges. */
36
36
  export type AccessTokenErrorCode = "invalid_token" | "insufficient_scope";
37
37
 
38
- /** A typed verification failure — `code` drives the `WWW-Authenticate` challenge. */
38
+ /**
39
+ * WHY verification failed, at the granularity an operator and an agent can act on.
40
+ *
41
+ * `code` above is the RFC 6750 challenge and there are only three of those, so it
42
+ * cannot tell "your connection lapsed, refresh it" from "this token is not for
43
+ * this server". That distinction is the whole difference between an assistant
44
+ * that tells its user to reconnect this server and one that reports a generic
45
+ * failure on every tool call, so it is carried alongside rather than folded
46
+ * into `code`.
47
+ *
48
+ * `unverified` stays deliberately COARSE. Signature, issuer and audience collapse
49
+ * into it because naming which one failed is an oracle for the next attempt.
50
+ * Expiry is the documented exception — RFC 6750 names it in `error_description`
51
+ * precisely because a client must be told to refresh — and it leaks nothing: a
52
+ * token's `exp` is readable by whoever holds the token.
53
+ */
54
+ export type AccessTokenFailureReason =
55
+ /** Valid in every other respect, but `exp` has passed. Refresh, do not re-consent. */
56
+ | "expired"
57
+ /** Signature, issuer or audience did not hold. Deliberately not narrowed further. */
58
+ | "unverified"
59
+ /** Verified, but missing the `sub`/`email` the identity is built from. */
60
+ | "incomplete"
61
+ /** No signing key is provisioned, so nothing can verify. An operator problem. */
62
+ | "not_provisioned"
63
+ /** A valid token that simply lacks the scope this call needs. */
64
+ | "insufficient_scope";
65
+
66
+ /**
67
+ * A typed verification failure — `code` drives the `WWW-Authenticate` challenge,
68
+ * {@link AccessTokenError.reason} drives what the caller is actually told.
69
+ */
39
70
  export class AccessTokenError extends Error {
40
71
  readonly code: AccessTokenErrorCode;
41
72
 
42
- constructor(code: AccessTokenErrorCode, message?: string) {
43
- super(message ?? code);
73
+ readonly reason: AccessTokenFailureReason;
74
+
75
+ constructor(code: AccessTokenErrorCode, reason: AccessTokenFailureReason, message?: string) {
76
+ super(message ?? reason);
44
77
  this.name = "AccessTokenError";
45
78
  this.code = code;
79
+ this.reason = reason;
46
80
  }
47
81
  }
48
82
 
@@ -131,12 +165,30 @@ function parseScopes(scope: unknown): string[] {
131
165
  * signature / wrong issuer / wrong audience / expired / malformed / unconfigured
132
166
  * key) from `insufficient_scope` (a valid token lacking the required scope).
133
167
  */
168
+ /** jose's code for a token that parsed and verified but whose `exp` has passed. */
169
+ const JWT_EXPIRED_CODE = "ERR_JWT_EXPIRED";
170
+
171
+ /** Whether a thrown value is jose's expiry error, by its stable `code`. */
172
+ function isExpiry(error: unknown): boolean {
173
+ return (
174
+ typeof error === "object" &&
175
+ error !== null &&
176
+ (error as { code?: unknown }).code === JWT_EXPIRED_CODE
177
+ );
178
+ }
179
+
134
180
  /**
135
181
  * The cryptographic half: signature, `iss`, `aud`, `exp`.
136
182
  *
137
- * Every jose failure — bad signature, wrong issuer, wrong audience, expiry,
138
- * malformed token, unknown key — collapses into ONE opaque `invalid_token`. A
139
- * message naming the failed claim would be an oracle for the next attempt.
183
+ * Bad signature, wrong issuer, wrong audience, malformed token and unknown key
184
+ * all collapse into ONE opaque `unverified`. A message naming the failed claim
185
+ * would be an oracle for the next attempt.
186
+ *
187
+ * EXPIRY is separated out, and only expiry. It is the one failure a
188
+ * well-behaved client is supposed to act on — refresh and retry — and it is the
189
+ * one the RFC gives a description for, so collapsing it left every lapsed
190
+ * connection indistinguishable from a broken one. It is not an oracle either:
191
+ * `exp` is a readable claim of a token the caller already holds.
140
192
  */
141
193
  async function verifiedPayload(
142
194
  loadSigningKey: McpSigningKeyProvider,
@@ -145,7 +197,9 @@ async function verifiedPayload(
145
197
  ): Promise<JWTPayload> {
146
198
  const key = await loadSigningKey();
147
199
  // No signing key configured → nothing can verify (safe-by-default).
148
- if (!key) throw new AccessTokenError("invalid_token", "no signing key configured");
200
+ if (!key) {
201
+ throw new AccessTokenError("invalid_token", "not_provisioned", "no signing key configured");
202
+ }
149
203
 
150
204
  try {
151
205
  const { payload } = await jwtVerify(token, await importJWK(key.publicJwk, SIGNING_ALG), {
@@ -156,8 +210,11 @@ async function verifiedPayload(
156
210
  currentDate: options.now === undefined ? undefined : new Date(options.now),
157
211
  });
158
212
  return payload;
159
- } catch {
160
- throw new AccessTokenError("invalid_token", "token verification failed");
213
+ } catch (error) {
214
+ if (isExpiry(error)) {
215
+ throw new AccessTokenError("invalid_token", "expired", "access token expired");
216
+ }
217
+ throw new AccessTokenError("invalid_token", "unverified", "token verification failed");
161
218
  }
162
219
  }
163
220
 
@@ -171,12 +228,17 @@ export async function verifyAccessToken(
171
228
  const email = typeof payload.email === "string" ? payload.email : null;
172
229
  const subject = typeof payload.sub === "string" ? payload.sub : null;
173
230
  if (!email || !subject) {
174
- throw new AccessTokenError("invalid_token", "missing subject or email claim");
231
+ throw new AccessTokenError(
232
+ "invalid_token",
233
+ "incomplete",
234
+ "missing subject or email claim",
235
+ );
175
236
  }
176
237
 
177
238
  const scopes = parseScopes(payload.scope);
178
239
  if (options.requiredScope && !scopes.includes(options.requiredScope)) {
179
240
  throw new AccessTokenError(
241
+ "insufficient_scope",
180
242
  "insufficient_scope",
181
243
  `token lacks required scope '${options.requiredScope}'`,
182
244
  );
@@ -9,6 +9,7 @@ import {
9
9
  } from "./code-replay";
10
10
  import { ACCESS_TOKEN_TTL_SECONDS } from "./access-token";
11
11
  import { REFRESH_TOKEN_TTL_MS } from "./refresh";
12
+ import { DEFAULT_ROTATION_GRACE_MS } from "./rotation-grace";
12
13
  import { loadSigningKeyFromEnv, type McpSigningKeyProvider } from "./keys";
13
14
  import type { ProviderAttributionRule } from "./clients";
14
15
  import type { McpOauthStores, StoredOAuthClient } from "./stores";
@@ -109,6 +110,22 @@ export interface McpOauthConfig {
109
110
  loginCallbackParam?: string;
110
111
  accessTokenTtlSeconds?: number;
111
112
  refreshTokenTtlMs?: number;
113
+ /**
114
+ * How long a just-rotated refresh token keeps answering with the successor it
115
+ * minted, instead of being treated as a replay. Default
116
+ * {@link DEFAULT_ROTATION_GRACE_MS}; `0` restores the strict single-use rule.
117
+ *
118
+ * It exists because one client using one token twice is routine — a response
119
+ * lost to a proxy timeout, or two of its own sessions refreshing at once — and
120
+ * the strict rule cannot tell either from theft, so it revoked the lineage and
121
+ * cost a connected user their session. Inside the window the retry is answered
122
+ * with the SAME successor, so no second family is ever created. It does NOT
123
+ * merely defer detection by one rotation: two parties left holding one
124
+ * successor take the retry path again at every rotation, so a collision is
125
+ * detected only once two uses fall more than this window apart. That trade is
126
+ * argued in full in `./rotation-grace.ts`.
127
+ */
128
+ refreshRotationGraceMs?: number;
112
129
  /**
113
130
  * The single-use guard for authorization codes — REQUIRED, and required on
114
131
  * purpose. Pass a shared atomic store, or the literal `'in-process'` to accept
@@ -169,6 +186,7 @@ export interface McpOauthContext {
169
186
  loginCallbackParam: string;
170
187
  accessTokenTtlSeconds: number;
171
188
  refreshTokenTtlMs: number;
189
+ refreshRotationGraceMs: number;
172
190
  codeReplay: CodeReplayStore;
173
191
  /**
174
192
  * The resolved consent decision for one authorize request. Always present: with
@@ -197,6 +215,7 @@ function resolveSurface(
197
215
  | "loginCallbackParam"
198
216
  | "accessTokenTtlSeconds"
199
217
  | "refreshTokenTtlMs"
218
+ | "refreshRotationGraceMs"
200
219
  > {
201
220
  return {
202
221
  scopes: config.scopes ?? [...MCP_SUPPORTED_SCOPES],
@@ -206,6 +225,7 @@ function resolveSurface(
206
225
  loginCallbackParam: config.loginCallbackParam ?? "callbackUrl",
207
226
  accessTokenTtlSeconds: config.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
208
227
  refreshTokenTtlMs: config.refreshTokenTtlMs ?? REFRESH_TOKEN_TTL_MS,
228
+ refreshRotationGraceMs: config.refreshRotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS,
209
229
  };
210
230
  }
211
231
 
@@ -40,6 +40,7 @@ export {
40
40
  signAccessToken,
41
41
  verifyAccessToken,
42
42
  type AccessTokenErrorCode,
43
+ type AccessTokenFailureReason,
43
44
  type SignAccessTokenInput,
44
45
  type VerifiedAccessToken,
45
46
  type VerifyAccessTokenOptions,
@@ -94,6 +95,7 @@ export {
94
95
  type RefreshTokenErrorCode,
95
96
  type RefreshTokenIdentity,
96
97
  } from "./refresh";
98
+ export { DEFAULT_ROTATION_GRACE_MS } from "./rotation-grace";
97
99
  export {
98
100
  inProcessCodeReplayStore,
99
101
  type CodeReplayStore,
@@ -50,7 +50,9 @@ export interface McpOauthPrisma {
50
50
  where:
51
51
  | { tokenHash: { in: string[] } }
52
52
  | { userEmail: string; clientId: string; revokedAt: null };
53
- data: { revokedAt: Date };
53
+ // `graceSeal` rides on every revoke: a revoked row must not keep an
54
+ // openable seal behind it (see `RefreshTokenStore.revokeHashes`).
55
+ data: { revokedAt: Date; graceSeal?: null };
54
56
  }): Promise<{ count: number }>;
55
57
  };
56
58
  mcpConnection: {
@@ -98,7 +100,7 @@ interface McpOauthTx {
98
100
  create(args: { data: NewRefreshToken }): Promise<unknown>;
99
101
  updateMany(args: {
100
102
  where: { tokenHash: string; revokedAt: null };
101
- data: { revokedAt: Date };
103
+ data: { revokedAt: Date; graceSeal?: null };
102
104
  }): Promise<{ count: number }>;
103
105
  };
104
106
  }
@@ -145,7 +147,10 @@ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore
145
147
  const prisma = await getPrisma();
146
148
  await prisma.oAuthRefreshToken.updateMany({
147
149
  where: { tokenHash: { in: [...tokenHashes] } },
148
- data: { revokedAt: at },
150
+ // The seals go with the revocation. A dead lineage that still carries
151
+ // openable seals is a chain anyone holding one of its plaintexts can
152
+ // still walk offline, which would make the revocation cosmetic.
153
+ data: { revokedAt: at, graceSeal: null },
149
154
  });
150
155
  },
151
156
  async rotate(successor, parentHash, at) {
@@ -162,7 +167,12 @@ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore
162
167
  // use of the parent that now never comes).
163
168
  const { count } = await tx.oAuthRefreshToken.updateMany({
164
169
  where: { tokenHash: parentHash, revokedAt: null },
165
- data: { revokedAt: at },
170
+ // `graceSeal: null` is part of the claim, not a cleanup. The parent's
171
+ // seal is openable by the plaintext it was rotated from, so leaving it
172
+ // behind would chain: one historical plaintext plus a copy of this
173
+ // table walks forward to the live token offline, hop by hop, with no
174
+ // server call to detect. Cleared here, at most one hop is ever open.
175
+ data: { revokedAt: at, graceSeal: null },
166
176
  });
167
177
  // Lost the claim: write NOTHING. The zero-row update commits as the no-op
168
178
  // it is, so there is nothing to roll back.
@@ -177,7 +187,8 @@ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore
177
187
  const prisma = await getPrisma();
178
188
  const { count } = await prisma.oAuthRefreshToken.updateMany({
179
189
  where: { userEmail, clientId, revokedAt: null },
180
- data: { revokedAt: new Date() },
190
+ // Disconnecting a host must leave nothing openable behind either.
191
+ data: { revokedAt: new Date(), graceSeal: null },
181
192
  });
182
193
  return count;
183
194
  },
@@ -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
+ }