diff --git a/apps/e2e/tests/js/mcp-auth.test.ts b/apps/e2e/tests/js/mcp-auth.test.ts new file mode 100644 index 000000000..19f2a395a --- /dev/null +++ b/apps/e2e/tests/js/mcp-auth.test.ts @@ -0,0 +1,276 @@ +import { createMcpAuthAdapter, InvalidMcpAccessTokenError, type McpAuthAdapter } from '@hexclave/js'; +import crypto from "node:crypto"; +import { describe } from "vitest"; +import { it } from "../helpers"; +import { createApp } from "./js-helpers"; + +const RESOURCE_URL = "https://mcp-test.example.com/mcp"; +const OAUTH_BASE = "https://mcp-test.example.com/api/mcp-oauth"; + +function unescapeHtml(escaped: string): string { + return escaped + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); +} + +function parseHiddenInputs(html: string): Record { + const result: Record = {}; + for (const match of html.matchAll(//g)) { + result[unescapeHtml(match[1])] = unescapeHtml(match[2]); + } + return result; +} + +function createPkcePair() { + const verifier = crypto.randomBytes(32).toString("base64url"); + const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +async function setUpAdapter() { + const { serverApp } = await createApp({ + config: { + credentialEnabled: true, + }, + }); + await serverApp.signUpWithCredential({ + email: "mcp-test@example.com", + password: "password123", + verificationCallbackUrl: "http://localhost:3000", + }); + const user = await serverApp.getUser({ or: "throw" }); + const adapter = createMcpAuthAdapter({ + app: serverApp, + resourceUrl: RESOURCE_URL, + scopesSupported: ["mcp:tools"], + }); + return { serverApp, user, adapter }; +} + +async function registerClient(adapter: McpAuthAdapter, redirectUri: string): Promise { + const response = await adapter.handler(new Request(`${OAUTH_BASE}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirect_uris: [redirectUri], client_name: "Test MCP Client" }), + })); + if (response.status !== 201) throw new Error(`Registration failed: ${await response.text()}`); + const body = await response.json(); + return body.client_id; +} + +async function authorizeAndGetCode(adapter: McpAuthAdapter, options: { clientId: string, redirectUri: string, challenge: string, state?: string, deny?: boolean }): Promise { + const authorizeUrl = new URL(`${OAUTH_BASE}/authorize`); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", options.clientId); + authorizeUrl.searchParams.set("redirect_uri", options.redirectUri); + authorizeUrl.searchParams.set("code_challenge", options.challenge); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("scope", "mcp:tools"); + if (options.state !== undefined) authorizeUrl.searchParams.set("state", options.state); + + const consentResponse = await adapter.handler(new Request(authorizeUrl.toString())); + if (consentResponse.status !== 200) throw new Error(`Authorize failed: ${await consentResponse.text()}`); + const hiddenInputs = parseHiddenInputs(await consentResponse.text()); + + const form = new URLSearchParams(hiddenInputs); + form.set("action", options.deny ? "deny" : "approve"); + const approveResponse = await adapter.handler(new Request(authorizeUrl.toString(), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + })); + if (approveResponse.status !== 302) throw new Error(`Consent submission failed: ${await approveResponse.text()}`); + return new URL(approveResponse.headers.get("location") ?? throwMissingLocation()); +} + +function throwMissingLocation(): never { + throw new Error("Redirect response has no location header"); +} + +async function exchangeCode(adapter: McpAuthAdapter, options: { code: string, verifier: string, clientId: string, redirectUri: string }): Promise { + return await adapter.handler(new Request(`${OAUTH_BASE}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options.code, + code_verifier: options.verifier, + client_id: options.clientId, + redirect_uri: options.redirectUri, + }).toString(), + })); +} + +describe("MCP auth adapter", () => { + it("serves OAuth metadata endpoints", async ({ expect }) => { + const { adapter } = await setUpAdapter(); + + const asMetadataResponse = await adapter.handler(new Request("https://mcp-test.example.com/.well-known/oauth-authorization-server")); + expect(asMetadataResponse.status).toBe(200); + const asMetadata = await asMetadataResponse.json(); + expect(asMetadata.issuer).toBe(OAUTH_BASE); + expect(asMetadata.authorization_endpoint).toBe(`${OAUTH_BASE}/authorize`); + expect(asMetadata.token_endpoint).toBe(`${OAUTH_BASE}/token`); + expect(asMetadata.registration_endpoint).toBe(`${OAUTH_BASE}/register`); + expect(asMetadata.code_challenge_methods_supported).toEqual(["S256"]); + expect(asMetadata.scopes_supported).toEqual(["mcp:tools"]); + + const prmResponse = await adapter.handler(new Request("https://mcp-test.example.com/.well-known/oauth-protected-resource/mcp")); + expect(prmResponse.status).toBe(200); + const prm = await prmResponse.json(); + expect(prm.resource).toBe(RESOURCE_URL); + expect(prm.authorization_servers).toEqual([OAUTH_BASE]); + }); + + it("completes the full authorization code flow with PKCE, token refresh, and bearer auth", async ({ expect }) => { + const { adapter, user } = await setUpAdapter(); + const redirectUri = "https://client.example.com/callback"; + const clientId = await registerClient(adapter, redirectUri); + const { verifier, challenge } = createPkcePair(); + + const callbackUrl = await authorizeAndGetCode(adapter, { clientId, redirectUri, challenge, state: "some-state" }); + expect(callbackUrl.origin + callbackUrl.pathname).toBe(redirectUri); + expect(callbackUrl.searchParams.get("state")).toBe("some-state"); + const code = callbackUrl.searchParams.get("code"); + expect(code).toBeTruthy(); + + const tokenResponse = await exchangeCode(adapter, { code: code!, verifier, clientId, redirectUri }); + expect(tokenResponse.status).toBe(200); + const tokens = await tokenResponse.json(); + expect(tokens.token_type).toBe("bearer"); + expect(tokens.access_token).toBeTruthy(); + expect(tokens.refresh_token).toBeTruthy(); + expect(tokens.expires_in).toBeGreaterThan(0); + expect(tokens.scope).toBe("mcp:tools"); + + const authInfo = await adapter.verifyAccessToken(tokens.access_token); + expect(authInfo.extra?.userId).toBe(user.id); + expect(authInfo.scopes).toEqual(["mcp:tools"]); + + const refreshResponse = await adapter.handler(new Request(`${OAUTH_BASE}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + }).toString(), + })); + expect(refreshResponse.status).toBe(200); + const refreshed = await refreshResponse.json(); + expect(refreshed.access_token).toBeTruthy(); + const refreshedAuthInfo = await adapter.verifyAccessToken(refreshed.access_token); + expect(refreshedAuthInfo.extra?.userId).toBe(user.id); + + const protectedHandler = adapter.withMcpAuth(async (_req, auth) => { + const authedUser = await auth.getUser(); + return new Response(JSON.stringify({ userId: authedUser.id }), { status: 200 }); + }); + + const unauthorizedResponse = await protectedHandler(new Request(RESOURCE_URL, { method: "POST" })); + expect(unauthorizedResponse.status).toBe(401); + expect(unauthorizedResponse.headers.get("www-authenticate")).toContain("resource_metadata="); + + const authorizedResponse = await protectedHandler(new Request(RESOURCE_URL, { + method: "POST", + headers: { Authorization: `Bearer ${tokens.access_token}` }, + })); + expect(authorizedResponse.status).toBe(200); + expect((await authorizedResponse.json()).userId).toBe(user.id); + }); + + it("rejects token exchange with an incorrect PKCE verifier or redirect URI", async ({ expect }) => { + const { adapter } = await setUpAdapter(); + const redirectUri = "https://client.example.com/callback"; + const clientId = await registerClient(adapter, redirectUri); + const { challenge } = createPkcePair(); + + const callbackUrl = await authorizeAndGetCode(adapter, { clientId, redirectUri, challenge }); + const code = callbackUrl.searchParams.get("code")!; + + const wrongVerifierResponse = await exchangeCode(adapter, { code, verifier: "wrong-verifier-wrong-verifier-wrong-verifier", clientId, redirectUri }); + expect(wrongVerifierResponse.status).toBe(400); + expect((await wrongVerifierResponse.json()).error).toBe("invalid_grant"); + + const wrongRedirectResponse = await exchangeCode(adapter, { code, verifier: "irrelevant-because-redirect-is-checked-first", clientId, redirectUri: "https://client.example.com/other" }); + expect(wrongRedirectResponse.status).toBe(400); + expect((await wrongRedirectResponse.json()).error).toBe("invalid_grant"); + }); + + it("redirects with access_denied when the user denies consent", async ({ expect }) => { + const { adapter } = await setUpAdapter(); + const redirectUri = "https://client.example.com/callback"; + const clientId = await registerClient(adapter, redirectUri); + const { challenge } = createPkcePair(); + + const callbackUrl = await authorizeAndGetCode(adapter, { clientId, redirectUri, challenge, state: "deny-state", deny: true }); + expect(callbackUrl.searchParams.get("error")).toBe("access_denied"); + expect(callbackUrl.searchParams.get("state")).toBe("deny-state"); + expect(callbackUrl.searchParams.get("code")).toBeNull(); + }); + + it("redirects signed-out users to the sign-in page", async ({ expect }) => { + const { serverApp } = await setUpAdapter(); + await serverApp.signOut(); + const adapter = createMcpAuthAdapter({ app: serverApp, resourceUrl: RESOURCE_URL }); + const redirectUri = "https://client.example.com/callback"; + const clientId = await registerClient(adapter, redirectUri); + const { challenge } = createPkcePair(); + + const authorizeUrl = new URL(`${OAUTH_BASE}/authorize`); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("code_challenge", challenge); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + + const response = await adapter.handler(new Request(authorizeUrl.toString())); + expect(response.status).toBe(302); + const location = new URL(response.headers.get("location") ?? throwMissingLocation()); + expect(location.pathname).toContain("sign-in"); + expect(location.searchParams.get("after_auth_return_to")).toContain("/authorize"); + }); + + it("rejects authorization requests with unknown clients or unregistered redirect URIs", async ({ expect }) => { + const { adapter } = await setUpAdapter(); + const redirectUri = "https://client.example.com/callback"; + const clientId = await registerClient(adapter, redirectUri); + + const unknownClientUrl = new URL(`${OAUTH_BASE}/authorize`); + unknownClientUrl.searchParams.set("response_type", "code"); + unknownClientUrl.searchParams.set("client_id", "not-a-real-client-id"); + unknownClientUrl.searchParams.set("redirect_uri", redirectUri); + unknownClientUrl.searchParams.set("code_challenge", "abc"); + unknownClientUrl.searchParams.set("code_challenge_method", "S256"); + const unknownClientResponse = await adapter.handler(new Request(unknownClientUrl.toString())); + expect(unknownClientResponse.status).toBe(400); + + const wrongRedirectUrl = new URL(`${OAUTH_BASE}/authorize`); + wrongRedirectUrl.searchParams.set("response_type", "code"); + wrongRedirectUrl.searchParams.set("client_id", clientId); + wrongRedirectUrl.searchParams.set("redirect_uri", "https://attacker.example.com/callback"); + wrongRedirectUrl.searchParams.set("code_challenge", "abc"); + wrongRedirectUrl.searchParams.set("code_challenge_method", "S256"); + const wrongRedirectResponse = await adapter.handler(new Request(wrongRedirectUrl.toString())); + expect(wrongRedirectResponse.status).toBe(400); + }); + + it("rejects registration with invalid redirect URIs", async ({ expect }) => { + const { adapter } = await setUpAdapter(); + for (const redirectUri of ["not-a-url", "javascript:alert(1)", "http://public.example.com/callback"]) { + const response = await adapter.handler(new Request(`${OAUTH_BASE}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirect_uris: [redirectUri] }), + })); + expect(response.status).toBe(400); + } + }); + + it("throws InvalidMcpAccessTokenError for invalid bearer tokens", async ({ expect }) => { + const { adapter } = await setUpAdapter(); + await expect(adapter.verifyAccessToken("not-a-valid-token")).rejects.toThrow(InvalidMcpAccessTokenError); + }); +}); diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts index 27447e069..241748cd8 100644 --- a/packages/template/src/index.ts +++ b/packages/template/src/index.ts @@ -1,5 +1,7 @@ export * from './lib/hexclave-app'; export { getConvexProvidersConfig } from "./integrations/convex"; +export { createMcpAuthAdapter, InvalidMcpAccessTokenError } from "./integrations/mcp"; +export type { McpAuthAdapter, McpAuthAdapterOptions, McpAuthInfo } from "./integrations/mcp"; // Hexclave aliases and legacy Stack* names — @deprecated JSDoc lives on the original // declarations in @hexclave/shared/config so it survives dts bundling // (per-specifier JSDoc on re-exports does not). diff --git a/packages/template/src/integrations/mcp.ts b/packages/template/src/integrations/mcp.ts new file mode 100644 index 000000000..a81760a34 --- /dev/null +++ b/packages/template/src/integrations/mcp.ts @@ -0,0 +1,639 @@ +import { HexclaveServerInterface } from "@hexclave/shared/dist/interface/server-interface"; +import { AccessToken, RefreshToken } from "@hexclave/shared/dist/sessions"; +import { encodeBase64Url } from "@hexclave/shared/dist/utils/bytes"; +import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import { globalVar } from "@hexclave/shared/dist/utils/globals"; +import { escapeHtml } from "@hexclave/shared/dist/utils/html"; +import { deindent } from "@hexclave/shared/dist/utils/strings"; +import { urlString } from "@hexclave/shared/dist/utils/urls"; +import * as jose from "jose"; +import { JOSEError } from "jose/errors"; +import { StackServerApp } from "../lib/hexclave-app/apps/interfaces/server-app"; +import { ServerUser } from "../lib/hexclave-app/users"; + +/** + * Structurally compatible with the `AuthInfo` type of the `@modelcontextprotocol/sdk` package, so the return value of + * `verifyAccessToken` can be passed anywhere the MCP SDK expects an `AuthInfo` (eg. `requireBearerAuth`), without + * this package having to depend on the MCP SDK. + */ +export type McpAuthInfo = { + token: string, + clientId: string, + scopes: string[], + expiresAt?: number, + resource?: URL, + extra?: Record, +}; + +export type McpAuthAdapterOptions = { + /** + * The Hexclave server app of the project whose users should be able to authenticate with the MCP server. + */ + app: StackServerApp, + /** + * The full, public URL of the MCP endpoint that is being protected, eg. `https://example.com/mcp`. + */ + resourceUrl: string | URL, + /** + * The path prefix (on the same origin as `resourceUrl`) where the adapter's OAuth endpoints are mounted. + * + * @default "/api/mcp-oauth" + */ + oauthBasePath?: string, + /** + * The OAuth scopes that this MCP server supports. Note that Hexclave access tokens are not scoped; the granted + * scopes are returned verbatim in the token response and in `McpAuthInfo.scopes` so the MCP server can do its own + * scope checks. + */ + scopesSupported?: string[], + /** + * How long the session created for an MCP client should be valid (this is the lifetime of the refresh token, not + * the access token). + * + * @default 30 days + */ + sessionExpiresInMillis?: number, +}; + +export type McpAuthAdapter = { + /** + * A fetch-style handler (`Request => Response`) for all OAuth endpoints of the adapter. Mount it (eg. as Next.js + * route handlers) such that it receives requests for: + * + * - `/.well-known/oauth-authorization-server` (and subpaths) + * - `/.well-known/oauth-protected-resource` (and subpaths) + * - `${oauthBasePath}/register|authorize|token` + */ + handler: (req: Request) => Promise, + /** + * Verifies a bearer token sent by an MCP client. Throws `InvalidMcpAccessTokenError` if the token is invalid, so + * it can be used directly as the MCP SDK's `OAuthTokenVerifier.verifyAccessToken`. + */ + verifyAccessToken: (token: string) => Promise, + /** + * Wraps a fetch-style MCP request handler with bearer authentication. Responds with 401 + `WWW-Authenticate` + * (pointing MCP clients at the protected resource metadata) when the token is missing or invalid. + */ + withMcpAuth: ( + handler: (req: Request, auth: { authInfo: McpAuthInfo, getUser: () => Promise }) => Promise, + ) => (req: Request) => Promise, +}; + +export class InvalidMcpAccessTokenError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidMcpAccessTokenError"; + } +} + +const DEFAULT_OAUTH_BASE_PATH = "/api/mcp-oauth"; +const DEFAULT_SESSION_EXPIRES_IN_MILLIS = 1000 * 60 * 60 * 24 * 30; +const AUTHORIZATION_CODE_EXPIRATION = "2m"; +const CONSENT_INTERACTION_EXPIRATION = "15m"; + +type TokenUse = "client" | "interaction" | "code"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, mcp-protocol-version", +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); +} + +function oauthErrorResponse(status: number, error: string, description: string): Response { + return jsonResponse(status, { error, error_description: description }); +} + +function htmlResponse(status: number, html: string): Response { + return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); +} + +function isAllowedRedirectUri(uri: string): boolean { + let url; + try { + url = new URL(uri); + } catch { + return false; + } + if (url.protocol === "https:") return true; + // plain HTTP is only allowed for loopback redirect URIs (native clients, RFC 8252 §7.3) + if (url.protocol === "http:") return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + // custom schemes (eg. `cursor://...`) are used by native MCP clients + return url.protocol !== "javascript:" && url.protocol !== "data:" && url.protocol !== "file:"; +} + +async function sha256Base64Url(input: string): Promise { + const digest = await globalVar.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return encodeBase64Url(new Uint8Array(digest)); +} + +export function createMcpAuthAdapter(options: McpAuthAdapterOptions): McpAuthAdapter { + const app = options.app; + // The interface (and thereby the secret server key, which we derive the adapter's stateless signing key from) is + // not part of the public StackServerApp type, so grab it off the implementation. The instanceof check validates + // the cast at runtime. + const iface = (app as unknown as { _interface: unknown })._interface; + if (!(iface instanceof HexclaveServerInterface)) { + throw new HexclaveAssertionError("createMcpAuthAdapter: expected a StackServerApp instance"); + } + const ifaceOptions = iface.options; + if (!("secretServerKey" in ifaceOptions)) { + throw new HexclaveAssertionError("createMcpAuthAdapter: the app passed to the MCP auth adapter must be constructed with a secretServerKey"); + } + const secretServerKey = ifaceOptions.secretServerKey; + const projectId = ifaceOptions.projectId; + const apiBaseUrl = ifaceOptions.getBaseUrl(); + + const resourceUrl = new URL(options.resourceUrl); + const oauthBasePath = options.oauthBasePath ?? DEFAULT_OAUTH_BASE_PATH; + if (!oauthBasePath.startsWith("/") || oauthBasePath.endsWith("/")) { + throw new HexclaveAssertionError("createMcpAuthAdapter: oauthBasePath must start with (but not end with) a slash", { oauthBasePath }); + } + const scopesSupported = options.scopesSupported ?? []; + const sessionExpiresInMillis = options.sessionExpiresInMillis ?? DEFAULT_SESSION_EXPIRES_IN_MILLIS; + + const issuer = new URL(oauthBasePath, resourceUrl.origin).toString(); + + let signingKeyPromise: Promise | null = null; + const getSigningKey = () => { + // The adapter is fully stateless: dynamic client registrations, consent interactions, and authorization codes + // are all HS256 JWTs signed with a key derived from the project's secret server key. + if (signingKeyPromise == null) { + signingKeyPromise = (async () => { + const digest: ArrayBuffer = await globalVar.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify(["hexclave-mcp-oauth-signing-key", secretServerKey])), + ); + return new Uint8Array(digest); + })(); + } + return signingKeyPromise; + }; + + const signToken = async (tokenUse: TokenUse, payload: Record, expirationTime?: string) => { + let jwt = new jose.SignJWT({ ...payload, token_use: tokenUse }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer(issuer); + if (expirationTime !== undefined) { + jwt = jwt.setExpirationTime(expirationTime); + } + return await jwt.sign(await getSigningKey()); + }; + + const verifySignedToken = async (tokenUse: TokenUse, token: string): Promise => { + try { + const { payload } = await jose.jwtVerify(token, await getSigningKey(), { issuer }); + if (payload.token_use !== tokenUse) return null; + return payload; + } catch (e) { + if (e instanceof JOSEError) return null; + throw e; + } + }; + + type RegisteredClient = { redirectUris: string[], clientName: string | null }; + + const parseClientId = async (clientId: string): Promise => { + const payload = await verifySignedToken("client", clientId); + if (!payload) return null; + const redirectUris = payload.redirect_uris; + if (!Array.isArray(redirectUris) || !redirectUris.every((u) => typeof u === "string")) return null; + return { + redirectUris, + clientName: typeof payload.client_name === "string" ? payload.client_name : null, + }; + }; + + const handleAuthorizationServerMetadata = () => { + return jsonResponse(200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ["code"], + response_modes_supported: ["query"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + ...scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {}, + }); + }; + + const handleProtectedResourceMetadata = () => { + return jsonResponse(200, { + resource: resourceUrl.toString(), + authorization_servers: [issuer], + bearer_methods_supported: ["header"], + ...scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {}, + }); + }; + + const handleRegister = async (req: Request): Promise => { + let body: unknown; + try { + body = await req.json(); + } catch { + return oauthErrorResponse(400, "invalid_client_metadata", "Request body is not valid JSON."); + } + if (typeof body !== "object" || body === null) { + return oauthErrorResponse(400, "invalid_client_metadata", "Request body must be a JSON object."); + } + const bodyObject = body as Record; + const redirectUris = bodyObject.redirect_uris; + if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every((u) => typeof u === "string")) { + return oauthErrorResponse(400, "invalid_redirect_uri", "redirect_uris must be a non-empty array of strings."); + } + for (const uri of redirectUris) { + if (!isAllowedRedirectUri(uri)) { + return oauthErrorResponse(400, "invalid_redirect_uri", `Invalid redirect URI: ${uri}`); + } + } + const clientName = typeof bodyObject.client_name === "string" ? bodyObject.client_name : undefined; + + const clientId = await signToken("client", { + redirect_uris: redirectUris, + ...clientName !== undefined ? { client_name: clientName } : {}, + }); + + return jsonResponse(201, { + client_id: clientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: redirectUris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + ...clientName !== undefined ? { client_name: clientName } : {}, + }); + }; + + type AuthorizeParams = { + clientId: string, + client: RegisteredClient, + redirectUri: string, + codeChallenge: string, + state: string | null, + scope: string | null, + resource: string | null, + }; + + const parseAndValidateAuthorizeParams = async (params: URLSearchParams): Promise<{ ok: true, value: AuthorizeParams } | { ok: false, response: Response } | { ok: false, redirectError: { redirectUri: string, state: string | null, error: string, description: string } }> => { + const clientId = params.get("client_id"); + const redirectUri = params.get("redirect_uri"); + // per OAuth 2.1, errors in client_id/redirect_uri must NOT redirect to the redirect URI + if (clientId == null || redirectUri == null) { + return { ok: false, response: htmlResponse(400, renderErrorPage("Missing client_id or redirect_uri parameter.")) }; + } + const client = await parseClientId(clientId); + if (client == null) { + return { ok: false, response: htmlResponse(400, renderErrorPage("Unknown client_id. Register the client first at the registration endpoint.")) }; + } + if (!client.redirectUris.includes(redirectUri)) { + return { ok: false, response: htmlResponse(400, renderErrorPage("The given redirect_uri is not registered for this client.")) }; + } + const state = params.get("state"); + if (params.get("response_type") !== "code") { + return { ok: false, redirectError: { redirectUri, state, error: "unsupported_response_type", description: "Only response_type=code is supported." } }; + } + const codeChallenge = params.get("code_challenge"); + if (codeChallenge == null || params.get("code_challenge_method") !== "S256") { + return { ok: false, redirectError: { redirectUri, state, error: "invalid_request", description: "PKCE with code_challenge_method=S256 is required." } }; + } + return { + ok: true, + value: { + clientId, + client, + redirectUri, + codeChallenge, + state, + scope: params.get("scope"), + resource: params.get("resource"), + }, + }; + }; + + const redirectWithError = (redirectUri: string, state: string | null, error: string, description: string): Response => { + const url = new URL(redirectUri); + url.searchParams.set("error", error); + url.searchParams.set("error_description", description); + if (state != null) url.searchParams.set("state", state); + return Response.redirect(url.toString(), 302); + }; + + const renderErrorPage = (message: string) => deindent` + + + Authorization error + +

Authorization error

+

${escapeHtml(message)}

+ + + `; + + const renderConsentPage = (reqUrl: URL, params: AuthorizeParams, interactionToken: string, userDisplay: string) => { + const clientDisplay = params.client.clientName ?? new URL(params.redirectUri).host; + const hiddenFields = [ + ...reqUrl.searchParams.entries(), + ["interaction_token", interactionToken], + ].map(([key, value]) => ``).join("\n"); + return deindent` + + + + Authorize ${escapeHtml(clientDisplay)} + + + + +
+

Authorization request

+

${escapeHtml(clientDisplay)} wants to access your account (${escapeHtml(userDisplay)}).

+ ${params.scope != null ? `

Requested scopes: ${escapeHtml(params.scope)}

` : ""} +
+ ${hiddenFields} +
+ + +
+
+
+ + + `; + }; + + const getCurrentUser = async () => { + // uses the token store the app was constructed with (eg. "nextjs-cookie"), which contains the session of the + // user currently signed in to the customer's app on this origin + return await app.getUser({ or: "return-null" }); + }; + + const handleAuthorizeGet = async (req: Request): Promise => { + const reqUrl = new URL(req.url); + const parsed = await parseAndValidateAuthorizeParams(reqUrl.searchParams); + if (!parsed.ok) { + return "response" in parsed ? parsed.response : redirectWithError(parsed.redirectError.redirectUri, parsed.redirectError.state, parsed.redirectError.error, parsed.redirectError.description); + } + const params = parsed.value; + + const user = await getCurrentUser(); + if (user == null) { + // reuse the app's existing sign-in page; it redirects back to this authorize URL after authentication + const signInUrl = new URL(app.urls.signIn, reqUrl); + signInUrl.searchParams.set("after_auth_return_to", reqUrl.pathname + reqUrl.search); + return Response.redirect(signInUrl.toString(), 302); + } + + const interactionToken = await signToken("interaction", { + sub: user.id, + authorize_params_hash: await sha256Base64Url(reqUrl.searchParams.toString()), + }, CONSENT_INTERACTION_EXPIRATION); + + return htmlResponse(200, renderConsentPage(reqUrl, params, interactionToken, user.primaryEmail ?? user.displayName ?? user.id)); + }; + + const handleAuthorizePost = async (req: Request): Promise => { + const formData = await req.formData(); + const formParams = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + if (typeof value === "string" && key !== "interaction_token" && key !== "action") { + formParams.append(key, value); + } + } + + const parsed = await parseAndValidateAuthorizeParams(formParams); + if (!parsed.ok) { + return "response" in parsed ? parsed.response : redirectWithError(parsed.redirectError.redirectUri, parsed.redirectError.state, parsed.redirectError.error, parsed.redirectError.description); + } + const params = parsed.value; + + const action = formData.get("action"); + if (action !== "approve") { + return redirectWithError(params.redirectUri, params.state, "access_denied", "The user denied the authorization request."); + } + + const interactionTokenValue = formData.get("interaction_token"); + const interaction = typeof interactionTokenValue === "string" ? await verifySignedToken("interaction", interactionTokenValue) : null; + if (interaction == null || interaction.authorize_params_hash !== await sha256Base64Url(formParams.toString())) { + return htmlResponse(400, renderErrorPage("Invalid or expired authorization interaction. Please try again.")); + } + + const user = await getCurrentUser(); + if (user == null || user.id !== interaction.sub) { + return htmlResponse(400, renderErrorPage("Your session has changed since the authorization request was started. Please try again.")); + } + + const code = await signToken("code", { + sub: user.id, + client_id_hash: await sha256Base64Url(params.clientId), + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + ...params.scope != null ? { scope: params.scope } : {}, + ...params.resource != null ? { resource: params.resource } : {}, + }, AUTHORIZATION_CODE_EXPIRATION); + + const url = new URL(params.redirectUri); + url.searchParams.set("code", code); + if (params.state != null) url.searchParams.set("state", params.state); + return Response.redirect(url.toString(), 302); + }; + + const handleToken = async (req: Request): Promise => { + let form: URLSearchParams; + try { + form = new URLSearchParams(await req.text()); + } catch { + return oauthErrorResponse(400, "invalid_request", "Request body must be application/x-www-form-urlencoded."); + } + const grantType = form.get("grant_type"); + switch (grantType) { + case "authorization_code": { + const code = form.get("code"); + const codeVerifier = form.get("code_verifier"); + const clientId = form.get("client_id"); + const redirectUri = form.get("redirect_uri"); + if (code == null || codeVerifier == null || clientId == null || redirectUri == null) { + return oauthErrorResponse(400, "invalid_request", "code, code_verifier, client_id, and redirect_uri are required."); + } + const payload = await verifySignedToken("code", code); + if (payload == null) { + return oauthErrorResponse(400, "invalid_grant", "Invalid or expired authorization code."); + } + if (payload.client_id_hash !== await sha256Base64Url(clientId)) { + return oauthErrorResponse(400, "invalid_grant", "The authorization code was issued to a different client."); + } + if (payload.redirect_uri !== redirectUri) { + return oauthErrorResponse(400, "invalid_grant", "redirect_uri does not match the one used in the authorization request."); + } + if (payload.code_challenge !== await sha256Base64Url(codeVerifier)) { + return oauthErrorResponse(400, "invalid_grant", "PKCE verification failed."); + } + const userId = payload.sub ?? throwErr("Authorization code has no sub; this should never happen because we always set it when signing"); + const user = await app.getUser(userId); + if (user == null) { + return oauthErrorResponse(400, "invalid_grant", "The user this authorization code was issued for no longer exists."); + } + const session = await user.createSession({ expiresInMillis: sessionExpiresInMillis }); + const tokens = await session.getTokens(); + if (tokens.accessToken == null || tokens.refreshToken == null) { + throw new HexclaveAssertionError("createSession did not return tokens; this should never happen for non-impersonation sessions"); + } + const accessToken = AccessToken.createIfValid(tokens.accessToken) ?? throwErr("Backend returned an invalid access token"); + return jsonResponse(200, { + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + token_type: "bearer", + expires_in: Math.max(0, Math.floor((accessToken.expiresAt.getTime() - Date.now()) / 1000)), + ...typeof payload.scope === "string" ? { scope: payload.scope } : {}, + }); + } + case "refresh_token": { + const refreshTokenValue = form.get("refresh_token"); + if (refreshTokenValue == null) { + return oauthErrorResponse(400, "invalid_request", "refresh_token is required."); + } + const accessToken = await iface.fetchNewAccessToken(new RefreshToken(refreshTokenValue)); + if (accessToken == null) { + return oauthErrorResponse(400, "invalid_grant", "Invalid or expired refresh token."); + } + return jsonResponse(200, { + access_token: accessToken.token, + refresh_token: refreshTokenValue, + token_type: "bearer", + expires_in: Math.max(0, Math.floor((accessToken.expiresAt.getTime() - Date.now()) / 1000)), + }); + } + default: { + return oauthErrorResponse(400, "unsupported_grant_type", `Unsupported grant_type: ${grantType ?? ""}`); + } + } + }; + + let remoteJwkSet: ReturnType | null = null; + const getRemoteJwkSet = () => { + remoteJwkSet ??= jose.createRemoteJWKSet(new URL(urlString`/api/v1/projects/${projectId}/.well-known/jwks.json`, apiBaseUrl)); + return remoteJwkSet; + }; + + const verifyAccessToken = async (token: string): Promise => { + let payload: jose.JWTPayload; + try { + // Hexclave access tokens are ES256 JWTs signed with the project's JWKS; the audience of non-anonymous, + // non-restricted user tokens is exactly the project ID (anonymous/restricted tokens have a different audience + // and are therefore rejected here). + const verified = await jose.jwtVerify(token, getRemoteJwkSet(), { audience: projectId }); + payload = verified.payload; + } catch (e) { + if (e instanceof JOSEError) { + throw new InvalidMcpAccessTokenError("Invalid or expired access token."); + } + throw e; + } + const userId = payload.sub ?? throwErr("Verified access token has no sub claim; this should never happen because the backend always sets it"); + return { + token, + // Hexclave access tokens are regular session tokens and don't carry the (stateless) MCP OAuth client ID, so we + // can't recover which dynamically registered client the token was issued to. + clientId: "hexclave-mcp-oauth-client", + scopes: scopesSupported, + ...payload.exp !== undefined ? { expiresAt: payload.exp } : {}, + extra: { userId }, + }; + }; + + const protectedResourceMetadataUrl = new URL( + `/.well-known/oauth-protected-resource${resourceUrl.pathname === "/" ? "" : resourceUrl.pathname}`, + resourceUrl.origin, + ).toString(); + + const withMcpAuth: McpAuthAdapter["withMcpAuth"] = (handler) => { + return async (req: Request) => { + const unauthorized = (description: string) => new Response(JSON.stringify({ error: "invalid_token", error_description: description }), { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer error="invalid_token", error_description="${description}", resource_metadata="${protectedResourceMetadataUrl}"`, + ...corsHeaders, + }, + }); + + const authorization = req.headers.get("authorization"); + if (authorization == null || !authorization.toLowerCase().startsWith("bearer ")) { + return unauthorized("Missing bearer token."); + } + let authInfo: McpAuthInfo; + try { + authInfo = await verifyAccessToken(authorization.slice("bearer ".length)); + } catch (e) { + if (e instanceof InvalidMcpAccessTokenError) { + return unauthorized("Invalid or expired access token."); + } + throw e; + } + return await handler(req, { + authInfo, + getUser: async () => { + const userId = authInfo.extra?.userId; + if (typeof userId !== "string") throw new HexclaveAssertionError("authInfo.extra.userId is not a string; this should never happen because verifyAccessToken always sets it"); + return await app.getUser(userId) ?? throwErr("The user of a verified access token no longer exists"); + }, + }); + }; + }; + + const handler = async (req: Request): Promise => { + const url = new URL(req.url); + const pathname = url.pathname; + + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + if (pathname === "/.well-known/oauth-authorization-server" || pathname.startsWith("/.well-known/oauth-authorization-server/")) { + if (req.method !== "GET") return oauthErrorResponse(405, "invalid_request", "Method not allowed."); + return handleAuthorizationServerMetadata(); + } + if (pathname === "/.well-known/oauth-protected-resource" || pathname.startsWith("/.well-known/oauth-protected-resource/")) { + if (req.method !== "GET") return oauthErrorResponse(405, "invalid_request", "Method not allowed."); + return handleProtectedResourceMetadata(); + } + switch (pathname) { + case `${oauthBasePath}/register`: { + if (req.method !== "POST") return oauthErrorResponse(405, "invalid_request", "Method not allowed."); + return await handleRegister(req); + } + case `${oauthBasePath}/authorize`: { + if (req.method === "GET") return await handleAuthorizeGet(req); + if (req.method === "POST") return await handleAuthorizePost(req); + return oauthErrorResponse(405, "invalid_request", "Method not allowed."); + } + case `${oauthBasePath}/token`: { + if (req.method !== "POST") return oauthErrorResponse(405, "invalid_request", "Method not allowed."); + return await handleToken(req); + } + default: { + return jsonResponse(404, { error: "not_found", error_description: `Unknown MCP OAuth endpoint: ${pathname}` }); + } + } + }; + + return { + handler, + verifyAccessToken, + withMcpAuth, + }; +}