@12-apps/mcp 1.19.0 → 1.20.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,205 @@
1
+ import { buildAuthorizationServerMetadata } from "../auth/authorization-server-metadata";
2
+ import { buildProtectedResourceMetadata } from "../auth/resource-metadata";
3
+
4
+ import { authorizeEndpoint } from "./authorize";
5
+ import { issuer, resourceAudience } from "./config";
6
+ import {
7
+ notFound,
8
+ resolveMcpOauthConfig,
9
+ type McpOauthConfig,
10
+ type McpOauthContext,
11
+ } from "./context";
12
+ import { registerEndpoint, registrationDisabled } from "./register";
13
+ import { tokenEndpoint } from "./token-grants";
14
+ import {
15
+ verifyAccessToken,
16
+ type VerifiedAccessToken,
17
+ type VerifyAccessTokenOptions,
18
+ } from "./access-token";
19
+
20
+ /**
21
+ * The OAuth 2.1 authorization server, as one mount (12-23).
22
+ *
23
+ * `@12-apps/mcp` shipped the OpenAPI→tools generator, the bearer proxy and the two
24
+ * discovery BUILDERS, and held zero authorization logic — which meant every new app
25
+ * still wrote the AS itself: ~1.5k LOC of authorize/token/register plus the code,
26
+ * token, PKCE, rotation and replay machinery under them. All of that is the
27
+ * surface's contract, not a host's, so it lives here.
28
+ *
29
+ * Routes are FRAMEWORK-NEUTRAL descriptors whose handler takes a Fetch `Request`
30
+ * and answers a Fetch `Response`. Unlike the report-builder-shaped surfaces there
31
+ * is no `{ data }` envelope to adapt: an OAuth response is a 302 with a `Location`,
32
+ * a form-encoded exchange answering RFC 6749 §5.1/§5.2 JSON, or an RFC 8414/9728
33
+ * document — shapes fixed by specification that a wrapper would only break. So the
34
+ * adapters are one line each, and a host with a file-per-route layout can export
35
+ * the named handlers directly:
36
+ *
37
+ * export const GET = mcpOauth.handlers.authorize; // app/api/oauth/authorize
38
+ * export const POST = mcpOauth.handlers.token; // app/api/oauth/token
39
+ *
40
+ * What stays the HOST's: the cookie session (`resolveSession`), where the data
41
+ * lives (`stores`), which origins are trusted, the operator gate, and its sign-in
42
+ * path. Everything else is the RFCs'.
43
+ */
44
+
45
+ export interface McpOauthRoute {
46
+ method: "GET" | "POST";
47
+ /** Absolute path from the ORIGIN ROOT — `.well-known/*` cannot live under a prefix. */
48
+ path: string;
49
+ handle(request: Request): Promise<Response>;
50
+ }
51
+
52
+ export interface McpOauthHandlers {
53
+ /** `GET` — Authorization Code + PKCE, identity from the session only. */
54
+ authorize: (request: Request) => Promise<Response>;
55
+ /** `POST` — the two grants, form-encoded, RFC 6749 bodies. */
56
+ token: (request: Request) => Promise<Response>;
57
+ /** `POST` — RFC 7591 dynamic client registration (403 when the gate is off). */
58
+ register: (request: Request) => Promise<Response>;
59
+ /** `GET` — the public JWKS (503 while no key is provisioned). */
60
+ jwks: (request: Request) => Promise<Response>;
61
+ /** `GET` — RFC 8414 authorization-server metadata. */
62
+ authorizationServerMetadata: (request: Request) => Promise<Response>;
63
+ /** `GET` — RFC 9728 protected-resource metadata. */
64
+ protectedResourceMetadata: (request: Request) => Promise<Response>;
65
+ }
66
+
67
+ export interface ApiMcpOauth {
68
+ /** Every endpoint, in mount order. */
69
+ routes: McpOauthRoute[];
70
+ /** The same handlers by name, for a host whose router is its file tree. */
71
+ handlers: McpOauthHandlers;
72
+ /**
73
+ * Verify a bearer token the way THIS surface mints them — the resource server's
74
+ * half. Bound to the same signing key, resource path and trusted-origin
75
+ * resolution, which is what stops "minted for origin A, verified against origin
76
+ * B" from rejecting valid tokens.
77
+ */
78
+ verifyBearer: (
79
+ token: string,
80
+ request: Request,
81
+ options?: Omit<VerifyAccessTokenOptions, "origin" | "resourcePath">,
82
+ ) => Promise<VerifiedAccessToken>;
83
+ /** The resolved config, for a host that needs the same origin/audience answers. */
84
+ context: McpOauthContext;
85
+ }
86
+
87
+ /** JSON, with the status and cache policy each document wants. */
88
+ function jsonResponse(
89
+ body: unknown,
90
+ status = 200,
91
+ headers: Record<string, string> = {},
92
+ ): Response {
93
+ return new Response(JSON.stringify(body), {
94
+ status,
95
+ headers: { "content-type": "application/json; charset=utf-8", ...headers },
96
+ });
97
+ }
98
+
99
+ /**
100
+ * The public key set, or a 503.
101
+ *
102
+ * Safe-by-default: an unprovisioned AS answers 503 rather than an empty or
103
+ * partial key set, so a client never mistakes "no key yet" for "a usable key".
104
+ */
105
+ async function jwksResponse(context: McpOauthContext): Promise<Response> {
106
+ const key = await context.signingKey();
107
+ if (!key) return jsonResponse({ error: "signing_key_unavailable" }, 503);
108
+ return jsonResponse({ keys: [key.publicJwk] }, 200, {
109
+ // Public, cacheable key set; hosts may cache it and re-fetch on a `kid` miss
110
+ // (rotation). A short max-age keeps the rotation overlap tight.
111
+ "cache-control": "public, max-age=300",
112
+ });
113
+ }
114
+
115
+ /** The two discovery documents, built from ONE origin and ONE scope source. */
116
+ function discoveryHandlers(
117
+ context: McpOauthContext,
118
+ ): Pick<McpOauthHandlers, "authorizationServerMetadata" | "protectedResourceMetadata"> {
119
+ return {
120
+ authorizationServerMetadata: async (request) =>
121
+ jsonResponse(
122
+ buildAuthorizationServerMetadata({
123
+ issuer: issuer(context.originOf(request)),
124
+ scopesSupported: [...context.scopes],
125
+ // The RESOLVED paths, so what a connector reads before its first request
126
+ // is where the endpoints actually are.
127
+ paths: context.paths,
128
+ }),
129
+ ),
130
+ protectedResourceMetadata: async (request) => {
131
+ const origin = context.originOf(request);
132
+ return jsonResponse(
133
+ buildProtectedResourceMetadata({
134
+ resource: resourceAudience(origin, context.resourcePath),
135
+ authorizationServers: [issuer(origin)],
136
+ scopesSupported: [...context.scopes],
137
+ }),
138
+ );
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Every endpoint, behind the operator gate.
145
+ *
146
+ * With the gate off the surface is INERT and answers 404, so a probe cannot tell
147
+ * a disabled AS from an app that has none. Registration is the one exception: it
148
+ * answers 403, because RFC 7591 has a code for "the endpoint is here, but
149
+ * registration is closed" and the documented static-client path is the answer.
150
+ */
151
+ function buildHandlers(context: McpOauthContext): McpOauthHandlers {
152
+ const gated =
153
+ (handler: (request: Request) => Promise<Response>) =>
154
+ async (request: Request): Promise<Response> =>
155
+ context.enabled() ? handler(request) : notFound();
156
+ const discovery = discoveryHandlers(context);
157
+
158
+ return {
159
+ authorize: gated((request) => authorizeEndpoint(context, request)),
160
+ token: gated((request) => tokenEndpoint(context, request)),
161
+ register: async (request) =>
162
+ context.enabled() ? registerEndpoint(context, request) : registrationDisabled(),
163
+ jwks: gated(() => jwksResponse(context)),
164
+ authorizationServerMetadata: gated(discovery.authorizationServerMetadata),
165
+ protectedResourceMetadata: gated(discovery.protectedResourceMetadata),
166
+ };
167
+ }
168
+
169
+ /** Mount order, and the paths a host may have moved. */
170
+ function buildRoutes(context: McpOauthContext, handlers: McpOauthHandlers): McpOauthRoute[] {
171
+ const { paths } = context;
172
+ return [
173
+ {
174
+ method: "GET",
175
+ path: paths.authorizationServerMetadata,
176
+ handle: handlers.authorizationServerMetadata,
177
+ },
178
+ {
179
+ method: "GET",
180
+ path: paths.protectedResourceMetadata,
181
+ handle: handlers.protectedResourceMetadata,
182
+ },
183
+ { method: "GET", path: paths.jwks, handle: handlers.jwks },
184
+ { method: "GET", path: paths.authorize, handle: handlers.authorize },
185
+ { method: "POST", path: paths.token, handle: handlers.token },
186
+ { method: "POST", path: paths.register, handle: handlers.register },
187
+ ];
188
+ }
189
+
190
+ export function createApiMcpOauth(config: McpOauthConfig): ApiMcpOauth {
191
+ const context = resolveMcpOauthConfig(config);
192
+ const handlers = buildHandlers(context);
193
+
194
+ return {
195
+ routes: buildRoutes(context, handlers),
196
+ handlers,
197
+ verifyBearer: (token, request, options) =>
198
+ verifyAccessToken(context.signingKey, token, {
199
+ ...options,
200
+ origin: context.originOf(request),
201
+ resourcePath: context.resourcePath,
202
+ }),
203
+ context,
204
+ };
205
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `@12-apps/mcp/oauth` — the OAuth 2.1 authorization server behind an MCP surface
3
+ * (12-23): register / authorize / token, the JWKS, and the two `.well-known`
4
+ * discovery documents, plus the primitives under them (stateless codes, ES256
5
+ * access tokens, PKCE, hashed rotating refresh tokens with replay revocation).
6
+ *
7
+ * Its own subpath because this half is Node-only — `jose`, `node:crypto` — and the
8
+ * package root is also imported by browsers through `@12-apps/mcp/react`. A barrel
9
+ * is evaluated whole by Node's ESM loader, so mixing the two would drag key
10
+ * material handling into a bundle that has no business with it.
11
+ */
12
+ export {
13
+ createApiMcpOauth,
14
+ type ApiMcpOauth,
15
+ type McpOauthHandlers,
16
+ type McpOauthRoute,
17
+ } from "./create-api-mcp-oauth";
18
+ export {
19
+ DEFAULT_OAUTH_PATHS,
20
+ resolveMcpOauthConfig,
21
+ type McpConnectionRecording,
22
+ type McpOauthConfig,
23
+ type McpOauthContext,
24
+ type McpOauthPaths,
25
+ type McpOauthSession,
26
+ } from "./context";
27
+ export {
28
+ DEFAULT_MCP_RESOURCE_PATH,
29
+ MCP_SUPPORTED_SCOPES,
30
+ issuer,
31
+ originFromRequest,
32
+ resolveTrustedOrigin,
33
+ resourceAudience,
34
+ trustedOriginsFromEnv,
35
+ type McpScope,
36
+ } from "./config";
37
+ export {
38
+ ACCESS_TOKEN_TTL_SECONDS,
39
+ AccessTokenError,
40
+ signAccessToken,
41
+ verifyAccessToken,
42
+ type AccessTokenErrorCode,
43
+ type SignAccessTokenInput,
44
+ type VerifiedAccessToken,
45
+ type VerifyAccessTokenOptions,
46
+ } from "./access-token";
47
+ export {
48
+ AUTHORIZATION_CODE_AUDIENCE,
49
+ AUTHORIZATION_CODE_TTL_SECONDS,
50
+ AuthorizationCodeError,
51
+ mintCode,
52
+ verifyCode,
53
+ type AuthorizationCodeErrorCode,
54
+ type MintCodeInput,
55
+ type VerifiedAuthorizationCode,
56
+ type VerifyCodeOptions,
57
+ } from "./authorization-code";
58
+ export {
59
+ SUPPORTED_CHALLENGE_METHOD,
60
+ UnsupportedChallengeMethodError,
61
+ computeChallenge,
62
+ verifyChallenge,
63
+ type CodeChallengeMethod,
64
+ } from "./pkce";
65
+ export {
66
+ DEFAULT_SIGNING_KEY_ENV,
67
+ DEFAULT_SIGNING_KEY_ID_ENV,
68
+ SIGNING_ALG,
69
+ loadSigningKeyFromEnv,
70
+ signingKeyProvider,
71
+ type McpSigningKey,
72
+ type McpSigningKeyProvider,
73
+ type PublicSigningJwk,
74
+ } from "./keys";
75
+ export {
76
+ DEFAULT_PROVIDER_ROOTS,
77
+ hashSecret,
78
+ matchesRedirectUri,
79
+ providerFromRedirectUris,
80
+ registerClient,
81
+ type ProviderAttributionRule,
82
+ type RegisterClientInput,
83
+ type RegisteredClient,
84
+ } from "./clients";
85
+ export {
86
+ REFRESH_TOKEN_TTL_MS,
87
+ RefreshTokenError,
88
+ getRefreshTokenIdentity,
89
+ hashToken,
90
+ issueRefreshToken,
91
+ rotateRefreshToken,
92
+ type IssuedRefreshToken,
93
+ type RefreshTokenContext,
94
+ type RefreshTokenErrorCode,
95
+ type RefreshTokenIdentity,
96
+ } from "./refresh";
97
+ export {
98
+ inProcessCodeReplayStore,
99
+ type CodeReplayStore,
100
+ } from "./code-replay";
101
+ export {
102
+ createPrismaMcpStores,
103
+ type McpOauthPrisma,
104
+ type McpOauthPrismaProvider,
105
+ } from "./prisma-stores";
106
+ export type {
107
+ McpConnectionStore,
108
+ McpOauthStores,
109
+ NewOAuthClient,
110
+ NewRefreshToken,
111
+ OAuthClientStore,
112
+ RefreshTokenStore,
113
+ StoredMcpConnection,
114
+ StoredOAuthClient,
115
+ StoredRefreshToken,
116
+ TokenEndpointAuthMethod,
117
+ } from "./stores";
@@ -0,0 +1,107 @@
1
+ import { exportJWK, importPKCS8, type CryptoKey, type JWK } from "jose";
2
+
3
+ /**
4
+ * Signing-key / JWK loading for the OAuth authorization server (12-23, ported
5
+ * from future-pay's `lib/mcp/oauth/keys.ts`).
6
+ *
7
+ * ES256 (P-256) from PEM material, the published public JWK (with `kid` for
8
+ * rotation), and a safe-by-default absence signal (`null`) when no key is
9
+ * configured — callers then refuse to issue tokens and serve the JWKS as 503
10
+ * rather than falling back to a weaker mode while the surface is mounted.
11
+ *
12
+ * WHERE the PEM comes from is the host's business: `loadSigningKeyFromEnv` keeps
13
+ * future-pay's env-var wiring, and any other provider (a secrets manager, a KMS
14
+ * export) satisfies the same `McpSigningKeyProvider` shape.
15
+ */
16
+
17
+ /** JWS algorithm for the signing key pair (asymmetric, self-validated via JWKS). */
18
+ export const SIGNING_ALG = "ES256";
19
+
20
+ /** A public JWK safe to publish at the JWKS endpoint (never carries `d`). */
21
+ export interface PublicSigningJwk extends JWK {
22
+ kid: string;
23
+ kty: "EC";
24
+ crv: "P-256";
25
+ alg: typeof SIGNING_ALG;
26
+ use: "sig";
27
+ }
28
+
29
+ /** The loaded signing material: the private key for signing + its public JWK. */
30
+ export interface McpSigningKey {
31
+ privateKey: CryptoKey;
32
+ publicJwk: PublicSigningJwk;
33
+ kid: string;
34
+ }
35
+
36
+ /**
37
+ * How the surface obtains signing material. Returning `null` means "not
38
+ * provisioned": the AS then mints nothing and the JWKS answers 503.
39
+ */
40
+ export type McpSigningKeyProvider = () => Promise<McpSigningKey | null>;
41
+
42
+ async function parseSigningKey(pem: string, kid: string): Promise<McpSigningKey> {
43
+ // `extractable: true` is required so `exportJWK` can derive the public JWK;
44
+ // jose imports keys as non-extractable by default, which blocks the export.
45
+ const privateKey = await importPKCS8(pem, SIGNING_ALG, { extractable: true });
46
+ const jwk = await exportJWK(privateKey);
47
+ // Strip the private component; publish only the public half.
48
+ const { d: _private, ...publicHalf } = jwk;
49
+ void _private;
50
+
51
+ const publicJwk: PublicSigningJwk = {
52
+ ...publicHalf,
53
+ kty: "EC",
54
+ crv: "P-256",
55
+ alg: SIGNING_ALG,
56
+ use: "sig",
57
+ kid,
58
+ };
59
+
60
+ return { privateKey, publicJwk, kid };
61
+ }
62
+
63
+ /**
64
+ * Build a provider over a PKCS#8 PEM + `kid` pair, with a per-process cache.
65
+ *
66
+ * Parsing PKCS#8 and exporting the JWK is pure for a given (pem, kid), so the
67
+ * promise is cached keyed on the material itself. A rotated key (different pem or
68
+ * kid) produces a different cache key and re-parses — the cache never masks a
69
+ * rotation.
70
+ *
71
+ * Rotation is BY `kid`: each key is published in the JWKS and selected by the
72
+ * `kid` header on issued JWTs, so publishing old + new during an overlap window
73
+ * lets both verify.
74
+ */
75
+ export function signingKeyProvider(
76
+ read: () => { pem: string | undefined; kid: string | undefined },
77
+ ): McpSigningKeyProvider {
78
+ let cache: { key: string; promise: Promise<McpSigningKey> } | null = null;
79
+ return async () => {
80
+ const { pem, kid } = read();
81
+ if (!pem || !kid) return null;
82
+ const cacheKey = `${kid} ${pem}`;
83
+ if (cache?.key === cacheKey) return cache.promise;
84
+ const promise = parseSigningKey(pem, kid);
85
+ cache = { key: cacheKey, promise };
86
+ return promise;
87
+ };
88
+ }
89
+
90
+ /** Env var carrying the ES256 private key as a PKCS#8 PEM (future-pay's name). */
91
+ export const DEFAULT_SIGNING_KEY_ENV = "MCP_OAUTH_SIGNING_KEY";
92
+ /** Env var carrying the key id (`kid`) used to select the key during rotation. */
93
+ export const DEFAULT_SIGNING_KEY_ID_ENV = "MCP_OAUTH_SIGNING_KEY_ID";
94
+
95
+ /**
96
+ * The env-backed provider — future-pay's wiring, kept identical, with the
97
+ * variable names as arguments so the package states no host's vocabulary.
98
+ */
99
+ export function loadSigningKeyFromEnv(
100
+ keyEnv: string = DEFAULT_SIGNING_KEY_ENV,
101
+ kidEnv: string = DEFAULT_SIGNING_KEY_ID_ENV,
102
+ ): McpSigningKeyProvider {
103
+ return signingKeyProvider(() => ({
104
+ pem: typeof process === "undefined" ? undefined : process.env?.[keyEnv],
105
+ kid: typeof process === "undefined" ? undefined : process.env?.[kidEnv],
106
+ }));
107
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * PKCE (RFC 7636) S256 challenge helpers for the OAuth authorization server
3
+ * (12-23, ported verbatim from future-pay's `lib/mcp/oauth/pkce.ts`).
4
+ *
5
+ * OAuth 2.1 mandates the `S256` code-challenge method and forbids `plain`, so
6
+ * this module computes `BASE64URL(SHA-256(code_verifier))` and compares it to
7
+ * the stored `code_challenge` in constant time. The authorization endpoint
8
+ * binds a `code_challenge` into the stateless authorization code; the token
9
+ * endpoint calls {@link verifyChallenge} with the presented `code_verifier` to
10
+ * prove the redeeming client is the one that started the flow.
11
+ *
12
+ * `plain` is refused (throws {@link UnsupportedChallengeMethodError}) rather
13
+ * than silently accepted: `plain` offers no protection against an intercepted
14
+ * authorization code, which is the exact threat PKCE exists to close.
15
+ */
16
+
17
+ /** The only PKCE method this server accepts (OAuth 2.1 requires S256). */
18
+ export const SUPPORTED_CHALLENGE_METHOD = "S256";
19
+
20
+ /**
21
+ * PKCE code-challenge methods, including the rejected legacy `plain`.
22
+ *
23
+ * @public exported because it is a parameter type of the exported
24
+ * {@link verifyChallenge}.
25
+ */
26
+ export type CodeChallengeMethod = "S256" | "plain";
27
+
28
+ /** Thrown when a caller supplies a challenge method other than `S256`. */
29
+ export class UnsupportedChallengeMethodError extends Error {
30
+ readonly method: string;
31
+
32
+ constructor(method: string) {
33
+ super(
34
+ `unsupported code_challenge_method '${method}' — only ${SUPPORTED_CHALLENGE_METHOD} is allowed`,
35
+ );
36
+ this.name = "UnsupportedChallengeMethodError";
37
+ this.method = method;
38
+ }
39
+ }
40
+
41
+ /** Encode raw bytes as unpadded base64url (RFC 7636 challenge encoding). */
42
+ function base64UrlEncode(bytes: Uint8Array): string {
43
+ // Buffer.toString("base64url") emits the URL-safe alphabet with no padding.
44
+ return Buffer.from(bytes).toString("base64url");
45
+ }
46
+
47
+ /**
48
+ * Compute the RFC 7636 S256 challenge for a `code_verifier`:
49
+ * `BASE64URL(SHA-256(ASCII(verifier)))`.
50
+ */
51
+ export async function computeChallenge(verifier: string): Promise<string> {
52
+ const data = new TextEncoder().encode(verifier);
53
+ const digest = await crypto.subtle.digest("SHA-256", data);
54
+ return base64UrlEncode(new Uint8Array(digest));
55
+ }
56
+
57
+ /**
58
+ * Constant-time string comparison over the base64url challenge bytes.
59
+ *
60
+ * Returns `false` immediately on a length mismatch (lengths are not secret);
61
+ * for equal-length inputs every byte is compared so the timing does not reveal
62
+ * how many leading characters matched.
63
+ */
64
+ function constantTimeEquals(a: string, b: string): boolean {
65
+ if (a.length !== b.length) return false;
66
+ let mismatch = 0;
67
+ for (let i = 0; i < a.length; i += 1) {
68
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
69
+ }
70
+ return mismatch === 0;
71
+ }
72
+
73
+ /**
74
+ * Verify a presented `code_verifier` against a stored `code_challenge`.
75
+ *
76
+ * Recomputes the S256 challenge from `verifier` and constant-time-compares it
77
+ * to `storedChallenge`. Returns `true` on a match, `false` on a mismatch (or an
78
+ * empty stored challenge). Any method other than `S256` throws
79
+ * {@link UnsupportedChallengeMethodError} — `plain` is never accepted.
80
+ */
81
+ export async function verifyChallenge(
82
+ verifier: string,
83
+ storedChallenge: string,
84
+ method: CodeChallengeMethod | string = SUPPORTED_CHALLENGE_METHOD,
85
+ ): Promise<boolean> {
86
+ if (method !== SUPPORTED_CHALLENGE_METHOD) {
87
+ throw new UnsupportedChallengeMethodError(method);
88
+ }
89
+ if (!storedChallenge) return false;
90
+
91
+ const computed = await computeChallenge(verifier);
92
+ return constantTimeEquals(computed, storedChallenge);
93
+ }