| 1 | import { |
| 2 | AuthMcpApprovalError, |
| 3 | type AuthMcpAuthorizationRequest, |
| 4 | AuthMcpClientAccess, |
| 5 | EnvironmentHttpApi, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Result from "effect/Result"; |
| 9 | import { HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 10 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 11 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 12 | |
| 13 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 14 | import * as McpOAuth from "./McpOAuth.ts"; |
| 15 | import { renderErrorPage } from "./mcpOAuthHtml.ts"; |
| 16 | |
| 17 | /** Where the approval page lives in the web app. */ |
| 18 | const APPROVAL_PAGE_PATH = "/connect-agent"; |
| 19 | |
| 20 | const PAGE_HEADERS = { |
| 21 | "content-security-policy": |
| 22 | "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'", |
| 23 | "x-frame-options": "DENY", |
| 24 | "cache-control": "no-store", |
| 25 | "referrer-policy": "no-referrer", |
| 26 | "x-content-type-options": "nosniff", |
| 27 | }; |
| 28 | |
| 29 | /** OAuth responses carry codes, tokens and client ids, none of which may be cached. */ |
| 30 | const noStore = HttpEffect.appendPreResponseHandler((_request, response) => |
| 31 | Effect.succeed( |
| 32 | HttpServerResponse.setHeaders(response, { "cache-control": "no-store", pragma: "no-cache" }), |
| 33 | ), |
| 34 | ); |
| 35 | |
| 36 | /** Issuer and resource for the origin this request reached. */ |
| 37 | const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.requestUrls); |
| 38 | |
| 39 | /** |
| 40 | * Validates the request the approval page forwards. An unverified client or |
| 41 | * redirect is reported, never redirected; any other problem goes back to the |
| 42 | * agent through its (verified) redirect URI. |
| 43 | */ |
| 44 | const forwardedAuthorization = ( |
| 45 | oauth: McpOAuth.McpOAuth["Service"], |
| 46 | request: AuthMcpAuthorizationRequest, |
| 47 | ) => |
| 48 | Effect.gen(function* () { |
| 49 | const urls = yield* requestUrls; |
| 50 | return yield* oauth.validateAuthorization({ urls, request }).pipe( |
| 51 | Effect.catchTags({ |
| 52 | McpOAuthPageError: (error) => |
| 53 | Effect.fail(new AuthMcpApprovalError({ message: error.description })), |
| 54 | McpOAuthRedirectError: (error) => |
| 55 | Effect.succeed({ redirectTo: McpOAuth.redirectForError(error, urls.issuer) }), |
| 56 | }), |
| 57 | ); |
| 58 | }); |
| 59 | |
| 60 | export const layer = HttpApiBuilder.group( |
| 61 | EnvironmentHttpApi, |
| 62 | "mcpOAuth", |
| 63 | Effect.fnUntraced(function* (handlers) { |
| 64 | const oauth = yield* McpOAuth.McpOAuth; |
| 65 | const protectedResource = () => |
| 66 | requestUrls.pipe(Effect.map(McpOAuth.protectedResourceMetadata)); |
| 67 | |
| 68 | return ( |
| 69 | handlers |
| 70 | .handle("protectedResource", protectedResource) |
| 71 | .handle("mcpProtectedResource", protectedResource) |
| 72 | .handle("authorizationServer", () => |
| 73 | requestUrls.pipe(Effect.map(McpOAuth.authorizationServerMetadata)), |
| 74 | ) |
| 75 | .handle("register", ({ payload }) => |
| 76 | oauth.register(payload).pipe( |
| 77 | Effect.map((client) => ({ |
| 78 | client_id: client.clientId, |
| 79 | client_name: client.name, |
| 80 | redirect_uris: client.redirectUris, |
| 81 | grant_types: ["authorization_code" as const], |
| 82 | response_types: ["code" as const], |
| 83 | token_endpoint_auth_method: "none" as const, |
| 84 | })), |
| 85 | Effect.tap(() => noStore), |
| 86 | ), |
| 87 | ) |
| 88 | // The browser lands here from the agent. A valid request is handed to the |
| 89 | // web app's approval page with its query intact. |
| 90 | .handleRaw("authorize", ({ request }) => |
| 91 | Effect.gen(function* () { |
| 92 | const urls = yield* requestUrls; |
| 93 | const url = new URL(request.url, urls.issuer); |
| 94 | const authorization = yield* oauth |
| 95 | .validateAuthorization({ urls, request: Object.fromEntries(url.searchParams) }) |
| 96 | .pipe(Effect.result); |
| 97 | const headers = { "cache-control": "no-store", "referrer-policy": "no-referrer" }; |
| 98 | if (Result.isSuccess(authorization)) { |
| 99 | return HttpServerResponse.redirect(`${APPROVAL_PAGE_PATH}${url.search}`, { headers }); |
| 100 | } |
| 101 | const error = authorization.failure; |
| 102 | return error._tag === "McpOAuthPageError" |
| 103 | ? HttpServerResponse.text(renderErrorPage(error.description), { |
| 104 | status: 400, |
| 105 | contentType: "text/html; charset=utf-8", |
| 106 | headers: PAGE_HEADERS, |
| 107 | }) |
| 108 | : HttpServerResponse.redirect(McpOAuth.redirectForError(error, urls.issuer), { |
| 109 | headers, |
| 110 | }); |
| 111 | }), |
| 112 | ) |
| 113 | // What the approval page shows, and whether one click may approve. |
| 114 | .handle("approval", ({ payload, request }) => |
| 115 | Effect.gen(function* () { |
| 116 | const resolved = yield* forwardedAuthorization(oauth, payload); |
| 117 | if ("redirectTo" in resolved) return resolved; |
| 118 | const urls = yield* requestUrls; |
| 119 | const session = yield* oauth.approvingBrowserSession(request, resolved); |
| 120 | yield* noStore; |
| 121 | return { |
| 122 | clientName: resolved.client.name, |
| 123 | redirectHost: new URL(resolved.redirectUri).host, |
| 124 | environmentHost: new URL(urls.issuer).host, |
| 125 | ...(session === undefined |
| 126 | ? {} |
| 127 | : { |
| 128 | csrfToken: session.csrfToken, |
| 129 | oneClickAccess: AuthMcpClientAccess.literals.filter((access) => |
| 130 | EnvironmentAuth.mcpClientScopes(access).every((scope) => |
| 131 | session.scopes.includes(scope), |
| 132 | ), |
| 133 | ), |
| 134 | }), |
| 135 | }; |
| 136 | }), |
| 137 | ) |
| 138 | .handle("decision", ({ payload, request }) => |
| 139 | Effect.gen(function* () { |
| 140 | const resolved = yield* forwardedAuthorization(oauth, payload.authorization); |
| 141 | if ("redirectTo" in resolved) return resolved; |
| 142 | yield* noStore; |
| 143 | const { decision } = payload; |
| 144 | if (decision._tag === "deny") return { redirectTo: oauth.deny(resolved) }; |
| 145 | return yield* oauth.approve({ request, authorization: resolved, decision }).pipe( |
| 146 | Effect.map((redirectTo) => ({ redirectTo })), |
| 147 | Effect.mapError((error) => new AuthMcpApprovalError({ message: error.message })), |
| 148 | ); |
| 149 | }), |
| 150 | ) |
| 151 | .handle("token", ({ payload, request }) => |
| 152 | Effect.gen(function* () { |
| 153 | yield* noStore; |
| 154 | return yield* oauth.exchangeCode({ request, urls: yield* requestUrls, token: payload }); |
| 155 | }), |
| 156 | ) |
| 157 | ); |
| 158 | }), |
| 159 | ); |