import { OAuthProvider, type AuthRequest, type OAuthHelpers, type OAuthProviderOptions, } from "@cloudflare/workers-oauth-provider"; import { z } from "zod"; import { getHostedBaseUrl } from "@/lib/auth"; import { getMcpResource, MCP_OAUTH_SCOPES, MCP_SCOPE, } from "@/lib/oauth-resource"; import { asAppError } from "@/server/lib/errors"; import { createWorkersOAuthMcpProps, MCP_ROUTE, withWorkersOAuthMcpScopes, } from "@/server/mcp/context"; import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; import { getPublicOrigin } from "@/server/mcp/public-origin"; import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; const OAUTH_AUTHORIZE_PATH = "/api/auth/oauth2/authorize"; const OAUTH_TOKEN_PATH = "/api/auth/oauth2/token"; const OAUTH_REGISTER_PATH = "/api/auth/oauth2/register"; const OAUTH_CONSENT_RESPONSE_PATH = "/api/oauth/consent"; const WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"; const OAUTH_AUTHORIZATION_PARAM_NAMES = [ "response_type", "client_id", "redirect_uri", "scope", "state", "code_challenge", "code_challenge_method", "resource", ] as const; // Keep access tokens reasonably short-lived while allowing refresh tokens to // preserve MCP sessions across normal usage. const MCP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24; const MCP_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; export type OpenSeoOAuthEnv = Env & { OAUTH_KV: KVNamespace; OAUTH_PROVIDER?: OAuthHelpers; }; type AppFetch = (request: Request) => Response | Promise; type OAuthExecutionContext = ExecutionContext & { props?: unknown; }; type ExportedHandlerWithFetch = ExportedHandler & { fetch: NonNullable["fetch"]>; }; const consentResponseSchema = z.object({ accept: z.boolean(), query: z.string(), }); function getOAuthHelpers(env: OpenSeoOAuthEnv) { if (!env.OAUTH_PROVIDER) { throw new Error("OAuth provider helpers are unavailable"); } return env.OAUTH_PROVIDER; } function getMcpResourceForRequest(request: Request) { return getMcpResource(getPublicOrigin(request)); } function getRelativeRequestTarget(request: Request) { const url = new URL(request.url); return `${url.pathname}${url.search}`; } function redirectToSignIn(request: Request) { const signInUrl = new URL("/sign-in", request.url); signInUrl.searchParams.set("redirect", getRelativeRequestTarget(request)); return Response.redirect(signInUrl.toString(), 302); } function invalidOAuthRequestResponse(error: unknown) { return new Response( error instanceof Error ? error.message : "Invalid OAuth request", { status: 400 }, ); } function jsonResponse(body: unknown, init?: ResponseInit) { const headers = new Headers(init?.headers); headers.set("Content-Type", "application/json"); return new Response(JSON.stringify(body), { ...init, headers, }); } function oauthErrorResponse(error: { code: string; description: string; status: number; headers: Record; }) { console.warn(`[oauth] ${error.status} ${error.code}: ${error.description}`); const headers = new Headers(error.headers); headers.set("Content-Type", "application/json"); if (headers.has(WWW_AUTHENTICATE_HEADER)) { headers.set("Access-Control-Expose-Headers", WWW_AUTHENTICATE_HEADER); } return new Response( JSON.stringify({ error: error.code, error_description: error.description, }), { status: error.status, headers, }, ); } function csrfProtected(request: Request) { const origin = request.headers.get("Origin"); return origin === getPublicOrigin(request); } async function getAuthorizeSessionBlocker(request: Request) { try { await resolveHostedContext(request.headers); return null; } catch (error) { const appError = asAppError(error); if (appError?.code === "UNAUTHENTICATED") { return redirectToSignIn(request); } if (appError?.code === "AUTH_CONFIG_MISSING") { return new Response("Missing Better Auth hosted configuration", { status: 500, }); } throw error; } } async function resolveContextForConsent(request: Request) { try { return await resolveHostedContext(request.headers); } catch (error) { const appError = asAppError(error); if (appError?.code === "UNAUTHENTICATED") { return null; } throw error; } } function buildConsentUrl(request: Request) { const sourceUrl = new URL(request.url); const consentUrl = new URL("/oauth-consent", request.url); for (const key of OAUTH_AUTHORIZATION_PARAM_NAMES) { for (const value of sourceUrl.searchParams.getAll(key)) { consentUrl.searchParams.append(key, value); } } return consentUrl; } function buildAuthorizeRequestFromConsentQuery( request: Request, query: string, ) { const authorizeUrl = new URL(OAUTH_AUTHORIZE_PATH, request.url); const params = new URLSearchParams(query); for (const key of OAUTH_AUTHORIZATION_PARAM_NAMES) { for (const value of params.getAll(key)) { authorizeUrl.searchParams.append(key, value); } } return new Request(authorizeUrl.toString(), { headers: request.headers, }); } function withDefaultMcpResource(authRequest: AuthRequest, request: Request) { const mcpResource = getMcpResourceForRequest(request); if (!authRequest.resource) { return { ...authRequest, resource: mcpResource, }; } const requestedResources = Array.isArray(authRequest.resource) ? authRequest.resource : [authRequest.resource]; if (requestedResources.some((resource) => resource !== mcpResource)) { throw new Error(`OAuth resource must be ${mcpResource}`); } return { ...authRequest, resource: mcpResource, }; } function getGrantedMcpScopes(requestedScopes: string[]) { if (requestedScopes.length === 0) { return [...MCP_OAUTH_SCOPES]; } const requested = new Set(requestedScopes); const granted = MCP_OAUTH_SCOPES.filter((scope) => requested.has(scope)); if (!granted.includes(MCP_SCOPE)) { throw new Error("The mcp scope is required"); } return granted; } function deniedRedirect(authRequest: AuthRequest) { const redirectUrl = new URL(authRequest.redirectUri); redirectUrl.searchParams.set("error", "access_denied"); redirectUrl.searchParams.set("error_description", "The user denied access"); if (authRequest.state) { redirectUrl.searchParams.set("state", authRequest.state); } return redirectUrl.toString(); } async function handleOAuthAuthorizeRequest( request: Request, env: OpenSeoOAuthEnv, ) { const oauth = getOAuthHelpers(env); try { await oauth.parseAuthRequest(request); } catch (error) { return invalidOAuthRequestResponse(error); } const sessionBlocker = await getAuthorizeSessionBlocker(request); if (sessionBlocker) return sessionBlocker; return Response.redirect(buildConsentUrl(request).toString(), 302); } async function handleOAuthConsentResponse( request: Request, env: OpenSeoOAuthEnv, ) { if (request.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } if (!csrfProtected(request)) { return jsonResponse({ error: "Invalid request origin" }, { status: 403 }); } let rawBody: unknown; try { rawBody = await request.json(); } catch { return jsonResponse({ error: "Invalid consent response" }, { status: 400 }); } const body = consentResponseSchema.safeParse(rawBody); if (!body.success) { return jsonResponse({ error: "Invalid consent response" }, { status: 400 }); } const oauth = getOAuthHelpers(env); const authorizeRequest = buildAuthorizeRequestFromConsentQuery( request, body.data.query, ); let authRequest: AuthRequest; try { authRequest = await oauth.parseAuthRequest(authorizeRequest); authRequest = withDefaultMcpResource(authRequest, request); } catch (error) { return jsonResponse( { error: error instanceof Error ? error.message : "Invalid OAuth request", }, { status: 400 }, ); } if (!body.data.accept) { return jsonResponse({ redirectTo: deniedRedirect(authRequest) }); } const context = await resolveContextForConsent(request); if (!context) { return jsonResponse({ error: "Sign in required" }, { status: 401 }); } let scopes: string[]; try { scopes = getGrantedMcpScopes(authRequest.scope); } catch (error) { return jsonResponse( { error: error instanceof Error ? error.message : "Invalid OAuth scopes", }, { status: 400 }, ); } const audience = getMcpResourceForRequest(request); const props = createWorkersOAuthMcpProps({ userId: context.userId, userEmail: context.userEmail, organizationId: context.organizationId, clientId: authRequest.clientId, scopes, audience, subject: context.userId, baseUrl: getHostedBaseUrl(), }); const { redirectTo } = await oauth.completeAuthorization({ request: authRequest, userId: context.userId, metadata: { clientId: authRequest.clientId, organizationId: context.organizationId, }, scope: scopes, props, }); return jsonResponse({ redirectTo }); } function createDefaultHandler( appFetch: AppFetch, ): ExportedHandlerWithFetch { return { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === OAUTH_AUTHORIZE_PATH) { return handleOAuthAuthorizeRequest(request, env); } if (url.pathname === OAUTH_CONSENT_RESPONSE_PATH) { return handleOAuthConsentResponse(request, env); } return appFetch(request); }, }; } const mcpApiHandler: ExportedHandlerWithFetch = { async fetch(request, env, ctx) { return handleAuthenticatedOpenSeoMcpRequest( request, (ctx as OAuthExecutionContext).props, env, ctx, ); }, }; export function createOpenSeoOAuthProvider(appFetch: AppFetch) { const options: OAuthProviderOptions = { apiRoute: MCP_ROUTE, apiHandler: mcpApiHandler, defaultHandler: createDefaultHandler(appFetch), authorizeEndpoint: OAUTH_AUTHORIZE_PATH, tokenEndpoint: OAUTH_TOKEN_PATH, clientRegistrationEndpoint: OAUTH_REGISTER_PATH, scopesSupported: [...MCP_OAUTH_SCOPES], accessTokenTTL: MCP_ACCESS_TOKEN_TTL_SECONDS, refreshTokenTTL: MCP_REFRESH_TOKEN_TTL_SECONDS, resourceMetadata: { scopes_supported: [...MCP_OAUTH_SCOPES], resource_name: "OpenSEO MCP", }, tokenExchangeCallback: ({ props, requestedScope }) => { const accessTokenProps = withWorkersOAuthMcpScopes(props, requestedScope); return accessTokenProps ? { accessTokenProps } : undefined; }, onError: oauthErrorResponse, }; const provider = new OAuthProvider(options); return { async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) { const url = new URL(request.url); if (url.pathname === OAUTH_REGISTER_PATH) { // Cloudflare's provider can reject public DCR clients, but Perplexity // does not appear to retry as confidential and instead expects a // client_secret. Normalize before handing the request to Cloudflare so // it still owns client creation, secret hashing, and token storage. request = await normalizeClientRegistrationRequest(request); } return provider.fetch(request, env, ctx); }, }; }