1import {
2 AuthMcpApprovalError,
3 type AuthMcpAuthorizationRequest,
4 AuthMcpClientAccess,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Result from "effect/Result";
9import { HttpServerRequest, HttpServerResponse } from "effect/http";
10import * as HttpEffect from "effect/http/HttpEffect";
11import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
12
13import * as EnvironmentAuth from "./EnvironmentAuth.ts";
14import * as McpOAuth from "./McpOAuth.ts";
15import { renderErrorPage } from "./mcpOAuthHtml.ts";
16
17/** Where the approval page lives in the web app. */
18const APPROVAL_PAGE_PATH = "/connect-agent";
19
20const 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. */
30const 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. */
37const 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 */
44const 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
60export 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);