From dbc892415bc1769437c29c73f3f985ba8a71c08c Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Thu, 23 Jul 2026 21:35:38 -0700 Subject: [PATCH] Add MCP OAuth 2.1 surface: discovery, DCR, authorize/consent, token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for a remote MCP endpoint at /mcp: MCP-spec OAuth so clients like Claude Code connect with the standard one-click browser flow, no CLI install required. - auth.ts: four new HMAC token kinds behind the signValue chokepoints — mcp-consent (user-bound consent transaction), mcp-code (60s one-time, PKCE-bound), and audience-bound mcp-access (1h) / mcp-refresh (session TTL, non-rotating) — under an independent MCP_TOKEN_VERSION revocation lever; requireMcpUser resolves the /mcp bearer principal (mcp-access, or an existing session bearer as a capability-neutral fallback; never cookies). - mcp-clients.ts: RFC 7591 dynamic client registration in PrimitiveDb (90-day TTL, 1000-live cap, redirect-URI policy with RFC 8252 loopback port variance). - mcp-oauth-routes.ts: RFC 9728/8414 discovery metadata, /oauth/register, /oauth/authorize + consent page, /oauth/token (code + refresh grants, RFC 6749 error bodies), enumerated in MCP_OAUTH_ROUTES for the policy matrix; ACAO * only on the cookie-free endpoints. - app.ts: dispatch + canonical app-origin redirect for /oauth/* and /.well-known/*; access.ts reserves the mcp and oauth slugs. - api-routes.ts: one-time code burn extracted as burnOneTimeCode, shared by the CLI and MCP exchanges. - Tests: token corpus rows for all four kinds (cross-kind, bit-flip, audience/scope/lifecycle), full-loop + adversarial mcp-oauth.test.ts, policy-matrix rows (mcp-access denied API-wide; MCP OAuth route completeness and CORS posture). Threat review (invariants 3/5): codes burn before possession checks so replays and wrong-verifier races fail closed; PKCE is mandatory S256 with no plain path; unvalidated client_id/redirect_uri 400s and never redirects, loopback matching varies only the port; audience confusion is closed both directions and mechanized in the matrix; DCR spam is bounded by body/URI/registration caps; consent is same-origin, user-bound, and unframeable. Co-Authored-By: Claude Fable 5 --- server/core/src/access.ts | 2 +- server/core/src/api-routes.ts | 30 +- server/core/src/app.ts | 12 +- server/core/src/auth.ts | 348 ++++++++++++- server/core/src/mcp-clients.ts | 247 ++++++++++ server/core/src/mcp-oauth-routes.ts | 670 ++++++++++++++++++++++++++ server/core/test/api-policy.test.ts | 81 +++- server/core/test/mcp-oauth.test.ts | 591 +++++++++++++++++++++++ server/core/test/token-corpus.test.ts | 164 ++++++- 9 files changed, 2119 insertions(+), 26 deletions(-) create mode 100644 server/core/src/mcp-clients.ts create mode 100644 server/core/src/mcp-oauth-routes.ts create mode 100644 server/core/test/mcp-oauth.test.ts diff --git a/server/core/src/access.ts b/server/core/src/access.ts index 7e46bf0..fc8cda2 100644 --- a/server/core/src/access.ts +++ b/server/core/src/access.ts @@ -49,7 +49,7 @@ export { isSafeProjectIdentifier } from "@scratchwork/shared/site/identifiers"; * ".well-known") is unclaimable without an entry here. */ const RESERVED_ROUTE_SLUGS: ReadonlySet = new Set([ // Server-owned routes. - "api", "auth", "health", "favicon.ico", "favicon.svg", + "api", "auth", "health", "favicon.ico", "favicon.svg", "mcp", "oauth", // Host-wide root files. "robots.txt", "sitemap.xml", "ads.txt", "app-ads.txt", "security.txt", // Future namespace prefixes. diff --git a/server/core/src/api-routes.ts b/server/core/src/api-routes.ts index 2cf5f53..554dcf6 100644 --- a/server/core/src/api-routes.ts +++ b/server/core/src/api-routes.ts @@ -182,19 +182,23 @@ const MAX_CLI_TOKEN_BODY_BYTES = 64 * 1024; /** Handles `POST /auth/cli/token`: the back-channel exchange of a one-time CLI * authorization code plus PKCE verifier for a bearer token. */ -function exchangeCliToken({ request, payload }: ApiContext<"cli-token-exchange">) { +/** Records the one-time redemption of a signed authorization code. Burn before + * checking possession: the conditional create fails on any second attempt, so an + * intercepted code that is replayed — or raced with a wrong verifier — fails + * closed instead of staying redeemable within its lifetime. Shared by the CLI + * and MCP code exchanges. */ +export function burnOneTimeCode( + namespace: string, + id: string, + expiresAt: number, +): Effect.Effect { return Effect.gen(function* () { - const config = yield* ServerConfig; const db = yield* PrimitiveDb; - const code = yield* decodeCliAuthorizationCode(payload.code, config.auth); - // Burn the code before checking possession: the first redemption attempt - // consumes it, so an intercepted code that is replayed — or raced with a wrong - // verifier — fails closed instead of staying redeemable within its lifetime. yield* db.put( - CLI_CODE_NAMESPACE, - code.id, + namespace, + id, { redeemedAt: Math.floor(Date.now() / 1000) }, - { ifNoneMatch: "*", expiresAt: code.expiresAt }, + { ifNoneMatch: "*", expiresAt }, ).pipe( Effect.mapError((error) => error._tag === "PrimitiveDbConflict" @@ -202,6 +206,14 @@ function exchangeCliToken({ request, payload }: ApiContext<"cli-token-exchange"> : new HttpError({ status: 500, message: "Could not record the code redemption" }), ), ); + }); +} + +function exchangeCliToken({ request, payload }: ApiContext<"cli-token-exchange">) { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const code = yield* decodeCliAuthorizationCode(payload.code, config.auth); + yield* burnOneTimeCode(CLI_CODE_NAMESPACE, code.id, code.expiresAt); const user = yield* verifyCliCodeExchange(code, payload.codeVerifier, payload.redirectUri, config.auth); const cfToken = yield* decryptCliCloudflareToken(code, config.auth); const token = yield* createSessionToken(user, config.auth); diff --git a/server/core/src/app.ts b/server/core/src/app.ts index 6bd2774..7432e49 100644 --- a/server/core/src/app.ts +++ b/server/core/src/app.ts @@ -22,6 +22,7 @@ import { Auth, AuthError, type AuthShape, type AuthUser } from "./auth.ts"; import { ServerConfig, type ServerConfigShape } from "./config.ts"; import { PrimitiveDb } from "./db.ts"; import { projectAccessCookie, projectAccessCookieValues } from "./cookies.ts"; +import { dispatchMcpOauthRoute } from "./mcp-oauth-routes.ts"; import { acceptsHtmlPage, errorPageResponse, errorResponse } from "./error-pages.ts"; import { appBaseUrl, @@ -73,7 +74,13 @@ function handleRequest(request: HttpServerRequest.HttpServerRequest): AppEffect const url = new URL(request.url, "http://scratchwork.local"); const config = yield* ServerConfig; - if (url.pathname.startsWith("/auth/")) { + // MCP OAuth routes join /auth/* in canonicalizing to the app origin before + // any cookie is read or issuer/audience value is derived from the request. + if ( + url.pathname.startsWith("/auth/") || + url.pathname.startsWith("/oauth/") || + url.pathname.startsWith("/.well-known/") + ) { const redirect = canonicalAppRedirect(request, url, config); if (redirect != null) return redirect; } @@ -97,6 +104,9 @@ function handleRequest(request: HttpServerRequest.HttpServerRequest): AppEffect return yield* issueProjectAccess(request, url); } + const mcpOauthResponse = yield* dispatchMcpOauthRoute(request, url); + if (mcpOauthResponse != null) return mcpOauthResponse; + const apiResponse = yield* dispatchApiRoute(request, url); if (apiResponse != null) return apiResponse; diff --git a/server/core/src/auth.ts b/server/core/src/auth.ts index a43e782..b498091 100644 --- a/server/core/src/auth.ts +++ b/server/core/src/auth.ts @@ -1,12 +1,15 @@ /** * The auth service — one implementation per auth mode (built-in Google OAuth, or * Cloudflare Access asserting identity via the Cf-Access-Jwt-Assertion header) — and - * the four signed HMAC token kinds it mints: session tokens (browser cookie or CLI + * the signed HMAC token kinds it mints: session tokens (browser cookie or CLI * bearer, session TTL), OAuth state tokens (10-minute, browser-bound, cookie-only — * they carry the PKCE verifier, which must never transit the provider), CLI * authorization codes (~60s one-time codes delivered to the CLI's loopback callback - * and exchanged for a session token at /auth/cli/token), and project-access tokens - * ("handoff": ~60s, query-string form; "cookie": session-length redeemed form). + * and exchanged for a session token at /auth/cli/token), project-access tokens + * ("handoff": ~60s, query-string form; "cookie": session-length redeemed form), and + * the four MCP OAuth kinds behind the /mcp endpoint (consent transactions, + * authorization codes, and audience-bound access/refresh tokens — see + * mcp-oauth-routes.ts for the endpoints that mint and redeem them). */ import * as Cookies from "@effect/platform/Cookies"; import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; @@ -54,6 +57,23 @@ const HANDOFF_TTL_SECONDS = 60; * and are additionally one-time: redemption is recorded in PrimitiveDb by the exchange * route, so a replayed code fails even inside this window. */ const CLI_CODE_TTL_SECONDS = 60; +/** Versioned separately from sessions: bumping this deliberately invalidates every + * outstanding MCP credential (consent, code, access, refresh) without logging anyone + * out or breaking CLI bearer tokens. */ +const MCP_TOKEN_VERSION = 1; +/** MCP consent transactions share the OAuth state TTL: long enough to read the + * consent page, short enough that a stale form cannot be replayed much later. */ +const MCP_CONSENT_TTL_SECONDS = STATE_TTL_SECONDS; +/** MCP authorization codes ride the client's redirect query string, so like CLI codes + * they live seconds and are one-time (the token endpoint burns them in PrimitiveDb). */ +const MCP_CODE_TTL_SECONDS = 60; +/** MCP access tokens are replayed on every /mcp request and traverse more logging + * surface than a session cookie, so they live an hour; clients mint replacements + * through the refresh grant. */ +export const MCP_ACCESS_TTL_SECONDS = 3600; +/** The one OAuth scope the MCP surface issues. Carried as a literal claim so a + * future narrower scope fails closed against verifiers expecting this one. */ +export const MCP_SCOPE = "mcp"; /** PKCE S256 challenges and verifiers are 43-128 base64url characters (RFC 7636 §4). */ const PKCE_PATTERN = /^[A-Za-z0-9_-]{43,128}$/; const CLI_STATE_MAX_LENGTH = 256; @@ -161,6 +181,78 @@ const ProjectAccessPayloadSchema = Schema.Struct({ }); type ProjectAccessPayload = typeof ProjectAccessPayloadSchema.Type; +/** Payload of an MCP consent token: the signed hidden form field on the + * /oauth/authorize consent page. It binds every parameter of the authorize + * request — and the user who saw the page — so the approval POST cannot be + * replayed for a different client, redirect, challenge, or account. */ +const McpConsentPayloadSchema = Schema.Struct({ + version: Schema.Literal(MCP_TOKEN_VERSION), + kind: Schema.Literal("mcp-consent"), + clientId: Schema.String, + redirectUri: Schema.String, + codeChallenge: Schema.String, + state: Schema.optional(Schema.String), + userId: Schema.String, + expiresAt: EpochSecondsSchema, +}); + +/** The decoded MCP consent payload. */ +export type McpConsentPayload = typeof McpConsentPayloadSchema.Type; + +/** Payload of an MCP authorization code: like a CLI code, a short-lived one-time + * token that grants nothing by itself — the token endpoint burns `id` and then + * requires the PKCE verifier from the exact client and redirect the code was + * issued to. */ +const McpCodePayloadSchema = Schema.Struct({ + version: Schema.Literal(MCP_TOKEN_VERSION), + kind: Schema.Literal("mcp-code"), + id: Schema.String, + user: AuthUserSchema, + clientId: Schema.String, + redirectUri: Schema.String, + codeChallenge: Schema.String, + expiresAt: EpochSecondsSchema, +}); + +/** The decoded MCP authorization-code payload. */ +export type McpCodePayload = typeof McpCodePayloadSchema.Type; + +/** Payload of an MCP access token: the bearer credential MCP clients replay on + * every /mcp call. `aud` pins it to this server's MCP resource URL so it can + * never be accepted by another resource (or another scratchwork origin), and + * the kind literal keeps it inert at every /api and cookie verifier. */ +const McpAccessPayloadSchema = Schema.Struct({ + version: Schema.Literal(MCP_TOKEN_VERSION), + kind: Schema.Literal("mcp-access"), + user: AuthUserSchema, + clientId: Schema.String, + aud: Schema.String, + scope: Schema.Literal(MCP_SCOPE), + issuedAt: EpochSecondsSchema, + expiresAt: EpochSecondsSchema, +}); + +/** Payload of an MCP refresh token: long-lived, presented only to the token + * endpoint's back channel to mint fresh access tokens. Stateless like sessions + * (accepted trade-off — the revocation levers are the allow-list and + * MCP_TOKEN_VERSION), so the refresh grant does not rotate it. */ +const McpRefreshPayloadSchema = Schema.Struct({ + version: Schema.Literal(MCP_TOKEN_VERSION), + kind: Schema.Literal("mcp-refresh"), + user: AuthUserSchema, + clientId: Schema.String, + aud: Schema.String, + scope: Schema.Literal(MCP_SCOPE), + issuedAt: EpochSecondsSchema, + expiresAt: EpochSecondsSchema, +}); + +/** The verified identity carried by an MCP access or refresh token. */ +export interface McpPrincipal { + readonly user: AuthUser; + readonly clientId: string; +} + /** Auth failure; `status` becomes the HTTP response status. */ export class AuthError extends Data.TaggedError("AuthError")<{ readonly status: number; @@ -668,8 +760,7 @@ export function verifyCliCodeExchange( if (!PKCE_PATTERN.test(codeVerifier)) { return yield* Effect.fail(new AuthError({ status: 400, message: "Invalid code verifier" })); } - const challenge = yield* sha256Challenge(codeVerifier); - if (!timingSafeEqual(challenge, payload.codeChallenge) || redirectUri !== payload.redirectUri) { + if (!(yield* pkceVerifierMatches(codeVerifier, payload.codeChallenge)) || redirectUri !== payload.redirectUri) { return yield* Effect.fail(new AuthError({ status: 400, message: "Authorization code does not match this login" })); } if (!allowedUser(payload.user, config)) { @@ -679,6 +770,253 @@ export function verifyCliCodeExchange( }); } +/** True when the presented PKCE verifier's S256 digest equals the bound challenge. + * Callers pre-validate the verifier against PKCE_PATTERN so their error messages + * can distinguish a malformed verifier from a failed possession proof. */ +function pkceVerifierMatches(codeVerifier: string, codeChallenge: string): Effect.Effect { + return Effect.map(sha256Challenge(codeVerifier), (challenge) => timingSafeEqual(challenge, codeChallenge)); +} + +/** True when a value is a syntactically valid PKCE S256 challenge or verifier + * (RFC 7636 §4). The /oauth/authorize validator uses this so a malformed + * challenge is rejected before a consent transaction ever binds it. */ +export function isPkceValue(value: string): boolean { + return PKCE_PATTERN.test(value); +} + +// --------------------------------------------------------------------------- +// MCP OAuth tokens (consent, authorization code, access, refresh) +// --------------------------------------------------------------------------- + +/** Signs the consent transaction token embedded in the /oauth/authorize form. */ +export function createMcpConsentToken( + input: { + readonly clientId: string; + readonly redirectUri: string; + readonly codeChallenge: string; + readonly state?: string; + readonly userId: string; + }, + config: AuthConfig, +): Effect.Effect { + return signValue( + { + version: MCP_TOKEN_VERSION, + kind: "mcp-consent", + clientId: input.clientId, + redirectUri: input.redirectUri, + codeChallenge: input.codeChallenge, + ...(input.state == null ? {} : { state: input.state }), + userId: input.userId, + expiresAt: epochSeconds() + MCP_CONSENT_TTL_SECONDS, + } satisfies McpConsentPayload, + config.sessionSecret, + ); +} + +/** Verifies a consent transaction token's signature, shape, and expiry. The + * caller must additionally check `userId` against the current session user and + * re-load the client registration before acting on the approval. */ +export function verifyMcpConsentToken( + token: string, + config: AuthConfig, +): Effect.Effect { + return Effect.gen(function* () { + const payload = yield* verifySignedValue(token, config.sessionSecret, McpConsentPayloadSchema).pipe( + Effect.mapError((cause) => new AuthError({ status: 400, message: "Invalid consent request", cause })), + ); + if (payload.expiresAt <= epochSeconds()) { + return yield* Effect.fail(new AuthError({ status: 400, message: "Consent request expired — retry the authorization" })); + } + return payload; + }); +} + +/** Signs a short-lived one-time MCP authorization code bound to a PKCE challenge + * and the exact registered client and redirect URI it is delivered to. */ +export function issueMcpAuthorizationCode( + user: AuthUser, + binding: { + readonly clientId: string; + readonly redirectUri: string; + readonly codeChallenge: string; + }, + config: AuthConfig, +): Effect.Effect { + return signValue( + { + version: MCP_TOKEN_VERSION, + kind: "mcp-code", + id: randomNonce(), + user, + clientId: binding.clientId, + redirectUri: binding.redirectUri, + codeChallenge: binding.codeChallenge, + expiresAt: epochSeconds() + MCP_CODE_TTL_SECONDS, + } satisfies McpCodePayload, + config.sessionSecret, + ); +} + +/** Verifies an MCP authorization code's signature, shape, and expiry. Like the + * CLI form, the one-time burn and the possession checks are separate steps: the + * token endpoint burns `id` between the two, so the first redemption attempt + * consumes the code whether or not it proves possession. */ +export function decodeMcpAuthorizationCode( + code: string, + config: AuthConfig, +): Effect.Effect { + return Effect.gen(function* () { + const payload = yield* verifySignedValue(code, config.sessionSecret, McpCodePayloadSchema).pipe( + Effect.mapError((cause) => new AuthError({ status: 400, message: "Invalid authorization code", cause })), + ); + if (payload.expiresAt <= epochSeconds()) { + return yield* Effect.fail(new AuthError({ status: 400, message: "Authorization code expired" })); + } + return payload; + }); +} + +/** Proves the token request comes from the client the code was issued to: PKCE + * verifier possession, byte-exact redirect URI, and the same registered client. + * The allow-list is re-applied so removal revokes a code issued moments earlier. */ +export function verifyMcpCodeExchange( + payload: McpCodePayload, + presented: { + readonly codeVerifier: string; + readonly redirectUri: string; + readonly clientId: string; + }, + config: AuthConfig, +): Effect.Effect { + return Effect.gen(function* () { + if (!PKCE_PATTERN.test(presented.codeVerifier)) { + return yield* Effect.fail(new AuthError({ status: 400, message: "Invalid code verifier" })); + } + if ( + !(yield* pkceVerifierMatches(presented.codeVerifier, payload.codeChallenge)) || + presented.redirectUri !== payload.redirectUri || + presented.clientId !== payload.clientId + ) { + return yield* Effect.fail(new AuthError({ status: 400, message: "Authorization code does not match this request" })); + } + if (!allowedUser(payload.user, config)) { + return yield* Effect.fail(new AuthError({ status: 403, message: "Account is not allowed on this server" })); + } + return payload.user; + }); +} + +/** Signs an MCP access token bound to one resource URL (`aud`). */ +export function createMcpAccessToken( + principal: McpPrincipal, + aud: string, + config: AuthConfig, +): Effect.Effect { + const issuedAt = epochSeconds(); + return signValue( + { + version: MCP_TOKEN_VERSION, + kind: "mcp-access", + user: principal.user, + clientId: principal.clientId, + aud, + scope: MCP_SCOPE, + issuedAt, + expiresAt: issuedAt + MCP_ACCESS_TTL_SECONDS, + } satisfies typeof McpAccessPayloadSchema.Type, + config.sessionSecret, + ); +} + +/** Signs an MCP refresh token bound to the same resource URL as its access tokens. */ +export function createMcpRefreshToken( + principal: McpPrincipal, + aud: string, + config: AuthConfig, +): Effect.Effect { + const issuedAt = epochSeconds(); + return signValue( + { + version: MCP_TOKEN_VERSION, + kind: "mcp-refresh", + user: principal.user, + clientId: principal.clientId, + aud, + scope: MCP_SCOPE, + issuedAt, + expiresAt: issuedAt + config.sessionTtlSeconds, + } satisfies typeof McpRefreshPayloadSchema.Type, + config.sessionSecret, + ); +} + +/** Verifies one MCP access token against the expected resource URL and applies + * current allow-list rules; null on expiry, future issuance, audience mismatch, + * or a disallowed account. */ +export function verifyMcpAccessToken( + token: string, + expectedAud: string, + config: AuthConfig, +): Effect.Effect { + return verifyMcpBearerPayload(token, expectedAud, config, McpAccessPayloadSchema); +} + +/** Verifies one MCP refresh token against the expected resource URL (see + * verifyMcpAccessToken). */ +export function verifyMcpRefreshToken( + token: string, + expectedAud: string, + config: AuthConfig, +): Effect.Effect { + return verifyMcpBearerPayload(token, expectedAud, config, McpRefreshPayloadSchema); +} + +/** The shared verification path of the two MCP bearer kinds: signature and + * strict shape via verifySignedValue, then expiry, issuance-skew, exact + * audience, and allow-list checks identical in both. */ +function verifyMcpBearerPayload( + token: string, + expectedAud: string, + config: AuthConfig, + schema: Schema.Schema, +): Effect.Effect { + return Effect.gen(function* () { + const payload = yield* verifySignedValue(token, config.sessionSecret, schema); + if (payload.expiresAt <= epochSeconds()) return null; + if (payload.issuedAt > epochSeconds() + ISSUED_AT_SKEW_SECONDS) return null; + // The audience is not a secret — this is an identity comparison, not a MAC check. + if (payload.aud !== expectedAud) return null; + if (!allowedUser(payload.user, config)) return null; + return { user: payload.user, clientId: payload.clientId }; + }); +} + +/** Resolves the /mcp bearer principal: an audience-bound MCP access token, or — + * so CLI-logged-in users and scripts can reach /mcp with the credential they + * already hold — a session bearer token. Session tokens already authorize the + * full /api surface, a superset of what MCP tools can do, so the fallback adds + * no capability; the reverse direction stays closed (an mcp-access token fails + * every session verifier on its kind literal). Cookies are never read here. */ +export function requireMcpUser( + request: HttpServerRequest.HttpServerRequest, + expectedAud: string, + config: AuthConfig, +): Effect.Effect { + return Effect.gen(function* () { + const token = bearerToken(request); + if (token != null) { + const principal = yield* verifyMcpAccessToken(token, expectedAud, config).pipe( + Effect.orElseSucceed(() => null), + ); + if (principal != null) return principal.user; + const sessionUser = yield* verifySessionToken(token, config).pipe(Effect.orElseSucceed(() => null)); + if (sessionUser != null) return sessionUser; + } + return yield* Effect.fail(new AuthError({ status: 401, message: "Authentication required" })); + }); +} + /** Tolerated clock skew before a session token's issuedAt reads as forged. */ const ISSUED_AT_SKEW_SECONDS = 60; diff --git a/server/core/src/mcp-clients.ts b/server/core/src/mcp-clients.ts new file mode 100644 index 0000000..e5c71a9 --- /dev/null +++ b/server/core/src/mcp-clients.ts @@ -0,0 +1,247 @@ +/** + * Dynamic Client Registration (RFC 7591) storage for the MCP OAuth surface. + * Registrations are public clients (PKCE only, no client secret) kept in + * PrimitiveDb with a 90-day expiry: expired registrations read as absent on + * every backend, and MCP clients re-register automatically when their + * client_id stops resolving. Redirect-URI policy lives here too, so the + * registration check and the authorize-time match cannot drift apart. + */ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Schema from "effect/Schema"; +import { PrimitiveDb, type PrimitiveDbConflict, type PrimitiveDbError } from "./db.ts"; + +/** Namespace of client registrations; the key is the client_id. */ +const MCP_CLIENT_NAMESPACE = "oauth-clients"; +/** Registrations expire after 90 days; clients re-register on invalid_client. */ +const MCP_CLIENT_TTL_SECONDS = 90 * 24 * 60 * 60; +/** Live-registration ceiling: one full list page. Registration is rare (once + * per client per 90 days), so a page read per registration is acceptable, and + * the cap bounds storage against automated registration spam. */ +const MAX_LIVE_CLIENTS = 1000; +/** RFC 7591 metadata limits. */ +const MAX_REDIRECT_URIS = 10; +const MAX_REDIRECT_URI_LENGTH = 2048; +const MAX_CLIENT_NAME_LENGTH = 256; +/** client_ids are 16 random bytes as 22 base64url characters. */ +const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; + +/** An RFC 7591 registration failure; `error` is the spec error code. */ +export class McpClientRegistrationError extends Data.TaggedError("McpClientRegistrationError")<{ + readonly status: number; + readonly error: "invalid_client_metadata" | "invalid_redirect_uri"; + readonly description: string; +}> {} + +/** One stored client registration. */ +const McpClientRecordSchema = Schema.Struct({ + clientName: Schema.optional(Schema.String), + redirectUris: Schema.Array(Schema.String), + createdAt: Schema.Number, +}); +type McpClientRecord = typeof McpClientRecordSchema.Type; + +/** A registered client joined with its id. */ +export interface McpClient extends McpClientRecord { + readonly clientId: string; +} + +/** The RFC 7591 request fields the server acts on. Decoding is deliberately + * tolerant of extra fields — registration requests legitimately carry + * arbitrary client metadata the server ignores — while the fields it does + * read are validated strictly below. */ +const RegistrationRequestSchema = Schema.Struct({ + redirect_uris: Schema.optional(Schema.Array(Schema.String)), + client_name: Schema.optional(Schema.String), + token_endpoint_auth_method: Schema.optional(Schema.String), + grant_types: Schema.optional(Schema.Array(Schema.String)), + response_types: Schema.optional(Schema.Array(Schema.String)), +}); + +/** Grants the server supports; a registration asking for more is rejected. */ +const SUPPORTED_GRANT_TYPES: ReadonlySet = new Set(["authorization_code", "refresh_token"]); +const SUPPORTED_RESPONSE_TYPES: ReadonlySet = new Set(["code"]); + +/** The RFC 7591 registration response body. */ +export interface McpClientRegistrationResponse { + readonly client_id: string; + readonly client_id_issued_at: number; + readonly client_name?: string; + readonly redirect_uris: ReadonlyArray; + readonly token_endpoint_auth_method: "none"; + readonly grant_types: ReadonlyArray; + readonly response_types: ReadonlyArray; +} + +/** Validates one registration request and stores the client. The parsed JSON + * body is taken as unknown so this module owns the whole RFC 7591 contract. */ +export function registerMcpClient( + body: unknown, +): Effect.Effect< + McpClientRegistrationResponse, + McpClientRegistrationError | PrimitiveDbError | PrimitiveDbConflict, + PrimitiveDb +> { + return Effect.gen(function* () { + const invalidMetadata = (description: string) => + new McpClientRegistrationError({ status: 400, error: "invalid_client_metadata", description }); + const request = yield* Schema.decodeUnknown(RegistrationRequestSchema)(body).pipe( + Effect.mapError(() => invalidMetadata("Malformed client metadata")), + ); + + const redirectUris = request.redirect_uris ?? []; + if (redirectUris.length === 0) { + return yield* Effect.fail( + new McpClientRegistrationError({ + status: 400, + error: "invalid_redirect_uri", + description: "redirect_uris is required and must not be empty", + }), + ); + } + if (redirectUris.length > MAX_REDIRECT_URIS) { + return yield* Effect.fail( + new McpClientRegistrationError({ + status: 400, + error: "invalid_redirect_uri", + description: `At most ${MAX_REDIRECT_URIS} redirect_uris are accepted`, + }), + ); + } + for (const uri of redirectUris) { + if (!validMcpRedirectUri(uri)) { + return yield* Effect.fail( + new McpClientRegistrationError({ + status: 400, + error: "invalid_redirect_uri", + description: "redirect_uris must be https URLs, or http URLs on a loopback host, without credentials or fragments", + }), + ); + } + } + + if (request.token_endpoint_auth_method != null && request.token_endpoint_auth_method !== "none") { + return yield* Effect.fail(invalidMetadata('Only token_endpoint_auth_method "none" is supported (public client with PKCE)')); + } + if (request.grant_types?.some((grant) => !SUPPORTED_GRANT_TYPES.has(grant))) { + return yield* Effect.fail(invalidMetadata('Only the "authorization_code" and "refresh_token" grant types are supported')); + } + if (request.response_types?.some((type) => !SUPPORTED_RESPONSE_TYPES.has(type))) { + return yield* Effect.fail(invalidMetadata('Only the "code" response type is supported')); + } + const clientName = request.client_name?.trim() || undefined; + if (clientName != null && clientName.length > MAX_CLIENT_NAME_LENGTH) { + return yield* Effect.fail(invalidMetadata(`client_name must be at most ${MAX_CLIENT_NAME_LENGTH} characters`)); + } + + const db = yield* PrimitiveDb; + const page = yield* db.list(MCP_CLIENT_NAMESPACE, { limit: MAX_LIVE_CLIENTS }); + if (page.cursor != null) { + return yield* Effect.fail( + new McpClientRegistrationError({ + status: 429, + error: "invalid_client_metadata", + description: "Client registration limit reached — retry later", + }), + ); + } + + const clientId = randomClientId(); + const createdAt = Math.floor(Date.now() / 1000); + const record: McpClientRecord = { + ...(clientName == null ? {} : { clientName }), + redirectUris, + createdAt, + }; + // ifNoneMatch guards against the astronomically unlikely id collision; a + // conflict surfaces as a 500 the client simply retries. + yield* db.put(MCP_CLIENT_NAMESPACE, clientId, record, { + ifNoneMatch: "*", + expiresAt: createdAt + MCP_CLIENT_TTL_SECONDS, + }); + + return { + client_id: clientId, + client_id_issued_at: createdAt, + ...(clientName == null ? {} : { client_name: clientName }), + redirect_uris: redirectUris, + token_endpoint_auth_method: "none", + grant_types: [...SUPPORTED_GRANT_TYPES], + response_types: [...SUPPORTED_RESPONSE_TYPES], + }; + }); +} + +/** Loads one live client registration; null when the id is malformed, unknown, + * or expired (PrimitiveDb reads expired records as absent on every backend). */ +export function loadMcpClient( + clientId: string, +): Effect.Effect { + return Effect.gen(function* () { + if (!CLIENT_ID_PATTERN.test(clientId)) return null; + const db = yield* PrimitiveDb; + const record = yield* db.get(MCP_CLIENT_NAMESPACE, clientId); + if (record == null) return null; + // A stored value this module did not write (or a corrupted one) reads as + // an unknown client, which makes the MCP client re-register. + const decoded = yield* Schema.decodeUnknown(McpClientRecordSchema)(record.value).pipe( + Effect.orElseSucceed(() => null), + ); + return decoded == null ? null : { clientId, ...decoded }; + }); +} + +/** Accepts an OAuth redirect URI per RFC 8252: https on any host, or http on a + * loopback host, never with credentials or a fragment. */ +export function validMcpRedirectUri(value: string): boolean { + if (value.length === 0 || value.length > MAX_REDIRECT_URI_LENGTH) return false; + try { + const url = new URL(value); + if (url.username !== "" || url.password !== "" || url.hash !== "") return false; + if (url.protocol === "https:") return true; + return url.protocol === "http:" && isMcpLoopbackHost(url.hostname); + } catch { + return false; + } +} + +/** Matches a presented redirect_uri against the registered set: byte-exact, + * except that http-loopback registrations match on host, path, and query with + * any port (RFC 8252 §7.3 — the client binds an ephemeral port per flow). */ +export function redirectUriMatches(registered: ReadonlyArray, presented: string): boolean { + if (!validMcpRedirectUri(presented)) return false; + if (registered.includes(presented)) return true; + let presentedUrl: URL; + try { + presentedUrl = new URL(presented); + } catch { + return false; + } + if (presentedUrl.protocol !== "http:" || !isMcpLoopbackHost(presentedUrl.hostname)) return false; + return registered.some((candidate) => { + try { + const url = new URL(candidate); + return ( + url.protocol === "http:" && + url.hostname === presentedUrl.hostname && + url.pathname === presentedUrl.pathname && + url.search === presentedUrl.search + ); + } catch { + return false; + } + }); +} + +/** RFC 8252 loopback hosts (same set the CLI login flow accepts). */ +function isMcpLoopbackHost(hostname: string): boolean { + return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]"; +} + +/** Generates a client_id: 16 random bytes as 22 base64url characters. */ +function randomClientId(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Encoding.encodeBase64Url(bytes); +} diff --git a/server/core/src/mcp-oauth-routes.ts b/server/core/src/mcp-oauth-routes.ts new file mode 100644 index 0000000..a23e186 --- /dev/null +++ b/server/core/src/mcp-oauth-routes.ts @@ -0,0 +1,670 @@ +/** + * The MCP OAuth 2.1 surface: the discovery metadata, dynamic client + * registration (RFC 7591), authorize + consent, and token endpoints that let a + * remote MCP client (Claude Code and friends) obtain an audience-bound bearer + * token for the /mcp endpoint with the standard browser flow. These are + * server-only fixed routes on the app origin — like /auth/* they never enter + * the shared CLI contract (invariant 2) — and every route is enumerated in + * MCP_OAUTH_ROUTES so the policy test matrix covers them (invariant 4). + * + * All token minting and verification lives in auth.ts behind the + * signValue/verifySignedValue chokepoints (invariant 3); this module owns only + * HTTP shaping: parameter validation, the consent page, and the RFC 6749/7591 + * error bodies. + * + * Trust boundaries (invariant 5): the four cookie-free endpoints (both + * metadata documents, register, token) send `Access-Control-Allow-Origin: *` + * — they never read an ambient credential, and browser-based MCP clients must + * be able to fetch them. Authorize and consent read the session cookie and + * therefore never relax CORS; the consent POST additionally requires the + * same-origin check and a signed, user-bound transaction token, and the + * consent page refuses framing. + */ +import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { FIGURE_SVG } from "@scratchwork/shared/assets/figure-svg.generated"; +import { burnOneTimeCode } from "./api-routes.ts"; +import { + Auth, + AuthError, + createMcpAccessToken, + createMcpConsentToken, + createMcpRefreshToken, + decodeMcpAuthorizationCode, + isPkceValue, + issueMcpAuthorizationCode, + MCP_ACCESS_TTL_SECONDS, + MCP_SCOPE, + verifyMcpConsentToken, + verifyMcpCodeExchange, + verifyMcpRefreshToken, + type AuthUser, +} from "./auth.ts"; +import { ServerConfig } from "./config.ts"; +import { PrimitiveDb, type PrimitiveDbConflict, type PrimitiveDbError } from "./db.ts"; +import { escapeHtml } from "./error-pages.ts"; +import { appBaseUrl, HttpError, rejectCrossOriginApiRequest, securityHeaders } from "./http.ts"; +import { loadMcpClient, redirectUriMatches, registerMcpClient, type McpClient } from "./mcp-clients.ts"; + +/** Failures the OAuth routes may raise; page-shaped errors propagate to the + * app's generic error rendering, RFC-shaped errors are built here. */ +type McpOauthError = HttpError | AuthError | PrimitiveDbError | PrimitiveDbConflict; +type McpOauthServices = ServerConfig | Auth | PrimitiveDb; +type McpOauthEffect = Effect.Effect; + +/** Namespace of one-time MCP authorization-code redemption records. */ +const MCP_CODE_NAMESPACE = "mcp-code-redemptions"; +/** Generous ceilings for the small JSON/form bodies these endpoints accept. */ +const MAX_OAUTH_BODY_BYTES = 16 * 1024; +/** Cap on the client-supplied `state` value echoed back on redirects. */ +const MAX_STATE_LENGTH = 512; + +/** One MCP OAuth route's declared policy, enumerated by the policy matrix. + * `auth` differs from the JSON API's modes: "none" (public metadata and the + * credential-free registration endpoint), "session" (browser cookie), or + * "code-exchange" (the presented code/refresh token is the credential). */ +export interface McpOauthRoute { + readonly name: string; + readonly method: string; + readonly path: string; + readonly auth: "none" | "session" | "code-exchange"; + readonly mutation: boolean; + readonly visibility: "metadata" | "client-registration" | "consent" | "oauth-token"; +} + +/** Every route this module dispatches, with its declared policy. */ +export const MCP_OAUTH_ROUTES: ReadonlyArray = [ + { name: "protected-resource-metadata", method: "GET", path: "/.well-known/oauth-protected-resource", auth: "none", mutation: false, visibility: "metadata" }, + { name: "authorization-server-metadata", method: "GET", path: "/.well-known/oauth-authorization-server", auth: "none", mutation: false, visibility: "metadata" }, + { name: "oauth-register", method: "POST", path: "/oauth/register", auth: "none", mutation: true, visibility: "client-registration" }, + { name: "oauth-authorize", method: "GET", path: "/oauth/authorize", auth: "session", mutation: false, visibility: "consent" }, + { name: "oauth-consent", method: "POST", path: "/oauth/consent", auth: "session", mutation: true, visibility: "consent" }, + { name: "oauth-token", method: "POST", path: "/oauth/token", auth: "code-exchange", mutation: true, visibility: "oauth-token" }, +]; + +/** The canonical MCP resource URL: the audience every access token is bound to. */ +export function mcpResourceUrl(request: HttpServerRequest.HttpServerRequest, config: { readonly appUrl?: string }): string { + return `${appBaseUrl(request, config)}/mcp`; +} + +/** The 401 an unauthenticated /mcp request receives. The WWW-Authenticate + * resource-metadata pointer is what starts a spec MCP client's OAuth flow. */ +export function mcpUnauthorizedResponse( + request: HttpServerRequest.HttpServerRequest, + config: { readonly appUrl?: string }, +): HttpServerResponse.HttpServerResponse { + const metadataUrl = `${appBaseUrl(request, config)}/.well-known/oauth-protected-resource/mcp`; + return HttpServerResponse.unsafeJson({ error: "Authentication required" }, { + status: 401, + headers: { + ...securityHeaders(), + "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}", error="invalid_token"`, + }, + }); +} + +/** Dispatches one request against the MCP OAuth routes; null when the path is + * not one of them (the caller falls through to API and content serving). */ +export function dispatchMcpOauthRoute( + request: HttpServerRequest.HttpServerRequest, + url: URL, +): Effect.Effect { + return dispatchMcpOauthRouteRaw(request, url).pipe( + // Storage failures never carry an OAuth meaning; they surface as the same + // opaque 500 the API routes emit. + Effect.catchTags({ + PrimitiveDbError: (cause) => Effect.fail(new HttpError({ status: 500, message: "Storage operation failed", cause })), + PrimitiveDbConflict: (cause) => Effect.fail(new HttpError({ status: 500, message: "Storage operation failed", cause })), + }), + ); +} + +function dispatchMcpOauthRouteRaw( + request: HttpServerRequest.HttpServerRequest, + url: URL, +): Effect.Effect { + return Effect.gen(function* () { + // Metadata documents are served at the root well-known path and at the + // /mcp path-inserted form (RFC 8414 / RFC 9728) — clients probe both. + if ( + url.pathname === "/.well-known/oauth-protected-resource" || + url.pathname === "/.well-known/oauth-protected-resource/mcp" + ) { + yield* requireMethod(request, "GET"); + return yield* protectedResourceMetadata(request); + } + if ( + url.pathname === "/.well-known/oauth-authorization-server" || + url.pathname === "/.well-known/oauth-authorization-server/mcp" + ) { + yield* requireMethod(request, "GET"); + return yield* authorizationServerMetadata(request); + } + if (url.pathname === "/oauth/register") { + yield* requireMethod(request, "POST"); + return yield* register(request); + } + if (url.pathname === "/oauth/authorize") { + yield* requireMethod(request, "GET"); + return yield* authorize(request, url); + } + if (url.pathname === "/oauth/consent") { + yield* requireMethod(request, "POST"); + return yield* consent(request); + } + if (url.pathname === "/oauth/token") { + yield* requireMethod(request, "POST"); + return yield* token(request); + } + return null; + }); +} + +/** 405s any method other than the one a route serves (HEAD folds into GET). */ +function requireMethod(request: HttpServerRequest.HttpServerRequest, method: string): Effect.Effect { + const requestMethod = request.method === "HEAD" ? "GET" : request.method; + return requestMethod === method + ? Effect.void + : Effect.fail(new HttpError({ status: 405, message: "Method not allowed" })); +} + +// --------------------------------------------------------------------------- +// Discovery metadata +// --------------------------------------------------------------------------- + +/** RFC 9728 protected-resource metadata: names this server's /mcp resource and + * points at the authorization server (this same origin). */ +function protectedResourceMetadata(request: HttpServerRequest.HttpServerRequest): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const base = appBaseUrl(request, config); + return publicJson({ + resource: `${base}/mcp`, + authorization_servers: [base], + bearer_methods_supported: ["header"], + scopes_supported: [MCP_SCOPE], + }); + }); +} + +/** RFC 8414 authorization-server metadata. */ +function authorizationServerMetadata(request: HttpServerRequest.HttpServerRequest): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const base = appBaseUrl(request, config); + return publicJson({ + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + registration_endpoint: `${base}/oauth/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: [MCP_SCOPE], + }); + }); +} + +// --------------------------------------------------------------------------- +// Dynamic client registration +// --------------------------------------------------------------------------- + +/** Handles `POST /oauth/register` (RFC 7591). */ +function register(request: HttpServerRequest.HttpServerRequest): McpOauthEffect { + return Effect.gen(function* () { + const body = yield* readJsonBody(request); + return yield* registerMcpClient(body).pipe( + Effect.map((registration) => publicJson(registration, 201)), + Effect.catchTag("McpClientRegistrationError", (error) => + Effect.succeed(publicJson({ error: error.error, error_description: error.description }, error.status)), + ), + ); + }); +} + +// --------------------------------------------------------------------------- +// Authorize + consent +// --------------------------------------------------------------------------- + +/** The validated parameters of one authorize request. */ +interface AuthorizeRequest { + readonly client: McpClient; + readonly redirectUri: string; + readonly codeChallenge: string; + readonly state?: string; +} + +/** Handles `GET /oauth/authorize`: validates the request, then either sends an + * anonymous browser through login and back, or renders the consent page. */ +function authorize(request: HttpServerRequest.HttpServerRequest, url: URL): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const validated = yield* validateAuthorizeRequest(request, url); + if ("response" in validated) return validated.response; + + const auth = yield* Auth; + const user = yield* auth.currentUser(request); + if (user == null) { + const loginUrl = new URL("/auth/login", appBaseUrl(request, config)); + loginUrl.searchParams.set("returnTo", `${url.pathname}${url.search}`); + return HttpServerResponse.redirect(loginUrl, { status: 302 }); + } + + const txn = yield* createMcpConsentToken( + { + clientId: validated.client.clientId, + redirectUri: validated.redirectUri, + codeChallenge: validated.codeChallenge, + ...(validated.state == null ? {} : { state: validated.state }), + userId: user.id, + }, + config.auth, + ); + return consentPageResponse(validated, user, txn, `${url.pathname}${url.search}`); + }); +} + +/** Validates one authorize request in RFC 9700 order: nothing redirects until + * the client and redirect URI are proven registered (an unproven redirect + * target gets a 400 page, never a redirect); every later problem redirects + * back to the now-trusted client with a spec error code. */ +function validateAuthorizeRequest( + request: HttpServerRequest.HttpServerRequest, + url: URL, +): Effect.Effect< + AuthorizeRequest | { readonly response: HttpServerResponse.HttpServerResponse }, + McpOauthError, + McpOauthServices +> { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const params = url.searchParams; + const badRequest = (message: string) => new HttpError({ status: 400, message }); + + const clientId = params.get("client_id"); + if (clientId == null || clientId === "") { + return yield* Effect.fail(badRequest("Missing client_id")); + } + const client = yield* loadMcpClient(clientId); + if (client == null) { + return yield* Effect.fail(badRequest("Unknown client — the registration may have expired; retry connecting from your MCP client")); + } + const redirectUri = params.get("redirect_uri"); + if (redirectUri == null || !redirectUriMatches(client.redirectUris, redirectUri)) { + return yield* Effect.fail(badRequest("redirect_uri is not registered for this client")); + } + // The state is echoed on every redirect below; an oversized one is + // rejected with a page so it is never reflected anywhere. + const state = params.get("state") ?? undefined; + if (state != null && state.length > MAX_STATE_LENGTH) { + return yield* Effect.fail(badRequest("state is too long")); + } + + const redirectError = (error: string, description: string) => ({ + response: errorRedirect(redirectUri, error, description, state), + }); + if (params.get("response_type") !== "code") { + return redirectError("unsupported_response_type", 'Only response_type "code" is supported'); + } + const codeChallenge = params.get("code_challenge"); + if (codeChallenge == null || !isPkceValue(codeChallenge)) { + return redirectError("invalid_request", "A PKCE S256 code_challenge is required"); + } + if (params.get("code_challenge_method") !== "S256") { + return redirectError("invalid_request", 'code_challenge_method must be "S256"'); + } + const scope = params.get("scope"); + if (scope != null && scope !== "" && scope !== MCP_SCOPE) { + return redirectError("invalid_scope", `Only the "${MCP_SCOPE}" scope is supported`); + } + const resource = params.get("resource"); + if (resource != null && resource !== mcpResourceUrl(request, config)) { + return redirectError("invalid_target", "This server's only resource is its /mcp endpoint"); + } + + return { client, redirectUri, codeChallenge, ...(state == null ? {} : { state }) }; + }); +} + +/** Handles `POST /oauth/consent`: the approval (or denial) form submission. */ +function consent(request: HttpServerRequest.HttpServerRequest): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); + + const form = yield* readFormBody(request); + const txn = form.get("txn"); + const decision = form.get("decision"); + if (txn == null || (decision !== "approve" && decision !== "deny")) { + return yield* Effect.fail(new HttpError({ status: 400, message: "Malformed consent submission" })); + } + const payload = yield* verifyMcpConsentToken(txn, config.auth); + + const auth = yield* Auth; + const user = yield* auth.currentUser(request); + if (user == null) { + return yield* Effect.fail(new AuthError({ status: 401, message: "Your session expired — sign in and retry the authorization" })); + } + if (user.id !== payload.userId) { + return yield* Effect.fail(new AuthError({ status: 403, message: "This authorization was started by a different account — retry it" })); + } + // Re-prove the registration: it may have expired (or been replaced) while + // the consent page sat open, and the redirect must never outlive it. + const client = yield* loadMcpClient(payload.clientId); + if (client == null || !redirectUriMatches(client.redirectUris, payload.redirectUri)) { + return yield* Effect.fail(new HttpError({ status: 400, message: "This client registration is no longer valid — retry connecting from your MCP client" })); + } + + if (decision === "deny") { + return errorRedirect(payload.redirectUri, "access_denied", "The user denied the authorization", payload.state); + } + const code = yield* issueMcpAuthorizationCode(user, { + clientId: payload.clientId, + redirectUri: payload.redirectUri, + codeChallenge: payload.codeChallenge, + }, config.auth); + const target = new URL(payload.redirectUri); + target.searchParams.set("code", code); + if (payload.state != null) target.searchParams.set("state", payload.state); + return HttpServerResponse.redirect(target.toString(), { status: 302 }); + }); +} + +// --------------------------------------------------------------------------- +// Token endpoint +// --------------------------------------------------------------------------- + +/** Handles `POST /oauth/token`: the authorization_code and refresh_token + * grants, with RFC 6749 §5.2 error bodies. */ +function token(request: HttpServerRequest.HttpServerRequest): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const form = yield* readFormBody(request); + const aud = mcpResourceUrl(request, config); + + const grantType = form.get("grant_type"); + const grant = grantType === "authorization_code" + ? codeGrant(form, aud) + : grantType === "refresh_token" + ? refreshGrant(form, aud) + : Effect.succeed(oauthTokenError(400, "unsupported_grant_type", 'grant_type must be "authorization_code" or "refresh_token"')); + return yield* grant; + }); +} + +/** The authorization_code grant: burn, prove possession, mint access + refresh. */ +function codeGrant(form: URLSearchParams, aud: string): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const code = form.get("code"); + const codeVerifier = form.get("code_verifier"); + const redirectUri = form.get("redirect_uri"); + const clientId = form.get("client_id"); + if (code == null || codeVerifier == null || redirectUri == null || clientId == null) { + return oauthTokenError(400, "invalid_request", "code, code_verifier, redirect_uri, and client_id are required"); + } + const resource = form.get("resource"); + if (resource != null && resource !== aud) { + return oauthTokenError(400, "invalid_target", "This server's only resource is its /mcp endpoint"); + } + + const payload = yield* decodeMcpAuthorizationCode(code, config.auth).pipe( + Effect.orElseSucceed(() => null), + ); + if (payload == null) { + return oauthTokenError(400, "invalid_grant", "Invalid or expired authorization code"); + } + // The registration must still be live: a code cannot outlive its client. + const client = yield* loadMcpClient(payload.clientId); + if (client == null) { + return oauthTokenError(401, "invalid_client", "Unknown client — the registration may have expired"); + } + const burned = yield* burnOneTimeCode(MCP_CODE_NAMESPACE, payload.id, payload.expiresAt).pipe( + Effect.as(true), + Effect.catchTag("AuthError", () => Effect.succeed(false)), + ); + if (!burned) { + return oauthTokenError(400, "invalid_grant", "Authorization code already redeemed"); + } + const user = yield* verifyMcpCodeExchange(payload, { codeVerifier, redirectUri, clientId }, config.auth).pipe( + Effect.orElseSucceed(() => null), + ); + if (user == null) { + return oauthTokenError(400, "invalid_grant", "Authorization code does not match this request"); + } + + const principal = { user, clientId: payload.clientId }; + const accessToken = yield* createMcpAccessToken(principal, aud, config.auth); + const refreshToken = yield* createMcpRefreshToken(principal, aud, config.auth); + return publicJson({ + access_token: accessToken, + token_type: "Bearer", + expires_in: MCP_ACCESS_TTL_SECONDS, + refresh_token: refreshToken, + scope: MCP_SCOPE, + }); + }); +} + +/** The refresh_token grant: mint a fresh access token. The refresh token is + * not rotated — stateless rotation cannot revoke the predecessor, so rotation + * would only pretend to; the levers are the allow-list and MCP_TOKEN_VERSION. */ +function refreshGrant(form: URLSearchParams, aud: string): McpOauthEffect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + const refreshToken = form.get("refresh_token"); + const clientId = form.get("client_id"); + if (refreshToken == null || clientId == null) { + return oauthTokenError(400, "invalid_request", "refresh_token and client_id are required"); + } + const principal = yield* verifyMcpRefreshToken(refreshToken, aud, config.auth).pipe( + Effect.orElseSucceed(() => null), + ); + if (principal == null || principal.clientId !== clientId) { + return oauthTokenError(400, "invalid_grant", "Invalid or expired refresh token"); + } + const client = yield* loadMcpClient(clientId); + if (client == null) { + return oauthTokenError(401, "invalid_client", "Unknown client — the registration may have expired"); + } + const accessToken = yield* createMcpAccessToken(principal, aud, config.auth); + return publicJson({ + access_token: accessToken, + token_type: "Bearer", + expires_in: MCP_ACCESS_TTL_SECONDS, + scope: MCP_SCOPE, + }); + }); +} + +// --------------------------------------------------------------------------- +// Consent page +// --------------------------------------------------------------------------- + +/** Renders the consent page. Framing is refused so approval clicks cannot be + * hijacked, and the page carries the standard no-store security headers. */ +function consentPageResponse( + authorizeRequest: AuthorizeRequest, + user: AuthUser, + txn: string, + retryPath: string, +): HttpServerResponse.HttpServerResponse { + const clientName = authorizeRequest.client.clientName ?? authorizeRequest.client.clientId; + const redirectHost = new URL(authorizeRequest.redirectUri).host || "your MCP client"; + return HttpServerResponse.text( + consentPageHtml({ clientName, redirectHost, email: user.email, txn, retryPath }), + { + status: 200, + contentType: "text/html; charset=utf-8", + headers: { + ...securityHeaders(), + "Content-Security-Policy": "frame-ancestors 'none'", + "X-Frame-Options": "DENY", + }, + }, + ); +} + +/** The consent page document, in the error pages' visual style. */ +function consentPageHtml(page: { + readonly clientName: string; + readonly redirectHost: string; + readonly email: string; + readonly txn: string; + readonly retryPath: string; +}): string { + const switchHref = `/auth/login?returnTo=${encodeURIComponent(page.retryPath)}`; + return ` + + + + + + Authorize ${escapeHtml(page.clientName)} · scratchwork + + + + +
+
${FIGURE_SVG.trim()}
+

Authorize ${escapeHtml(page.clientName)}?

+

${escapeHtml(page.clientName)} wants to publish and manage Scratchwork projects as ${escapeHtml(page.email)}.

+

You'll be sent back to ${escapeHtml(page.redirectHost)}. Not you? Use a different account.

+
+ + + +
+
+ + +`; +} + +// --------------------------------------------------------------------------- +// Request/response plumbing +// --------------------------------------------------------------------------- + +/** Reads and parses a small JSON request body. */ +function readJsonBody(request: HttpServerRequest.HttpServerRequest): Effect.Effect { + return Effect.gen(function* () { + const text = yield* readCappedBody(request); + return yield* Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: () => new HttpError({ status: 400, message: "Invalid JSON body" }), + }); + }); +} + +/** Reads and parses a small application/x-www-form-urlencoded request body. */ +function readFormBody(request: HttpServerRequest.HttpServerRequest): Effect.Effect { + return Effect.map(readCappedBody(request), (text) => new URLSearchParams(text)); +} + +/** Reads a request body under the shared OAuth-endpoint size cap. */ +function readCappedBody(request: HttpServerRequest.HttpServerRequest): Effect.Effect { + return Effect.gen(function* () { + const text = yield* request.text.pipe( + HttpServerRequest.withMaxBodySize(Option.some(MAX_OAUTH_BODY_BYTES)), + Effect.mapError((cause) => new HttpError({ status: 413, message: "Request body is too large", cause })), + ); + if (new TextEncoder().encode(text).byteLength > MAX_OAUTH_BODY_BYTES) { + return yield* Effect.fail(new HttpError({ status: 413, message: "Request body is too large" })); + } + return text; + }); +} + +/** A JSON response readable by any origin: only for the four endpoints that + * never read an ambient credential (metadata, register, token). */ +function publicJson(body: unknown, status = 200): HttpServerResponse.HttpServerResponse { + return HttpServerResponse.unsafeJson(body, { + status, + headers: { ...securityHeaders(), "Access-Control-Allow-Origin": "*" }, + }); +} + +/** An RFC 6749 §5.2 token-endpoint error body. */ +function oauthTokenError(status: number, error: string, description: string): HttpServerResponse.HttpServerResponse { + return publicJson({ error, error_description: description }, status); +} + +/** Redirects an authorize-time failure back to the (already validated) + * redirect URI with the spec error parameters. */ +function errorRedirect( + redirectUri: string, + error: string, + description: string, + state: string | undefined, +): HttpServerResponse.HttpServerResponse { + const target = new URL(redirectUri); + target.searchParams.set("error", error); + target.searchParams.set("error_description", description); + if (state != null) target.searchParams.set("state", state); + return HttpServerResponse.redirect(target.toString(), { status: 302 }); +} diff --git a/server/core/test/api-policy.test.ts b/server/core/test/api-policy.test.ts index 0a99bc4..a12316d 100644 --- a/server/core/test/api-policy.test.ts +++ b/server/core/test/api-policy.test.ts @@ -11,10 +11,11 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import { API_ROUTES, type ApiRoute } from "../src/api-routes"; -import { createSessionToken, type AuthUser } from "../src/auth"; +import { createMcpAccessToken, createSessionToken, type AuthUser } from "../src/auth"; import type { AuthConfig } from "../src/config"; +import { MCP_OAUTH_ROUTES } from "../src/mcp-oauth-routes"; import { roleAtLeast, type ProjectRole } from "../src/site-store"; -import { appHandler, bundle } from "./helpers"; +import { appHandler, bundle, json } from "./helpers"; /** Must match the appHandler defaults in helpers.ts so minted bearers verify. */ const authConfig: AuthConfig = { @@ -222,6 +223,82 @@ describe("api route policy matrix", () => { expect(empty.status).toBe(400); }); + test("an mcp-access bearer never authenticates on the JSON API (audience confusion)", async () => { + // The MCP access token authorizes only the /mcp endpoint. Presented to any + // JSON API route it must read as no credential at all: bearer routes 401, + // optional routes answer anonymously. This is the deny-all matrix row for + // the credential kind introduced by the MCP OAuth surface. + const handler = await fixture(); + const token = await Effect.runPromise(createMcpAccessToken( + { user: users.owner, clientId: "matrix-client-1" }, + "https://scratch.test/mcp", + authConfig, + )); + for (const route of API_ROUTES.filter((candidate) => candidate.auth !== "code-exchange")) { + const { path, method, body } = FIXTURES[route.name]!("site"); + const response = await handler(new Request(`https://scratch.test${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + })); + const label = `${route.name} × mcp-access bearer`; + if (route.auth === "bearer") { + expect({ label, status: response.status }).toEqual({ label, status: 401 }); + } else if (route.name === "me") { + expect({ label, body: await json(response) }).toEqual({ label, body: { authenticated: false, user: null } }); + } else { + expect({ label, status: response.status }).toEqual({ label, status: 200 }); + } + } + }); + + test("every MCP OAuth route is dispatched at its declared method, and only there", async () => { + // The MCP OAuth surface keeps its own registry (it is server-only, outside + // the shared contract); this is its completeness check — a dispatched path + // missing from MCP_OAUTH_ROUTES, or a registry row nothing dispatches, + // fails here. Behavior per endpoint is covered in mcp-oauth.test.ts. + const handler = await fixture(); + for (const route of MCP_OAUTH_ROUTES) { + const declared = await handler(new Request(`https://scratch.test${route.path}`, { + method: route.method, + ...(route.method === "POST" ? { headers: { "content-type": "application/json" }, body: "{}" } : {}), + })); + // Dispatched: anything but the not-found/method-not-allowed fallthroughs. + expect({ route: route.name, dispatched: declared.status }).not.toEqual({ route: route.name, dispatched: 404 }); + expect({ route: route.name, dispatched: declared.status }).not.toEqual({ route: route.name, dispatched: 405 }); + const wrongMethod = await handler(new Request(`https://scratch.test${route.path}`, { + method: route.method === "GET" ? "POST" : "GET", + })); + expect({ route: route.name, status: wrongMethod.status }).toEqual({ route: route.name, status: 405 }); + } + }); + + test("MCP OAuth cross-origin policy follows each route's credential model", async () => { + const handler = await fixture(); + // Consent reads the session cookie, so it must reject cross-origin like + // every API route. The credential-free endpoints are deliberately + // CORS-open (they never read an ambient credential), and that must stay + // visible as an explicit ACAO header, not an accident. + const sessionUser = await bearer(users.owner); + const consent = await handler(new Request("https://scratch.test/oauth/consent", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Bearer ${sessionUser}`, + origin: "https://evil.example", + }, + body: "txn=x&decision=approve", + })); + expect(consent.status).toBe(403); + for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server"]) { + const response = await handler(new Request(`https://scratch.test${path}`)); + expect({ path, acao: response.headers.get("access-control-allow-origin") }).toEqual({ path, acao: "*" }); + } + }); + test("permissions are visible only to admin+ callers (declared visibility)", async () => { const handler = await fixture(); const info = async (credential: (typeof CREDENTIALS)[number]) => { diff --git a/server/core/test/mcp-oauth.test.ts b/server/core/test/mcp-oauth.test.ts new file mode 100644 index 0000000..d8eb7c6 --- /dev/null +++ b/server/core/test/mcp-oauth.test.ts @@ -0,0 +1,591 @@ +/* + * Handler-level tests of the MCP OAuth surface: the discovery documents, the + * full register → authorize → consent → token → /mcp-principal loop, and the + * adversarial negatives from the invariant-3/5 threat review — code replay, + * PKCE downgrade, redirect_uri confusion, audience confusion, consent CSRF, + * and registration expiry. Token bit-flip/parser hardening lives in + * token-corpus.test.ts; this file exercises the endpoints. + */ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { sha256Base64Url } from "@scratchwork/shared/crypto/digest"; +import { createSessionToken, requireMcpUser, type AuthUser } from "../src/auth"; +import type { AuthConfig } from "../src/config"; +import { redirectUriMatches, validMcpRedirectUri } from "../src/mcp-clients"; +import { MCP_OAUTH_ROUTES } from "../src/mcp-oauth-routes"; +import { appHandler, json } from "./helpers"; + +/** Must match the appHandler defaults in helpers.ts so minted bearers verify. */ +const authConfig: AuthConfig = { + mode: "oauth", + clientId: "test-client-id", + clientSecret: "test-client-secret", + sessionSecret: "test-session-secret-test-session-secret", + allowedUsers: "public", + sessionTtlSeconds: 60, +}; + +const BASE = "https://scratch.test"; +const AUD = `${BASE}/mcp`; +const REDIRECT = "http://127.0.0.1:33418/callback"; +const VERIFIER = "test-verifier-test-verifier-test-verifier-1"; + +const alice: AuthUser = { id: "alice-1", email: "alice@example.com" }; +const bob: AuthUser = { id: "bob-1", email: "bob@example.com" }; + +type Handler = Awaited>; + +async function bearer(user: AuthUser): Promise { + return Effect.runPromise(createSessionToken(user, authConfig)); +} + +/** POSTs a form-encoded body, optionally authenticated with a session bearer. */ +function postForm( + handler: Handler, + path: string, + fields: Record, + headers: Record = {}, +): Promise { + return handler(new Request(`${BASE}${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, + body: new URLSearchParams(fields).toString(), + })); +} + +/** Registers a client and returns its id. */ +async function register(handler: Handler, overrides: Record = {}): Promise { + const response = await handler(new Request(`${BASE}/oauth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + redirect_uris: [REDIRECT], + client_name: "Claude Code", + token_endpoint_auth_method: "none", + ...overrides, + }), + })); + expect(response.status).toBe(201); + const body = await json(response) as { client_id: string }; + expect(body.client_id).toMatch(/^[A-Za-z0-9_-]{22}$/); + return body.client_id; +} + +/** The canonical authorize query for one client. */ +async function authorizeQuery(clientId: string, overrides: Record = {}): Promise { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: REDIRECT, + response_type: "code", + code_challenge: await sha256Base64Url(VERIFIER), + code_challenge_method: "S256", + state: "client-state-123", + }); + for (const [key, value] of Object.entries(overrides)) { + if (value == null) params.delete(key); + else params.set(key, value); + } + return params.toString(); +} + +/** Renders the consent page as a signed-in user and extracts the txn token. */ +async function consentTxn(handler: Handler, clientId: string, user: AuthUser, query?: string): Promise { + const response = await handler(new Request(`${BASE}/oauth/authorize?${query ?? await authorizeQuery(clientId)}`, { + headers: { authorization: `Bearer ${await bearer(user)}`, accept: "text/html" }, + })); + expect(response.status).toBe(200); + const html = await response.text(); + const match = /name="txn" value="([^"]+)"/.exec(html); + if (match == null) throw new Error("consent page has no txn field"); + return match[1]; +} + +/** Runs the whole browser leg (authorize + approve) and returns the code. */ +async function obtainCode(handler: Handler, clientId: string, user: AuthUser = alice): Promise { + const txn = await consentTxn(handler, clientId, user); + const approved = await postForm(handler, "/oauth/consent", { txn, decision: "approve" }, { + authorization: `Bearer ${await bearer(user)}`, + }); + expect(approved.status).toBe(302); + const location = new URL(approved.headers.get("location") ?? ""); + expect(`${location.origin}${location.pathname}`).toBe(REDIRECT); + expect(location.searchParams.get("state")).toBe("client-state-123"); + const code = location.searchParams.get("code"); + if (code == null) throw new Error("approval redirect has no code"); + return code; +} + +/** Redeems a code at the token endpoint. */ +function redeemCode( + handler: Handler, + clientId: string, + code: string, + overrides: Record = {}, +): Promise { + return postForm(handler, "/oauth/token", { + grant_type: "authorization_code", + code, + code_verifier: VERIFIER, + redirect_uri: REDIRECT, + client_id: clientId, + ...overrides, + }); +} + +describe("mcp oauth: discovery", () => { + test("protected-resource metadata names /mcp and this origin, at both well-known paths", async () => { + const handler = await appHandler({}); + for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) { + const response = await handler(new Request(`${BASE}${path}`)); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(await json(response)).toEqual({ + resource: AUD, + authorization_servers: [BASE], + bearer_methods_supported: ["header"], + scopes_supported: ["mcp"], + }); + } + }); + + test("authorization-server metadata advertises PKCE-only public clients", async () => { + const handler = await appHandler({}); + for (const path of ["/.well-known/oauth-authorization-server", "/.well-known/oauth-authorization-server/mcp"]) { + const response = await handler(new Request(`${BASE}${path}`)); + expect(response.status).toBe(200); + const body = await json(response) as Record; + expect(body.issuer).toBe(BASE); + expect(body.authorization_endpoint).toBe(`${BASE}/oauth/authorize`); + expect(body.token_endpoint).toBe(`${BASE}/oauth/token`); + expect(body.registration_endpoint).toBe(`${BASE}/oauth/register`); + expect(body.code_challenge_methods_supported).toEqual(["S256"]); + expect(body.token_endpoint_auth_methods_supported).toEqual(["none"]); + expect(body.grant_types_supported).toEqual(["authorization_code", "refresh_token"]); + } + }); + + test("metadata endpoints reject non-GET methods", async () => { + const handler = await appHandler({}); + for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server"]) { + const response = await handler(new Request(`${BASE}${path}`, { method: "POST" })); + expect({ path, status: response.status }).toEqual({ path, status: 405 }); + } + }); +}); + +describe("mcp oauth: registration", () => { + test("registers a public client and echoes the accepted metadata", async () => { + const handler = await appHandler({}); + const response = await handler(new Request(`${BASE}/oauth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + redirect_uris: [REDIRECT, "https://claude.ai/api/mcp/auth_callback"], + client_name: "Claude Code", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + // Arbitrary RFC 7591 metadata the server ignores. + client_uri: "https://claude.com/claude-code", + scope: "mcp", + }), + })); + expect(response.status).toBe(201); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + const body = await json(response) as Record; + expect(body.token_endpoint_auth_method).toBe("none"); + expect(body.redirect_uris).toEqual([REDIRECT, "https://claude.ai/api/mcp/auth_callback"]); + expect(body.client_name).toBe("Claude Code"); + expect(typeof body.client_id_issued_at).toBe("number"); + }); + + test("rejects bad registrations with RFC 7591 error bodies", async () => { + const handler = await appHandler({}); + const attempt = async (body: unknown) => { + const response = await handler(new Request(`${BASE}/oauth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + })); + return { status: response.status, ...(await json(response) as { error: string }) }; + }; + expect((await attempt({})).error).toBe("invalid_redirect_uri"); + expect((await attempt({ redirect_uris: [] })).error).toBe("invalid_redirect_uri"); + expect((await attempt({ redirect_uris: ["http://evil.example/cb"] })).error).toBe("invalid_redirect_uri"); + expect((await attempt({ redirect_uris: ["https://ok.example/cb#frag"] })).error).toBe("invalid_redirect_uri"); + expect((await attempt({ redirect_uris: ["https://user:pw@ok.example/cb"] })).error).toBe("invalid_redirect_uri"); + expect((await attempt({ redirect_uris: Array.from({ length: 11 }, (_, i) => `https://ok.example/cb${i}`) })).error) + .toBe("invalid_redirect_uri"); + expect((await attempt({ redirect_uris: [REDIRECT], token_endpoint_auth_method: "client_secret_basic" })).error) + .toBe("invalid_client_metadata"); + expect((await attempt({ redirect_uris: [REDIRECT], grant_types: ["implicit"] })).error) + .toBe("invalid_client_metadata"); + expect((await attempt({ redirect_uris: [REDIRECT], response_types: ["token"] })).error) + .toBe("invalid_client_metadata"); + expect((await attempt({ redirect_uris: [REDIRECT], client_name: "x".repeat(300) })).error) + .toBe("invalid_client_metadata"); + }); +}); + +describe("mcp oauth: authorize", () => { + test("an anonymous browser is sent through login and back to the exact authorize URL", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const query = await authorizeQuery(clientId); + const response = await handler(new Request(`${BASE}/oauth/authorize?${query}`, { headers: { accept: "text/html" } })); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("location") ?? ""); + expect(location.pathname).toBe("/auth/login"); + expect(location.searchParams.get("returnTo")).toBe(`/oauth/authorize?${query}`); + }); + + test("a signed-in user gets the consent page: client name, account, no framing, no CORS", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const response = await handler(new Request(`${BASE}/oauth/authorize?${await authorizeQuery(clientId)}`, { + headers: { authorization: `Bearer ${await bearer(alice)}`, accept: "text/html" }, + })); + expect(response.status).toBe(200); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + const html = await response.text(); + expect(html).toContain("Claude Code"); + expect(html).toContain(alice.email); + expect(html).toContain('name="txn"'); + }); + + test("an unknown or expired client never redirects — 400 page instead", async () => { + const handler = await appHandler({}); + const anon = await handler(new Request(`${BASE}/oauth/authorize?${await authorizeQuery("A".repeat(22))}`)); + expect(anon.status).toBe(400); + expect(anon.headers.get("location")).toBeNull(); + }); + + test("an unregistered redirect_uri never redirects — 400 page instead", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const query = await authorizeQuery(clientId, { redirect_uri: "http://127.0.0.1:33418/other-path" }); + const response = await handler(new Request(`${BASE}/oauth/authorize?${query}`)); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + }); + + test("loopback registrations match any port; the path must still match", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const otherPort = REDIRECT.replace(":33418", ":49152"); + const query = await authorizeQuery(clientId, { redirect_uri: otherPort }); + const txn = await consentTxn(handler, clientId, alice, query); + expect(txn.length).toBeGreaterThan(0); + }); + + test("validated-client errors redirect with the spec error code and echo state", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const expectRedirectError = async (overrides: Record, error: string) => { + const response = await handler(new Request(`${BASE}/oauth/authorize?${await authorizeQuery(clientId, overrides)}`, { + headers: { authorization: `Bearer ${await bearer(alice)}` }, + })); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("location") ?? ""); + expect({ overrides, error: location.searchParams.get("error") }).toEqual({ overrides, error }); + expect(location.searchParams.get("state")).toBe("client-state-123"); + }; + await expectRedirectError({ response_type: "token" }, "unsupported_response_type"); + await expectRedirectError({ code_challenge: null }, "invalid_request"); + await expectRedirectError({ code_challenge: "too-short" }, "invalid_request"); + await expectRedirectError({ code_challenge_method: "plain" }, "invalid_request"); + await expectRedirectError({ scope: "admin" }, "invalid_scope"); + await expectRedirectError({ resource: "https://other.example/mcp" }, "invalid_target"); + }); + + test("an oversized state is rejected with a page, never echoed", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const query = await authorizeQuery(clientId, { state: "s".repeat(513) }); + const response = await handler(new Request(`${BASE}/oauth/authorize?${query}`)); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + }); +}); + +describe("mcp oauth: consent", () => { + test("deny redirects with access_denied and mints nothing", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const txn = await consentTxn(handler, clientId, alice); + const denied = await postForm(handler, "/oauth/consent", { txn, decision: "deny" }, { + authorization: `Bearer ${await bearer(alice)}`, + }); + expect(denied.status).toBe(302); + const location = new URL(denied.headers.get("location") ?? ""); + expect(location.searchParams.get("error")).toBe("access_denied"); + expect(location.searchParams.get("code")).toBeNull(); + }); + + test("the consent POST rejects cross-origin submissions", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const txn = await consentTxn(handler, clientId, alice); + const response = await postForm(handler, "/oauth/consent", { txn, decision: "approve" }, { + authorization: `Bearer ${await bearer(alice)}`, + origin: "https://evil.example", + }); + expect(response.status).toBe(403); + }); + + test("a consent token minted for one account cannot be approved by another", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const txn = await consentTxn(handler, clientId, alice); + const response = await postForm(handler, "/oauth/consent", { txn, decision: "approve" }, { + authorization: `Bearer ${await bearer(bob)}`, + }); + expect(response.status).toBe(403); + }); + + test("a consent submission without a session is rejected", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const txn = await consentTxn(handler, clientId, alice); + const response = await postForm(handler, "/oauth/consent", { txn, decision: "approve" }); + expect(response.status).toBe(401); + }); + + test("a malformed submission is rejected", async () => { + const handler = await appHandler({}); + const response = await postForm(handler, "/oauth/consent", { decision: "approve" }, { + authorization: `Bearer ${await bearer(alice)}`, + }); + expect(response.status).toBe(400); + const tampered = await postForm(handler, "/oauth/consent", { txn: "garbage.token", decision: "approve" }, { + authorization: `Bearer ${await bearer(alice)}`, + }); + expect(tampered.status).toBe(400); + }); +}); + +describe("mcp oauth: token endpoint and the full loop", () => { + test("register → authorize → consent → token → the bearer authenticates at /mcp", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const code = await obtainCode(handler, clientId); + const response = await redeemCode(handler, clientId, code); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + const body = await json(response) as Record; + expect(body.token_type).toBe("Bearer"); + expect(body.expires_in).toBe(3600); + expect(body.scope).toBe("mcp"); + + // The access token resolves the authorizing user through the /mcp + // principal chokepoint, audience-bound to this server. + const principal = await Effect.runPromise(requireMcpUser( + { headers: { authorization: `Bearer ${body.access_token as string}` } } as never, + AUD, + authConfig, + )); + expect(principal).toEqual(alice); + + // ...and refuses the same token at a different audience. + const elsewhere = Effect.runPromise(requireMcpUser( + { headers: { authorization: `Bearer ${body.access_token as string}` } } as never, + "https://other.example/mcp", + authConfig, + )); + expect(elsewhere).rejects.toThrow("Authentication required"); + + // The refresh grant mints a fresh access token and never a new refresh token. + const refreshed = await postForm(handler, "/oauth/token", { + grant_type: "refresh_token", + refresh_token: body.refresh_token as string, + client_id: clientId, + }); + expect(refreshed.status).toBe(200); + const refreshedBody = await json(refreshed) as Record; + expect(typeof refreshedBody.access_token).toBe("string"); + expect(refreshedBody.refresh_token).toBeUndefined(); + }); + + test("an explicit matching resource parameter is accepted; a mismatch is invalid_target", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const good = await redeemCode(handler, clientId, await obtainCode(handler, clientId), { resource: AUD }); + expect(good.status).toBe(200); + const bad = await redeemCode(handler, clientId, await obtainCode(handler, clientId), { resource: "https://other.example/mcp" }); + expect(bad.status).toBe(400); + expect((await json(bad) as { error: string }).error).toBe("invalid_target"); + }); + + test("a code redeems exactly once — replay fails after success", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const code = await obtainCode(handler, clientId); + expect((await redeemCode(handler, clientId, code)).status).toBe(200); + const replay = await redeemCode(handler, clientId, code); + expect(replay.status).toBe(400); + expect((await json(replay) as { error: string }).error).toBe("invalid_grant"); + }); + + test("a wrong-verifier attempt burns the code — the interceptor race fails closed", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const code = await obtainCode(handler, clientId); + const wrong = await redeemCode(handler, clientId, code, { code_verifier: "w".repeat(43) }); + expect(wrong.status).toBe(400); + // The failed attempt consumed the code: the correct verifier now fails too. + const after = await redeemCode(handler, clientId, code); + expect(after.status).toBe(400); + }); + + test("possession checks: wrong redirect_uri and wrong client are invalid_grant", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const otherClient = await register(handler); + const badRedirect = await redeemCode(handler, clientId, await obtainCode(handler, clientId), { + redirect_uri: "http://127.0.0.1:33418/other", + }); + expect(badRedirect.status).toBe(400); + expect((await json(badRedirect) as { error: string }).error).toBe("invalid_grant"); + const badClient = await redeemCode(handler, otherClient, await obtainCode(handler, clientId)); + expect(badClient.status).toBe(400); + expect((await json(badClient) as { error: string }).error).toBe("invalid_grant"); + }); + + test("garbage codes, unsupported grants, and missing fields are spec errors", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const garbage = await redeemCode(handler, clientId, "garbage.code"); + expect(garbage.status).toBe(400); + expect((await json(garbage) as { error: string }).error).toBe("invalid_grant"); + const unsupported = await postForm(handler, "/oauth/token", { grant_type: "password" }); + expect((await json(unsupported) as { error: string }).error).toBe("unsupported_grant_type"); + const missing = await postForm(handler, "/oauth/token", { grant_type: "authorization_code" }); + expect((await json(missing) as { error: string }).error).toBe("invalid_request"); + }); + + test("a session cookie lends the token endpoint nothing", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const response = await redeemCode(handler, clientId, "garbage.code"); + const withCookie = await postForm(handler, "/oauth/token", { + grant_type: "authorization_code", + code: "garbage.code", + code_verifier: VERIFIER, + redirect_uri: REDIRECT, + client_id: clientId, + }, { cookie: `__Host-scratchwork_session=${encodeURIComponent(await bearer(alice))}` }); + expect(withCookie.status).toBe(response.status); + expect((await json(withCookie) as { error: string }).error).toBe("invalid_grant"); + }); + + test("a refresh token from another client id is rejected", async () => { + const handler = await appHandler({}); + const clientId = await register(handler); + const otherClient = await register(handler); + const token = await redeemCode(handler, clientId, await obtainCode(handler, clientId)); + const body = await json(token) as { refresh_token: string }; + const response = await postForm(handler, "/oauth/token", { + grant_type: "refresh_token", + refresh_token: body.refresh_token, + client_id: otherClient, + }); + expect(response.status).toBe(400); + expect((await json(response) as { error: string }).error).toBe("invalid_grant"); + }); + + test("GET /oauth/token and GET /oauth/register are 405", async () => { + const handler = await appHandler({}); + for (const path of ["/oauth/token", "/oauth/register", "/oauth/consent"]) { + const response = await handler(new Request(`${BASE}${path}`)); + expect({ path, status: response.status }).toEqual({ path, status: 405 }); + } + }); +}); + +describe("mcp oauth: registration lifecycle", () => { + test("an expired registration is unknown at authorize and token time", async () => { + const handler = await appHandler({}); + // Mint the registration in the past so its 90-day expiry has elapsed. + const realNow = Date.now; + Date.now = () => realNow() - 91 * 24 * 60 * 60 * 1000; + let clientId: string; + try { + clientId = await register(handler); + } finally { + Date.now = realNow; + } + const response = await handler(new Request(`${BASE}/oauth/authorize?${await authorizeQuery(clientId)}`)); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + }); +}); + +describe("mcp oauth: session bearers and /mcp principal resolution", () => { + test("a session bearer is accepted at the /mcp chokepoint — documented fallback", async () => { + const principal = await Effect.runPromise(requireMcpUser( + { headers: { authorization: `Bearer ${await bearer(alice)}` } } as never, + AUD, + authConfig, + )); + expect(principal).toEqual(alice); + }); + + test("no bearer, garbage bearers, and cookies alone never authenticate", async () => { + for (const headers of [ + {}, + { authorization: "Bearer garbage.token" }, + { cookie: `__Host-scratchwork_session=${encodeURIComponent(await bearer(alice))}` }, + ]) { + const attempt = Effect.runPromise(requireMcpUser( + { headers } as never, + AUD, + authConfig, + )); + expect(attempt).rejects.toThrow("Authentication required"); + } + }); +}); + +describe("mcp oauth: route registry", () => { + test("the registry names every dispatched route exactly once", () => { + expect(MCP_OAUTH_ROUTES.map((route) => `${route.method} ${route.path}`).sort()).toEqual([ + "GET /.well-known/oauth-authorization-server", + "GET /.well-known/oauth-protected-resource", + "GET /oauth/authorize", + "POST /oauth/consent", + "POST /oauth/register", + "POST /oauth/token", + ]); + expect(new Set(MCP_OAUTH_ROUTES.map((route) => route.name)).size).toBe(MCP_OAUTH_ROUTES.length); + }); +}); + +describe("mcp redirect-uri rules", () => { + test("validMcpRedirectUri accepts https and loopback http only", () => { + expect(validMcpRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true); + expect(validMcpRedirectUri("http://127.0.0.1:33418/callback")).toBe(true); + expect(validMcpRedirectUri("http://localhost:8080/cb")).toBe(true); + expect(validMcpRedirectUri("http://[::1]:8080/cb")).toBe(true); + expect(validMcpRedirectUri("http://evil.example/cb")).toBe(false); + expect(validMcpRedirectUri("https://ok.example/cb#fragment")).toBe(false); + expect(validMcpRedirectUri("https://user:pw@ok.example/cb")).toBe(false); + expect(validMcpRedirectUri("custom-scheme://callback")).toBe(false); + expect(validMcpRedirectUri("not a url")).toBe(false); + expect(validMcpRedirectUri("")).toBe(false); + expect(validMcpRedirectUri(`https://ok.example/${"x".repeat(2100)}`)).toBe(false); + }); + + test("redirectUriMatches is exact except for loopback ports", () => { + const registered = ["http://127.0.0.1:33418/callback", "https://claude.ai/api/mcp/auth_callback"]; + expect(redirectUriMatches(registered, "http://127.0.0.1:33418/callback")).toBe(true); + expect(redirectUriMatches(registered, "http://127.0.0.1:49152/callback")).toBe(true); + expect(redirectUriMatches(registered, "http://127.0.0.1:49152/other")).toBe(false); + expect(redirectUriMatches(registered, "http://localhost:33418/callback")).toBe(false); + expect(redirectUriMatches(registered, "https://claude.ai/api/mcp/auth_callback")).toBe(true); + expect(redirectUriMatches(registered, "https://claude.ai:8443/api/mcp/auth_callback")).toBe(false); + expect(redirectUriMatches(registered, "https://claude.ai/api/mcp/auth_callback/extra")).toBe(false); + expect(redirectUriMatches(registered, "http://evil.example/callback")).toBe(false); + expect(redirectUriMatches([], "http://127.0.0.1:1/cb")).toBe(false); + }); +}); diff --git a/server/core/test/token-corpus.test.ts b/server/core/test/token-corpus.test.ts index 98271fe..1eec860 100644 --- a/server/core/test/token-corpus.test.ts +++ b/server/core/test/token-corpus.test.ts @@ -24,10 +24,18 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import { + createMcpAccessToken, + createMcpConsentToken, + createMcpRefreshToken, createSessionToken, decodeCliAuthorizationCode, + decodeMcpAuthorizationCode, issueCliAuthorizationCode, + issueMcpAuthorizationCode, makeAuth, + verifyMcpAccessToken, + verifyMcpConsentToken, + verifyMcpRefreshToken, type AuthUser, } from "../src/auth"; import type { AuthConfig } from "../src/config"; @@ -118,6 +126,11 @@ async function oauthStateAccepts(token: string, stateParam: string = FIXED_STATE } const CLI_REDIRECT = "http://127.0.0.1:5555/callback"; +/** The MCP resource URL every mcp-access/mcp-refresh oracle verifies against. */ +const MCP_AUD = `${BASE_URL}/mcp`; +const MCP_CLIENT_ID = "corpus-mcp-client-1"; +const MCP_REDIRECT = "http://127.0.0.1:6060/callback"; +const MCP_CHALLENGE = "corpus-challenge-corpus-challenge-corpus-ch1"; const KINDS: readonly TokenKind[] = [ { @@ -205,6 +218,88 @@ const KINDS: readonly TokenKind[] = [ () => false, ), }, + { + name: "mcp-consent", + mint: () => + Effect.runPromise(createMcpConsentToken({ + clientId: MCP_CLIENT_ID, + redirectUri: MCP_REDIRECT, + codeChallenge: MCP_CHALLENGE, + userId: user.id, + }, config)), + validPayload: () => ({ + version: 1, + kind: "mcp-consent", + clientId: MCP_CLIENT_ID, + redirectUri: MCP_REDIRECT, + codeChallenge: MCP_CHALLENGE, + userId: user.id, + expiresAt: now() + 600, + }), + accepts: (token) => + Effect.runPromise(verifyMcpConsentToken(token, config)).then(() => true, () => false), + }, + { + name: "mcp-code", + mint: () => + Effect.runPromise(issueMcpAuthorizationCode(user, { + clientId: MCP_CLIENT_ID, + redirectUri: MCP_REDIRECT, + codeChallenge: MCP_CHALLENGE, + }, config)), + validPayload: () => ({ + version: 1, + kind: "mcp-code", + id: "corpus-mcp-code-1", + user: { id: user.id, email: user.email }, + clientId: MCP_CLIENT_ID, + redirectUri: MCP_REDIRECT, + codeChallenge: MCP_CHALLENGE, + expiresAt: now() + 60, + }), + accepts: (token) => + Effect.runPromise(decodeMcpAuthorizationCode(token, config)).then(() => true, () => false), + }, + { + name: "mcp-access", + mint: () => + Effect.runPromise(createMcpAccessToken({ user, clientId: MCP_CLIENT_ID }, MCP_AUD, config)), + validPayload: () => ({ + version: 1, + kind: "mcp-access", + user: { id: user.id, email: user.email }, + clientId: MCP_CLIENT_ID, + aud: MCP_AUD, + scope: "mcp", + issuedAt: now(), + expiresAt: now() + 3600, + }), + accepts: (token) => + Effect.runPromise(verifyMcpAccessToken(token, MCP_AUD, config)).then( + (found) => found != null, + () => false, + ), + }, + { + name: "mcp-refresh", + mint: () => + Effect.runPromise(createMcpRefreshToken({ user, clientId: MCP_CLIENT_ID }, MCP_AUD, config)), + validPayload: () => ({ + version: 1, + kind: "mcp-refresh", + user: { id: user.id, email: user.email }, + clientId: MCP_CLIENT_ID, + aud: MCP_AUD, + scope: "mcp", + issuedAt: now(), + expiresAt: now() + 3600, + }), + accepts: (token) => + Effect.runPromise(verifyMcpRefreshToken(token, MCP_AUD, config)).then( + (found) => found != null, + () => false, + ), + }, ]; describe("token corpus: sanity", () => { @@ -333,6 +428,38 @@ describe("token corpus: typed meaning", () => { const token = await craft(KINDS[1].validPayload()); expect(await oauthStateAccepts(token, "different-state-param")).toBe(false); }); + + test("mcp-access and mcp-refresh: reject a token bound to a different audience", async () => { + const otherAud = "https://other.scratch.test/mcp"; + for (const kind of [KINDS[7], KINDS[8]]) { + // A token minted for another resource URL fails here... + expect(await kind.accepts(await craft({ ...kind.validPayload(), aud: otherAud }))).toBe(false); + } + // ...and a token minted for this resource fails at another resource. + const minted = await Effect.runPromise( + createMcpAccessToken({ user, clientId: MCP_CLIENT_ID }, MCP_AUD, config), + ); + const elsewhere = await Effect.runPromise(verifyMcpAccessToken(minted, otherAud, config)); + expect(elsewhere).toBeNull(); + }); + + test("mcp-access and mcp-refresh: reject a scope other than the literal", async () => { + for (const kind of [KINDS[7], KINDS[8]]) { + for (const scope of ["admin", "mcp mcp", "", "MCP"]) { + expect({ scope, accepted: await kind.accepts(await craft({ ...kind.validPayload(), scope })) }).toEqual({ + scope, + accepted: false, + }); + } + } + }); + + test("mcp-access and mcp-refresh: reject future issuance beyond clock skew", async () => { + for (const kind of [KINDS[7], KINDS[8]]) { + expect(await kind.accepts(await craft({ ...kind.validPayload(), issuedAt: now() + 3600 }))).toBe(false); + expect(await kind.accepts(await craft({ ...kind.validPayload(), issuedAt: now() + 5 }))).toBe(true); + } + }); }); describe("token corpus: parser hardening", () => { @@ -415,15 +542,36 @@ describe("token corpus: lifecycle", () => { expect(await KINDS[1].accepts(await craft({ ...KINDS[1].validPayload(), expiresAt: now() - 1 }))).toBe(false); }); + test("mcp-consent: expires after the consent TTL", async () => { + const expired = await withShiftedClock(-(600 + 60), () => KINDS[5].mint()); + expect(await KINDS[5].accepts(expired)).toBe(false); + }); + + test("mcp-code: expires after its ~60s window", async () => { + const expired = await withShiftedClock(-120, () => KINDS[6].mint()); + expect(await KINDS[6].accepts(expired)).toBe(false); + }); + + test("mcp-access: expires after its one-hour TTL", async () => { + const expired = await withShiftedClock(-(3600 + 60), () => KINDS[7].mint()); + expect(await KINDS[7].accepts(expired)).toBe(false); + }); + + test("mcp-refresh: expires after the session TTL", async () => { + const expired = await withShiftedClock(-(config.sessionTtlSeconds + 60), () => KINDS[8].mint()); + expect(await KINDS[8].accepts(expired)).toBe(false); + }); + test("stateless kinds are replayable within their lifetime — by design", async () => { - // Documented accepted trade-off (AGENTS.md invariant 3): sessions and - // project-access tokens are stateless HMAC, so a token verifies any number - // of times until expiry or an allow-list/SESSION_VERSION revocation. The - // cli-code kind is the deliberate exception: its one-time redemption - // record lives in PrimitiveDb at the /auth/cli/token exchange route - // (exercised in server/core/test/app.test.ts and the e2e auth-negative - // lanes), not in the stateless decode exercised here. - for (const kind of [KINDS[0], KINDS[3], KINDS[4]]) { + // Documented accepted trade-off (AGENTS.md invariant 3): sessions, + // project-access tokens, and MCP access/refresh tokens are stateless HMAC, + // so a token verifies any number of times until expiry or an allow-list / + // SESSION_VERSION / MCP_TOKEN_VERSION revocation. The cli-code and + // mcp-code kinds are the deliberate exceptions: their one-time redemption + // records live in PrimitiveDb at their exchange routes (exercised in + // server/core/test/app.test.ts, mcp-oauth.test.ts, and the e2e + // auth-negative lanes), not in the stateless decode exercised here. + for (const kind of [KINDS[0], KINDS[3], KINDS[4], KINDS[7], KINDS[8]]) { const token = await kind.mint(); expect(await kind.accepts(token)).toBe(true); expect(await kind.accepts(token)).toBe(true);