1import {
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";
17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
18import * as Clock from "effect/Clock";
19import * as Context from "effect/Context";
20import * as Crypto from "effect/Crypto";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as Ref from "effect/Ref";
25import * as Schema from "effect/Schema";
26import { HttpServerRequest } from "effect/http";
27
28import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
29import * as McpHttpServer from "../mcp/McpHttpServer.ts";
30import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
31import * as EnvironmentAuth from "./EnvironmentAuth.ts";
32import * as ServerSecretStore from "./ServerSecretStore.ts";
33import {
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
54const SIGNING_SECRET_NAME = "mcp-oauth-signing-key";
55const AUTHORIZATION_CODE_TTL_MS = 60_000;
56const MAX_CLIENT_NAME_LENGTH = 100;
57const MAX_REDIRECT_URIS = 5;
58const MAX_REDIRECT_URI_LENGTH = 512;
59const DEFAULT_CLIENT_NAME = "MCP client";
60const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
61const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
62const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/;
63
64const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
65
66export 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 */
76export 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
84export 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
94export 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. */
110const 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 */
129export 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
141const 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
151const ClientIdPayload = Schema.Struct({
152 v: Schema.Literal(1),
153 n: Schema.String,
154 r: Schema.Array(Schema.String),
155});
156const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload));
157
158export 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. */
165export 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. */
175export 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
185export 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
194interface 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
204export type ApprovalDecision = Exclude<AuthMcpApprovalDecision, { readonly _tag: "deny" }>;
205
206export 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. */
245const 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
253export 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
261const 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
554export 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 */
561export 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);