| 1 | import { |
| 2 | AuthAccessWriteScope, |
| 3 | type AuthMcpApprovalDecision, |
| 4 | type AuthMcpAuthorizationRequest, |
| 5 | type AuthMcpAuthorizationServerMetadata, |
| 6 | type AuthMcpClientRegistration, |
| 7 | type AuthMcpProtectedResourceMetadata, |
| 8 | AuthMcpRegistrationError, |
| 9 | AuthMcpTokenError, |
| 10 | type AuthMcpTokenRequest, |
| 11 | type AuthMcpTokenResult, |
| 12 | type AuthMcpClientAccess, |
| 13 | type AuthEnvironmentScope, |
| 14 | AuthOrchestrationOperateScope, |
| 15 | AuthOrchestrationReadScope, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 18 | import * as Clock from "effect/Clock"; |
| 19 | import * as Context from "effect/Context"; |
| 20 | import * as Crypto from "effect/Crypto"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as Layer from "effect/Layer"; |
| 23 | import * as Option from "effect/Option"; |
| 24 | import * as Ref from "effect/Ref"; |
| 25 | import * as Schema from "effect/Schema"; |
| 26 | import { HttpServerRequest } from "effect/http"; |
| 27 | |
| 28 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 29 | import * as McpHttpServer from "../mcp/McpHttpServer.ts"; |
| 30 | import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; |
| 31 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 32 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 33 | import { |
| 34 | base64UrlDecodeUtf8, |
| 35 | base64UrlEncode, |
| 36 | deriveAuthClientMetadata, |
| 37 | signPayload, |
| 38 | timingSafeEqualBase64Url, |
| 39 | } from "./utils.ts"; |
| 40 | |
| 41 | /** |
| 42 | * The OAuth authorization server MCP clients (Claude Code, Codex, any agent |
| 43 | * T3 Code did not launch) use to sign in to this environment's `/mcp`. |
| 44 | * |
| 45 | * Every URL is derived from the request's own origin, so the same server |
| 46 | * answers correctly over loopback, Tailscale Serve and a T3 Connect tunnel. |
| 47 | * Client registration is stateless: a client id is its signed metadata, so |
| 48 | * an unauthenticated caller cannot grow server state. Redirects go to a |
| 49 | * loopback address (a CLI agent on the user's machine) or any https address |
| 50 | * (a hosted agent); the approval page names where access goes, and the user |
| 51 | * decides. |
| 52 | */ |
| 53 | |
| 54 | const SIGNING_SECRET_NAME = "mcp-oauth-signing-key"; |
| 55 | const AUTHORIZATION_CODE_TTL_MS = 60_000; |
| 56 | const MAX_CLIENT_NAME_LENGTH = 100; |
| 57 | const MAX_REDIRECT_URIS = 5; |
| 58 | const MAX_REDIRECT_URI_LENGTH = 512; |
| 59 | const DEFAULT_CLIENT_NAME = "MCP client"; |
| 60 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); |
| 61 | const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; |
| 62 | const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; |
| 63 | |
| 64 | const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; |
| 65 | |
| 66 | export interface McpOAuthUrls { |
| 67 | readonly issuer: string; |
| 68 | readonly resource: string; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Issuer and MCP resource for the origin this request reached: its Host, and |
| 73 | * https when a proxy says so. A Host that is not a valid authority falls back |
| 74 | * to localhost, which no client will have asked for. |
| 75 | */ |
| 76 | export const requestUrls = (request: HttpServerRequest.HttpServerRequest): McpOAuthUrls => { |
| 77 | const origin = Option.match(HttpServerRequest.toURL(request), { |
| 78 | onNone: () => "http://localhost", |
| 79 | onSome: (url) => url.origin, |
| 80 | }); |
| 81 | return { issuer: origin, resource: `${origin}/mcp` }; |
| 82 | }; |
| 83 | |
| 84 | export const protectedResourceMetadata = ( |
| 85 | urls: McpOAuthUrls, |
| 86 | ): AuthMcpProtectedResourceMetadata => ({ |
| 87 | resource: urls.resource, |
| 88 | authorization_servers: [urls.issuer], |
| 89 | scopes_supported: MCP_OAUTH_SCOPES, |
| 90 | bearer_methods_supported: ["header"], |
| 91 | resource_name: "T3 Code", |
| 92 | }); |
| 93 | |
| 94 | export const authorizationServerMetadata = ( |
| 95 | urls: McpOAuthUrls, |
| 96 | ): AuthMcpAuthorizationServerMetadata => ({ |
| 97 | issuer: urls.issuer, |
| 98 | authorization_endpoint: `${urls.issuer}/oauth/mcp/authorize`, |
| 99 | token_endpoint: `${urls.issuer}/oauth/mcp/token`, |
| 100 | registration_endpoint: `${urls.issuer}/oauth/mcp/register`, |
| 101 | response_types_supported: ["code"], |
| 102 | grant_types_supported: ["authorization_code"], |
| 103 | code_challenge_methods_supported: ["S256"], |
| 104 | token_endpoint_auth_methods_supported: ["none"], |
| 105 | scopes_supported: MCP_OAUTH_SCOPES, |
| 106 | authorization_response_iss_parameter_supported: true, |
| 107 | }); |
| 108 | |
| 109 | /** A redirect URI an MCP client may register: loopback http, or any https. */ |
| 110 | const parseRedirect = (value: string): URL | undefined => { |
| 111 | // No fragment, not even an empty one, which `URL.hash` reports as "" (RFC 6749 §3.1.2). |
| 112 | if (value.length > MAX_REDIRECT_URI_LENGTH || value.includes("#")) return undefined; |
| 113 | try { |
| 114 | const url = new URL(value); |
| 115 | const allowed = |
| 116 | url.protocol === "https:" || |
| 117 | (url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname)); |
| 118 | return allowed && url.username === "" && url.password === "" ? url : undefined; |
| 119 | } catch { |
| 120 | return undefined; |
| 121 | } |
| 122 | }; |
| 123 | |
| 124 | /** |
| 125 | * Whether a presented redirect is one the client registered. Loopback |
| 126 | * redirects match on everything but the port (RFC 8252 §7.3), since CLI |
| 127 | * agents listen on whatever port is free; https redirects match exactly. |
| 128 | */ |
| 129 | export const redirectMatches = (registered: string, presented: string): boolean => { |
| 130 | const left = parseRedirect(registered); |
| 131 | const right = parseRedirect(presented); |
| 132 | if (left === undefined || right === undefined) return false; |
| 133 | if (left.protocol === "https:" || right.protocol === "https:") return left.href === right.href; |
| 134 | return ( |
| 135 | left.hostname === right.hostname && |
| 136 | left.pathname === right.pathname && |
| 137 | left.search === right.search |
| 138 | ); |
| 139 | }; |
| 140 | |
| 141 | const sameResource = (urls: McpOAuthUrls, presented: string | undefined): boolean => { |
| 142 | if (presented === undefined) return true; |
| 143 | try { |
| 144 | const url = new URL(presented); |
| 145 | return url.hash === "" && `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource; |
| 146 | } catch { |
| 147 | return false; |
| 148 | } |
| 149 | }; |
| 150 | |
| 151 | const ClientIdPayload = Schema.Struct({ |
| 152 | v: Schema.Literal(1), |
| 153 | n: Schema.String, |
| 154 | r: Schema.Array(Schema.String), |
| 155 | }); |
| 156 | const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload)); |
| 157 | |
| 158 | export interface McpOAuthClient { |
| 159 | readonly clientId: string; |
| 160 | readonly name: string; |
| 161 | readonly redirectUris: ReadonlyArray<string>; |
| 162 | } |
| 163 | |
| 164 | /** Problems the authorize page shows the user without redirecting anywhere. */ |
| 165 | export class McpOAuthPageError extends Schema.TaggedError<McpOAuthPageError>()( |
| 166 | "McpOAuthPageError", |
| 167 | { description: Schema.String }, |
| 168 | ) { |
| 169 | override get message(): string { |
| 170 | return this.description; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /** Problems reported back to the client through its (validated) redirect URI. */ |
| 175 | export class McpOAuthRedirectError extends Schema.TaggedError<McpOAuthRedirectError>()( |
| 176 | "McpOAuthRedirectError", |
| 177 | { |
| 178 | error: Schema.Literals(["invalid_request", "unsupported_response_type", "invalid_target"]), |
| 179 | description: Schema.String, |
| 180 | redirectUri: Schema.String, |
| 181 | state: Schema.optional(Schema.String), |
| 182 | }, |
| 183 | ) {} |
| 184 | |
| 185 | export interface AuthorizationRequest { |
| 186 | readonly client: McpOAuthClient; |
| 187 | readonly redirectUri: string; |
| 188 | readonly codeChallenge: string; |
| 189 | readonly state: string | undefined; |
| 190 | readonly resource: string; |
| 191 | readonly issuer: string; |
| 192 | } |
| 193 | |
| 194 | interface PendingCode { |
| 195 | readonly clientId: string; |
| 196 | readonly clientName: string; |
| 197 | readonly redirectUri: string; |
| 198 | readonly codeChallenge: string; |
| 199 | readonly resource: string; |
| 200 | readonly access: AuthMcpClientAccess; |
| 201 | readonly expiresAtMs: number; |
| 202 | } |
| 203 | |
| 204 | export type ApprovalDecision = Exclude<AuthMcpApprovalDecision, { readonly _tag: "deny" }>; |
| 205 | |
| 206 | export class McpOAuth extends Context.Service< |
| 207 | McpOAuth, |
| 208 | { |
| 209 | readonly register: ( |
| 210 | input: AuthMcpClientRegistration, |
| 211 | ) => Effect.Effect<McpOAuthClient, AuthMcpRegistrationError>; |
| 212 | /** Validates an authorize request. Page errors must never redirect. */ |
| 213 | readonly validateAuthorization: (input: { |
| 214 | readonly urls: McpOAuthUrls; |
| 215 | readonly request: AuthMcpAuthorizationRequest; |
| 216 | }) => Effect.Effect<AuthorizationRequest, McpOAuthPageError | McpOAuthRedirectError>; |
| 217 | /** |
| 218 | * The signed-in owner on this origin, when their browser session may |
| 219 | * approve at least read-only access. `approve` checks the chosen access |
| 220 | * against the session's scopes. |
| 221 | */ |
| 222 | readonly approvingBrowserSession: ( |
| 223 | request: HttpServerRequest.HttpServerRequest, |
| 224 | authorization: AuthorizationRequest, |
| 225 | ) => Effect.Effect< |
| 226 | | { readonly csrfToken: string; readonly scopes: ReadonlyArray<AuthEnvironmentScope> } |
| 227 | | undefined |
| 228 | >; |
| 229 | /** Approves and returns the URL to send the browser to. */ |
| 230 | readonly approve: (input: { |
| 231 | readonly request: HttpServerRequest.HttpServerRequest; |
| 232 | readonly authorization: AuthorizationRequest; |
| 233 | readonly decision: ApprovalDecision; |
| 234 | }) => Effect.Effect<string, EnvironmentAuth.ServerAuthMcpApprovalCodeError | McpOAuthPageError>; |
| 235 | readonly deny: (authorization: AuthorizationRequest) => string; |
| 236 | readonly exchangeCode: (input: { |
| 237 | readonly request: HttpServerRequest.HttpServerRequest; |
| 238 | readonly urls: McpOAuthUrls; |
| 239 | readonly token: AuthMcpTokenRequest; |
| 240 | }) => Effect.Effect<AuthMcpTokenResult, AuthMcpTokenError>; |
| 241 | } |
| 242 | >()("t3/auth/McpOAuth") {} |
| 243 | |
| 244 | /** Appends OAuth response parameters, keeping any the client put in its redirect URI. */ |
| 245 | const redirectWith = (redirectUri: string, params: Record<string, string | undefined>) => { |
| 246 | const url = new URL(redirectUri); |
| 247 | for (const [name, value] of Object.entries(params)) { |
| 248 | if (value !== undefined) url.searchParams.set(name, value); |
| 249 | } |
| 250 | return url.toString(); |
| 251 | }; |
| 252 | |
| 253 | export const redirectForError = (error: McpOAuthRedirectError, issuer: string) => |
| 254 | redirectWith(error.redirectUri, { |
| 255 | error: error.error, |
| 256 | error_description: error.description, |
| 257 | state: error.state, |
| 258 | iss: issuer, |
| 259 | }); |
| 260 | |
| 261 | const make = Effect.gen(function* () { |
| 262 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 263 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 264 | const crypto = yield* Crypto.Crypto; |
| 265 | const signingKey = yield* secretStore |
| 266 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) |
| 267 | .pipe(Effect.orDie); |
| 268 | const codes = yield* Ref.make<ReadonlyMap<string, PendingCode>>(new Map()); |
| 269 | |
| 270 | const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey); |
| 271 | |
| 272 | const signClientId = (name: string, redirectUris: ReadonlyArray<string>) => { |
| 273 | const body = base64UrlEncode(JSON.stringify({ v: 1, n: name, r: redirectUris })); |
| 274 | return `${body}.${sign("mcp-client-id", body)}`; |
| 275 | }; |
| 276 | |
| 277 | const parseClientId = (clientId: string): McpOAuthClient | undefined => { |
| 278 | const [body, signature, extra] = clientId.split("."); |
| 279 | if (!body || !signature || extra !== undefined) return undefined; |
| 280 | if (!timingSafeEqualBase64Url(signature, sign("mcp-client-id", body))) return undefined; |
| 281 | let json: string; |
| 282 | try { |
| 283 | json = base64UrlDecodeUtf8(body); |
| 284 | } catch { |
| 285 | return undefined; |
| 286 | } |
| 287 | return Option.getOrUndefined( |
| 288 | decodeClientIdPayload(json).pipe( |
| 289 | Option.map((payload) => ({ clientId, name: payload.n, redirectUris: payload.r })), |
| 290 | ), |
| 291 | ); |
| 292 | }; |
| 293 | |
| 294 | const pkceVerifies = (verifier: string, challenge: string) => |
| 295 | CODE_VERIFIER_PATTERN.test(verifier) |
| 296 | ? crypto.digest("SHA-256", new TextEncoder().encode(verifier)).pipe( |
| 297 | Effect.orDie, |
| 298 | Effect.map((digest) => timingSafeEqualBase64Url(base64UrlEncode(digest), challenge)), |
| 299 | ) |
| 300 | : Effect.succeed(false); |
| 301 | |
| 302 | const csrfToken = (sessionId: string, authorization: AuthorizationRequest) => |
| 303 | sign( |
| 304 | "mcp-csrf", |
| 305 | JSON.stringify([ |
| 306 | sessionId, |
| 307 | authorization.client.clientId, |
| 308 | authorization.redirectUri, |
| 309 | authorization.codeChallenge, |
| 310 | ]), |
| 311 | ); |
| 312 | |
| 313 | const register: McpOAuth["Service"]["register"] = (metadata) => |
| 314 | Effect.gen(function* () { |
| 315 | const rawName = metadata.client_name?.trim() ?? ""; |
| 316 | const name = (rawName.length > 0 ? rawName : DEFAULT_CLIENT_NAME).slice( |
| 317 | 0, |
| 318 | MAX_CLIENT_NAME_LENGTH, |
| 319 | ); |
| 320 | const redirectUris = metadata.redirect_uris ?? []; |
| 321 | if (redirectUris.length === 0 || redirectUris.length > MAX_REDIRECT_URIS) { |
| 322 | return yield* new AuthMcpRegistrationError({ |
| 323 | error: "invalid_redirect_uri", |
| 324 | error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`, |
| 325 | }); |
| 326 | } |
| 327 | if (!redirectUris.every((uri) => parseRedirect(uri) !== undefined)) { |
| 328 | return yield* new AuthMcpRegistrationError({ |
| 329 | error: "invalid_redirect_uri", |
| 330 | error_description: |
| 331 | "Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1].", |
| 332 | }); |
| 333 | } |
| 334 | // Requested grant types, scopes and token endpoint auth methods are ignored rather |
| 335 | // than rejected: RFC 7591 lets the server answer with what it supports. Claude Code |
| 336 | // asks for refresh_token, and hosted agents may ask for a client secret; every client |
| 337 | // is registered as public and proves itself with PKCE instead. |
| 338 | return { clientId: signClientId(name, redirectUris), name, redirectUris }; |
| 339 | }); |
| 340 | |
| 341 | const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = ({ urls, request }) => |
| 342 | Effect.gen(function* () { |
| 343 | const client = parseClientId(request.client_id ?? ""); |
| 344 | if (client === undefined) { |
| 345 | return yield* new McpOAuthPageError({ |
| 346 | description: "This sign-in link names an unknown app. Start the sign-in again from it.", |
| 347 | }); |
| 348 | } |
| 349 | const redirectUri = request.redirect_uri; |
| 350 | if ( |
| 351 | redirectUri === undefined || |
| 352 | !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri)) |
| 353 | ) { |
| 354 | return yield* new McpOAuthPageError({ |
| 355 | description: "This sign-in link sends you to an address the app did not register.", |
| 356 | }); |
| 357 | } |
| 358 | const state = request.state; |
| 359 | const fail = (error: McpOAuthRedirectError["error"], description: string) => |
| 360 | new McpOAuthRedirectError({ |
| 361 | error, |
| 362 | description, |
| 363 | redirectUri, |
| 364 | ...(state === undefined ? {} : { state }), |
| 365 | }); |
| 366 | if (request.response_type !== "code") { |
| 367 | return yield* fail("unsupported_response_type", "Only response_type=code is supported."); |
| 368 | } |
| 369 | const codeChallenge = request.code_challenge; |
| 370 | if ( |
| 371 | request.code_challenge_method !== "S256" || |
| 372 | codeChallenge === undefined || |
| 373 | !CODE_CHALLENGE_PATTERN.test(codeChallenge) |
| 374 | ) { |
| 375 | return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); |
| 376 | } |
| 377 | if (!sameResource(urls, request.resource)) { |
| 378 | return yield* fail( |
| 379 | "invalid_target", |
| 380 | `This server only issues tokens for ${urls.resource}.`, |
| 381 | ); |
| 382 | } |
| 383 | return { |
| 384 | client, |
| 385 | redirectUri, |
| 386 | codeChallenge, |
| 387 | state, |
| 388 | resource: urls.resource, |
| 389 | issuer: urls.issuer, |
| 390 | } satisfies AuthorizationRequest; |
| 391 | }); |
| 392 | |
| 393 | const approvingBrowserSession: McpOAuth["Service"]["approvingBrowserSession"] = ( |
| 394 | request, |
| 395 | authorization, |
| 396 | ) => |
| 397 | environmentAuth.authenticateBrowserSession(request).pipe( |
| 398 | Effect.map((session) => |
| 399 | // Approving manages access, and a session may only hand out scopes it holds. |
| 400 | session.scopes.includes(AuthAccessWriteScope) && |
| 401 | session.scopes.includes(AuthOrchestrationReadScope) |
| 402 | ? { csrfToken: csrfToken(session.sessionId, authorization), scopes: session.scopes } |
| 403 | : undefined, |
| 404 | ), |
| 405 | Effect.orElseSucceed(() => undefined), |
| 406 | ); |
| 407 | |
| 408 | const mintCode = (authorization: AuthorizationRequest, access: AuthMcpClientAccess) => |
| 409 | Effect.gen(function* () { |
| 410 | const now = yield* Clock.currentTimeMillis; |
| 411 | const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( |
| 412 | "base64url", |
| 413 | ); |
| 414 | yield* Ref.update(codes, (current) => { |
| 415 | const next = new Map( |
| 416 | Array.from(current).filter(([, pending]) => pending.expiresAtMs > now), |
| 417 | ); |
| 418 | next.set(code, { |
| 419 | clientId: authorization.client.clientId, |
| 420 | clientName: authorization.client.name, |
| 421 | redirectUri: authorization.redirectUri, |
| 422 | codeChallenge: authorization.codeChallenge, |
| 423 | resource: authorization.resource, |
| 424 | access, |
| 425 | expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, |
| 426 | }); |
| 427 | return next; |
| 428 | }); |
| 429 | return code; |
| 430 | }); |
| 431 | |
| 432 | const approve: McpOAuth["Service"]["approve"] = (input) => |
| 433 | Effect.gen(function* () { |
| 434 | const { decision } = input; |
| 435 | if (decision._tag === "pairing-code") { |
| 436 | yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( |
| 437 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => |
| 438 | Effect.logError("MCP approval code check failed.", { cause }).pipe( |
| 439 | Effect.andThen( |
| 440 | Effect.fail( |
| 441 | new McpOAuthPageError({ |
| 442 | description: "The code could not be checked. Try again.", |
| 443 | }), |
| 444 | ), |
| 445 | ), |
| 446 | ), |
| 447 | ), |
| 448 | ); |
| 449 | } else { |
| 450 | const session = yield* approvingBrowserSession(input.request, input.authorization); |
| 451 | if ( |
| 452 | session === undefined || |
| 453 | !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || |
| 454 | !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => |
| 455 | session.scopes.includes(scope), |
| 456 | ) |
| 457 | ) { |
| 458 | return yield* new McpOAuthPageError({ |
| 459 | description: "Your session cannot approve this request. Enter a pairing code instead.", |
| 460 | }); |
| 461 | } |
| 462 | } |
| 463 | const code = yield* mintCode(input.authorization, decision.access); |
| 464 | yield* Effect.logInfo("Approved an MCP client sign-in.", { |
| 465 | client: input.authorization.client.name, |
| 466 | access: decision.access, |
| 467 | method: decision._tag, |
| 468 | }); |
| 469 | return redirectWith(input.authorization.redirectUri, { |
| 470 | code, |
| 471 | state: input.authorization.state, |
| 472 | iss: input.authorization.issuer, |
| 473 | }); |
| 474 | }); |
| 475 | |
| 476 | const deny: McpOAuth["Service"]["deny"] = (authorization) => |
| 477 | redirectWith(authorization.redirectUri, { |
| 478 | error: "access_denied", |
| 479 | state: authorization.state, |
| 480 | iss: authorization.issuer, |
| 481 | }); |
| 482 | |
| 483 | const exchangeCode: McpOAuth["Service"]["exchangeCode"] = ({ request, urls, token }) => |
| 484 | Effect.gen(function* () { |
| 485 | const fail = (error: AuthMcpTokenError["error"], description: string) => |
| 486 | new AuthMcpTokenError({ error, error_description: description }); |
| 487 | if (token.grant_type !== "authorization_code") { |
| 488 | return yield* fail("unsupported_grant_type", "Only authorization_code is supported."); |
| 489 | } |
| 490 | const { code, redirect_uri: redirectUri, client_id: clientId } = token; |
| 491 | const verifier = token.code_verifier; |
| 492 | if (!code || !redirectUri || !clientId || !verifier) { |
| 493 | return yield* fail( |
| 494 | "invalid_request", |
| 495 | "code, redirect_uri, client_id and code_verifier are required.", |
| 496 | ); |
| 497 | } |
| 498 | if (parseClientId(clientId) === undefined) { |
| 499 | return yield* fail("invalid_client", "The client is unknown."); |
| 500 | } |
| 501 | // A presented code is spent even when a later check fails (RFC 6749 §4.1.2). |
| 502 | const pending = yield* Ref.modify(codes, (current) => { |
| 503 | const found = current.get(code); |
| 504 | if (found === undefined) return [undefined, current] as const; |
| 505 | const next = new Map(current); |
| 506 | next.delete(code); |
| 507 | return [found, next] as const; |
| 508 | }); |
| 509 | const now = yield* Clock.currentTimeMillis; |
| 510 | if ( |
| 511 | pending === undefined || |
| 512 | pending.expiresAtMs <= now || |
| 513 | pending.clientId !== clientId || |
| 514 | pending.redirectUri !== redirectUri || |
| 515 | pending.resource !== urls.resource || |
| 516 | !sameResource(urls, token.resource) || |
| 517 | !(yield* pkceVerifies(verifier, pending.codeChallenge)) |
| 518 | ) { |
| 519 | return yield* fail("invalid_grant", "The authorization code is invalid or expired."); |
| 520 | } |
| 521 | const issued = yield* environmentAuth |
| 522 | .issueMcpClientSession({ |
| 523 | label: pending.clientName, |
| 524 | access: pending.access, |
| 525 | client: deriveAuthClientMetadata({ request }), |
| 526 | }) |
| 527 | .pipe( |
| 528 | Effect.catch((cause) => |
| 529 | Effect.logError("Could not issue an MCP client session.", { cause }).pipe( |
| 530 | Effect.andThen( |
| 531 | Effect.fail(fail("invalid_grant", "The session could not be issued.")), |
| 532 | ), |
| 533 | ), |
| 534 | ), |
| 535 | ); |
| 536 | return { |
| 537 | access_token: issued.token, |
| 538 | token_type: "Bearer", |
| 539 | expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)), |
| 540 | scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)), |
| 541 | }; |
| 542 | }); |
| 543 | |
| 544 | return McpOAuth.of({ |
| 545 | register, |
| 546 | validateAuthorization, |
| 547 | approvingBrowserSession, |
| 548 | approve, |
| 549 | deny, |
| 550 | exchangeCode, |
| 551 | }); |
| 552 | }); |
| 553 | |
| 554 | export const layer = Layer.effect(McpOAuth, make); |
| 555 | |
| 556 | /** |
| 557 | * Lets `/mcp` admit OAuth clients. Their scope has no calling thread, and |
| 558 | * never carries preview or device access: those tools act on the caller's |
| 559 | * own thread. |
| 560 | */ |
| 561 | export const layerMcpClientAuthenticator = Layer.effect( |
| 562 | McpHttpServer.McpClientAuthenticator, |
| 563 | Effect.gen(function* () { |
| 564 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 565 | const environment = yield* ServerEnvironment.ServerEnvironment; |
| 566 | const environmentId = yield* environment.getEnvironmentId; |
| 567 | return McpHttpServer.McpClientAuthenticator.of({ |
| 568 | authenticate: (request) => |
| 569 | environmentAuth.authenticateMcpClient(request).pipe( |
| 570 | Effect.flatMap((client) => |
| 571 | Clock.currentTimeMillis.pipe( |
| 572 | Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({ |
| 573 | environmentId, |
| 574 | requestNamespace: `client:${client.sessionId}`, |
| 575 | thread: undefined, |
| 576 | client: { |
| 577 | sessionId: client.sessionId, |
| 578 | label: client.label, |
| 579 | access: client.access, |
| 580 | }, |
| 581 | capabilities: new Set<McpInvocationContext.McpCapability>([ |
| 582 | "orchestration", |
| 583 | "worktree", |
| 584 | "pull-requests", |
| 585 | ]), |
| 586 | issuedAt, |
| 587 | })), |
| 588 | ), |
| 589 | ), |
| 590 | Effect.catch((error) => |
| 591 | (EnvironmentAuth.isServerAuthCredentialError(error) |
| 592 | ? Effect.void |
| 593 | : Effect.logWarning("MCP client authentication failed.", { cause: error }) |
| 594 | ).pipe(Effect.as(undefined)), |
| 595 | ), |
| 596 | ), |
| 597 | }); |
| 598 | }), |
| 599 | ); |