@12-apps/mcp 1.19.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADOPTING.md +248 -0
- package/README.md +39 -12
- package/package.json +28 -9
- package/prisma/mcp.prisma +108 -0
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/scripts/sync-mcp-schema.mjs +61 -0
- package/src/auth/authorization-server-metadata.ts +27 -8
- package/src/coverage-gate/index.ts +243 -0
- package/src/coverage-gate/route-methods.ts +86 -0
- package/src/generate/index.ts +197 -0
- package/src/guide.ts +144 -98
- package/src/hono/index.ts +43 -0
- package/src/index.ts +4 -2
- package/src/oauth/access-token.ts +186 -0
- package/src/oauth/authorization-code.ts +215 -0
- package/src/oauth/authorize.ts +260 -0
- package/src/oauth/clients.ts +158 -0
- package/src/oauth/code-replay.ts +51 -0
- package/src/oauth/config.ts +142 -0
- package/src/oauth/context.ts +253 -0
- package/src/oauth/create-api-mcp-oauth.ts +205 -0
- package/src/oauth/index.ts +117 -0
- package/src/oauth/keys.ts +107 -0
- package/src/oauth/pkce.ts +93 -0
- package/src/oauth/prisma-stores.ts +306 -0
- package/src/oauth/refresh.ts +286 -0
- package/src/oauth/register.ts +282 -0
- package/src/oauth/stores.ts +157 -0
- package/src/oauth/token-grants.ts +326 -0
- package/src/oauth/token-response.ts +154 -0
- package/src/react/ai-onboarding.tsx +54 -13
- package/src/react/index.ts +3 -2
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|