@12-apps/mcp 1.18.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.
package/src/index.ts CHANGED
@@ -73,4 +73,5 @@ export {
73
73
  buildAuthorizationServerMetadata,
74
74
  type AuthorizationServerMetadata,
75
75
  type AuthorizationServerMetadataInput,
76
+ type AuthorizationServerPaths,
76
77
  } from "./auth/authorization-server-metadata";
@@ -0,0 +1,186 @@
1
+ import { SignJWT, jwtVerify, importJWK, type JWTPayload } from "jose";
2
+
3
+ import { issuer, resourceAudience, DEFAULT_MCP_RESOURCE_PATH, type McpScope } from "./config";
4
+ import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
5
+
6
+ /**
7
+ * JWT access-token issuer + verifier (12-23, ported from future-pay's
8
+ * `lib/mcp/oauth/jwt.ts` — behaviour unchanged; the signing key arrives through a
9
+ * provider and the resource path is config).
10
+ *
11
+ * The access token is a short-lived, ES256-signed JWT bound to the signed-in
12
+ * user. It carries the claims the resource server checks LOCALLY against the
13
+ * published JWKS (no introspection round-trip): `iss` (the issuer origin), `aud`
14
+ * (`${origin}${resourcePath}`), `sub`, `email`, `scope` (space-delimited), `iat`,
15
+ * `exp` (short TTL), and `jti`; the JWT header carries `kid` so the verifier can
16
+ * select the public key during rotation.
17
+ *
18
+ * Failures are typed so the caller maps them to the right OAuth challenge
19
+ * (`invalid_token` vs `insufficient_scope`).
20
+ */
21
+
22
+ /** Access-token lifetime — short-lived (15 min) per the spec. */
23
+ export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
24
+
25
+ /** Clock skew tolerated on `exp`/`iat` validation, in seconds. */
26
+ const CLOCK_TOLERANCE_SECONDS = 5;
27
+
28
+ /** The identity a verified access token resolves to. */
29
+ export interface VerifiedAccessToken {
30
+ email: string;
31
+ subject: string;
32
+ scopes: string[];
33
+ }
34
+
35
+ /** Distinct verification failure reasons the caller maps to OAuth challenges. */
36
+ export type AccessTokenErrorCode = "invalid_token" | "insufficient_scope";
37
+
38
+ /** A typed verification failure — `code` drives the `WWW-Authenticate` challenge. */
39
+ export class AccessTokenError extends Error {
40
+ readonly code: AccessTokenErrorCode;
41
+
42
+ constructor(code: AccessTokenErrorCode, message?: string) {
43
+ super(message ?? code);
44
+ this.name = "AccessTokenError";
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ /** Inputs bound into a minted access token. */
50
+ export interface SignAccessTokenInput {
51
+ email: string;
52
+ subject: string;
53
+ scopes: readonly McpScope[] | readonly string[];
54
+ origin: string;
55
+ /** Where the MCP resource is mounted. Default `/api/mcp`. */
56
+ resourcePath?: string;
57
+ /** Token lifetime in seconds. Default 15 minutes. */
58
+ ttlSeconds?: number;
59
+ }
60
+
61
+ /** Deterministic-clock option shared by mint + verify. */
62
+ interface ClockOption {
63
+ /** Epoch milliseconds; defaults to `Date.now()`. Injected for deterministic tests. */
64
+ now?: number;
65
+ }
66
+
67
+ /** The full access-token claim set (beyond the registered JWT claims). */
68
+ interface AccessTokenClaims extends JWTPayload {
69
+ email: string;
70
+ scope: string;
71
+ }
72
+
73
+ function nowSeconds(now?: number): number {
74
+ return Math.floor((now ?? Date.now()) / 1000);
75
+ }
76
+
77
+ /**
78
+ * Mint an ES256-signed access token bound to the user.
79
+ *
80
+ * Returns `null` when no signing key is configured (safe-by-default: the AS
81
+ * refuses to issue rather than falling back to a weaker mode). Sets the `kid`
82
+ * header from the loaded key so the verifier can resolve the public JWK during
83
+ * rotation.
84
+ */
85
+ export async function signAccessToken(
86
+ loadSigningKey: McpSigningKeyProvider,
87
+ input: SignAccessTokenInput,
88
+ options?: ClockOption,
89
+ ): Promise<string | null> {
90
+ const key = await loadSigningKey();
91
+ if (!key) return null;
92
+
93
+ const iat = nowSeconds(options?.now);
94
+ const exp = iat + (input.ttlSeconds ?? ACCESS_TOKEN_TTL_SECONDS);
95
+ const scope = input.scopes.join(" ");
96
+
97
+ return new SignJWT({ email: input.email, scope } satisfies AccessTokenClaims)
98
+ .setProtectedHeader({ alg: SIGNING_ALG, kid: key.kid })
99
+ .setIssuer(issuer(input.origin))
100
+ .setAudience(resourceAudience(input.origin, input.resourcePath ?? DEFAULT_MCP_RESOURCE_PATH))
101
+ .setSubject(input.subject)
102
+ .setIssuedAt(iat)
103
+ .setExpirationTime(exp)
104
+ .setJti(crypto.randomUUID())
105
+ .sign(key.privateKey);
106
+ }
107
+
108
+ /** Options for {@link verifyAccessToken}. */
109
+ export interface VerifyAccessTokenOptions extends ClockOption {
110
+ /** The deployment origin — derives the expected `iss` and `aud`. */
111
+ origin: string;
112
+ /** Where the MCP resource is mounted. Default `/api/mcp`. */
113
+ resourcePath?: string;
114
+ /** When set, the token must carry this scope or verification fails `insufficient_scope`. */
115
+ requiredScope?: McpScope | string;
116
+ }
117
+
118
+ /** Parse the space-delimited `scope` claim into a de-duplicated string array. */
119
+ function parseScopes(scope: unknown): string[] {
120
+ if (typeof scope !== "string" || scope.trim() === "") return [];
121
+ return [...new Set(scope.trim().split(/\s+/))];
122
+ }
123
+
124
+ /**
125
+ * Verify a bearer access token locally against the published JWKS public key.
126
+ *
127
+ * Checks signature (via the public JWK selected by the token's `kid`), `iss`,
128
+ * `aud`, and `exp`. When `requiredScope` is supplied, also enforces scope. On
129
+ * success returns `{ email, subject, scopes }`; on failure throws an
130
+ * {@link AccessTokenError} whose `code` distinguishes `invalid_token` (bad
131
+ * signature / wrong issuer / wrong audience / expired / malformed / unconfigured
132
+ * key) from `insufficient_scope` (a valid token lacking the required scope).
133
+ */
134
+ /**
135
+ * The cryptographic half: signature, `iss`, `aud`, `exp`.
136
+ *
137
+ * Every jose failure — bad signature, wrong issuer, wrong audience, expiry,
138
+ * malformed token, unknown key — collapses into ONE opaque `invalid_token`. A
139
+ * message naming the failed claim would be an oracle for the next attempt.
140
+ */
141
+ async function verifiedPayload(
142
+ loadSigningKey: McpSigningKeyProvider,
143
+ token: string,
144
+ options: VerifyAccessTokenOptions,
145
+ ): Promise<JWTPayload> {
146
+ const key = await loadSigningKey();
147
+ // No signing key configured → nothing can verify (safe-by-default).
148
+ if (!key) throw new AccessTokenError("invalid_token", "no signing key configured");
149
+
150
+ try {
151
+ const { payload } = await jwtVerify(token, await importJWK(key.publicJwk, SIGNING_ALG), {
152
+ algorithms: [SIGNING_ALG],
153
+ issuer: issuer(options.origin),
154
+ audience: resourceAudience(options.origin, options.resourcePath ?? DEFAULT_MCP_RESOURCE_PATH),
155
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
156
+ currentDate: options.now === undefined ? undefined : new Date(options.now),
157
+ });
158
+ return payload;
159
+ } catch {
160
+ throw new AccessTokenError("invalid_token", "token verification failed");
161
+ }
162
+ }
163
+
164
+ export async function verifyAccessToken(
165
+ loadSigningKey: McpSigningKeyProvider,
166
+ token: string,
167
+ options: VerifyAccessTokenOptions,
168
+ ): Promise<VerifiedAccessToken> {
169
+ const payload = await verifiedPayload(loadSigningKey, token, options);
170
+
171
+ const email = typeof payload.email === "string" ? payload.email : null;
172
+ const subject = typeof payload.sub === "string" ? payload.sub : null;
173
+ if (!email || !subject) {
174
+ throw new AccessTokenError("invalid_token", "missing subject or email claim");
175
+ }
176
+
177
+ const scopes = parseScopes(payload.scope);
178
+ if (options.requiredScope && !scopes.includes(options.requiredScope)) {
179
+ throw new AccessTokenError(
180
+ "insufficient_scope",
181
+ `token lacks required scope '${options.requiredScope}'`,
182
+ );
183
+ }
184
+
185
+ return { email, subject, scopes };
186
+ }
@@ -0,0 +1,215 @@
1
+ import { SignJWT, jwtVerify, importJWK, type JWTPayload } from "jose";
2
+
3
+ import { issuer } from "./config";
4
+ import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
5
+
6
+ /**
7
+ * Stateless authorization-code mint/verify (12-23, ported from future-pay's
8
+ * `lib/mcp/oauth/authorization-code.ts` — behaviour unchanged; the signing key
9
+ * arrives through a provider instead of an env read).
10
+ *
11
+ * The authorization code is a short-lived (<=60s) ES256-signed JWT — no DB table,
12
+ * no cleanup job. It binds the signed-in user (`sub`/`email`), the `client_id`,
13
+ * the `redirect_uri`, the PKCE `code_challenge`, and the requested `scope`, plus a
14
+ * unique `jti` the token endpoint records once to enforce single-use (replay)
15
+ * semantics on top of the short expiry.
16
+ *
17
+ * The code carries a DISTINCT audience (`oauth:code`) from the access token
18
+ * (`${origin}/api/mcp`), so a code can never be presented to the resource server
19
+ * as a bearer access token (and vice versa): {@link verifyCode} pins
20
+ * `audience: "oauth:code"`, and the access-token verifier pins the resource
21
+ * audience — each rejects the other's blobs.
22
+ */
23
+
24
+ /**
25
+ * Audience pinning the code to the OAuth code-exchange step only. Distinct from
26
+ * the access-token audience so a code cannot be replayed as an access token.
27
+ */
28
+ export const AUTHORIZATION_CODE_AUDIENCE = "oauth:code";
29
+
30
+ /** Authorization-code lifetime — single-use and short-lived (<=60s per spec). */
31
+ export const AUTHORIZATION_CODE_TTL_SECONDS = 60;
32
+
33
+ /** Clock skew tolerated on `exp`/`iat` validation, in seconds. */
34
+ const CLOCK_TOLERANCE_SECONDS = 5;
35
+
36
+ /** Fields bound into a minted authorization code. */
37
+ export interface MintCodeInput {
38
+ /** The OAuth subject bound to the code (identity from the cookie session). */
39
+ sub: string;
40
+ /** The signed-in user's email (the identity all downstream tokens bind to). */
41
+ email: string;
42
+ /** The OAuth client the code is issued to. */
43
+ clientId: string;
44
+ /** The exact registered redirect URI the flow started with. */
45
+ redirectUri: string;
46
+ /** The PKCE S256 `code_challenge` the token endpoint verifies against. */
47
+ codeChallenge: string;
48
+ /** The requested scope (space-delimited), carried through to the token. */
49
+ scope: string;
50
+ /** The deployment origin — derives the code's `iss`. */
51
+ origin: string;
52
+ }
53
+
54
+ /** The bound fields a verified authorization code resolves to. */
55
+ export interface VerifiedAuthorizationCode {
56
+ sub: string;
57
+ email: string;
58
+ clientId: string;
59
+ redirectUri: string;
60
+ codeChallenge: string;
61
+ scope: string;
62
+ /** The one-time identifier the token endpoint records to enforce single-use. */
63
+ jti: string;
64
+ }
65
+
66
+ /** The single failure discriminator for the OAuth token endpoint. */
67
+ export type AuthorizationCodeErrorCode = "invalid_grant";
68
+
69
+ /**
70
+ * A typed authorization-code failure. Every rejection (expired, wrong-audience,
71
+ * wrong-issuer, tampered, bad-signature, unconfigured key) surfaces as
72
+ * `invalid_grant` per RFC 6749 §5.2 for the token endpoint.
73
+ */
74
+ export class AuthorizationCodeError extends Error {
75
+ readonly code: AuthorizationCodeErrorCode;
76
+
77
+ constructor(message?: string) {
78
+ super(message ?? "invalid_grant");
79
+ this.name = "AuthorizationCodeError";
80
+ this.code = "invalid_grant";
81
+ }
82
+ }
83
+
84
+ /** Deterministic-clock option shared by mint + verify. */
85
+ interface ClockOption {
86
+ /** Epoch milliseconds; defaults to `Date.now()`. Injected for deterministic tests. */
87
+ now?: number;
88
+ }
89
+
90
+ /** The JWT claim shape of an authorization code. */
91
+ interface AuthorizationCodeClaims extends JWTPayload {
92
+ email: string;
93
+ client_id: string;
94
+ redirect_uri: string;
95
+ code_challenge: string;
96
+ scope: string;
97
+ }
98
+
99
+ function nowSeconds(now?: number): number {
100
+ return Math.floor((now ?? Date.now()) / 1000);
101
+ }
102
+
103
+ /** Read a required string claim, or `null` when absent/wrong-typed. */
104
+ function stringClaim(payload: JWTPayload, key: string): string | null {
105
+ const value = payload[key];
106
+ return typeof value === "string" ? value : null;
107
+ }
108
+
109
+ /**
110
+ * Extract the bound fields from an already-signature/iss/aud/exp-validated code
111
+ * payload, enforcing that every required claim is a present string (`scope` may be
112
+ * the empty string but must be present). Throws {@link AuthorizationCodeError}
113
+ * when any required bound field is missing or wrong-typed.
114
+ */
115
+ function extractBoundFields(payload: JWTPayload): VerifiedAuthorizationCode {
116
+ const sub = stringClaim(payload, "sub");
117
+ const email = stringClaim(payload, "email");
118
+ const clientId = stringClaim(payload, "client_id");
119
+ const redirectUri = stringClaim(payload, "redirect_uri");
120
+ const codeChallenge = stringClaim(payload, "code_challenge");
121
+ const scope = stringClaim(payload, "scope");
122
+ const jti = stringClaim(payload, "jti");
123
+
124
+ if (!sub || !email || !clientId || !redirectUri || !codeChallenge || scope === null || !jti) {
125
+ throw new AuthorizationCodeError("code is missing required bound fields");
126
+ }
127
+
128
+ return { sub, email, clientId, redirectUri, codeChallenge, scope, jti };
129
+ }
130
+
131
+ /**
132
+ * Mint a single-use, stateless authorization code bound to the flow inputs.
133
+ *
134
+ * Returns `null` when no signing key is configured (safe-by-default: the AS
135
+ * refuses to issue rather than falling back to a weaker mode). Sets the `kid`
136
+ * header so the same key resolves the code at verify time.
137
+ */
138
+ export async function mintCode(
139
+ loadSigningKey: McpSigningKeyProvider,
140
+ input: MintCodeInput,
141
+ options?: ClockOption,
142
+ ): Promise<string | null> {
143
+ const key = await loadSigningKey();
144
+ if (!key) return null;
145
+
146
+ const iat = nowSeconds(options?.now);
147
+ const exp = iat + AUTHORIZATION_CODE_TTL_SECONDS;
148
+
149
+ const claims: AuthorizationCodeClaims = {
150
+ email: input.email,
151
+ client_id: input.clientId,
152
+ redirect_uri: input.redirectUri,
153
+ code_challenge: input.codeChallenge,
154
+ scope: input.scope,
155
+ };
156
+
157
+ return new SignJWT(claims)
158
+ .setProtectedHeader({ alg: SIGNING_ALG, kid: key.kid })
159
+ .setIssuer(issuer(input.origin))
160
+ .setAudience(AUTHORIZATION_CODE_AUDIENCE)
161
+ .setSubject(input.sub)
162
+ .setIssuedAt(iat)
163
+ .setExpirationTime(exp)
164
+ .setJti(crypto.randomUUID())
165
+ .sign(key.privateKey);
166
+ }
167
+
168
+ /** Options for {@link verifyCode}. */
169
+ export interface VerifyCodeOptions extends ClockOption {
170
+ /** The deployment origin — derives the expected `iss`. */
171
+ origin: string;
172
+ }
173
+
174
+ /**
175
+ * Verify a stateless authorization code and return its bound fields.
176
+ *
177
+ * Validates signature (via the public JWK selected by `kid`), `iss`, the
178
+ * `oauth:code` audience, and `exp`. Every failure — expired, wrong-audience (e.g.
179
+ * an access token), wrong-issuer, tampered, bad-signature, or no configured key —
180
+ * throws an {@link AuthorizationCodeError} (`invalid_grant`).
181
+ *
182
+ * The returned `jti` is the one-time identifier the token endpoint records to
183
+ * enforce single-use on top of the short expiry (replay guard).
184
+ */
185
+ export async function verifyCode(
186
+ loadSigningKey: McpSigningKeyProvider,
187
+ code: string,
188
+ options: VerifyCodeOptions,
189
+ ): Promise<VerifiedAuthorizationCode> {
190
+ const key = await loadSigningKey();
191
+ if (!key) {
192
+ // No signing key configured → nothing can verify (safe-by-default).
193
+ throw new AuthorizationCodeError("no signing key configured");
194
+ }
195
+
196
+ const publicKey = await importJWK(key.publicJwk, SIGNING_ALG);
197
+
198
+ let payload: JWTPayload;
199
+ try {
200
+ const result = await jwtVerify(code, publicKey, {
201
+ algorithms: [SIGNING_ALG],
202
+ issuer: issuer(options.origin),
203
+ audience: AUTHORIZATION_CODE_AUDIENCE,
204
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
205
+ currentDate: options.now === undefined ? undefined : new Date(options.now),
206
+ });
207
+ payload = result.payload;
208
+ } catch {
209
+ // jose throws on bad signature, wrong iss/aud, expiry, malformed token,
210
+ // unknown key — all map to a single opaque `invalid_grant`.
211
+ throw new AuthorizationCodeError("code verification failed");
212
+ }
213
+
214
+ return extractBoundFields(payload);
215
+ }
@@ -0,0 +1,260 @@
1
+ import { mintCode } from "./authorization-code";
2
+ import { matchesRedirectUri } from "./clients";
3
+ import type { McpOauthContext } from "./context";
4
+ import { SUPPORTED_CHALLENGE_METHOD } from "./pkce";
5
+ import type { StoredOAuthClient } from "./stores";
6
+
7
+ /**
8
+ * The OAuth 2.1 Authorization Code + PKCE authorization endpoint (12-23, ported
9
+ * from future-pay's `app/api/oauth/authorize/route.ts`).
10
+ *
11
+ * It renders no UI: it authenticates the caller against the host's cookie session
12
+ * (through `resolveSession`), validates the request, and either 302-redirects an
13
+ * unauthenticated caller into the host's sign-in flow (so the flow resumes
14
+ * post-login) or, for a signed-in caller with a valid request, mints a stateless
15
+ * authorization code bound to the SESSION identity and 302-redirects back to the
16
+ * client's registered `redirect_uri` with the code and echoed `state`.
17
+ *
18
+ * Security invariants, unchanged:
19
+ * - **Open-redirect prevention:** `client_id` + `redirect_uri` are validated
20
+ * against the registered client BEFORE anything else; an unknown client or a
21
+ * `redirect_uri` that is not an EXACT registered match yields a 400 plain-text
22
+ * response — the endpoint NEVER redirects an error to an unvalidated URI. Only
23
+ * once the URI is validated do other failures redirect back to it.
24
+ * - **Mandatory PKCE S256:** a missing `code_challenge`, or a method other than
25
+ * `S256` (incl. `plain`), is rejected.
26
+ * - **Identity from the session only:** `sub`/`email` come solely from the
27
+ * verified session; a client can never supply the identity via a query param.
28
+ * - **No key, no code:** an unprovisioned signing key is a `server_error`
29
+ * redirect, never a weaker mode.
30
+ */
31
+
32
+ /** OAuth 2.1 error codes this endpoint can emit on a validated redirect_uri. */
33
+ type AuthorizeErrorCode =
34
+ | "invalid_request"
35
+ | "unsupported_response_type"
36
+ | "invalid_scope"
37
+ /** The resource owner said no — or nobody was asked and nobody approved. */
38
+ | "access_denied"
39
+ | "server_error";
40
+
41
+ /** The parsed, still-untrusted query parameters of an authorize request. */
42
+ interface AuthorizeParams {
43
+ responseType: string | null;
44
+ clientId: string | null;
45
+ redirectUri: string | null;
46
+ codeChallenge: string | null;
47
+ codeChallengeMethod: string | null;
48
+ scope: string | null;
49
+ state: string | null;
50
+ }
51
+
52
+ function parseParams(url: URL): AuthorizeParams {
53
+ const q = url.searchParams;
54
+ return {
55
+ responseType: q.get("response_type"),
56
+ clientId: q.get("client_id"),
57
+ redirectUri: q.get("redirect_uri"),
58
+ codeChallenge: q.get("code_challenge"),
59
+ codeChallengeMethod: q.get("code_challenge_method"),
60
+ scope: q.get("scope"),
61
+ state: q.get("state"),
62
+ };
63
+ }
64
+
65
+ /** A 302 response to `location` with no body. */
66
+ function redirectTo(location: string): Response {
67
+ return new Response(null, { status: 302, headers: { location } });
68
+ }
69
+
70
+ /**
71
+ * A 400 plain-text refusal used ONLY when the `redirect_uri`/`client_id` are
72
+ * themselves invalid — i.e. there is no validated URI to safely redirect an error
73
+ * to (the open-redirect guard).
74
+ */
75
+ function badRequest(message: string): Response {
76
+ return new Response(message, {
77
+ status: 400,
78
+ headers: { "content-type": "text/plain; charset=utf-8" },
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Build an error redirect back to the (already-validated) `redirect_uri`, carrying
84
+ * the OAuth `error` and the echoed `state` per OAuth 2.1 §4.1.2.1.
85
+ */
86
+ function errorRedirect(
87
+ redirectUri: string,
88
+ error: AuthorizeErrorCode,
89
+ state: string | null,
90
+ ): Response {
91
+ const target = new URL(redirectUri);
92
+ target.searchParams.set("error", error);
93
+ if (state !== null) target.searchParams.set("state", state);
94
+ return redirectTo(target.toString());
95
+ }
96
+
97
+ /**
98
+ * Whether every space-delimited requested scope is within `allowed`. An
99
+ * empty/absent scope is permitted (the server applies its default), but any present
100
+ * scope must be in `allowed` — and `allowed` is the SPECIFIC CLIENT's registered
101
+ * scopes, so a client that registered for only `mcp:read` cannot request
102
+ * `mcp:write` and be issued a code for it ("no privilege escalation via metadata",
103
+ * enforced at authorize rather than trusted at registration).
104
+ */
105
+ function scopeIsSupported(scope: string | null, allowed: readonly string[]): boolean {
106
+ if (!scope) return true;
107
+ const requested = scope.split(/\s+/).filter(Boolean);
108
+ const allowedSet = new Set<string>(allowed);
109
+ return requested.every((candidate) => allowedSet.has(candidate));
110
+ }
111
+
112
+ /**
113
+ * Resolve + validate the client and its `redirect_uri` FIRST (the open-redirect
114
+ * guard). Returns the validated URI AND the client's registered scopes, or a plain
115
+ * 400 — NEVER a redirect — when the client or URI is unknown/unregistered, so an
116
+ * error is never steered to an unvalidated URI.
117
+ */
118
+ async function validateClientAndRedirect(
119
+ context: McpOauthContext,
120
+ params: AuthorizeParams,
121
+ ): Promise<{ client: StoredOAuthClient; redirectUri: string } | Response> {
122
+ if (!params.clientId) return badRequest("invalid_request: missing client_id");
123
+ if (!params.redirectUri) return badRequest("invalid_request: missing redirect_uri");
124
+
125
+ const client = await context.stores.clients.findByClientId(params.clientId);
126
+ if (!client) return badRequest("invalid_client: unknown client_id");
127
+ if (!matchesRedirectUri(client, params.redirectUri)) {
128
+ return badRequest("invalid_request: redirect_uri is not registered");
129
+ }
130
+
131
+ return { client, redirectUri: params.redirectUri };
132
+ }
133
+
134
+ /**
135
+ * Validate the response_type, mandatory PKCE S256, and the requested scope against
136
+ * the already-validated `redirectUri`. Returns `null` when the request passes, or an
137
+ * error redirect back to the validated URI on the first failure.
138
+ */
139
+ function validateAuthorizeRequest(
140
+ params: AuthorizeParams,
141
+ redirectUri: string,
142
+ clientScopes: readonly string[],
143
+ ): Response | null {
144
+ const { state } = params;
145
+
146
+ if (params.responseType !== "code") {
147
+ return errorRedirect(redirectUri, "unsupported_response_type", state);
148
+ }
149
+ // Mandatory PKCE S256: reject a missing challenge or any non-S256 method.
150
+ if (!params.codeChallenge || params.codeChallengeMethod !== SUPPORTED_CHALLENGE_METHOD) {
151
+ return errorRedirect(redirectUri, "invalid_request", state);
152
+ }
153
+ if (!scopeIsSupported(params.scope, clientScopes)) {
154
+ return errorRedirect(redirectUri, "invalid_scope", state);
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** The already-validated inputs an authorize request resolves to before minting. */
160
+ interface ValidatedAuthorize {
161
+ /** The registered client — needed by the approval seam, not just its id. */
162
+ client: StoredOAuthClient;
163
+ clientId: string;
164
+ redirectUri: string;
165
+ codeChallenge: string;
166
+ scope: string;
167
+ state: string | null;
168
+ }
169
+
170
+ /**
171
+ * With a validated request, resolve the authenticated session (identity from the
172
+ * session ONLY) and either send the caller through sign-in, mint the code, or
173
+ * `server_error` when no signing key is configured.
174
+ */
175
+ async function authenticateAndMint(
176
+ context: McpOauthContext,
177
+ request: Request,
178
+ url: URL,
179
+ origin: string,
180
+ validated: ValidatedAuthorize,
181
+ ): Promise<Response> {
182
+ const { redirectUri, state } = validated;
183
+
184
+ const session = await context.resolveSession(request);
185
+ if (!session?.email) {
186
+ // No session: send the caller through the host's sign-in flow with a callback
187
+ // back to THIS authorize URL so the flow resumes post-login. No code is minted
188
+ // for an unauthenticated request.
189
+ const loginUrl = new URL(context.loginPath, origin);
190
+ loginUrl.searchParams.set(context.loginCallbackParam, url.pathname + url.search);
191
+ return redirectTo(loginUrl.toString());
192
+ }
193
+
194
+ // CONSENT. A session proves WHO is asking; it never proves they agreed to THIS
195
+ // client holding THESE scopes. Registration is open (RFC 7591), so without this
196
+ // step anyone may register a client carrying their own redirect URI and their own
197
+ // scope ceiling, send a signed-in admin a single link, and have that admin's
198
+ // browser mint them a code — and the two guards that look like they would stop it,
199
+ // exact redirect-URI matching and the per-client scope ceiling, are both checked
200
+ // against the ATTACKER'S OWN registration. Refuses by default; see
201
+ // `resolveApproval` / `preApprovedClientIds`.
202
+ const scopes = validated.scope.split(/\s+/).filter(Boolean);
203
+ if (!(await context.approve(request, validated.client, scopes))) {
204
+ // The answer a human refusal gives, at the URI validated further up.
205
+ return errorRedirect(redirectUri, "access_denied", state);
206
+ }
207
+
208
+ const code = await mintCode(context.signingKey, {
209
+ // The subject is the OAuth `sub` the host resolved, NOT a DB id: downstream
210
+ // guards resolve the user by EMAIL, and the code carries only what the session
211
+ // verified.
212
+ sub: session.subject || session.email,
213
+ email: session.email,
214
+ clientId: validated.clientId,
215
+ redirectUri,
216
+ codeChallenge: validated.codeChallenge,
217
+ scope: validated.scope,
218
+ origin,
219
+ });
220
+
221
+ if (!code) {
222
+ // No signing key configured while the surface is on — refuse to issue rather
223
+ // than fall back to a weaker mode (safe-by-default).
224
+ return errorRedirect(redirectUri, "server_error", state);
225
+ }
226
+
227
+ const success = new URL(redirectUri);
228
+ success.searchParams.set("code", code);
229
+ if (state !== null) success.searchParams.set("state", state);
230
+ return redirectTo(success.toString());
231
+ }
232
+
233
+ /** `GET <authorize>` — the whole endpoint. */
234
+ export async function authorizeEndpoint(
235
+ context: McpOauthContext,
236
+ request: Request,
237
+ ): Promise<Response> {
238
+ const url = new URL(request.url);
239
+ const origin = context.originOf(request);
240
+ const params = parseParams(url);
241
+
242
+ // --- Validate client + redirect_uri FIRST (the open-redirect guard) --------
243
+ const clientResult = await validateClientAndRedirect(context, params);
244
+ if (clientResult instanceof Response) return clientResult;
245
+ const { client, redirectUri } = clientResult;
246
+
247
+ // --- Validate the rest (scope checked against the CLIENT's own registration)
248
+ const requestError = validateAuthorizeRequest(params, redirectUri, client.scopes);
249
+ if (requestError) return requestError;
250
+
251
+ // The guards above guarantee a present client_id + PKCE challenge; narrow them.
252
+ return authenticateAndMint(context, request, url, origin, {
253
+ client,
254
+ clientId: params.clientId as string,
255
+ redirectUri,
256
+ codeChallenge: params.codeChallenge as string,
257
+ scope: params.scope ?? "",
258
+ state: params.state,
259
+ });
260
+ }