@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,158 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+
3
+ import type {
4
+ OAuthClientStore,
5
+ StoredOAuthClient,
6
+ TokenEndpointAuthMethod,
7
+ } from "./stores";
8
+
9
+ /**
10
+ * Client registration and the open-redirect guard (12-23, ported from
11
+ * future-pay's `lib/mcp/oauth/clients.ts`).
12
+ *
13
+ * A registered client is an external host (a Claude.ai / ChatGPT connector) from
14
+ * RFC 7591 dynamic client registration, or a static registration an operator
15
+ * created out of band. A confidential client's secret is generated HERE, returned
16
+ * exactly once, and stored only as a SHA-256 hash — it is never persisted in
17
+ * plaintext, never logged, and never re-derivable.
18
+ */
19
+
20
+ /** Default grant types for a registered client (OAuth 2.1 code + refresh). */
21
+ const DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const;
22
+
23
+ /** Bytes of entropy for a generated confidential-client secret (→ 64 hex). */
24
+ const CLIENT_SECRET_BYTES = 32;
25
+
26
+ /** RFC 7591 registration input (the durable subset the store persists). */
27
+ export interface RegisterClientInput {
28
+ /** The exact-match redirect-uri allowlist (open-redirect guard). */
29
+ redirectUris: string[];
30
+ clientName?: string | null;
31
+ /**
32
+ * `none` (public PKCE client, the default) or `client_secret_basic`
33
+ * (confidential — a secret is generated and its hash stored).
34
+ */
35
+ tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
36
+ /** Grant types; defaults to authorization_code + refresh_token. */
37
+ grantTypes?: string[];
38
+ scopes: string[];
39
+ }
40
+
41
+ /**
42
+ * The registration RESULT. `clientSecret` is present (plaintext, ONCE) only for a
43
+ * confidential client — it is never stored and never returned again.
44
+ */
45
+ export interface RegisteredClient {
46
+ clientId: string;
47
+ clientSecret?: string;
48
+ redirectUris: string[];
49
+ clientName: string | null;
50
+ tokenEndpointAuthMethod: TokenEndpointAuthMethod;
51
+ grantTypes: string[];
52
+ scopes: string[];
53
+ }
54
+
55
+ /** SHA-256 hex digest — the at-rest form of the client secret. */
56
+ export function hashSecret(secret: string): string {
57
+ return createHash("sha256").update(secret).digest("hex");
58
+ }
59
+
60
+ /**
61
+ * Register an OAuth client under a generated `clientId`. For a confidential
62
+ * client a random secret is generated and its hash stored; the plaintext is
63
+ * returned once.
64
+ */
65
+ export async function registerClient(
66
+ store: OAuthClientStore,
67
+ input: RegisterClientInput,
68
+ ): Promise<RegisteredClient> {
69
+ const clientId = randomUUID();
70
+ const authMethod: TokenEndpointAuthMethod = input.tokenEndpointAuthMethod ?? "none";
71
+ const grantTypes = input.grantTypes ?? [...DEFAULT_GRANT_TYPES];
72
+
73
+ // Only a confidential client gets a secret; a public PKCE client has none.
74
+ const clientSecret =
75
+ authMethod === "client_secret_basic"
76
+ ? randomBytes(CLIENT_SECRET_BYTES).toString("hex")
77
+ : undefined;
78
+
79
+ const row = await store.create({
80
+ clientId,
81
+ clientSecretHash: clientSecret ? hashSecret(clientSecret) : null,
82
+ redirectUris: input.redirectUris,
83
+ clientName: input.clientName ?? null,
84
+ tokenEndpointAuthMethod: authMethod,
85
+ grantTypes,
86
+ scopes: input.scopes,
87
+ });
88
+
89
+ return {
90
+ clientId: row.clientId,
91
+ ...(clientSecret ? { clientSecret } : {}),
92
+ redirectUris: row.redirectUris,
93
+ clientName: row.clientName,
94
+ tokenEndpointAuthMethod: row.tokenEndpointAuthMethod as TokenEndpointAuthMethod,
95
+ grantTypes: row.grantTypes,
96
+ scopes: row.scopes,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Open-redirect guard: a redirect target is accepted ONLY when it EXACTLY equals a
102
+ * registered `redirect_uri`. No normalization, no prefix, no trailing-slash
103
+ * leniency — an intercepted authorization request must not be steerable to any URI
104
+ * the client did not register.
105
+ */
106
+ export function matchesRedirectUri(
107
+ client: Pick<StoredOAuthClient, "redirectUris">,
108
+ redirectUri: string,
109
+ ): boolean {
110
+ if (!redirectUri) return false;
111
+ return client.redirectUris.includes(redirectUri);
112
+ }
113
+
114
+ /**
115
+ * Provider attribution rules: the canonical root domains that own each host's
116
+ * OAuth callback. A redirect host matches a root only as the exact domain or a
117
+ * real (dot-guarded) subdomain — never a suffix, so `evilchatgpt.com` never
118
+ * matches `chatgpt.com`.
119
+ */
120
+ export interface ProviderAttributionRule {
121
+ roots: readonly string[];
122
+ provider: string;
123
+ }
124
+
125
+ /** future-pay's rules, and a sane default for any host talking to the same two. */
126
+ export const DEFAULT_PROVIDER_ROOTS: readonly ProviderAttributionRule[] = [
127
+ { roots: ["claude.ai", "anthropic.com"], provider: "claude" },
128
+ { roots: ["chatgpt.com", "openai.com"], provider: "chatgpt" },
129
+ ];
130
+
131
+ /** Whether `host` is exactly `root` or a real subdomain of it (dot-guarded). */
132
+ function hostMatchesRoot(host: string, root: string): boolean {
133
+ return host === root || host.endsWith(`.${root}`);
134
+ }
135
+
136
+ /**
137
+ * Best-effort provider attribution from a client's redirect URIs: the host that
138
+ * owns the callback (`claude.ai` → claude, `chatgpt.com` → chatgpt). Returns
139
+ * `null` when nothing matches — the UI then falls back to what the owner
140
+ * completed the flow with, and a self-report (the `announce` path) can attribute
141
+ * it later.
142
+ */
143
+ export function providerFromRedirectUris(
144
+ redirectUris: readonly string[],
145
+ rules: readonly ProviderAttributionRule[] = DEFAULT_PROVIDER_ROOTS,
146
+ ): string | null {
147
+ for (const uri of redirectUris) {
148
+ let host: string;
149
+ try {
150
+ host = new URL(uri).host.toLowerCase();
151
+ } catch {
152
+ continue;
153
+ }
154
+ const match = rules.find((rule) => rule.roots.some((root) => hostMatchesRoot(host, root)));
155
+ if (match) return match.provider;
156
+ }
157
+ return null;
158
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Single-use guard for the stateless authorization codes (12-23, ported from
3
+ * future-pay's `lib/mcp/oauth/token-replay.ts`).
4
+ *
5
+ * A code is a signed blob with a `jti`, so "already redeemed" has to be remembered
6
+ * somewhere. The in-process option remembers it IN THIS PROCESS: a small map of
7
+ * `jti → expiry`, self-pruning once the code that carried it would have expired
8
+ * anyway, so the set never grows without bound.
9
+ *
10
+ * ⚠️ MULTI-INSTANCE LIMITATION (best-effort single-use): that map lives in ONE
11
+ * process. On a horizontally-scaled deployment a code could be replayed against a
12
+ * second instance that has not yet seen the `jti`, within the ≤60s code lifetime.
13
+ * Single-use is therefore strictly guaranteed only on a SINGLE instance — which is
14
+ * why choosing it is explicit and cannot happen by omission: `codeReplay` has no
15
+ * default, so a host either names a shared store or types `'in-process'`. BEFORE
16
+ * running this surface on more than one instance, pass a `codeReplay` store backed
17
+ * by something shared and atomic — a short-TTL row with a unique constraint, or a
18
+ * distributed cache with an atomic set-if-absent. The port exists precisely so
19
+ * that is a config change rather than a patch to the grant handler.
20
+ */
21
+
22
+ export interface CodeReplayStore {
23
+ /**
24
+ * Record a code's `jti` as consumed. `false` means it was ALREADY recorded (a
25
+ * replay); `true` is the first redemption. Must be atomic to be a real guard.
26
+ */
27
+ consume(jti: string, nowMs: number): Promise<boolean> | boolean;
28
+ }
29
+
30
+ /** Retain slightly beyond the 60s code TTL to cover the verify clock tolerance. */
31
+ const RETENTION_MS = 90_000;
32
+
33
+ /**
34
+ * The in-process store — correct on ONE instance, see the caveat above. Reached by
35
+ * passing `codeReplay: 'in-process'`, which is a required acknowledgement rather
36
+ * than a default: the config has no default for this field precisely because the
37
+ * only possible one would fail open on a multi-pod deployment.
38
+ */
39
+ export function inProcessCodeReplayStore(): CodeReplayStore {
40
+ const usedJtis = new Map<string, number>();
41
+ return {
42
+ consume(jti: string, nowMs: number): boolean {
43
+ for (const [seen, expiresAt] of usedJtis) {
44
+ if (expiresAt <= nowMs) usedJtis.delete(seen);
45
+ }
46
+ if (usedJtis.has(jti)) return false;
47
+ usedJtis.set(jti, nowMs + RETENTION_MS);
48
+ return true;
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The OAuth 2.1 authorization-server foundation: the shared scope source, the
3
+ * issuer/audience derivation, and the trusted-origin resolver every URL in the
4
+ * surface is built from (12-23, ported from future-pay's
5
+ * `lib/mcp/oauth/config.ts`).
6
+ *
7
+ * Keeping the scopes and the origin resolution in ONE place is what stops the
8
+ * two discovery documents — RFC 8414 `/.well-known/oauth-authorization-server`
9
+ * and RFC 9728 `/.well-known/oauth-protected-resource` — from drifting apart,
10
+ * and what makes a token minted for an origin verify against that same origin.
11
+ *
12
+ * What was env-reading in the host is CONFIG here (the package must not learn a
13
+ * host's variable names); `trustedOriginsFromEnv` is the one-line helper that
14
+ * keeps future-pay's wiring identical.
15
+ */
16
+
17
+ /** Scopes advertised by both discovery documents. `mcp:write` gates mutating tools. */
18
+ export const MCP_SUPPORTED_SCOPES = ["mcp:read", "mcp:write"] as const;
19
+
20
+ export type McpScope = (typeof MCP_SUPPORTED_SCOPES)[number];
21
+
22
+ /** Path the MCP JSON-RPC endpoint is mounted at — the access token's audience. */
23
+ export const DEFAULT_MCP_RESOURCE_PATH = "/api/mcp";
24
+
25
+ /** The OAuth `iss` — the deployment origin, used verbatim. */
26
+ export function issuer(origin: string): string {
27
+ return origin;
28
+ }
29
+
30
+ /** The access-token `aud` — the MCP resource URL (`${origin}${resourcePath}`). */
31
+ export function resourceAudience(
32
+ origin: string,
33
+ resourcePath: string = DEFAULT_MCP_RESOURCE_PATH,
34
+ ): string {
35
+ return `${origin}${resourcePath}`;
36
+ }
37
+
38
+ /**
39
+ * A single bare `host` or `host:port` — no scheme, path, userinfo (`@`), or
40
+ * whitespace. A syntactic guard so a forwarded host cannot smuggle anything but
41
+ * a hostname; it does NOT by itself decide trust (the allowlist does).
42
+ */
43
+ const HOST_ONLY =
44
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::\d{1,5})?$/i;
45
+
46
+ /** ASCII `/`. */
47
+ const SLASH = 0x2f;
48
+
49
+ /**
50
+ * Strip trailing slashes by index, not by regex.
51
+ *
52
+ * `replace(/\/+$/, "")` is quadratic on a string of many slashes — the classic
53
+ * anchored-quantifier backtrack — and this function is reached from an operator's
54
+ * env var AND (through `resolveTrustedOrigin`) from values that arrive with a
55
+ * request. A backwards walk is linear and needs no reasoning about the engine.
56
+ */
57
+ function stripTrailingSlashes(value: string): string {
58
+ let end = value.length;
59
+ while (end > 0 && value.charCodeAt(end - 1) === SLASH) end -= 1;
60
+ return value.slice(0, end);
61
+ }
62
+
63
+ /** Normalize an allowlist entry (trailing `/` stripped, blanks dropped). */
64
+ function normalizeOrigins(origins: readonly string[]): string[] {
65
+ return origins.map((origin) => stripTrailingSlashes(origin.trim())).filter(Boolean);
66
+ }
67
+
68
+ /**
69
+ * Read a comma-separated allowlist out of an environment variable — future-pay
70
+ * passes `trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS')`, so the behaviour
71
+ * is identical while the variable's NAME stays the host's.
72
+ */
73
+ export function trustedOriginsFromEnv(name: string): string[] {
74
+ const raw = typeof process === "undefined" ? undefined : process.env?.[name];
75
+ return raw ? normalizeOrigins(raw.split(",")) : [];
76
+ }
77
+
78
+ /**
79
+ * The origin a request's forwarded headers CLAIM, or `null` when
80
+ * absent/malformed. Only the first hop of a comma-separated list is taken, the
81
+ * host must be a bare host[:port], and the proto is restricted to http/https.
82
+ * This is a claim to be checked against the allowlist — never trusted alone.
83
+ */
84
+ function claimedForwardedOrigin(getHeader: (name: string) => string | null): string | null {
85
+ const host = getHeader("x-forwarded-host")?.split(",")[0]?.trim();
86
+ if (!host || !HOST_ONLY.test(host)) return null;
87
+ const proto = getHeader("x-forwarded-proto")?.split(",")[0]?.trim();
88
+ return `${proto === "http" ? "http" : "https"}://${host}`;
89
+ }
90
+
91
+ /**
92
+ * THE single trusted-origin resolver, shared by token ISSUANCE (the `iss`/`aud` a
93
+ * token is minted with) and bearer VERIFICATION (the expected `aud` a protected
94
+ * route checks). Because both sides pass the SAME request's headers, a token
95
+ * minted for the allowlisted origin verifies against that same origin — they
96
+ * cannot drift (e.g. mint `https://app.example.com` but verify
97
+ * `http://0.0.0.0:3000` and reject a valid token).
98
+ *
99
+ * The origin must NEVER be attacker-controllable. Behind a reverse proxy the
100
+ * server sees only its internal bind on `request.url`, so the public origin comes
101
+ * from the proxy's `X-Forwarded-Host` — but a forwarded host is honored ONLY when
102
+ * it is on the operator-configured allowlist; ANY other value (a spoofed/foreign
103
+ * host, or an absent header) resolves to the canonical (FIRST) allowlisted origin.
104
+ * So even if the edge forwards `X-Forwarded-Host: evil.example.com`, the origin
105
+ * stays the trusted one.
106
+ *
107
+ * With NO allowlist configured a forwarded host is NEVER trusted — a spoofed
108
+ * header must not be able to choose the issuer — so `fallbackOrigin` (the
109
+ * request's OWN origin) is used instead. A proxied deployment therefore REQUIRES
110
+ * the allowlist; until it is set the surface fails closed to the internal origin
111
+ * rather than a foreign one.
112
+ */
113
+ export function resolveTrustedOrigin(
114
+ getHeader: (name: string) => string | null,
115
+ fallbackOrigin: string | undefined,
116
+ trustedOrigins: readonly string[] = [],
117
+ ): string | undefined {
118
+ const [canonical, ...rest] = normalizeOrigins(trustedOrigins);
119
+ // No allowlist → the forwarded host is untrusted; fall back to the request's
120
+ // own origin so a spoofed X-Forwarded-Host can never become the issuer.
121
+ if (!canonical) return fallbackOrigin;
122
+
123
+ const allowed = [canonical, ...rest];
124
+ const claimed = claimedForwardedOrigin(getHeader);
125
+ return claimed && allowed.includes(claimed) ? claimed : canonical;
126
+ }
127
+
128
+ /**
129
+ * The PUBLIC origin every URL in the surface derives from (issuer, endpoint URLs,
130
+ * access-token `iss`/`aud`). A thin wrapper over {@link resolveTrustedOrigin}
131
+ * bound to a `Request`, falling back to the request URL's origin when no
132
+ * allowlist is configured.
133
+ */
134
+ export function originFromRequest(
135
+ request: Request,
136
+ trustedOrigins: readonly string[] = [],
137
+ ): string {
138
+ const fallback = new URL(request.url).origin;
139
+ return (
140
+ resolveTrustedOrigin((name) => request.headers.get(name), fallback, trustedOrigins) ?? fallback
141
+ );
142
+ }
@@ -0,0 +1,253 @@
1
+ import {
2
+ DEFAULT_MCP_RESOURCE_PATH,
3
+ MCP_SUPPORTED_SCOPES,
4
+ originFromRequest,
5
+ } from "./config";
6
+ import {
7
+ inProcessCodeReplayStore,
8
+ type CodeReplayStore,
9
+ } from "./code-replay";
10
+ import { ACCESS_TOKEN_TTL_SECONDS } from "./access-token";
11
+ import { REFRESH_TOKEN_TTL_MS } from "./refresh";
12
+ import { loadSigningKeyFromEnv, type McpSigningKeyProvider } from "./keys";
13
+ import type { ProviderAttributionRule } from "./clients";
14
+ import type { McpOauthStores, StoredOAuthClient } from "./stores";
15
+
16
+ /**
17
+ * The config seam of the authorization server, and its resolved form (12-23).
18
+ *
19
+ * Everything a HOST knows and the package cannot: who the signed-in caller is,
20
+ * where the data lives, which origins are trusted, whether the surface is turned
21
+ * on at all, and where its endpoints are mounted. Everything else — the RFC wire,
22
+ * PKCE, rotation, replay, the discovery documents — is the package's.
23
+ */
24
+
25
+ /** The identity an authorize request binds a code to. From the SESSION only. */
26
+ export interface McpOauthSession {
27
+ /**
28
+ * The OAuth subject (future-pay passes the Google `sub`, falling back to the
29
+ * email). Carried through every rotation so a refreshed token keeps the same
30
+ * stable `sub`.
31
+ */
32
+ subject: string;
33
+ /** The signed-in user's email — the identity the AS binds to. */
34
+ email: string;
35
+ }
36
+
37
+ /** Where each endpoint of the surface lives, from the origin root. */
38
+ export interface McpOauthPaths {
39
+ authorize: string;
40
+ token: string;
41
+ register: string;
42
+ jwks: string;
43
+ authorizationServerMetadata: string;
44
+ protectedResourceMetadata: string;
45
+ }
46
+
47
+ export const DEFAULT_OAUTH_PATHS: McpOauthPaths = {
48
+ // future-pay's paths, and the ones the RFC 8414 document has always advertised.
49
+ authorize: "/api/oauth/authorize",
50
+ token: "/api/oauth/token",
51
+ register: "/api/oauth/register",
52
+ jwks: "/.well-known/jwks.json",
53
+ authorizationServerMetadata: "/.well-known/oauth-authorization-server",
54
+ protectedResourceMetadata: "/.well-known/oauth-protected-resource",
55
+ };
56
+
57
+ /** How a connection's liveness is recorded on a successful grant. */
58
+ export interface McpConnectionRecording {
59
+ /**
60
+ * The host's DB user id for a token's email, or `null` when there is no user row
61
+ * yet (recording is then skipped — email is the identity, not the id).
62
+ */
63
+ resolveUserId: (email: string) => Promise<string | null> | string | null;
64
+ /** Provider attribution rules; defaults to claude/chatgpt roots. */
65
+ providerRules?: readonly ProviderAttributionRule[];
66
+ /** Don't rewrite on every grant — refresh liveness at most this often. */
67
+ activityThrottleMs?: number;
68
+ }
69
+
70
+ export interface McpOauthConfig {
71
+ /** Where the three owned tables live (see `./stores.ts`). */
72
+ stores: McpOauthStores;
73
+ /**
74
+ * Resolve the caller's COOKIE SESSION for the authorize endpoint. `null` sends
75
+ * the caller through the host's sign-in flow; no code is ever minted for an
76
+ * unauthenticated request, and a client can never supply the identity itself.
77
+ */
78
+ resolveSession: (request: Request) => Promise<McpOauthSession | null> | McpOauthSession | null;
79
+ /**
80
+ * The operator gate. `false` makes the whole surface inert — authorize/token/jwks
81
+ * answer 404 and registration answers 403 — which is how future-pay ships it OFF
82
+ * by default (`MCP_BEARER_ENABLED`). Default: enabled (mounting is the opt-in).
83
+ */
84
+ enabled?: boolean | (() => boolean);
85
+ /**
86
+ * Signing material. Default: the env-backed provider with future-pay's variable
87
+ * names. `null` from the provider means "not provisioned": nothing is minted and
88
+ * the JWKS answers 503 rather than falling back to a weaker mode.
89
+ */
90
+ signingKey?: McpSigningKeyProvider;
91
+ /**
92
+ * The trusted PUBLIC origin allowlist — REQUIRED behind a reverse proxy, where
93
+ * the server sees only its internal bind. The FIRST entry is canonical. With
94
+ * none configured a forwarded host is never trusted (see `resolveTrustedOrigin`).
95
+ */
96
+ trustedOrigins?: readonly string[];
97
+ /** Scopes the AS advertises and validates against. Default `mcp:read mcp:write`. */
98
+ scopes?: readonly string[];
99
+ /** Where the MCP resource is mounted — the token audience. Default `/api/mcp`. */
100
+ resourcePath?: string;
101
+ /** Endpoint paths, if the host mounts them somewhere else. */
102
+ paths?: Partial<McpOauthPaths>;
103
+ /** Where an unauthenticated authorize request is sent. Default `/login`. */
104
+ loginPath?: string;
105
+ /**
106
+ * The query parameter carrying the post-login return path. Default
107
+ * `callbackUrl` (Auth.js's name).
108
+ */
109
+ loginCallbackParam?: string;
110
+ accessTokenTtlSeconds?: number;
111
+ refreshTokenTtlMs?: number;
112
+ /**
113
+ * The single-use guard for authorization codes — REQUIRED, and required on
114
+ * purpose. Pass a shared atomic store, or the literal `'in-process'` to accept
115
+ * the single-instance limitation explicitly.
116
+ *
117
+ * There is deliberately NO default, because a default here would be the only one
118
+ * in this config that fails OPEN. Every other one fails closed: no signing key
119
+ * mints nothing and answers JWKS 503; `enabled: false` is 404 everywhere; an
120
+ * empty `trustedOrigins` never trusts a forwarded host. An in-process default
121
+ * instead silently permits cross-instance code replay — against an OAuth 2.1
122
+ * MUST, on the very deployment shape a reusable package exists for (two pods
123
+ * behind one load balancer), with nothing in the types to notice. Scaling out
124
+ * must not be able to weaken the guard without somebody having typed something.
125
+ */
126
+ codeReplay: CodeReplayStore | "in-process";
127
+ /**
128
+ * Approve an authorize request before a code is minted — the CONSENT step.
129
+ *
130
+ * Registration is open whenever `enabled` is true (RFC 7591), so without an
131
+ * approval step anyone may register a client carrying their OWN redirect URI and
132
+ * their OWN scope ceiling, send a signed-in admin one link, and have the endpoint
133
+ * mint them a code with no interaction: the redirect URI is exact-matched against
134
+ * the attacker's own registration and the scope ceiling is the attacker's too, so
135
+ * every other guard here holds and none of them helps.
136
+ *
137
+ * Until a host supplies this, `authorize` REFUSES any client it cannot see the
138
+ * operator behind — i.e. any client not named in {@link preApprovedClientIds}.
139
+ * Return `false` to deny (the caller gets an `access_denied` redirect, exactly as
140
+ * a human refusal would).
141
+ */
142
+ resolveApproval?: (
143
+ request: Request,
144
+ client: StoredOAuthClient,
145
+ scopes: readonly string[],
146
+ ) => Promise<boolean> | boolean;
147
+ /**
148
+ * Client ids the OPERATOR registered, exempt from the approval gate above — the
149
+ * escape hatch for a host that ships its own first-party clients and has no
150
+ * consent screen to offer. Anything NOT listed here is treated as dynamically
151
+ * registered, i.e. as attacker-controllable.
152
+ */
153
+ preApprovedClientIds?: readonly string[];
154
+ /** Liveness recording on a grant; omit to record nothing. */
155
+ connections?: McpConnectionRecording;
156
+ }
157
+
158
+ /** The config with every default applied — what the handlers actually read. */
159
+ export interface McpOauthContext {
160
+ stores: McpOauthStores;
161
+ resolveSession: McpOauthConfig["resolveSession"];
162
+ enabled: () => boolean;
163
+ signingKey: McpSigningKeyProvider;
164
+ trustedOrigins: readonly string[];
165
+ scopes: readonly string[];
166
+ resourcePath: string;
167
+ paths: McpOauthPaths;
168
+ loginPath: string;
169
+ loginCallbackParam: string;
170
+ accessTokenTtlSeconds: number;
171
+ refreshTokenTtlMs: number;
172
+ codeReplay: CodeReplayStore;
173
+ /**
174
+ * The resolved consent decision for one authorize request. Always present: with
175
+ * no host seam it refuses every client the operator did not pre-approve, so the
176
+ * handler has no "unset" case to forget.
177
+ */
178
+ approve: (
179
+ request: Request,
180
+ client: StoredOAuthClient,
181
+ scopes: readonly string[],
182
+ ) => Promise<boolean>;
183
+ connections?: McpConnectionRecording;
184
+ /** The trusted public origin for THIS request (issuance and verification agree). */
185
+ originOf: (request: Request) => string;
186
+ }
187
+
188
+ /** The surface's own shape: what it advertises, where it lives, how long it lasts. */
189
+ function resolveSurface(
190
+ config: McpOauthConfig,
191
+ ): Pick<
192
+ McpOauthContext,
193
+ | "scopes"
194
+ | "resourcePath"
195
+ | "paths"
196
+ | "loginPath"
197
+ | "loginCallbackParam"
198
+ | "accessTokenTtlSeconds"
199
+ | "refreshTokenTtlMs"
200
+ > {
201
+ return {
202
+ scopes: config.scopes ?? [...MCP_SUPPORTED_SCOPES],
203
+ resourcePath: config.resourcePath ?? DEFAULT_MCP_RESOURCE_PATH,
204
+ paths: { ...DEFAULT_OAUTH_PATHS, ...config.paths },
205
+ loginPath: config.loginPath ?? "/login",
206
+ loginCallbackParam: config.loginCallbackParam ?? "callbackUrl",
207
+ accessTokenTtlSeconds: config.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
208
+ refreshTokenTtlMs: config.refreshTokenTtlMs ?? REFRESH_TOKEN_TTL_MS,
209
+ };
210
+ }
211
+
212
+ export function resolveMcpOauthConfig(config: McpOauthConfig): McpOauthContext {
213
+ const enabled = config.enabled ?? true;
214
+ const trustedOrigins = config.trustedOrigins ?? [];
215
+ return {
216
+ stores: config.stores,
217
+ resolveSession: config.resolveSession,
218
+ // Mounting is the opt-in, so the gate defaults to ON; a host that ships the
219
+ // surface dark passes its own flag (future-pay: `MCP_BEARER_ENABLED`).
220
+ enabled: typeof enabled === "function" ? enabled : () => enabled,
221
+ // `null` from the provider means "not provisioned": nothing is minted and the
222
+ // JWKS answers 503 rather than falling back to a weaker mode.
223
+ signingKey: config.signingKey ?? loadSigningKeyFromEnv(),
224
+ trustedOrigins,
225
+ ...resolveSurface(config),
226
+ // `'in-process'` is an ACKNOWLEDGEMENT, not a default — see the field's docs.
227
+ codeReplay:
228
+ config.codeReplay === "in-process" ? inProcessCodeReplayStore() : config.codeReplay,
229
+ approve: resolveApprover(config),
230
+ ...(config.connections ? { connections: config.connections } : {}),
231
+ originOf: (request) => originFromRequest(request, trustedOrigins),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * The consent decision, resolved once. A host seam wins; otherwise only a client
237
+ * the OPERATOR named may proceed, so an open registration endpoint cannot mint a
238
+ * code for a client nobody approved.
239
+ */
240
+ function resolveApprover(config: McpOauthConfig): McpOauthContext["approve"] {
241
+ const preApproved = new Set(config.preApprovedClientIds ?? []);
242
+ const { resolveApproval } = config;
243
+ return async (request, client, scopes) => {
244
+ if (preApproved.has(client.clientId)) return true;
245
+ if (!resolveApproval) return false;
246
+ return resolveApproval(request, client, scopes);
247
+ };
248
+ }
249
+
250
+ /** The gate's own answer: 404, so a disabled surface looks like no surface. */
251
+ export function notFound(): Response {
252
+ return new Response("Not Found", { status: 404 });
253
+ }