@12-apps/mcp 3.1.0 → 3.2.1
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 +32 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-FYEVBTDU.js +162 -0
- package/dist/chunk-FYEVBTDU.js.map +1 -0
- package/dist/chunk-HAZOPC6U.js +229 -0
- package/dist/chunk-HAZOPC6U.js.map +1 -0
- package/dist/chunk-UIILEGAC.js +1247 -0
- package/dist/chunk-UIILEGAC.js.map +1 -0
- package/dist/chunk-WJJNKKNS.js +63 -0
- package/dist/chunk-WJJNKKNS.js.map +1 -0
- package/dist/coverage-gate/index.d.ts +129 -0
- package/dist/coverage-gate/index.js +163 -0
- package/dist/coverage-gate/index.js.map +1 -0
- package/dist/create-api-mcp-oauth-CwVXKK-A.d.ts +647 -0
- package/dist/generate/index.d.ts +91 -0
- package/dist/generate/index.js +101 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/generate-Dx3cK8th.d.ts +184 -0
- package/dist/guide-DV5MQbCg.d.ts +135 -0
- package/dist/hono/index.d.ts +28 -0
- package/dist/hono/index.js +25 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/index.d.ts +382 -0
- package/dist/index.js +331 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/index.d.ts +446 -0
- package/dist/oauth/index.js +279 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/react/index.d.ts +239 -0
- package/dist/react/index.js +1183 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +31 -10
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +6 -6
- package/src/index.ts +13 -0
- package/src/server/jsonrpc.ts +167 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { CryptoKey, JWK } from 'jose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single-use guard for the stateless authorization codes (12-23, ported from
|
|
5
|
+
* the origin host's `lib/mcp/oauth/token-replay.ts`).
|
|
6
|
+
*
|
|
7
|
+
* A code is a signed blob with a `jti`, so "already redeemed" has to be remembered
|
|
8
|
+
* somewhere. The in-process option remembers it IN THIS PROCESS: a small map of
|
|
9
|
+
* `jti → expiry`, self-pruning once the code that carried it would have expired
|
|
10
|
+
* anyway, so the set never grows without bound.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ MULTI-INSTANCE LIMITATION (best-effort single-use): that map lives in ONE
|
|
13
|
+
* process. On a horizontally-scaled deployment a code could be replayed against a
|
|
14
|
+
* second instance that has not yet seen the `jti`, within the ≤60s code lifetime.
|
|
15
|
+
* Single-use is therefore strictly guaranteed only on a SINGLE instance — which is
|
|
16
|
+
* why choosing it is explicit and cannot happen by omission: `codeReplay` has no
|
|
17
|
+
* default, so a host either names a shared store or types `'in-process'`. BEFORE
|
|
18
|
+
* running this surface on more than one instance, pass a `codeReplay` store backed
|
|
19
|
+
* by something shared and atomic — a short-TTL row with a unique constraint, or a
|
|
20
|
+
* distributed cache with an atomic set-if-absent. The port exists precisely so
|
|
21
|
+
* that is a config change rather than a patch to the grant handler.
|
|
22
|
+
*/
|
|
23
|
+
interface CodeReplayStore {
|
|
24
|
+
/**
|
|
25
|
+
* Record a code's `jti` as consumed. `false` means it was ALREADY recorded (a
|
|
26
|
+
* replay); `true` is the first redemption. Must be atomic to be a real guard.
|
|
27
|
+
*/
|
|
28
|
+
consume(jti: string, nowMs: number): Promise<boolean> | boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The in-process store — correct on ONE instance, see the caveat above. Reached by
|
|
32
|
+
* passing `codeReplay: 'in-process'`, which is a required acknowledgement rather
|
|
33
|
+
* than a default: the config has no default for this field precisely because the
|
|
34
|
+
* only possible one would fail open on a multi-pod deployment.
|
|
35
|
+
*/
|
|
36
|
+
declare function inProcessCodeReplayStore(): CodeReplayStore;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Signing-key / JWK loading for the OAuth authorization server (12-23, ported
|
|
40
|
+
* from the origin host's `lib/mcp/oauth/keys.ts`).
|
|
41
|
+
*
|
|
42
|
+
* ES256 (P-256) from PEM material, the published public JWK (with `kid` for
|
|
43
|
+
* rotation), and a safe-by-default absence signal (`null`) when no key is
|
|
44
|
+
* configured — callers then refuse to issue tokens and serve the JWKS as 503
|
|
45
|
+
* rather than falling back to a weaker mode while the surface is mounted.
|
|
46
|
+
*
|
|
47
|
+
* WHERE the PEM comes from is the host's business: `loadSigningKeyFromEnv` keeps
|
|
48
|
+
* the origin host's env-var wiring, and any other provider (a secrets manager, a KMS
|
|
49
|
+
* export) satisfies the same `McpSigningKeyProvider` shape.
|
|
50
|
+
*/
|
|
51
|
+
/** JWS algorithm for the signing key pair (asymmetric, self-validated via JWKS). */
|
|
52
|
+
declare const SIGNING_ALG = "ES256";
|
|
53
|
+
/** A public JWK safe to publish at the JWKS endpoint (never carries `d`). */
|
|
54
|
+
interface PublicSigningJwk extends JWK {
|
|
55
|
+
kid: string;
|
|
56
|
+
kty: "EC";
|
|
57
|
+
crv: "P-256";
|
|
58
|
+
alg: typeof SIGNING_ALG;
|
|
59
|
+
use: "sig";
|
|
60
|
+
}
|
|
61
|
+
/** The loaded signing material: the private key for signing + its public JWK. */
|
|
62
|
+
interface McpSigningKey {
|
|
63
|
+
privateKey: CryptoKey;
|
|
64
|
+
publicJwk: PublicSigningJwk;
|
|
65
|
+
kid: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* How the surface obtains signing material. Returning `null` means "not
|
|
69
|
+
* provisioned": the AS then mints nothing and the JWKS answers 503.
|
|
70
|
+
*/
|
|
71
|
+
type McpSigningKeyProvider = () => Promise<McpSigningKey | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Build a provider over a PKCS#8 PEM + `kid` pair, with a per-process cache.
|
|
74
|
+
*
|
|
75
|
+
* Parsing PKCS#8 and exporting the JWK is pure for a given (pem, kid), so the
|
|
76
|
+
* promise is cached keyed on the material itself. A rotated key (different pem or
|
|
77
|
+
* kid) produces a different cache key and re-parses — the cache never masks a
|
|
78
|
+
* rotation.
|
|
79
|
+
*
|
|
80
|
+
* Rotation is BY `kid`: each key is published in the JWKS and selected by the
|
|
81
|
+
* `kid` header on issued JWTs, so publishing old + new during an overlap window
|
|
82
|
+
* lets both verify.
|
|
83
|
+
*/
|
|
84
|
+
declare function signingKeyProvider(read: () => {
|
|
85
|
+
pem: string | undefined;
|
|
86
|
+
kid: string | undefined;
|
|
87
|
+
}): McpSigningKeyProvider;
|
|
88
|
+
/** Env var carrying the ES256 private key as a PKCS#8 PEM (the origin host's name). */
|
|
89
|
+
declare const DEFAULT_SIGNING_KEY_ENV = "MCP_OAUTH_SIGNING_KEY";
|
|
90
|
+
/** Env var carrying the key id (`kid`) used to select the key during rotation. */
|
|
91
|
+
declare const DEFAULT_SIGNING_KEY_ID_ENV = "MCP_OAUTH_SIGNING_KEY_ID";
|
|
92
|
+
/**
|
|
93
|
+
* The env-backed provider — the origin host's wiring, kept identical, with the
|
|
94
|
+
* variable names as arguments so the package states no host's vocabulary.
|
|
95
|
+
*/
|
|
96
|
+
declare function loadSigningKeyFromEnv(keyEnv?: string, kidEnv?: string): McpSigningKeyProvider;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The persistence PORTS of the authorization server (12-23).
|
|
100
|
+
*
|
|
101
|
+
* Three tables back the AS, and the package owns all three (see
|
|
102
|
+
* `prisma/mcp.prisma`): registered clients, rotating refresh tokens, and the
|
|
103
|
+
* per-user record of which AI host is live. What the package does NOT own is the
|
|
104
|
+
* client library used to reach them — so every read and write in the surface goes
|
|
105
|
+
* through these narrow ports, and `createPrismaMcpStores` (in
|
|
106
|
+
* `./prisma-stores.ts`) fills them for the common case in one line.
|
|
107
|
+
*
|
|
108
|
+
* The shapes are deliberately CLOSED and small: a host on something other than
|
|
109
|
+
* Prisma has a finite surface to fill, and the harness fills exactly this with
|
|
110
|
+
* hand-written SQL over a real Postgres.
|
|
111
|
+
*/
|
|
112
|
+
/** The two token-endpoint auth methods the AS accepts (matches the DB CHECK). */
|
|
113
|
+
type TokenEndpointAuthMethod = "none" | "client_secret_basic";
|
|
114
|
+
/** A registered OAuth client (an external host app — Claude.ai, ChatGPT…). */
|
|
115
|
+
interface StoredOAuthClient {
|
|
116
|
+
clientId: string;
|
|
117
|
+
/** SHA-256 hex of the secret; `null` for a public PKCE client. */
|
|
118
|
+
clientSecretHash: string | null;
|
|
119
|
+
/** The EXACT-MATCH allowlist the authorize endpoint validates against. */
|
|
120
|
+
redirectUris: string[];
|
|
121
|
+
clientName: string | null;
|
|
122
|
+
tokenEndpointAuthMethod: string;
|
|
123
|
+
grantTypes: string[];
|
|
124
|
+
scopes: string[];
|
|
125
|
+
}
|
|
126
|
+
/** What `register` persists (the durable subset of RFC 7591 metadata). */
|
|
127
|
+
interface NewOAuthClient {
|
|
128
|
+
clientId: string;
|
|
129
|
+
clientSecretHash: string | null;
|
|
130
|
+
redirectUris: string[];
|
|
131
|
+
clientName: string | null;
|
|
132
|
+
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
|
|
133
|
+
grantTypes: string[];
|
|
134
|
+
scopes: string[];
|
|
135
|
+
}
|
|
136
|
+
interface OAuthClientStore {
|
|
137
|
+
create(client: NewOAuthClient): Promise<StoredOAuthClient>;
|
|
138
|
+
/** By the PUBLIC `client_id`, or `null` when unknown. */
|
|
139
|
+
findByClientId(clientId: string): Promise<StoredOAuthClient | null>;
|
|
140
|
+
}
|
|
141
|
+
/** A rotating refresh token, stored HASHED — never plaintext. */
|
|
142
|
+
interface StoredRefreshToken {
|
|
143
|
+
tokenHash: string;
|
|
144
|
+
userEmail: string;
|
|
145
|
+
/** The original OAuth subject, kept stable across every rotation. */
|
|
146
|
+
userSub: string;
|
|
147
|
+
clientId: string;
|
|
148
|
+
scopes: string[];
|
|
149
|
+
expiresAt: Date;
|
|
150
|
+
/** The prior token's hash — the rotation lineage. `null` for a root token. */
|
|
151
|
+
rotatedFrom: string | null;
|
|
152
|
+
revokedAt: Date | null;
|
|
153
|
+
}
|
|
154
|
+
/** A token about to be stored (the plaintext never is). */
|
|
155
|
+
type NewRefreshToken = Omit<StoredRefreshToken, "revokedAt">;
|
|
156
|
+
interface RefreshTokenStore {
|
|
157
|
+
create(token: NewRefreshToken): Promise<void>;
|
|
158
|
+
findByHash(tokenHash: string): Promise<StoredRefreshToken | null>;
|
|
159
|
+
/** Whether some token was already rotated FROM this hash (replay detection). */
|
|
160
|
+
hasSuccessor(tokenHash: string): Promise<boolean>;
|
|
161
|
+
/** Every token of one `(userEmail, clientId)` family — the lineage walk's input. */
|
|
162
|
+
listFamily(userEmail: string, clientId: string): Promise<StoredRefreshToken[]>;
|
|
163
|
+
/** Revoke exactly these hashes (idempotent). */
|
|
164
|
+
revokeHashes(tokenHashes: readonly string[], at: Date): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* CLAIM the parent and store the successor, atomically. The whole of OAuth 2.1
|
|
167
|
+
* §4.3.1 replay protection rests on this one method, so read the contract before
|
|
168
|
+
* implementing it.
|
|
169
|
+
*
|
|
170
|
+
* Returns `true` when THIS call is the one that consumed `parentHash`, `false`
|
|
171
|
+
* when another call already had. `false` MUST mean nothing was written: no
|
|
172
|
+
* successor row, no second revocation.
|
|
173
|
+
*
|
|
174
|
+
* An implementation MUST revoke the parent CONDITIONALLY on it still being
|
|
175
|
+
* unrevoked — `updateMany({ where: { tokenHash: parentHash, revokedAt: null } })`,
|
|
176
|
+
* requiring a count of exactly 1 — and create the successor in the SAME
|
|
177
|
+
* transaction. An unconditional `update` is NOT enough: two concurrent rotations
|
|
178
|
+
* of one parent would both succeed, leaving two live successors of one token with
|
|
179
|
+
* no replay ever detected, because the replay rule fires on a THIRD use of the
|
|
180
|
+
* parent that then never comes. That is replay protection defeated by WINNING a
|
|
181
|
+
* race rather than by arriving second — precisely the attack rotation exists to
|
|
182
|
+
* stop, since an attacker holding a stolen refresh token need only fire it
|
|
183
|
+
* alongside the legitimate client to walk away with a live, independently
|
|
184
|
+
* rotating family.
|
|
185
|
+
*
|
|
186
|
+
* Atomicity against a CRASH is necessary too (a half-applied rotation leaves a
|
|
187
|
+
* live parent AND a live child) but it is not sufficient, and it is the easier
|
|
188
|
+
* half to satisfy by accident.
|
|
189
|
+
*/
|
|
190
|
+
rotate(successor: NewRefreshToken, parentHash: string, at: Date): Promise<boolean>;
|
|
191
|
+
/**
|
|
192
|
+
* Revoke every LIVE token a user holds for one client; returns how many were
|
|
193
|
+
* actually ended (already-revoked rows are skipped, so a repeat reports 0).
|
|
194
|
+
*/
|
|
195
|
+
revokeLiveForClient(userEmail: string, clientId: string): Promise<number>;
|
|
196
|
+
}
|
|
197
|
+
/** The AI provider a connection is attributed to. */
|
|
198
|
+
type McpConnectionHost = string;
|
|
199
|
+
/** A live connection, as the account surface shows it. */
|
|
200
|
+
interface StoredMcpConnection {
|
|
201
|
+
oauthClientId: string;
|
|
202
|
+
clientName: string | null;
|
|
203
|
+
/** `null` for a pre-attribution connection. */
|
|
204
|
+
host: string | null;
|
|
205
|
+
connectedAt: Date;
|
|
206
|
+
lastActiveAt: Date;
|
|
207
|
+
}
|
|
208
|
+
interface McpConnectionStore {
|
|
209
|
+
/** Liveness of one `(user, client)` pair, for the activity throttle. */
|
|
210
|
+
lastActiveAt(userId: string, oauthClientId: string): Promise<Date | null>;
|
|
211
|
+
/**
|
|
212
|
+
* Record (or refresh) liveness. Any activity CLEARS a prior `revokedAt` — the
|
|
213
|
+
* host is talking to us again — and must never blank a known `host` when this
|
|
214
|
+
* grant cannot derive one.
|
|
215
|
+
*/
|
|
216
|
+
recordActivity(input: {
|
|
217
|
+
userId: string;
|
|
218
|
+
oauthClientId: string;
|
|
219
|
+
clientName: string | null;
|
|
220
|
+
host: string | null;
|
|
221
|
+
at: Date;
|
|
222
|
+
}): Promise<void>;
|
|
223
|
+
/** A user's active (non-revoked) connections, most-recently-active first. */
|
|
224
|
+
listActive(userId: string): Promise<StoredMcpConnection[]>;
|
|
225
|
+
/**
|
|
226
|
+
* Revoke every live connection of one provider for this user and return the
|
|
227
|
+
* OAuth client ids that were revoked — the caller ends their refresh tokens,
|
|
228
|
+
* which is what actually cuts access.
|
|
229
|
+
*/
|
|
230
|
+
revokeByHost(userId: string, host: McpConnectionHost): Promise<string[]>;
|
|
231
|
+
/**
|
|
232
|
+
* The self-report path: attribute this user's just-connected, still-unattributed
|
|
233
|
+
* connection to `host`, or refresh the one already attributed to it. Returns
|
|
234
|
+
* rows touched.
|
|
235
|
+
*/
|
|
236
|
+
announce(userId: string, host: McpConnectionHost): Promise<number>;
|
|
237
|
+
}
|
|
238
|
+
/** The stores the AS needs. `connections` is optional — see the config docs. */
|
|
239
|
+
interface McpOauthStores {
|
|
240
|
+
clients: OAuthClientStore;
|
|
241
|
+
refreshTokens: RefreshTokenStore;
|
|
242
|
+
connections?: McpConnectionStore;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** RFC 7591 registration input (the durable subset the store persists). */
|
|
246
|
+
interface RegisterClientInput {
|
|
247
|
+
/** The exact-match redirect-uri allowlist (open-redirect guard). */
|
|
248
|
+
redirectUris: string[];
|
|
249
|
+
clientName?: string | null;
|
|
250
|
+
/**
|
|
251
|
+
* `none` (public PKCE client, the default) or `client_secret_basic`
|
|
252
|
+
* (confidential — a secret is generated and its hash stored).
|
|
253
|
+
*/
|
|
254
|
+
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
255
|
+
/** Grant types; defaults to authorization_code + refresh_token. */
|
|
256
|
+
grantTypes?: string[];
|
|
257
|
+
scopes: string[];
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The registration RESULT. `clientSecret` is present (plaintext, ONCE) only for a
|
|
261
|
+
* confidential client — it is never stored and never returned again.
|
|
262
|
+
*/
|
|
263
|
+
interface RegisteredClient {
|
|
264
|
+
clientId: string;
|
|
265
|
+
clientSecret?: string;
|
|
266
|
+
redirectUris: string[];
|
|
267
|
+
clientName: string | null;
|
|
268
|
+
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
|
|
269
|
+
grantTypes: string[];
|
|
270
|
+
scopes: string[];
|
|
271
|
+
}
|
|
272
|
+
/** SHA-256 hex digest — the at-rest form of the client secret. */
|
|
273
|
+
declare function hashSecret(secret: string): string;
|
|
274
|
+
/**
|
|
275
|
+
* Register an OAuth client under a generated `clientId`. For a confidential
|
|
276
|
+
* client a random secret is generated and its hash stored; the plaintext is
|
|
277
|
+
* returned once.
|
|
278
|
+
*/
|
|
279
|
+
declare function registerClient(store: OAuthClientStore, input: RegisterClientInput): Promise<RegisteredClient>;
|
|
280
|
+
/**
|
|
281
|
+
* Open-redirect guard: a redirect target is accepted ONLY when it EXACTLY equals a
|
|
282
|
+
* registered `redirect_uri`. No normalization, no prefix, no trailing-slash
|
|
283
|
+
* leniency — an intercepted authorization request must not be steerable to any URI
|
|
284
|
+
* the client did not register.
|
|
285
|
+
*/
|
|
286
|
+
declare function matchesRedirectUri(client: Pick<StoredOAuthClient, "redirectUris">, redirectUri: string): boolean;
|
|
287
|
+
/**
|
|
288
|
+
* Provider attribution rules: the canonical root domains that own each host's
|
|
289
|
+
* OAuth callback. A redirect host matches a root only as the exact domain or a
|
|
290
|
+
* real (dot-guarded) subdomain — never a suffix, so `evilchatgpt.com` never
|
|
291
|
+
* matches `chatgpt.com`.
|
|
292
|
+
*/
|
|
293
|
+
interface ProviderAttributionRule {
|
|
294
|
+
roots: readonly string[];
|
|
295
|
+
provider: string;
|
|
296
|
+
}
|
|
297
|
+
/** The origin host's rules, and a sane default for any host talking to the same two. */
|
|
298
|
+
declare const DEFAULT_PROVIDER_ROOTS: readonly ProviderAttributionRule[];
|
|
299
|
+
/**
|
|
300
|
+
* Best-effort provider attribution from a client's redirect URIs: the host that
|
|
301
|
+
* owns the callback (`claude.ai` → claude, `chatgpt.com` → chatgpt). Returns
|
|
302
|
+
* `null` when nothing matches — the UI then falls back to what the owner
|
|
303
|
+
* completed the flow with, and a self-report (the `announce` path) can attribute
|
|
304
|
+
* it later.
|
|
305
|
+
*/
|
|
306
|
+
declare function providerFromRedirectUris(redirectUris: readonly string[], rules?: readonly ProviderAttributionRule[]): string | null;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The config seam of the authorization server, and its resolved form (12-23).
|
|
310
|
+
*
|
|
311
|
+
* Everything a HOST knows and the package cannot: who the signed-in caller is,
|
|
312
|
+
* where the data lives, which origins are trusted, whether the surface is turned
|
|
313
|
+
* on at all, and where its endpoints are mounted. Everything else — the RFC wire,
|
|
314
|
+
* PKCE, rotation, replay, the discovery documents — is the package's.
|
|
315
|
+
*/
|
|
316
|
+
/** The identity an authorize request binds a code to. From the SESSION only. */
|
|
317
|
+
interface McpOauthSession {
|
|
318
|
+
/**
|
|
319
|
+
* The OAuth subject (the origin host passes the Google `sub`, falling back to the
|
|
320
|
+
* email). Carried through every rotation so a refreshed token keeps the same
|
|
321
|
+
* stable `sub`.
|
|
322
|
+
*/
|
|
323
|
+
subject: string;
|
|
324
|
+
/** The signed-in user's email — the identity the AS binds to. */
|
|
325
|
+
email: string;
|
|
326
|
+
}
|
|
327
|
+
/** Where each endpoint of the surface lives, from the origin root. */
|
|
328
|
+
interface McpOauthPaths {
|
|
329
|
+
authorize: string;
|
|
330
|
+
token: string;
|
|
331
|
+
register: string;
|
|
332
|
+
jwks: string;
|
|
333
|
+
authorizationServerMetadata: string;
|
|
334
|
+
protectedResourceMetadata: string;
|
|
335
|
+
}
|
|
336
|
+
declare const DEFAULT_OAUTH_PATHS: McpOauthPaths;
|
|
337
|
+
/** How a connection's liveness is recorded on a successful grant. */
|
|
338
|
+
interface McpConnectionRecording {
|
|
339
|
+
/**
|
|
340
|
+
* The host's DB user id for a token's email, or `null` when there is no user row
|
|
341
|
+
* yet (recording is then skipped — email is the identity, not the id).
|
|
342
|
+
*/
|
|
343
|
+
resolveUserId: (email: string) => Promise<string | null> | string | null;
|
|
344
|
+
/** Provider attribution rules; defaults to claude/chatgpt roots. */
|
|
345
|
+
providerRules?: readonly ProviderAttributionRule[];
|
|
346
|
+
/** Don't rewrite on every grant — refresh liveness at most this often. */
|
|
347
|
+
activityThrottleMs?: number;
|
|
348
|
+
}
|
|
349
|
+
interface McpOauthConfig {
|
|
350
|
+
/** Where the three owned tables live (see `./stores.ts`). */
|
|
351
|
+
stores: McpOauthStores;
|
|
352
|
+
/**
|
|
353
|
+
* Resolve the caller's COOKIE SESSION for the authorize endpoint. `null` sends
|
|
354
|
+
* the caller through the host's sign-in flow; no code is ever minted for an
|
|
355
|
+
* unauthenticated request, and a client can never supply the identity itself.
|
|
356
|
+
*/
|
|
357
|
+
resolveSession: (request: Request) => Promise<McpOauthSession | null> | McpOauthSession | null;
|
|
358
|
+
/**
|
|
359
|
+
* The operator gate. `false` makes the whole surface inert — authorize/token/jwks
|
|
360
|
+
* answer 404 and registration answers 403 — which is how the origin host ships it OFF
|
|
361
|
+
* by default (`MCP_BEARER_ENABLED`). Default: enabled (mounting is the opt-in).
|
|
362
|
+
*/
|
|
363
|
+
enabled?: boolean | (() => boolean);
|
|
364
|
+
/**
|
|
365
|
+
* Signing material. Default: the env-backed provider with the origin host's variable
|
|
366
|
+
* names. `null` from the provider means "not provisioned": nothing is minted and
|
|
367
|
+
* the JWKS answers 503 rather than falling back to a weaker mode.
|
|
368
|
+
*/
|
|
369
|
+
signingKey?: McpSigningKeyProvider;
|
|
370
|
+
/**
|
|
371
|
+
* The trusted PUBLIC origin allowlist — REQUIRED behind a reverse proxy, where
|
|
372
|
+
* the server sees only its internal bind. The FIRST entry is canonical. With
|
|
373
|
+
* none configured a forwarded host is never trusted (see `resolveTrustedOrigin`).
|
|
374
|
+
*/
|
|
375
|
+
trustedOrigins?: readonly string[];
|
|
376
|
+
/** Scopes the AS advertises and validates against. Default `mcp:read mcp:write`. */
|
|
377
|
+
scopes?: readonly string[];
|
|
378
|
+
/** Where the MCP resource is mounted — the token audience. Default `/api/mcp`. */
|
|
379
|
+
resourcePath?: string;
|
|
380
|
+
/** Endpoint paths, if the host mounts them somewhere else. */
|
|
381
|
+
paths?: Partial<McpOauthPaths>;
|
|
382
|
+
/** Where an unauthenticated authorize request is sent. Default `/login`. */
|
|
383
|
+
loginPath?: string;
|
|
384
|
+
/**
|
|
385
|
+
* The query parameter carrying the post-login return path. Default
|
|
386
|
+
* `callbackUrl` (Auth.js's name).
|
|
387
|
+
*/
|
|
388
|
+
loginCallbackParam?: string;
|
|
389
|
+
accessTokenTtlSeconds?: number;
|
|
390
|
+
refreshTokenTtlMs?: number;
|
|
391
|
+
/**
|
|
392
|
+
* The single-use guard for authorization codes — REQUIRED, and required on
|
|
393
|
+
* purpose. Pass a shared atomic store, or the literal `'in-process'` to accept
|
|
394
|
+
* the single-instance limitation explicitly.
|
|
395
|
+
*
|
|
396
|
+
* There is deliberately NO default, because a default here would be the only one
|
|
397
|
+
* in this config that fails OPEN. Every other one fails closed: no signing key
|
|
398
|
+
* mints nothing and answers JWKS 503; `enabled: false` is 404 everywhere; an
|
|
399
|
+
* empty `trustedOrigins` never trusts a forwarded host. An in-process default
|
|
400
|
+
* instead silently permits cross-instance code replay — against an OAuth 2.1
|
|
401
|
+
* MUST, on the very deployment shape a reusable package exists for (two pods
|
|
402
|
+
* behind one load balancer), with nothing in the types to notice. Scaling out
|
|
403
|
+
* must not be able to weaken the guard without somebody having typed something.
|
|
404
|
+
*/
|
|
405
|
+
codeReplay: CodeReplayStore | "in-process";
|
|
406
|
+
/**
|
|
407
|
+
* Approve an authorize request before a code is minted — the CONSENT step.
|
|
408
|
+
*
|
|
409
|
+
* Registration is open whenever `enabled` is true (RFC 7591), so without an
|
|
410
|
+
* approval step anyone may register a client carrying their OWN redirect URI and
|
|
411
|
+
* their OWN scope ceiling, send a signed-in admin one link, and have the endpoint
|
|
412
|
+
* mint them a code with no interaction: the redirect URI is exact-matched against
|
|
413
|
+
* the attacker's own registration and the scope ceiling is the attacker's too, so
|
|
414
|
+
* every other guard here holds and none of them helps.
|
|
415
|
+
*
|
|
416
|
+
* Until a host supplies this, `authorize` REFUSES any client it cannot see the
|
|
417
|
+
* operator behind — i.e. any client not named in {@link preApprovedClientIds}.
|
|
418
|
+
* Return `false` to deny (the caller gets an `access_denied` redirect, exactly as
|
|
419
|
+
* a human refusal would).
|
|
420
|
+
*/
|
|
421
|
+
resolveApproval?: (request: Request, client: StoredOAuthClient, scopes: readonly string[]) => Promise<boolean> | boolean;
|
|
422
|
+
/**
|
|
423
|
+
* Client ids the OPERATOR registered, exempt from the approval gate above — the
|
|
424
|
+
* escape hatch for a host that ships its own first-party clients and has no
|
|
425
|
+
* consent screen to offer. Anything NOT listed here is treated as dynamically
|
|
426
|
+
* registered, i.e. as attacker-controllable.
|
|
427
|
+
*/
|
|
428
|
+
preApprovedClientIds?: readonly string[];
|
|
429
|
+
/** Liveness recording on a grant; omit to record nothing. */
|
|
430
|
+
connections?: McpConnectionRecording;
|
|
431
|
+
}
|
|
432
|
+
/** The config with every default applied — what the handlers actually read. */
|
|
433
|
+
interface McpOauthContext {
|
|
434
|
+
stores: McpOauthStores;
|
|
435
|
+
resolveSession: McpOauthConfig["resolveSession"];
|
|
436
|
+
enabled: () => boolean;
|
|
437
|
+
signingKey: McpSigningKeyProvider;
|
|
438
|
+
trustedOrigins: readonly string[];
|
|
439
|
+
scopes: readonly string[];
|
|
440
|
+
resourcePath: string;
|
|
441
|
+
paths: McpOauthPaths;
|
|
442
|
+
loginPath: string;
|
|
443
|
+
loginCallbackParam: string;
|
|
444
|
+
accessTokenTtlSeconds: number;
|
|
445
|
+
refreshTokenTtlMs: number;
|
|
446
|
+
codeReplay: CodeReplayStore;
|
|
447
|
+
/**
|
|
448
|
+
* The resolved consent decision for one authorize request. Always present: with
|
|
449
|
+
* no host seam it refuses every client the operator did not pre-approve, so the
|
|
450
|
+
* handler has no "unset" case to forget.
|
|
451
|
+
*/
|
|
452
|
+
approve: (request: Request, client: StoredOAuthClient, scopes: readonly string[]) => Promise<boolean>;
|
|
453
|
+
connections?: McpConnectionRecording;
|
|
454
|
+
/** The trusted public origin for THIS request (issuance and verification agree). */
|
|
455
|
+
originOf: (request: Request) => string;
|
|
456
|
+
}
|
|
457
|
+
declare function resolveMcpOauthConfig(config: McpOauthConfig): McpOauthContext;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The OAuth 2.1 authorization-server foundation: the shared scope source, the
|
|
461
|
+
* issuer/audience derivation, and the trusted-origin resolver every URL in the
|
|
462
|
+
* surface is built from (12-23, ported from the origin host's
|
|
463
|
+
* `lib/mcp/oauth/config.ts`).
|
|
464
|
+
*
|
|
465
|
+
* Keeping the scopes and the origin resolution in ONE place is what stops the
|
|
466
|
+
* two discovery documents — RFC 8414 `/.well-known/oauth-authorization-server`
|
|
467
|
+
* and RFC 9728 `/.well-known/oauth-protected-resource` — from drifting apart,
|
|
468
|
+
* and what makes a token minted for an origin verify against that same origin.
|
|
469
|
+
*
|
|
470
|
+
* What was env-reading in the host is CONFIG here (the package must not learn a
|
|
471
|
+
* host's variable names); `trustedOriginsFromEnv` is the one-line helper that
|
|
472
|
+
* keeps the origin host's wiring identical.
|
|
473
|
+
*/
|
|
474
|
+
/** Scopes advertised by both discovery documents. `mcp:write` gates mutating tools. */
|
|
475
|
+
declare const MCP_SUPPORTED_SCOPES: readonly ["mcp:read", "mcp:write"];
|
|
476
|
+
type McpScope = (typeof MCP_SUPPORTED_SCOPES)[number];
|
|
477
|
+
/** Path the MCP JSON-RPC endpoint is mounted at — the access token's audience. */
|
|
478
|
+
declare const DEFAULT_MCP_RESOURCE_PATH = "/api/mcp";
|
|
479
|
+
/** The OAuth `iss` — the deployment origin, used verbatim. */
|
|
480
|
+
declare function issuer(origin: string): string;
|
|
481
|
+
/** The access-token `aud` — the MCP resource URL (`${origin}${resourcePath}`). */
|
|
482
|
+
declare function resourceAudience(origin: string, resourcePath?: string): string;
|
|
483
|
+
/**
|
|
484
|
+
* Read a comma-separated allowlist out of an environment variable — the origin host
|
|
485
|
+
* passes `trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS')`, so the behaviour
|
|
486
|
+
* is identical while the variable's NAME stays the host's.
|
|
487
|
+
*/
|
|
488
|
+
declare function trustedOriginsFromEnv(name: string): string[];
|
|
489
|
+
/**
|
|
490
|
+
* THE single trusted-origin resolver, shared by token ISSUANCE (the `iss`/`aud` a
|
|
491
|
+
* token is minted with) and bearer VERIFICATION (the expected `aud` a protected
|
|
492
|
+
* route checks). Because both sides pass the SAME request's headers, a token
|
|
493
|
+
* minted for the allowlisted origin verifies against that same origin — they
|
|
494
|
+
* cannot drift (e.g. mint `https://app.example.com` but verify
|
|
495
|
+
* `http://0.0.0.0:3000` and reject a valid token).
|
|
496
|
+
*
|
|
497
|
+
* The origin must NEVER be attacker-controllable. Behind a reverse proxy the
|
|
498
|
+
* server sees only its internal bind on `request.url`, so the public origin comes
|
|
499
|
+
* from the proxy's `X-Forwarded-Host` — but a forwarded host is honored ONLY when
|
|
500
|
+
* it is on the operator-configured allowlist; ANY other value (a spoofed/foreign
|
|
501
|
+
* host, or an absent header) resolves to the canonical (FIRST) allowlisted origin.
|
|
502
|
+
* So even if the edge forwards `X-Forwarded-Host: evil.example.com`, the origin
|
|
503
|
+
* stays the trusted one.
|
|
504
|
+
*
|
|
505
|
+
* With NO allowlist configured a forwarded host is NEVER trusted — a spoofed
|
|
506
|
+
* header must not be able to choose the issuer — so `fallbackOrigin` (the
|
|
507
|
+
* request's OWN origin) is used instead. A proxied deployment therefore REQUIRES
|
|
508
|
+
* the allowlist; until it is set the surface fails closed to the internal origin
|
|
509
|
+
* rather than a foreign one.
|
|
510
|
+
*/
|
|
511
|
+
declare function resolveTrustedOrigin(getHeader: (name: string) => string | null, fallbackOrigin: string | undefined, trustedOrigins?: readonly string[]): string | undefined;
|
|
512
|
+
/**
|
|
513
|
+
* The PUBLIC origin every URL in the surface derives from (issuer, endpoint URLs,
|
|
514
|
+
* access-token `iss`/`aud`). A thin wrapper over {@link resolveTrustedOrigin}
|
|
515
|
+
* bound to a `Request`, falling back to the request URL's origin when no
|
|
516
|
+
* allowlist is configured.
|
|
517
|
+
*/
|
|
518
|
+
declare function originFromRequest(request: Request, trustedOrigins?: readonly string[]): string;
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* JWT access-token issuer + verifier (12-23, ported from the origin host's
|
|
522
|
+
* `lib/mcp/oauth/jwt.ts` — behaviour unchanged; the signing key arrives through a
|
|
523
|
+
* provider and the resource path is config).
|
|
524
|
+
*
|
|
525
|
+
* The access token is a short-lived, ES256-signed JWT bound to the signed-in
|
|
526
|
+
* user. It carries the claims the resource server checks LOCALLY against the
|
|
527
|
+
* published JWKS (no introspection round-trip): `iss` (the issuer origin), `aud`
|
|
528
|
+
* (`${origin}${resourcePath}`), `sub`, `email`, `scope` (space-delimited), `iat`,
|
|
529
|
+
* `exp` (short TTL), and `jti`; the JWT header carries `kid` so the verifier can
|
|
530
|
+
* select the public key during rotation.
|
|
531
|
+
*
|
|
532
|
+
* Failures are typed so the caller maps them to the right OAuth challenge
|
|
533
|
+
* (`invalid_token` vs `insufficient_scope`).
|
|
534
|
+
*/
|
|
535
|
+
/** Access-token lifetime — short-lived (15 min) per the spec. */
|
|
536
|
+
declare const ACCESS_TOKEN_TTL_SECONDS: number;
|
|
537
|
+
/** The identity a verified access token resolves to. */
|
|
538
|
+
interface VerifiedAccessToken {
|
|
539
|
+
email: string;
|
|
540
|
+
subject: string;
|
|
541
|
+
scopes: string[];
|
|
542
|
+
}
|
|
543
|
+
/** Distinct verification failure reasons the caller maps to OAuth challenges. */
|
|
544
|
+
type AccessTokenErrorCode = "invalid_token" | "insufficient_scope";
|
|
545
|
+
/** A typed verification failure — `code` drives the `WWW-Authenticate` challenge. */
|
|
546
|
+
declare class AccessTokenError extends Error {
|
|
547
|
+
readonly code: AccessTokenErrorCode;
|
|
548
|
+
constructor(code: AccessTokenErrorCode, message?: string);
|
|
549
|
+
}
|
|
550
|
+
/** Inputs bound into a minted access token. */
|
|
551
|
+
interface SignAccessTokenInput {
|
|
552
|
+
email: string;
|
|
553
|
+
subject: string;
|
|
554
|
+
scopes: readonly McpScope[] | readonly string[];
|
|
555
|
+
origin: string;
|
|
556
|
+
/** Where the MCP resource is mounted. Default `/api/mcp`. */
|
|
557
|
+
resourcePath?: string;
|
|
558
|
+
/** Token lifetime in seconds. Default 15 minutes. */
|
|
559
|
+
ttlSeconds?: number;
|
|
560
|
+
}
|
|
561
|
+
/** Deterministic-clock option shared by mint + verify. */
|
|
562
|
+
interface ClockOption {
|
|
563
|
+
/** Epoch milliseconds; defaults to `Date.now()`. Injected for deterministic tests. */
|
|
564
|
+
now?: number;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Mint an ES256-signed access token bound to the user.
|
|
568
|
+
*
|
|
569
|
+
* Returns `null` when no signing key is configured (safe-by-default: the AS
|
|
570
|
+
* refuses to issue rather than falling back to a weaker mode). Sets the `kid`
|
|
571
|
+
* header from the loaded key so the verifier can resolve the public JWK during
|
|
572
|
+
* rotation.
|
|
573
|
+
*/
|
|
574
|
+
declare function signAccessToken(loadSigningKey: McpSigningKeyProvider, input: SignAccessTokenInput, options?: ClockOption): Promise<string | null>;
|
|
575
|
+
/** Options for {@link verifyAccessToken}. */
|
|
576
|
+
interface VerifyAccessTokenOptions extends ClockOption {
|
|
577
|
+
/** The deployment origin — derives the expected `iss` and `aud`. */
|
|
578
|
+
origin: string;
|
|
579
|
+
/** Where the MCP resource is mounted. Default `/api/mcp`. */
|
|
580
|
+
resourcePath?: string;
|
|
581
|
+
/** When set, the token must carry this scope or verification fails `insufficient_scope`. */
|
|
582
|
+
requiredScope?: McpScope | string;
|
|
583
|
+
}
|
|
584
|
+
declare function verifyAccessToken(loadSigningKey: McpSigningKeyProvider, token: string, options: VerifyAccessTokenOptions): Promise<VerifiedAccessToken>;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The OAuth 2.1 authorization server, as one mount (12-23).
|
|
588
|
+
*
|
|
589
|
+
* `@12-apps/mcp` shipped the OpenAPI→tools generator, the bearer proxy and the two
|
|
590
|
+
* discovery BUILDERS, and held zero authorization logic — which meant every new app
|
|
591
|
+
* still wrote the AS itself: ~1.5k LOC of authorize/token/register plus the code,
|
|
592
|
+
* token, PKCE, rotation and replay machinery under them. All of that is the
|
|
593
|
+
* surface's contract, not a host's, so it lives here.
|
|
594
|
+
*
|
|
595
|
+
* Routes are FRAMEWORK-NEUTRAL descriptors whose handler takes a Fetch `Request`
|
|
596
|
+
* and answers a Fetch `Response`. Unlike the report-builder-shaped surfaces there
|
|
597
|
+
* is no `{ data }` envelope to adapt: an OAuth response is a 302 with a `Location`,
|
|
598
|
+
* a form-encoded exchange answering RFC 6749 §5.1/§5.2 JSON, or an RFC 8414/9728
|
|
599
|
+
* document — shapes fixed by specification that a wrapper would only break. So the
|
|
600
|
+
* adapters are one line each, and a host with a file-per-route layout can export
|
|
601
|
+
* the named handlers directly:
|
|
602
|
+
*
|
|
603
|
+
* export const GET = mcpOauth.handlers.authorize; // app/api/oauth/authorize
|
|
604
|
+
* export const POST = mcpOauth.handlers.token; // app/api/oauth/token
|
|
605
|
+
*
|
|
606
|
+
* What stays the HOST's: the cookie session (`resolveSession`), where the data
|
|
607
|
+
* lives (`stores`), which origins are trusted, the operator gate, and its sign-in
|
|
608
|
+
* path. Everything else is the RFCs'.
|
|
609
|
+
*/
|
|
610
|
+
interface McpOauthRoute {
|
|
611
|
+
method: "GET" | "POST";
|
|
612
|
+
/** Absolute path from the ORIGIN ROOT — `.well-known/*` cannot live under a prefix. */
|
|
613
|
+
path: string;
|
|
614
|
+
handle(request: Request): Promise<Response>;
|
|
615
|
+
}
|
|
616
|
+
interface McpOauthHandlers {
|
|
617
|
+
/** `GET` — Authorization Code + PKCE, identity from the session only. */
|
|
618
|
+
authorize: (request: Request) => Promise<Response>;
|
|
619
|
+
/** `POST` — the two grants, form-encoded, RFC 6749 bodies. */
|
|
620
|
+
token: (request: Request) => Promise<Response>;
|
|
621
|
+
/** `POST` — RFC 7591 dynamic client registration (403 when the gate is off). */
|
|
622
|
+
register: (request: Request) => Promise<Response>;
|
|
623
|
+
/** `GET` — the public JWKS (503 while no key is provisioned). */
|
|
624
|
+
jwks: (request: Request) => Promise<Response>;
|
|
625
|
+
/** `GET` — RFC 8414 authorization-server metadata. */
|
|
626
|
+
authorizationServerMetadata: (request: Request) => Promise<Response>;
|
|
627
|
+
/** `GET` — RFC 9728 protected-resource metadata. */
|
|
628
|
+
protectedResourceMetadata: (request: Request) => Promise<Response>;
|
|
629
|
+
}
|
|
630
|
+
interface ApiMcpOauth {
|
|
631
|
+
/** Every endpoint, in mount order. */
|
|
632
|
+
routes: McpOauthRoute[];
|
|
633
|
+
/** The same handlers by name, for a host whose router is its file tree. */
|
|
634
|
+
handlers: McpOauthHandlers;
|
|
635
|
+
/**
|
|
636
|
+
* Verify a bearer token the way THIS surface mints them — the resource server's
|
|
637
|
+
* half. Bound to the same signing key, resource path and trusted-origin
|
|
638
|
+
* resolution, which is what stops "minted for origin A, verified against origin
|
|
639
|
+
* B" from rejecting valid tokens.
|
|
640
|
+
*/
|
|
641
|
+
verifyBearer: (token: string, request: Request, options?: Omit<VerifyAccessTokenOptions, "origin" | "resourcePath">) => Promise<VerifiedAccessToken>;
|
|
642
|
+
/** The resolved config, for a host that needs the same origin/audience answers. */
|
|
643
|
+
context: McpOauthContext;
|
|
644
|
+
}
|
|
645
|
+
declare function createApiMcpOauth(config: McpOauthConfig): ApiMcpOauth;
|
|
646
|
+
|
|
647
|
+
export { trustedOriginsFromEnv as $, type ApiMcpOauth as A, type StoredMcpConnection as B, type CodeReplayStore as C, DEFAULT_MCP_RESOURCE_PATH as D, type VerifyAccessTokenOptions as E, createApiMcpOauth as F, hashSecret as G, inProcessCodeReplayStore as H, issuer as I, loadSigningKeyFromEnv as J, matchesRedirectUri as K, originFromRequest as L, type McpOauthConfig as M, type NewOAuthClient as N, type OAuthClientStore as O, type ProviderAttributionRule as P, providerFromRedirectUris as Q, type RefreshTokenStore as R, type StoredOAuthClient as S, type TokenEndpointAuthMethod as T, registerClient as U, type VerifiedAccessToken as V, resolveMcpOauthConfig as W, resolveTrustedOrigin as X, resourceAudience as Y, signAccessToken as Z, signingKeyProvider as _, type McpSigningKeyProvider as a, verifyAccessToken as a0, type NewRefreshToken as b, type StoredRefreshToken as c, type McpOauthStores as d, type McpConnectionStore as e, ACCESS_TOKEN_TTL_SECONDS as f, AccessTokenError as g, type AccessTokenErrorCode as h, DEFAULT_OAUTH_PATHS as i, DEFAULT_PROVIDER_ROOTS as j, DEFAULT_SIGNING_KEY_ENV as k, DEFAULT_SIGNING_KEY_ID_ENV as l, MCP_SUPPORTED_SCOPES as m, type McpConnectionRecording as n, type McpOauthContext as o, type McpOauthHandlers as p, type McpOauthPaths as q, type McpOauthRoute as r, type McpOauthSession as s, type McpScope as t, type McpSigningKey as u, type PublicSigningJwk as v, type RegisterClientInput as w, type RegisteredClient as x, SIGNING_ALG as y, type SignAccessTokenInput as z };
|