| author | |
| committer | |
| log | 2c8be5893eb3754071162e344a8c8e8a67bef597 |
| tree | 147bd933ebff3d13738a697c43a0fedac8e4d706 |
| parent | 2f85686d9dca5ec98c892a2a26c321e09d746d71 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>29 files changed, 2531 insertions(+), 67 deletions(-)
apps/server/src/auth/EnvironmentAuth.ts+161-2| ... | @@ -2,6 +2,8 @@ import { | ... | @@ -2,6 +2,8 @@ import { |
| 2 | AuthAccessTokenType, | 2 | AuthAccessTokenType, |
| 3 | AuthAccessWriteScope, | 3 | AuthAccessWriteScope, |
| 4 | AuthAdministrativeScopes, | 4 | AuthAdministrativeScopes, |
| 5 | AuthOrchestrationOperateScope, | ||
| 6 | AuthOrchestrationReadScope, | ||
| 5 | AuthStandardClientScopes, | 7 | AuthStandardClientScopes, |
| 6 | type AuthAccessTokenResult, | 8 | type AuthAccessTokenResult, |
| 7 | type AuthBrowserSessionResult, | 9 | type AuthBrowserSessionResult, |
| ... | @@ -9,6 +11,7 @@ import { | ... | @@ -9,6 +11,7 @@ import { |
| 9 | type AuthClientSession, | 11 | type AuthClientSession, |
| 10 | type AuthCreatePairingCredentialInput, | 12 | type AuthCreatePairingCredentialInput, |
| 11 | type AuthEnvironmentScope, | 13 | type AuthEnvironmentScope, |
| 14 | type AuthMcpClientAccess, | ||
| 12 | type AuthPairingLink, | 15 | type AuthPairingLink, |
| 13 | type AuthPairingCredentialResult, | 16 | type AuthPairingCredentialResult, |
| 14 | type AuthSessionId, | 17 | type AuthSessionId, |
| ... | @@ -63,6 +66,31 @@ export interface IssuedBearerSession { | ... | @@ -63,6 +66,31 @@ export interface IssuedBearerSession { |
| 63 | readonly expiresAt: DateTime.Utc; | 66 | readonly expiresAt: DateTime.Utc; |
| 64 | } | 67 | } |
| 65 | 68 | ||
| 69 | /** | ||
| 70 | * Sessions an MCP client (an agent T3 Code did not launch) obtains through | ||
| 71 | * OAuth. They are accepted only by `/mcp`, where every action is capped by the | ||
| 72 | * access the user approved; the HTTP API and WebSocket reject them so an agent | ||
| 73 | * token cannot reach the full RPC surface around that cap. | ||
| 74 | * | ||
| 75 | * A read-only grant holds `orchestration:read` alone. Any other grant also | ||
| 76 | * holds `orchestration:operate` and carries its runtime-mode ceiling. | ||
| 77 | */ | ||
| 78 | const MCP_CLIENT_SUBJECT = "mcp-client"; | ||
| 79 | const MCP_CLIENT_SESSION_TTL = Duration.days(30); | ||
| 80 | |||
| 81 | export const mcpClientScopes = ( | ||
| 82 | access: AuthMcpClientAccess, | ||
| 83 | ): ReadonlyArray<AuthEnvironmentScope> => | ||
| 84 | access === "read-only" | ||
| 85 | ? [AuthOrchestrationReadScope] | ||
| 86 | : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; | ||
| 87 | |||
| 88 | export interface McpClientSession { | ||
| 89 | readonly sessionId: AuthSessionId; | ||
| 90 | readonly label: string; | ||
| 91 | readonly access: AuthMcpClientAccess; | ||
| 92 | } | ||
| 93 | |||
| 66 | export interface AuthenticatedSession { | 94 | export interface AuthenticatedSession { |
| 67 | readonly sessionId: AuthSessionId; | 95 | readonly sessionId: AuthSessionId; |
| 68 | readonly subject: string; | 96 | readonly subject: string; |
| ... | @@ -406,6 +434,19 @@ export const serverAuthInvalidRequestReason = ( | ... | @@ -406,6 +434,19 @@ export const serverAuthInvalidRequestReason = ( |
| 406 | ): "invalid_scope" | "scope_not_granted" => | 434 | ): "invalid_scope" | "scope_not_granted" => |
| 407 | error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; | 435 | error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; |
| 408 | 436 | ||
| 437 | export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError<ServerAuthMcpApprovalCodeError>()( | ||
| 438 | "ServerAuthMcpApprovalCodeError", | ||
| 439 | { reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) }, | ||
| 440 | ) { | ||
| 441 | override get message(): string { | ||
| 442 | return this.reason === "insufficient_scope" | ||
| 443 | ? "That pairing code cannot grant this access, and it is now used up. Create one with the standard scopes, or choose Read only with a new code." | ||
| 444 | : this.reason === "not_a_pairing_code" | ||
| 445 | ? "That is not a one-time pairing code." | ||
| 446 | : "That pairing code is unknown, expired, or already used."; | ||
| 447 | } | ||
| 448 | } | ||
| 449 | |||
| 409 | export class ServerAuthForbiddenOperationError extends Schema.TaggedError<ServerAuthForbiddenOperationError>()( | 450 | export class ServerAuthForbiddenOperationError extends Schema.TaggedError<ServerAuthForbiddenOperationError>()( |
| 410 | "ServerAuthForbiddenOperationError", | 451 | "ServerAuthForbiddenOperationError", |
| 411 | {}, | 452 | {}, |
| ... | @@ -502,6 +543,32 @@ export class EnvironmentAuth extends Context.Service< | ... | @@ -502,6 +543,32 @@ export class EnvironmentAuth extends Context.Service< |
| 502 | readonly issueStartupPairingUrl: ( | 543 | readonly issueStartupPairingUrl: ( |
| 503 | baseUrl: string, | 544 | baseUrl: string, |
| 504 | ) => Effect.Effect<string, ServerAuthInternalError>; | 545 | ) => Effect.Effect<string, ServerAuthInternalError>; |
| 546 | /** Only bearer `mcp-client` sessions; never cookies or proof-bound tokens. */ | ||
| 547 | readonly authenticateMcpClient: ( | ||
| 548 | request: HttpServerRequest.HttpServerRequest, | ||
| 549 | ) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>; | ||
| 550 | readonly issueMcpClientSession: (input: { | ||
| 551 | readonly label: string; | ||
| 552 | readonly access: AuthMcpClientAccess; | ||
| 553 | readonly client: AuthClientMetadata; | ||
| 554 | }) => Effect.Effect< | ||
| 555 | { readonly token: string; readonly expiresAt: DateTime.DateTime }, | ||
| 556 | ServerAuthInternalError | ||
| 557 | >; | ||
| 558 | /** | ||
| 559 | * Spends a one-time pairing code as approval for an MCP client with the | ||
| 560 | * given access; the code must hold every scope that access grants. | ||
| 561 | * Proof-bound codes (T3 Connect) are refused without being spent, and | ||
| 562 | * desktop bootstrap grants never qualify. | ||
| 563 | */ | ||
| 564 | readonly consumeMcpApprovalCode: ( | ||
| 565 | code: string, | ||
| 566 | access: AuthMcpClientAccess, | ||
| 567 | ) => Effect.Effect<void, ServerAuthMcpApprovalCodeError | ServerAuthInternalError>; | ||
| 568 | /** A browser cookie session only; a bearer header never counts as one. */ | ||
| 569 | readonly authenticateBrowserSession: ( | ||
| 570 | request: HttpServerRequest.HttpServerRequest, | ||
| 571 | ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>; | ||
| 505 | } | 572 | } |
| 506 | >()("t3/auth/EnvironmentAuth") {} | 573 | >()("t3/auth/EnvironmentAuth") {} |
| 507 | 574 | ||
| ... | @@ -540,6 +607,15 @@ export function toBootstrapExchangeError( | ... | @@ -540,6 +607,15 @@ export function toBootstrapExchangeError( |
| 540 | }); | 607 | }); |
| 541 | } | 608 | } |
| 542 | 609 | ||
| 610 | const rejectMcpClientAudience = <S extends { readonly subject: string }>(session: S) => | ||
| 611 | session.subject === MCP_CLIENT_SUBJECT | ||
| 612 | ? Effect.fail( | ||
| 613 | new ServerAuthInvalidCredentialError({ | ||
| 614 | diagnostic: "MCP client sessions are only accepted by the MCP endpoint.", | ||
| 615 | }), | ||
| 616 | ).pipe(Effect.tap(() => Effect.logWarning("Rejected an MCP client session outside /mcp."))) | ||
| 617 | : Effect.succeed(session); | ||
| 618 | |||
| 543 | const mapSessionVerificationErrors = <A, R>( | 619 | const mapSessionVerificationErrors = <A, R>( |
| 544 | effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>, | 620 | effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>, |
| 545 | ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> => | 621 | ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> => |
| ... | @@ -624,6 +700,8 @@ export const make = Effect.gen(function* () { | ... | @@ -624,6 +700,8 @@ export const make = Effect.gen(function* () { |
| 624 | ) | 700 | ) |
| 625 | : Effect.void, | 701 | : Effect.void, |
| 626 | ), | 702 | ), |
| 703 | mapSessionVerificationErrors, | ||
| 704 | Effect.flatMap(rejectMcpClientAudience), | ||
| 627 | Effect.map((session) => ({ | 705 | Effect.map((session) => ({ |
| 628 | sessionId: session.sessionId, | 706 | sessionId: session.sessionId, |
| 629 | subject: session.subject, | 707 | subject: session.subject, |
| ... | @@ -632,7 +710,6 @@ export const make = Effect.gen(function* () { | ... | @@ -632,7 +710,6 @@ export const make = Effect.gen(function* () { |
| 632 | ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), | 710 | ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), |
| 633 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | 711 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), |
| 634 | })), | 712 | })), |
| 635 | mapSessionVerificationErrors, | ||
| 636 | ); | 713 | ); |
| 637 | 714 | ||
| 638 | const authenticateRequest = ( | 715 | const authenticateRequest = ( |
| ... | @@ -1079,6 +1156,8 @@ export const make = Effect.gen(function* () { | ... | @@ -1079,6 +1156,8 @@ export const make = Effect.gen(function* () { |
| 1079 | const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); | 1156 | const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); |
| 1080 | if (websocketTicket && websocketTicket.trim().length > 0) { | 1157 | if (websocketTicket && websocketTicket.trim().length > 0) { |
| 1081 | return yield* sessions.verifyWebSocketToken(websocketTicket).pipe( | 1158 | return yield* sessions.verifyWebSocketToken(websocketTicket).pipe( |
| 1159 | mapSessionVerificationErrors, | ||
| 1160 | Effect.flatMap(rejectMcpClientAudience), | ||
| 1082 | Effect.map((session) => ({ | 1161 | Effect.map((session) => ({ |
| 1083 | sessionId: session.sessionId, | 1162 | sessionId: session.sessionId, |
| 1084 | subject: session.subject, | 1163 | subject: session.subject, |
| ... | @@ -1086,7 +1165,6 @@ export const make = Effect.gen(function* () { | ... | @@ -1086,7 +1165,6 @@ export const make = Effect.gen(function* () { |
| 1086 | scopes: session.scopes, | 1165 | scopes: session.scopes, |
| 1087 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | 1166 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), |
| 1088 | })), | 1167 | })), |
| 1089 | mapSessionVerificationErrors, | ||
| 1090 | ); | 1168 | ); |
| 1091 | } | 1169 | } |
| 1092 | } | 1170 | } |
| ... | @@ -1094,6 +1172,83 @@ export const make = Effect.gen(function* () { | ... | @@ -1094,6 +1172,83 @@ export const make = Effect.gen(function* () { |
| 1094 | return yield* authenticateRequest(request); | 1172 | return yield* authenticateRequest(request); |
| 1095 | }); | 1173 | }); |
| 1096 | 1174 | ||
| 1175 | const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { | ||
| 1176 | const token = parseBearerToken(request); | ||
| 1177 | if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); | ||
| 1178 | return sessions.verify(token).pipe( | ||
| 1179 | mapSessionVerificationErrors, | ||
| 1180 | Effect.flatMap((session) => | ||
| 1181 | session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token" | ||
| 1182 | ? Effect.succeed({ | ||
| 1183 | sessionId: session.sessionId, | ||
| 1184 | label: session.client.label ?? "MCP client", | ||
| 1185 | access: session.scopes.includes(AuthOrchestrationOperateScope) | ||
| 1186 | ? (session.runtimeModeCeiling ?? "approval-required") | ||
| 1187 | : "read-only", | ||
| 1188 | } satisfies McpClientSession) | ||
| 1189 | : Effect.fail( | ||
| 1190 | new ServerAuthInvalidCredentialError({ | ||
| 1191 | diagnostic: "Only MCP client sessions are accepted here.", | ||
| 1192 | }), | ||
| 1193 | ), | ||
| 1194 | ), | ||
| 1195 | Effect.withSpan("EnvironmentAuth.authenticateMcpClient"), | ||
| 1196 | ); | ||
| 1197 | }; | ||
| 1198 | |||
| 1199 | const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) => | ||
| 1200 | sessions | ||
| 1201 | .issue({ | ||
| 1202 | subject: MCP_CLIENT_SUBJECT, | ||
| 1203 | method: "bearer-access-token", | ||
| 1204 | scopes: mcpClientScopes(input.access), | ||
| 1205 | ttl: MCP_CLIENT_SESSION_TTL, | ||
| 1206 | ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), | ||
| 1207 | client: { ...input.client, label: input.label, deviceType: "bot" }, | ||
| 1208 | }) | ||
| 1209 | .pipe( | ||
| 1210 | Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })), | ||
| 1211 | Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), | ||
| 1212 | Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), | ||
| 1213 | ); | ||
| 1214 | |||
| 1215 | const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( | ||
| 1216 | request, | ||
| 1217 | ) => { | ||
| 1218 | const token = | ||
| 1219 | request.cookies[sessions.cookieName] ?? | ||
| 1220 | (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ?? | ||
| 1221 | (devAuth ? request.cookies[devAuth.cookieName] : undefined); | ||
| 1222 | if (!token) return Effect.fail(new ServerAuthMissingCredentialError({})); | ||
| 1223 | return authenticateToken(token).pipe( | ||
| 1224 | Effect.filterOrFail( | ||
| 1225 | (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint, | ||
| 1226 | () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }), | ||
| 1227 | ), | ||
| 1228 | Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"), | ||
| 1229 | ); | ||
| 1230 | }; | ||
| 1231 | |||
| 1232 | const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = ( | ||
| 1233 | code, | ||
| 1234 | access, | ||
| 1235 | ) => | ||
| 1236 | // No proof key: a code bound to a T3 Connect client's key fails without being spent. | ||
| 1237 | resolveBootstrapGrant(code.trim()).pipe( | ||
| 1238 | Effect.catchTags({ | ||
| 1239 | ServerAuthInvalidCredentialError: () => | ||
| 1240 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), | ||
| 1241 | }), | ||
| 1242 | Effect.flatMap((grant) => | ||
| 1243 | grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" | ||
| 1244 | ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) | ||
| 1245 | : mcpClientScopes(access).every((scope) => grant.scopes.includes(scope)) | ||
| 1246 | ? Effect.void | ||
| 1247 | : Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), | ||
| 1248 | ), | ||
| 1249 | Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), | ||
| 1250 | ); | ||
| 1251 | |||
| 1097 | return EnvironmentAuth.of({ | 1252 | return EnvironmentAuth.of({ |
| 1098 | getDescriptor: () => | 1253 | getDescriptor: () => |
| 1099 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), | 1254 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), |
| ... | @@ -1116,6 +1271,10 @@ export const make = Effect.gen(function* () { | ... | @@ -1116,6 +1271,10 @@ export const make = Effect.gen(function* () { |
| 1116 | authenticateWebSocketUpgrade, | 1271 | authenticateWebSocketUpgrade, |
| 1117 | issueWebSocketTicket, | 1272 | issueWebSocketTicket, |
| 1118 | issueStartupPairingUrl, | 1273 | issueStartupPairingUrl, |
| 1274 | authenticateMcpClient, | ||
| 1275 | issueMcpClientSession, | ||
| 1276 | consumeMcpApprovalCode, | ||
| 1277 | authenticateBrowserSession, | ||
| 1119 | }); | 1278 | }); |
| 1120 | }); | 1279 | }); |
| 1121 | 1280 |
apps/server/src/auth/McpOAuth.test.ts created+505| ... | @@ -0,0 +1,505 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { | ||
| 3 | AuthAdministrativeScopes, | ||
| 4 | type AuthEnvironmentScope, | ||
| 5 | EnvironmentHttpApi, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { expect, it } from "@effect/vitest"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Etag from "effect/http/Etag"; | ||
| 13 | import * as HttpPlatform from "effect/http/HttpPlatform"; | ||
| 14 | import * as HttpRouter from "effect/http/HttpRouter"; | ||
| 15 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; | ||
| 16 | import * as HttpApi from "effect/http-api/HttpApi"; | ||
| 17 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | |||
| 20 | import * as ServerConfig from "../config.ts"; | ||
| 21 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 22 | import * as Sqlite from "../persistence/Sqlite.ts"; | ||
| 23 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | ||
| 24 | import * as McpOAuth from "./McpOAuth.ts"; | ||
| 25 | import * as McpOAuthHttp from "./mcpOAuthHttp.ts"; | ||
| 26 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | ||
| 27 | import * as AuthHttp from "./http.ts"; | ||
| 28 | |||
| 29 | class AuthTestApi extends HttpApi.make("environment") | ||
| 30 | .add(EnvironmentHttpApi.groups.auth) | ||
| 31 | .add(EnvironmentHttpApi.groups.mcpOAuth) {} | ||
| 32 | |||
| 33 | const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-oauth-test-" }); | ||
| 34 | const layerEnvironmentAuth = EnvironmentAuth.layer.pipe( | ||
| 35 | Layer.provide(Sqlite.layerMemory), | ||
| 36 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 37 | Layer.provideMerge(ServerEnvironment.layerIdentity), | ||
| 38 | Layer.provide(layerConfig), | ||
| 39 | ); | ||
| 40 | // Each router gets its own database; the capture hands that router's EnvironmentAuth to its test. | ||
| 41 | const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Service"]) => void) => | ||
| 42 | Layer.mergeAll( | ||
| 43 | Layer.effectDiscard( | ||
| 44 | EnvironmentAuth.EnvironmentAuth.pipe(Effect.tap((auth) => Effect.sync(() => capture(auth)))), | ||
| 45 | ), | ||
| 46 | HttpApiBuilder.layer(AuthTestApi).pipe( | ||
| 47 | Layer.provide(AuthHttp.layer), | ||
| 48 | Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), | ||
| 49 | Layer.provide(AuthHttp.layerAuthenticatedAuth), | ||
| 50 | ), | ||
| 51 | ).pipe( | ||
| 52 | Layer.provideMerge(layerEnvironmentAuth), | ||
| 53 | Layer.provide(layerConfig), | ||
| 54 | Layer.provideMerge( | ||
| 55 | HttpPlatform.layer.pipe( | ||
| 56 | Layer.provideMerge(NodeServices.layer), | ||
| 57 | Layer.provideMerge(Etag.layerWeak), | ||
| 58 | ), | ||
| 59 | ), | ||
| 60 | Layer.provide(NodeServices.layer), | ||
| 61 | ); | ||
| 62 | |||
| 63 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 64 | const ORIGIN = "https://box.example.ts.net"; | ||
| 65 | const REDIRECT = "http://localhost/callback"; | ||
| 66 | const verifier = "a".repeat(43) + "-verifier-for-tests"; | ||
| 67 | /** base64url(SHA-256(verifier)), the S256 challenge for `verifier`. */ | ||
| 68 | const challenge = "DeB41nTVkPwpbbYecrnqtVq7VXLezustdHAK4SWt13c"; | ||
| 69 | |||
| 70 | /** Requests as they arrive behind an https proxy: plain http with the public Host. */ | ||
| 71 | const at = (path: string, init?: RequestInit) => | ||
| 72 | new Request(`http://127.0.0.1${path}`, { | ||
| 73 | ...init, | ||
| 74 | headers: { host: "box.example.ts.net", "x-forwarded-proto": "https", ...init?.headers }, | ||
| 75 | }); | ||
| 76 | const form = (body: Record<string, string>) => ({ | ||
| 77 | method: "POST", | ||
| 78 | headers: { "content-type": "application/x-www-form-urlencoded" }, | ||
| 79 | body: new URLSearchParams(body).toString(), | ||
| 80 | }); | ||
| 81 | |||
| 82 | type Handler = (request: Request) => Effect.Effect<Response>; | ||
| 83 | |||
| 84 | const withRoutes = <A, E>( | ||
| 85 | use: (handler: Handler, auth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>, | ||
| 86 | ) => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const crypto = yield* Crypto.Crypto; | ||
| 89 | const context = Context.make(Crypto.Crypto, crypto); | ||
| 90 | let routerAuth: EnvironmentAuth.EnvironmentAuth["Service"] | undefined; | ||
| 91 | const layerRoutes = makeLayerRoutes((auth) => { | ||
| 92 | routerAuth = auth; | ||
| 93 | }); | ||
| 94 | return yield* Effect.acquireUseRelease( | ||
| 95 | Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })), | ||
| 96 | (web) => | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const handler: Handler = (request) => Effect.promise(() => web.handler(request, context)); | ||
| 99 | // The router builds its layer on first request; a metadata read warms it. | ||
| 100 | yield* handler(at("/.well-known/oauth-authorization-server")); | ||
| 101 | return yield* use(handler, routerAuth!); | ||
| 102 | }), | ||
| 103 | (web) => Effect.promise(() => web.dispose()), | ||
| 104 | ); | ||
| 105 | }).pipe(Effect.provide(NodeServices.layer)); | ||
| 106 | |||
| 107 | const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>); | ||
| 108 | |||
| 109 | const postJson = (path: string, body: unknown, cookie?: string) => | ||
| 110 | at(path, { | ||
| 111 | method: "POST", | ||
| 112 | headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) }, | ||
| 113 | body: encodeJson(body), | ||
| 114 | }); | ||
| 115 | |||
| 116 | /** What the approval page posts: the agent's request plus the user's choice. */ | ||
| 117 | const decide = ( | ||
| 118 | handler: Handler, | ||
| 119 | authorization: Record<string, string>, | ||
| 120 | decision: Record<string, string>, | ||
| 121 | cookie?: string, | ||
| 122 | ) => | ||
| 123 | handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe( | ||
| 124 | Effect.flatMap((response) => | ||
| 125 | json<{ redirectTo?: string; message?: string }>(response).pipe( | ||
| 126 | Effect.map((payload) => ({ status: response.status, ...payload })), | ||
| 127 | ), | ||
| 128 | ), | ||
| 129 | ); | ||
| 130 | |||
| 131 | const register = (handler: Handler, redirect = REDIRECT) => | ||
| 132 | handler( | ||
| 133 | at("/oauth/mcp/register", { | ||
| 134 | method: "POST", | ||
| 135 | headers: { "content-type": "application/json" }, | ||
| 136 | body: encodeJson({ | ||
| 137 | client_name: "Claude Code", | ||
| 138 | redirect_uris: [redirect], | ||
| 139 | grant_types: ["authorization_code", "refresh_token"], | ||
| 140 | token_endpoint_auth_method: "none", | ||
| 141 | }), | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | const registeredClientId = (handler: Handler) => | ||
| 146 | register(handler).pipe( | ||
| 147 | Effect.flatMap((response) => json<{ client_id: string }>(response)), | ||
| 148 | Effect.map((body) => body.client_id), | ||
| 149 | ); | ||
| 150 | |||
| 151 | const authorizeParams = (clientId: string, redirect = "http://localhost:51234/callback") => ({ | ||
| 152 | response_type: "code", | ||
| 153 | client_id: clientId, | ||
| 154 | redirect_uri: redirect, | ||
| 155 | code_challenge: challenge, | ||
| 156 | code_challenge_method: "S256", | ||
| 157 | state: "state-1", | ||
| 158 | resource: `${ORIGIN}/mcp`, | ||
| 159 | }); | ||
| 160 | |||
| 161 | it.live("derives discovery metadata from the origin the client reached", () => | ||
| 162 | withRoutes((handler) => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const resource = yield* handler(at("/.well-known/oauth-protected-resource/mcp")); | ||
| 165 | expect(yield* json<unknown>(resource)).toMatchObject({ | ||
| 166 | resource: `${ORIGIN}/mcp`, | ||
| 167 | authorization_servers: [ORIGIN], | ||
| 168 | }); | ||
| 169 | const server = yield* json<unknown>( | ||
| 170 | yield* handler(at("/.well-known/oauth-authorization-server")), | ||
| 171 | ); | ||
| 172 | expect(server).toMatchObject({ | ||
| 173 | issuer: ORIGIN, | ||
| 174 | authorization_endpoint: `${ORIGIN}/oauth/mcp/authorize`, | ||
| 175 | token_endpoint: `${ORIGIN}/oauth/mcp/token`, | ||
| 176 | registration_endpoint: `${ORIGIN}/oauth/mcp/register`, | ||
| 177 | code_challenge_methods_supported: ["S256"], | ||
| 178 | }); | ||
| 179 | }), | ||
| 180 | ), | ||
| 181 | ); | ||
| 182 | |||
| 183 | it.live("registers only loopback clients and never redirects for an unverified client", () => | ||
| 184 | withRoutes((handler) => | ||
| 185 | Effect.gen(function* () { | ||
| 186 | const remote = yield* register(handler, "https://attacker.example/callback"); | ||
| 187 | expect(remote.status).toBe(400); | ||
| 188 | expect(yield* json<unknown>(remote)).toMatchObject({ error: "invalid_redirect_uri" }); | ||
| 189 | |||
| 190 | const registered = yield* register(handler); | ||
| 191 | expect(registered.status).toBe(201); | ||
| 192 | const { client_id: clientId } = (yield* json<unknown>(registered)) as { client_id: string }; | ||
| 193 | |||
| 194 | const forged = yield* handler( | ||
| 195 | at( | ||
| 196 | `/oauth/mcp/authorize?${new URLSearchParams(authorizeParams(`${clientId.split(".")[0]}.forged`))}`, | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | expect(forged.status).toBe(400); | ||
| 200 | expect(forged.headers.get("location")).toBeNull(); | ||
| 201 | |||
| 202 | const unregistered = yield* handler( | ||
| 203 | at( | ||
| 204 | `/oauth/mcp/authorize?${new URLSearchParams( | ||
| 205 | authorizeParams(clientId, "http://localhost:51234/elsewhere"), | ||
| 206 | )}`, | ||
| 207 | ), | ||
| 208 | ); | ||
| 209 | expect(unregistered.status).toBe(400); | ||
| 210 | expect(unregistered.headers.get("location")).toBeNull(); | ||
| 211 | |||
| 212 | // A valid request is handed to the web app's approval page, query intact. | ||
| 213 | const query = new URLSearchParams(authorizeParams(clientId)).toString(); | ||
| 214 | const handoff = yield* handler(at(`/oauth/mcp/authorize?${query}`)); | ||
| 215 | expect(handoff.status).toBe(302); | ||
| 216 | expect(handoff.headers.get("location")).toBe(`/connect-agent?${query}`); | ||
| 217 | const details = yield* handler( | ||
| 218 | postJson("/oauth/mcp/approval", authorizeParams(clientId)), | ||
| 219 | ).pipe(Effect.flatMap(json<Record<string, unknown>>)); | ||
| 220 | expect(details).toEqual({ | ||
| 221 | clientName: "Claude Code", | ||
| 222 | redirectHost: "localhost:51234", | ||
| 223 | environmentHost: "box.example.ts.net", | ||
| 224 | }); | ||
| 225 | |||
| 226 | // The approval endpoints re-check the request: a forged client gets a message, not a URL. | ||
| 227 | const forgedDetails = yield* handler( | ||
| 228 | postJson("/oauth/mcp/approval", authorizeParams(`${clientId.split(".")[0]}.forged`)), | ||
| 229 | ); | ||
| 230 | expect(forgedDetails.status).toBe(400); | ||
| 231 | expect(yield* json<Record<string, unknown>>(forgedDetails)).not.toHaveProperty("redirectTo"); | ||
| 232 | }), | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | |||
| 236 | it.live("signs in with a pairing code and issues a token only /mcp accepts", () => | ||
| 237 | withRoutes((handler, auth) => | ||
| 238 | Effect.gen(function* () { | ||
| 239 | const clientId = yield* registeredClientId(handler); | ||
| 240 | const pairing = yield* auth.issuePairingCredential(); | ||
| 241 | const params = authorizeParams(clientId); | ||
| 242 | |||
| 243 | const wrongCode = yield* decide(handler, params, { | ||
| 244 | _tag: "pairing-code", | ||
| 245 | access: "auto", | ||
| 246 | code: "nope", | ||
| 247 | }); | ||
| 248 | expect(wrongCode.status).toBe(400); | ||
| 249 | expect(wrongCode.message).toContain("unknown, expired, or already used"); | ||
| 250 | |||
| 251 | const approved = yield* decide(handler, params, { | ||
| 252 | _tag: "pairing-code", | ||
| 253 | access: "auto", | ||
| 254 | code: pairing.credential, | ||
| 255 | }); | ||
| 256 | expect(approved.status).toBe(200); | ||
| 257 | const callback = new URL(approved.redirectTo!); | ||
| 258 | expect(callback.origin).toBe("http://localhost:51234"); | ||
| 259 | expect(callback.searchParams.get("state")).toBe("state-1"); | ||
| 260 | expect(callback.searchParams.get("iss")).toBe(ORIGIN); | ||
| 261 | const code = callback.searchParams.get("code")!; | ||
| 262 | |||
| 263 | const exchange = (codeVerifier: string) => | ||
| 264 | handler( | ||
| 265 | at( | ||
| 266 | "/oauth/mcp/token", | ||
| 267 | form({ | ||
| 268 | grant_type: "authorization_code", | ||
| 269 | code, | ||
| 270 | redirect_uri: params.redirect_uri, | ||
| 271 | client_id: clientId, | ||
| 272 | code_verifier: codeVerifier, | ||
| 273 | resource: `${ORIGIN}/mcp`, | ||
| 274 | }), | ||
| 275 | ), | ||
| 276 | ); | ||
| 277 | const tokenResponse = yield* exchange(verifier); | ||
| 278 | expect(tokenResponse.status).toBe(200); | ||
| 279 | const token = (yield* json<unknown>(tokenResponse)) as { | ||
| 280 | access_token: string; | ||
| 281 | scope: string; | ||
| 282 | }; | ||
| 283 | expect(token.scope).toBe("orchestration:read orchestration:operate"); | ||
| 284 | |||
| 285 | // Codes are single use. | ||
| 286 | expect((yield* exchange(verifier)).status).toBe(400); | ||
| 287 | |||
| 288 | const client = yield* auth.authenticateMcpClient( | ||
| 289 | HttpServerRequest.fromWeb( | ||
| 290 | at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | expect(client).toMatchObject({ label: "Claude Code", access: "auto" }); | ||
| 294 | |||
| 295 | // The same token is refused by the rest of the environment. | ||
| 296 | const session = yield* handler( | ||
| 297 | at("/api/auth/session", { headers: { authorization: `Bearer ${token.access_token}` } }), | ||
| 298 | ); | ||
| 299 | expect(yield* json<unknown>(session)).toMatchObject({ authenticated: false }); | ||
| 300 | const ticket = yield* handler( | ||
| 301 | at("/api/auth/websocket-ticket", { | ||
| 302 | method: "POST", | ||
| 303 | headers: { authorization: `Bearer ${token.access_token}` }, | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | expect(ticket.status).toBe(401); | ||
| 307 | }), | ||
| 308 | ), | ||
| 309 | ); | ||
| 310 | |||
| 311 | it.live("rejects a wrong PKCE verifier and spends the code", () => | ||
| 312 | withRoutes((handler, auth) => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | const clientId = yield* registeredClientId(handler); | ||
| 315 | const pairing = yield* auth.issuePairingCredential(); | ||
| 316 | const params = authorizeParams(clientId); | ||
| 317 | const approved = yield* decide(handler, params, { | ||
| 318 | _tag: "pairing-code", | ||
| 319 | access: "approval-required", | ||
| 320 | code: pairing.credential, | ||
| 321 | }); | ||
| 322 | const code = new URL(approved.redirectTo!).searchParams.get("code")!; | ||
| 323 | const exchange = (codeVerifier: string) => | ||
| 324 | handler( | ||
| 325 | at( | ||
| 326 | "/oauth/mcp/token", | ||
| 327 | form({ | ||
| 328 | grant_type: "authorization_code", | ||
| 329 | code, | ||
| 330 | redirect_uri: params.redirect_uri, | ||
| 331 | client_id: clientId, | ||
| 332 | code_verifier: codeVerifier, | ||
| 333 | }), | ||
| 334 | ), | ||
| 335 | ); | ||
| 336 | const wrong = yield* exchange("b".repeat(64)); | ||
| 337 | expect(wrong.status).toBe(400); | ||
| 338 | expect(yield* json<unknown>(wrong)).toMatchObject({ error: "invalid_grant" }); | ||
| 339 | expect((yield* exchange(verifier)).status).toBe(400); | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | ); | ||
| 343 | |||
| 344 | it.live( | ||
| 345 | "denies and refuses codes bound to another client's key or without the scopes it grants", | ||
| 346 | () => | ||
| 347 | withRoutes((handler, auth) => | ||
| 348 | Effect.gen(function* () { | ||
| 349 | const clientId = yield* registeredClientId(handler); | ||
| 350 | const params = authorizeParams(clientId); | ||
| 351 | |||
| 352 | const denied = yield* decide(handler, params, { _tag: "deny" }); | ||
| 353 | const deniedUrl = new URL(denied.redirectTo!); | ||
| 354 | expect(deniedUrl.searchParams.get("error")).toBe("access_denied"); | ||
| 355 | expect(deniedUrl.searchParams.get("state")).toBe("state-1"); | ||
| 356 | |||
| 357 | const approveWith = (code: string) => | ||
| 358 | decide(handler, params, { _tag: "pairing-code", access: "auto", code }); | ||
| 359 | |||
| 360 | // A T3 Connect code is bound to a device key: refused, and still usable by its device. | ||
| 361 | const bound = yield* auth.createPairingLink({ | ||
| 362 | proofKeyThumbprint: "device-key-thumbprint", | ||
| 363 | }); | ||
| 364 | expect((yield* approveWith(bound.credential)).status).toBe(400); | ||
| 365 | const stillValid = yield* auth | ||
| 366 | .exchangeBootstrapCredentialForAccessToken( | ||
| 367 | bound.credential, | ||
| 368 | undefined, | ||
| 369 | { deviceType: "mobile" }, | ||
| 370 | { proofKeyThumbprint: "device-key-thumbprint" }, | ||
| 371 | ) | ||
| 372 | .pipe( | ||
| 373 | Effect.as(true), | ||
| 374 | Effect.orElseSucceed(() => false), | ||
| 375 | ); | ||
| 376 | expect(stillValid).toBe(true); | ||
| 377 | |||
| 378 | const readOnly = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); | ||
| 379 | const readOnlyResponse = yield* approveWith(readOnly.credential); | ||
| 380 | expect(readOnlyResponse.status).toBe(400); | ||
| 381 | expect(readOnlyResponse.message).toContain("cannot grant this access"); | ||
| 382 | |||
| 383 | // A read-only code can approve read-only access. | ||
| 384 | const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); | ||
| 385 | const readOnlyApproval = yield* decide(handler, params, { | ||
| 386 | _tag: "pairing-code", | ||
| 387 | access: "read-only", | ||
| 388 | code: readOnlyCode.credential, | ||
| 389 | }); | ||
| 390 | expect(readOnlyApproval.status).toBe(200); | ||
| 391 | const readOnlyToken = yield* handler( | ||
| 392 | at( | ||
| 393 | "/oauth/mcp/token", | ||
| 394 | form({ | ||
| 395 | grant_type: "authorization_code", | ||
| 396 | code: new URL(readOnlyApproval.redirectTo!).searchParams.get("code")!, | ||
| 397 | redirect_uri: params.redirect_uri, | ||
| 398 | client_id: clientId, | ||
| 399 | code_verifier: verifier, | ||
| 400 | resource: `${ORIGIN}/mcp`, | ||
| 401 | }), | ||
| 402 | ), | ||
| 403 | ).pipe(Effect.flatMap(json<{ access_token: string; scope: string }>)); | ||
| 404 | expect(readOnlyToken.scope).toBe("orchestration:read"); | ||
| 405 | const readOnlyClient = yield* auth.authenticateMcpClient( | ||
| 406 | HttpServerRequest.fromWeb( | ||
| 407 | at("/mcp", { headers: { authorization: `Bearer ${readOnlyToken.access_token}` } }), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | expect(readOnlyClient.access).toBe("read-only"); | ||
| 411 | }), | ||
| 412 | ), | ||
| 413 | ); | ||
| 414 | |||
| 415 | it.live("one-click approves only access the browser session holds the scopes for", () => | ||
| 416 | withRoutes((handler, auth) => | ||
| 417 | Effect.gen(function* () { | ||
| 418 | const clientId = yield* registeredClientId(handler); | ||
| 419 | const params = authorizeParams(clientId); | ||
| 420 | // Signs a browser in through the real route and returns its session cookie. | ||
| 421 | const browserCookie = (scopes: ReadonlyArray<AuthEnvironmentScope>) => | ||
| 422 | Effect.gen(function* () { | ||
| 423 | const pairing = yield* auth.issuePairingCredential({ scopes }); | ||
| 424 | const response = yield* handler( | ||
| 425 | at("/api/auth/browser-session", { | ||
| 426 | method: "POST", | ||
| 427 | headers: { "content-type": "application/json" }, | ||
| 428 | body: encodeJson({ credential: pairing.credential }), | ||
| 429 | }), | ||
| 430 | ); | ||
| 431 | return response.headers.getSetCookie()[0]!.split(";", 1)[0]!; | ||
| 432 | }); | ||
| 433 | const details = (cookie: string) => | ||
| 434 | handler(postJson("/oauth/mcp/approval", params, cookie)).pipe( | ||
| 435 | Effect.flatMap(json<{ csrfToken?: string; oneClickAccess?: ReadonlyArray<string> }>), | ||
| 436 | ); | ||
| 437 | |||
| 438 | const admin = yield* browserCookie([...AuthAdministrativeScopes]); | ||
| 439 | const adminDetails = yield* details(admin); | ||
| 440 | expect(adminDetails.csrfToken).toEqual(expect.any(String)); | ||
| 441 | expect(adminDetails.oneClickAccess).toEqual([ | ||
| 442 | "read-only", | ||
| 443 | "approval-required", | ||
| 444 | "auto-accept-edits", | ||
| 445 | "auto", | ||
| 446 | "full-access", | ||
| 447 | ]); | ||
| 448 | const oneClick = yield* decide( | ||
| 449 | handler, | ||
| 450 | params, | ||
| 451 | { _tag: "browser-session", access: "auto", csrfToken: adminDetails.csrfToken! }, | ||
| 452 | admin, | ||
| 453 | ); | ||
| 454 | expect(new URL(oneClick.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); | ||
| 455 | |||
| 456 | // access:write alone cannot hand an agent thread control it does not hold. | ||
| 457 | const accessOnly = yield* browserCookie(["access:read", "access:write"]); | ||
| 458 | expect((yield* details(accessOnly)).csrfToken).toBeUndefined(); | ||
| 459 | const forged = yield* decide( | ||
| 460 | handler, | ||
| 461 | params, | ||
| 462 | { _tag: "browser-session", access: "auto", csrfToken: "forged" }, | ||
| 463 | accessOnly, | ||
| 464 | ); | ||
| 465 | expect(forged.status).toBe(400); | ||
| 466 | expect(forged.message).toContain("Enter a pairing code instead"); | ||
| 467 | |||
| 468 | // A session that can read but not operate threads may approve read-only access only. | ||
| 469 | const reader = yield* browserCookie(["access:write", "orchestration:read"]); | ||
| 470 | const readerDetails = yield* details(reader); | ||
| 471 | expect(readerDetails.csrfToken).toEqual(expect.any(String)); | ||
| 472 | // The page shows the pairing-code field for anything above read only. | ||
| 473 | expect(readerDetails.oneClickAccess).toEqual(["read-only"]); | ||
| 474 | const tooBroad = yield* decide( | ||
| 475 | handler, | ||
| 476 | params, | ||
| 477 | { _tag: "browser-session", access: "auto", csrfToken: readerDetails.csrfToken! }, | ||
| 478 | reader, | ||
| 479 | ); | ||
| 480 | expect(tooBroad.status).toBe(400); | ||
| 481 | const readOnly = yield* decide( | ||
| 482 | handler, | ||
| 483 | params, | ||
| 484 | { _tag: "browser-session", access: "read-only", csrfToken: readerDetails.csrfToken! }, | ||
| 485 | reader, | ||
| 486 | ); | ||
| 487 | expect(new URL(readOnly.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); | ||
| 488 | }), | ||
| 489 | ), | ||
| 490 | ); | ||
| 491 | |||
| 492 | it("matches loopback redirects on everything but the port", () => { | ||
| 493 | expect( | ||
| 494 | McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://localhost:9/callback"), | ||
| 495 | ).toBe(true); | ||
| 496 | expect( | ||
| 497 | McpOAuth.loopbackRedirectMatches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback"), | ||
| 498 | ).toBe(true); | ||
| 499 | expect( | ||
| 500 | McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://127.0.0.1/callback"), | ||
| 501 | ).toBe(false); | ||
| 502 | expect( | ||
| 503 | McpOAuth.loopbackRedirectMatches("http://localhost/callback", "https://localhost/callback"), | ||
| 504 | ).toBe(false); | ||
| 505 | }); | ||
apps/server/src/auth/McpOAuth.ts created+603| ... | @@ -0,0 +1,603 @@ | ||
| 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. Only loopback redirect | ||
| 49 | * URIs are accepted; an https redirect would let anyone mail the owner an | ||
| 50 | * approval link that delivers the code to their own server. | ||
| 51 | */ | ||
| 52 | |||
| 53 | const SIGNING_SECRET_NAME = "mcp-oauth-signing-key"; | ||
| 54 | const AUTHORIZATION_CODE_TTL_MS = 60_000; | ||
| 55 | const MAX_CLIENT_NAME_LENGTH = 100; | ||
| 56 | const MAX_REDIRECT_URIS = 5; | ||
| 57 | const MAX_REDIRECT_URI_LENGTH = 512; | ||
| 58 | const DEFAULT_CLIENT_NAME = "MCP client"; | ||
| 59 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); | ||
| 60 | const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; | ||
| 61 | const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; | ||
| 62 | |||
| 63 | const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; | ||
| 64 | |||
| 65 | export interface McpOAuthUrls { | ||
| 66 | readonly issuer: string; | ||
| 67 | readonly resource: string; | ||
| 68 | } | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Issuer and MCP resource for the origin this request reached: its Host, and | ||
| 72 | * https when a proxy says so. A Host that is not a valid authority falls back | ||
| 73 | * to localhost, which no client will have asked for. | ||
| 74 | */ | ||
| 75 | export const requestUrls = (request: HttpServerRequest.HttpServerRequest): McpOAuthUrls => { | ||
| 76 | const origin = Option.match(HttpServerRequest.toURL(request), { | ||
| 77 | onNone: () => "http://localhost", | ||
| 78 | onSome: (url) => url.origin, | ||
| 79 | }); | ||
| 80 | return { issuer: origin, resource: `${origin}/mcp` }; | ||
| 81 | }; | ||
| 82 | |||
| 83 | export const protectedResourceMetadata = ( | ||
| 84 | urls: McpOAuthUrls, | ||
| 85 | ): AuthMcpProtectedResourceMetadata => ({ | ||
| 86 | resource: urls.resource, | ||
| 87 | authorization_servers: [urls.issuer], | ||
| 88 | scopes_supported: MCP_OAUTH_SCOPES, | ||
| 89 | bearer_methods_supported: ["header"], | ||
| 90 | resource_name: "T3 Code", | ||
| 91 | }); | ||
| 92 | |||
| 93 | export const authorizationServerMetadata = ( | ||
| 94 | urls: McpOAuthUrls, | ||
| 95 | ): AuthMcpAuthorizationServerMetadata => ({ | ||
| 96 | issuer: urls.issuer, | ||
| 97 | authorization_endpoint: `${urls.issuer}/oauth/mcp/authorize`, | ||
| 98 | token_endpoint: `${urls.issuer}/oauth/mcp/token`, | ||
| 99 | registration_endpoint: `${urls.issuer}/oauth/mcp/register`, | ||
| 100 | response_types_supported: ["code"], | ||
| 101 | grant_types_supported: ["authorization_code"], | ||
| 102 | code_challenge_methods_supported: ["S256"], | ||
| 103 | token_endpoint_auth_methods_supported: ["none"], | ||
| 104 | scopes_supported: MCP_OAUTH_SCOPES, | ||
| 105 | authorization_response_iss_parameter_supported: true, | ||
| 106 | }); | ||
| 107 | |||
| 108 | const parseLoopbackRedirect = (value: string): URL | undefined => { | ||
| 109 | if (value.length > MAX_REDIRECT_URI_LENGTH) return undefined; | ||
| 110 | try { | ||
| 111 | const url = new URL(value); | ||
| 112 | return url.protocol === "http:" && | ||
| 113 | LOOPBACK_HOSTNAMES.has(url.hostname) && | ||
| 114 | url.username === "" && | ||
| 115 | url.password === "" && | ||
| 116 | url.hash === "" | ||
| 117 | ? url | ||
| 118 | : undefined; | ||
| 119 | } catch { | ||
| 120 | return undefined; | ||
| 121 | } | ||
| 122 | }; | ||
| 123 | |||
| 124 | /** RFC 8252 §7.3: loopback redirects match on everything but the port. */ | ||
| 125 | export const loopbackRedirectMatches = (registered: string, presented: string): boolean => { | ||
| 126 | const left = parseLoopbackRedirect(registered); | ||
| 127 | const right = parseLoopbackRedirect(presented); | ||
| 128 | return ( | ||
| 129 | left !== undefined && | ||
| 130 | right !== undefined && | ||
| 131 | left.hostname === right.hostname && | ||
| 132 | left.pathname === right.pathname && | ||
| 133 | left.search === right.search | ||
| 134 | ); | ||
| 135 | }; | ||
| 136 | |||
| 137 | const sameResource = (urls: McpOAuthUrls, presented: string | undefined): boolean => { | ||
| 138 | if (presented === undefined) return true; | ||
| 139 | try { | ||
| 140 | const url = new URL(presented); | ||
| 141 | return url.hash === "" && `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource; | ||
| 142 | } catch { | ||
| 143 | return false; | ||
| 144 | } | ||
| 145 | }; | ||
| 146 | |||
| 147 | const ClientIdPayload = Schema.Struct({ | ||
| 148 | v: Schema.Literal(1), | ||
| 149 | n: Schema.String, | ||
| 150 | r: Schema.Array(Schema.String), | ||
| 151 | }); | ||
| 152 | const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload)); | ||
| 153 | |||
| 154 | export interface McpOAuthClient { | ||
| 155 | readonly clientId: string; | ||
| 156 | readonly name: string; | ||
| 157 | readonly redirectUris: ReadonlyArray<string>; | ||
| 158 | } | ||
| 159 | |||
| 160 | /** Problems the authorize page shows the user without redirecting anywhere. */ | ||
| 161 | export class McpOAuthPageError extends Schema.TaggedError<McpOAuthPageError>()( | ||
| 162 | "McpOAuthPageError", | ||
| 163 | { description: Schema.String }, | ||
| 164 | ) { | ||
| 165 | override get message(): string { | ||
| 166 | return this.description; | ||
| 167 | } | ||
| 168 | } | ||
| 169 | |||
| 170 | /** Problems reported back to the client through its (validated) redirect URI. */ | ||
| 171 | export class McpOAuthRedirectError extends Schema.TaggedError<McpOAuthRedirectError>()( | ||
| 172 | "McpOAuthRedirectError", | ||
| 173 | { | ||
| 174 | error: Schema.Literals(["invalid_request", "unsupported_response_type", "invalid_target"]), | ||
| 175 | description: Schema.String, | ||
| 176 | redirectUri: Schema.String, | ||
| 177 | state: Schema.optional(Schema.String), | ||
| 178 | }, | ||
| 179 | ) {} | ||
| 180 | |||
| 181 | export interface AuthorizationRequest { | ||
| 182 | readonly client: McpOAuthClient; | ||
| 183 | readonly redirectUri: string; | ||
| 184 | readonly codeChallenge: string; | ||
| 185 | readonly state: string | undefined; | ||
| 186 | readonly resource: string; | ||
| 187 | readonly issuer: string; | ||
| 188 | } | ||
| 189 | |||
| 190 | interface PendingCode { | ||
| 191 | readonly clientId: string; | ||
| 192 | readonly clientName: string; | ||
| 193 | readonly redirectUri: string; | ||
| 194 | readonly codeChallenge: string; | ||
| 195 | readonly resource: string; | ||
| 196 | readonly access: AuthMcpClientAccess; | ||
| 197 | readonly expiresAtMs: number; | ||
| 198 | } | ||
| 199 | |||
| 200 | export type ApprovalDecision = Exclude<AuthMcpApprovalDecision, { readonly _tag: "deny" }>; | ||
| 201 | |||
| 202 | export class McpOAuth extends Context.Service< | ||
| 203 | McpOAuth, | ||
| 204 | { | ||
| 205 | readonly register: ( | ||
| 206 | input: AuthMcpClientRegistration, | ||
| 207 | ) => Effect.Effect<McpOAuthClient, AuthMcpRegistrationError>; | ||
| 208 | /** Validates an authorize request. Page errors must never redirect. */ | ||
| 209 | readonly validateAuthorization: (input: { | ||
| 210 | readonly urls: McpOAuthUrls; | ||
| 211 | readonly request: AuthMcpAuthorizationRequest; | ||
| 212 | }) => Effect.Effect<AuthorizationRequest, McpOAuthPageError | McpOAuthRedirectError>; | ||
| 213 | /** | ||
| 214 | * The signed-in owner on this origin, when their browser session may | ||
| 215 | * approve at least read-only access. `approve` checks the chosen access | ||
| 216 | * against the session's scopes. | ||
| 217 | */ | ||
| 218 | readonly approvingBrowserSession: ( | ||
| 219 | request: HttpServerRequest.HttpServerRequest, | ||
| 220 | authorization: AuthorizationRequest, | ||
| 221 | ) => Effect.Effect< | ||
| 222 | | { readonly csrfToken: string; readonly scopes: ReadonlyArray<AuthEnvironmentScope> } | ||
| 223 | | undefined | ||
| 224 | >; | ||
| 225 | /** Approves and returns the URL to send the browser to. */ | ||
| 226 | readonly approve: (input: { | ||
| 227 | readonly request: HttpServerRequest.HttpServerRequest; | ||
| 228 | readonly authorization: AuthorizationRequest; | ||
| 229 | readonly decision: ApprovalDecision; | ||
| 230 | }) => Effect.Effect<string, EnvironmentAuth.ServerAuthMcpApprovalCodeError | McpOAuthPageError>; | ||
| 231 | readonly deny: (authorization: AuthorizationRequest) => string; | ||
| 232 | readonly exchangeCode: (input: { | ||
| 233 | readonly request: HttpServerRequest.HttpServerRequest; | ||
| 234 | readonly urls: McpOAuthUrls; | ||
| 235 | readonly token: AuthMcpTokenRequest; | ||
| 236 | }) => Effect.Effect<AuthMcpTokenResult, AuthMcpTokenError>; | ||
| 237 | } | ||
| 238 | >()("t3/auth/McpOAuth") {} | ||
| 239 | |||
| 240 | /** Appends OAuth response parameters, keeping any the client put in its redirect URI. */ | ||
| 241 | const redirectWith = (redirectUri: string, params: Record<string, string | undefined>) => { | ||
| 242 | const url = new URL(redirectUri); | ||
| 243 | for (const [name, value] of Object.entries(params)) { | ||
| 244 | if (value !== undefined) url.searchParams.set(name, value); | ||
| 245 | } | ||
| 246 | return url.toString(); | ||
| 247 | }; | ||
| 248 | |||
| 249 | export const redirectForError = (error: McpOAuthRedirectError, issuer: string) => | ||
| 250 | redirectWith(error.redirectUri, { | ||
| 251 | error: error.error, | ||
| 252 | error_description: error.description, | ||
| 253 | state: error.state, | ||
| 254 | iss: issuer, | ||
| 255 | }); | ||
| 256 | |||
| 257 | const make = Effect.gen(function* () { | ||
| 258 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 259 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 260 | const crypto = yield* Crypto.Crypto; | ||
| 261 | const signingKey = yield* secretStore | ||
| 262 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) | ||
| 263 | .pipe(Effect.orDie); | ||
| 264 | const codes = yield* Ref.make<ReadonlyMap<string, PendingCode>>(new Map()); | ||
| 265 | |||
| 266 | const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey); | ||
| 267 | |||
| 268 | const signClientId = (name: string, redirectUris: ReadonlyArray<string>) => { | ||
| 269 | const body = base64UrlEncode(JSON.stringify({ v: 1, n: name, r: redirectUris })); | ||
| 270 | return `${body}.${sign("mcp-client-id", body)}`; | ||
| 271 | }; | ||
| 272 | |||
| 273 | const parseClientId = (clientId: string): McpOAuthClient | undefined => { | ||
| 274 | const [body, signature, extra] = clientId.split("."); | ||
| 275 | if (!body || !signature || extra !== undefined) return undefined; | ||
| 276 | if (!timingSafeEqualBase64Url(signature, sign("mcp-client-id", body))) return undefined; | ||
| 277 | let json: string; | ||
| 278 | try { | ||
| 279 | json = base64UrlDecodeUtf8(body); | ||
| 280 | } catch { | ||
| 281 | return undefined; | ||
| 282 | } | ||
| 283 | return Option.getOrUndefined( | ||
| 284 | decodeClientIdPayload(json).pipe( | ||
| 285 | Option.map((payload) => ({ clientId, name: payload.n, redirectUris: payload.r })), | ||
| 286 | ), | ||
| 287 | ); | ||
| 288 | }; | ||
| 289 | |||
| 290 | const pkceVerifies = (verifier: string, challenge: string) => | ||
| 291 | CODE_VERIFIER_PATTERN.test(verifier) | ||
| 292 | ? crypto.digest("SHA-256", new TextEncoder().encode(verifier)).pipe( | ||
| 293 | Effect.orDie, | ||
| 294 | Effect.map((digest) => timingSafeEqualBase64Url(base64UrlEncode(digest), challenge)), | ||
| 295 | ) | ||
| 296 | : Effect.succeed(false); | ||
| 297 | |||
| 298 | const csrfToken = (sessionId: string, authorization: AuthorizationRequest) => | ||
| 299 | sign( | ||
| 300 | "mcp-csrf", | ||
| 301 | JSON.stringify([ | ||
| 302 | sessionId, | ||
| 303 | authorization.client.clientId, | ||
| 304 | authorization.redirectUri, | ||
| 305 | authorization.codeChallenge, | ||
| 306 | ]), | ||
| 307 | ); | ||
| 308 | |||
| 309 | const register: McpOAuth["Service"]["register"] = (metadata) => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const rawName = metadata.client_name?.trim() ?? ""; | ||
| 312 | const name = (rawName.length > 0 ? rawName : DEFAULT_CLIENT_NAME).slice( | ||
| 313 | 0, | ||
| 314 | MAX_CLIENT_NAME_LENGTH, | ||
| 315 | ); | ||
| 316 | const redirectUris = metadata.redirect_uris ?? []; | ||
| 317 | if (redirectUris.length === 0 || redirectUris.length > MAX_REDIRECT_URIS) { | ||
| 318 | return yield* new AuthMcpRegistrationError({ | ||
| 319 | error: "invalid_redirect_uri", | ||
| 320 | error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`, | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | if (!redirectUris.every((uri) => parseLoopbackRedirect(uri) !== undefined)) { | ||
| 324 | return yield* new AuthMcpRegistrationError({ | ||
| 325 | error: "invalid_redirect_uri", | ||
| 326 | error_description: | ||
| 327 | "Only http://localhost, 127.0.0.1 or [::1] redirect URIs are accepted.", | ||
| 328 | }); | ||
| 329 | } | ||
| 330 | if ( | ||
| 331 | metadata.token_endpoint_auth_method !== undefined && | ||
| 332 | metadata.token_endpoint_auth_method !== "none" | ||
| 333 | ) { | ||
| 334 | return yield* new AuthMcpRegistrationError({ | ||
| 335 | error: "invalid_client_metadata", | ||
| 336 | error_description: "Only public clients (token_endpoint_auth_method none) are supported.", | ||
| 337 | }); | ||
| 338 | } | ||
| 339 | // Requested grant types and scopes are ignored rather than rejected: RFC 7591 lets | ||
| 340 | // the server answer with what it supports, and Claude Code asks for refresh_token. | ||
| 341 | return { clientId: signClientId(name, redirectUris), name, redirectUris }; | ||
| 342 | }); | ||
| 343 | |||
| 344 | const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = ({ urls, request }) => | ||
| 345 | Effect.gen(function* () { | ||
| 346 | const client = parseClientId(request.client_id ?? ""); | ||
| 347 | if (client === undefined) { | ||
| 348 | return yield* new McpOAuthPageError({ | ||
| 349 | description: "This sign-in link names an unknown app. Start the sign-in again from it.", | ||
| 350 | }); | ||
| 351 | } | ||
| 352 | const redirectUri = request.redirect_uri; | ||
| 353 | if ( | ||
| 354 | redirectUri === undefined || | ||
| 355 | parseLoopbackRedirect(redirectUri) === undefined || | ||
| 356 | !client.redirectUris.some((registered) => loopbackRedirectMatches(registered, redirectUri)) | ||
| 357 | ) { | ||
| 358 | return yield* new McpOAuthPageError({ | ||
| 359 | description: "This sign-in link sends you to an address the app did not register.", | ||
| 360 | }); | ||
| 361 | } | ||
| 362 | const state = request.state; | ||
| 363 | const fail = (error: McpOAuthRedirectError["error"], description: string) => | ||
| 364 | new McpOAuthRedirectError({ | ||
| 365 | error, | ||
| 366 | description, | ||
| 367 | redirectUri, | ||
| 368 | ...(state === undefined ? {} : { state }), | ||
| 369 | }); | ||
| 370 | if (request.response_type !== "code") { | ||
| 371 | return yield* fail("unsupported_response_type", "Only response_type=code is supported."); | ||
| 372 | } | ||
| 373 | const codeChallenge = request.code_challenge; | ||
| 374 | if ( | ||
| 375 | request.code_challenge_method !== "S256" || | ||
| 376 | codeChallenge === undefined || | ||
| 377 | !CODE_CHALLENGE_PATTERN.test(codeChallenge) | ||
| 378 | ) { | ||
| 379 | return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); | ||
| 380 | } | ||
| 381 | if (!sameResource(urls, request.resource)) { | ||
| 382 | return yield* fail( | ||
| 383 | "invalid_target", | ||
| 384 | `This server only issues tokens for ${urls.resource}.`, | ||
| 385 | ); | ||
| 386 | } | ||
| 387 | return { | ||
| 388 | client, | ||
| 389 | redirectUri, | ||
| 390 | codeChallenge, | ||
| 391 | state, | ||
| 392 | resource: urls.resource, | ||
| 393 | issuer: urls.issuer, | ||
| 394 | } satisfies AuthorizationRequest; | ||
| 395 | }); | ||
| 396 | |||
| 397 | const approvingBrowserSession: McpOAuth["Service"]["approvingBrowserSession"] = ( | ||
| 398 | request, | ||
| 399 | authorization, | ||
| 400 | ) => | ||
| 401 | environmentAuth.authenticateBrowserSession(request).pipe( | ||
| 402 | Effect.map((session) => | ||
| 403 | // Approving manages access, and a session may only hand out scopes it holds. | ||
| 404 | session.scopes.includes(AuthAccessWriteScope) && | ||
| 405 | session.scopes.includes(AuthOrchestrationReadScope) | ||
| 406 | ? { csrfToken: csrfToken(session.sessionId, authorization), scopes: session.scopes } | ||
| 407 | : undefined, | ||
| 408 | ), | ||
| 409 | Effect.orElseSucceed(() => undefined), | ||
| 410 | ); | ||
| 411 | |||
| 412 | const mintCode = (authorization: AuthorizationRequest, access: AuthMcpClientAccess) => | ||
| 413 | Effect.gen(function* () { | ||
| 414 | const now = yield* Clock.currentTimeMillis; | ||
| 415 | const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( | ||
| 416 | "base64url", | ||
| 417 | ); | ||
| 418 | yield* Ref.update(codes, (current) => { | ||
| 419 | const next = new Map( | ||
| 420 | Array.from(current).filter(([, pending]) => pending.expiresAtMs > now), | ||
| 421 | ); | ||
| 422 | next.set(code, { | ||
| 423 | clientId: authorization.client.clientId, | ||
| 424 | clientName: authorization.client.name, | ||
| 425 | redirectUri: authorization.redirectUri, | ||
| 426 | codeChallenge: authorization.codeChallenge, | ||
| 427 | resource: authorization.resource, | ||
| 428 | access, | ||
| 429 | expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, | ||
| 430 | }); | ||
| 431 | return next; | ||
| 432 | }); | ||
| 433 | return code; | ||
| 434 | }); | ||
| 435 | |||
| 436 | const approve: McpOAuth["Service"]["approve"] = (input) => | ||
| 437 | Effect.gen(function* () { | ||
| 438 | const { decision } = input; | ||
| 439 | if (decision._tag === "pairing-code") { | ||
| 440 | yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( | ||
| 441 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => | ||
| 442 | Effect.logError("MCP approval code check failed.", { cause }).pipe( | ||
| 443 | Effect.andThen( | ||
| 444 | Effect.fail( | ||
| 445 | new McpOAuthPageError({ | ||
| 446 | description: "The code could not be checked. Try again.", | ||
| 447 | }), | ||
| 448 | ), | ||
| 449 | ), | ||
| 450 | ), | ||
| 451 | ), | ||
| 452 | ); | ||
| 453 | } else { | ||
| 454 | const session = yield* approvingBrowserSession(input.request, input.authorization); | ||
| 455 | if ( | ||
| 456 | session === undefined || | ||
| 457 | !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || | ||
| 458 | !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => | ||
| 459 | session.scopes.includes(scope), | ||
| 460 | ) | ||
| 461 | ) { | ||
| 462 | return yield* new McpOAuthPageError({ | ||
| 463 | description: "Your session cannot approve this request. Enter a pairing code instead.", | ||
| 464 | }); | ||
| 465 | } | ||
| 466 | } | ||
| 467 | const code = yield* mintCode(input.authorization, decision.access); | ||
| 468 | yield* Effect.logInfo("Approved an MCP client sign-in.", { | ||
| 469 | client: input.authorization.client.name, | ||
| 470 | access: decision.access, | ||
| 471 | method: decision._tag, | ||
| 472 | }); | ||
| 473 | return redirectWith(input.authorization.redirectUri, { | ||
| 474 | code, | ||
| 475 | state: input.authorization.state, | ||
| 476 | iss: input.authorization.issuer, | ||
| 477 | }); | ||
| 478 | }); | ||
| 479 | |||
| 480 | const deny: McpOAuth["Service"]["deny"] = (authorization) => | ||
| 481 | redirectWith(authorization.redirectUri, { | ||
| 482 | error: "access_denied", | ||
| 483 | state: authorization.state, | ||
| 484 | iss: authorization.issuer, | ||
| 485 | }); | ||
| 486 | |||
| 487 | const exchangeCode: McpOAuth["Service"]["exchangeCode"] = ({ request, urls, token }) => | ||
| 488 | Effect.gen(function* () { | ||
| 489 | const fail = (error: AuthMcpTokenError["error"], description: string) => | ||
| 490 | new AuthMcpTokenError({ error, error_description: description }); | ||
| 491 | if (token.grant_type !== "authorization_code") { | ||
| 492 | return yield* fail("unsupported_grant_type", "Only authorization_code is supported."); | ||
| 493 | } | ||
| 494 | const { code, redirect_uri: redirectUri, client_id: clientId } = token; | ||
| 495 | const verifier = token.code_verifier; | ||
| 496 | if (!code || !redirectUri || !clientId || !verifier) { | ||
| 497 | return yield* fail( | ||
| 498 | "invalid_request", | ||
| 499 | "code, redirect_uri, client_id and code_verifier are required.", | ||
| 500 | ); | ||
| 501 | } | ||
| 502 | if (parseClientId(clientId) === undefined) { | ||
| 503 | return yield* fail("invalid_client", "The client is unknown."); | ||
| 504 | } | ||
| 505 | // A presented code is spent even when a later check fails (RFC 6749 §4.1.2). | ||
| 506 | const pending = yield* Ref.modify(codes, (current) => { | ||
| 507 | const found = current.get(code); | ||
| 508 | if (found === undefined) return [undefined, current] as const; | ||
| 509 | const next = new Map(current); | ||
| 510 | next.delete(code); | ||
| 511 | return [found, next] as const; | ||
| 512 | }); | ||
| 513 | const now = yield* Clock.currentTimeMillis; | ||
| 514 | if ( | ||
| 515 | pending === undefined || | ||
| 516 | pending.expiresAtMs <= now || | ||
| 517 | pending.clientId !== clientId || | ||
| 518 | pending.redirectUri !== redirectUri || | ||
| 519 | pending.resource !== urls.resource || | ||
| 520 | !sameResource(urls, token.resource) || | ||
| 521 | !(yield* pkceVerifies(verifier, pending.codeChallenge)) | ||
| 522 | ) { | ||
| 523 | return yield* fail("invalid_grant", "The authorization code is invalid or expired."); | ||
| 524 | } | ||
| 525 | const issued = yield* environmentAuth | ||
| 526 | .issueMcpClientSession({ | ||
| 527 | label: pending.clientName, | ||
| 528 | access: pending.access, | ||
| 529 | client: deriveAuthClientMetadata({ request }), | ||
| 530 | }) | ||
| 531 | .pipe( | ||
| 532 | Effect.catch((cause) => | ||
| 533 | Effect.logError("Could not issue an MCP client session.", { cause }).pipe( | ||
| 534 | Effect.andThen( | ||
| 535 | Effect.fail(fail("invalid_grant", "The session could not be issued.")), | ||
| 536 | ), | ||
| 537 | ), | ||
| 538 | ), | ||
| 539 | ); | ||
| 540 | return { | ||
| 541 | access_token: issued.token, | ||
| 542 | token_type: "Bearer", | ||
| 543 | expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)), | ||
| 544 | scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)), | ||
| 545 | }; | ||
| 546 | }); | ||
| 547 | |||
| 548 | return McpOAuth.of({ | ||
| 549 | register, | ||
| 550 | validateAuthorization, | ||
| 551 | approvingBrowserSession, | ||
| 552 | approve, | ||
| 553 | deny, | ||
| 554 | exchangeCode, | ||
| 555 | }); | ||
| 556 | }); | ||
| 557 | |||
| 558 | export const layer = Layer.effect(McpOAuth, make); | ||
| 559 | |||
| 560 | /** | ||
| 561 | * Lets `/mcp` admit OAuth clients. Their scope has no calling thread, and | ||
| 562 | * never carries preview or device access: those tools act on the caller's | ||
| 563 | * own thread. | ||
| 564 | */ | ||
| 565 | export const layerMcpClientAuthenticator = Layer.effect( | ||
| 566 | McpHttpServer.McpClientAuthenticator, | ||
| 567 | Effect.gen(function* () { | ||
| 568 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 569 | const environment = yield* ServerEnvironment.ServerEnvironment; | ||
| 570 | const environmentId = yield* environment.getEnvironmentId; | ||
| 571 | return McpHttpServer.McpClientAuthenticator.of({ | ||
| 572 | authenticate: (request) => | ||
| 573 | environmentAuth.authenticateMcpClient(request).pipe( | ||
| 574 | Effect.flatMap((client) => | ||
| 575 | Clock.currentTimeMillis.pipe( | ||
| 576 | Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({ | ||
| 577 | environmentId, | ||
| 578 | requestNamespace: `client:${client.sessionId}`, | ||
| 579 | thread: undefined, | ||
| 580 | client: { | ||
| 581 | sessionId: client.sessionId, | ||
| 582 | label: client.label, | ||
| 583 | access: client.access, | ||
| 584 | }, | ||
| 585 | capabilities: new Set<McpInvocationContext.McpCapability>([ | ||
| 586 | "orchestration", | ||
| 587 | "worktree", | ||
| 588 | "pull-requests", | ||
| 589 | ]), | ||
| 590 | issuedAt, | ||
| 591 | })), | ||
| 592 | ), | ||
| 593 | ), | ||
| 594 | Effect.catch((error) => | ||
| 595 | (EnvironmentAuth.isServerAuthCredentialError(error) | ||
| 596 | ? Effect.void | ||
| 597 | : Effect.logWarning("MCP client authentication failed.", { cause: error }) | ||
| 598 | ).pipe(Effect.as(undefined)), | ||
| 599 | ), | ||
| 600 | ), | ||
| 601 | }); | ||
| 602 | }), | ||
| 603 | ); | ||
apps/server/src/auth/SessionStore.test.ts+14| ... | @@ -276,6 +276,20 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | ... | @@ -276,6 +276,20 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 276 | expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); | 276 | expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); |
| 277 | }).pipe(Effect.provide(layerSessionStore())), | 277 | }).pipe(Effect.provide(layerSessionStore())), |
| 278 | ); | 278 | ); |
| 279 | it.effect("carries a runtime-mode ceiling only on sessions issued with one", () => | ||
| 280 | Effect.gen(function* () { | ||
| 281 | const sessions = yield* SessionStore.SessionStore; | ||
| 282 | const capped = yield* sessions.issue({ | ||
| 283 | subject: "mcp-client", | ||
| 284 | method: "bearer-access-token", | ||
| 285 | runtimeModeCeiling: "auto", | ||
| 286 | }); | ||
| 287 | const uncapped = yield* sessions.issue({ method: "bearer-access-token" }); | ||
| 288 | |||
| 289 | expect((yield* sessions.verify(capped.token)).runtimeModeCeiling).toBe("auto"); | ||
| 290 | expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); | ||
| 291 | }).pipe(Effect.provide(layerSessionStore())), | ||
| 292 | ); | ||
| 279 | it.effect("rejects malformed session tokens", () => | 293 | it.effect("rejects malformed session tokens", () => |
| 280 | Effect.gen(function* () { | 294 | Effect.gen(function* () { |
| 281 | const sessions = yield* SessionStore.SessionStore; | 295 | const sessions = yield* SessionStore.SessionStore; |
apps/server/src/auth/SessionStore.ts+7| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | type AuthClientSession, | 7 | type AuthClientSession, |
| 8 | type AuthEnvironmentScope, | 8 | type AuthEnvironmentScope, |
| 9 | type ClientSurface, | 9 | type ClientSurface, |
| 10 | RuntimeMode, | ||
| 10 | type ServerAuthSessionMethod, | 11 | type ServerAuthSessionMethod, |
| 11 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 12 | import * as Context from "effect/Context"; | 13 | import * as Context from "effect/Context"; |
| ... | @@ -58,6 +59,8 @@ export interface VerifiedSession { | ... | @@ -58,6 +59,8 @@ export interface VerifiedSession { |
| 58 | readonly subject: string; | 59 | readonly subject: string; |
| 59 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 60 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 60 | readonly proofKeyThumbprint?: string; | 61 | readonly proofKeyThumbprint?: string; |
| 62 | /** The most an MCP client approved through OAuth may hand to the threads it drives. */ | ||
| 63 | readonly runtimeModeCeiling?: RuntimeMode; | ||
| 61 | } | 64 | } |
| 62 | 65 | ||
| 63 | export type SessionCredentialChange = | 66 | export type SessionCredentialChange = |
| ... | @@ -374,6 +377,7 @@ export class SessionStore extends Context.Service< | ... | @@ -374,6 +377,7 @@ export class SessionStore extends Context.Service< |
| 374 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 377 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 375 | readonly client?: AuthClientMetadata; | 378 | readonly client?: AuthClientMetadata; |
| 376 | readonly proofKeyThumbprint?: string; | 379 | readonly proofKeyThumbprint?: string; |
| 380 | readonly runtimeModeCeiling?: RuntimeMode; | ||
| 377 | /** | 381 | /** |
| 378 | * Atomically revoke active sessions with the same subject and method | 382 | * Atomically revoke active sessions with the same subject and method |
| 379 | * before storing this session. | 383 | * before storing this session. |
| ... | @@ -430,6 +434,7 @@ const SessionClaims = Schema.Struct({ | ... | @@ -430,6 +434,7 @@ const SessionClaims = Schema.Struct({ |
| 430 | scopes: AuthEnvironmentScopes, | 434 | scopes: AuthEnvironmentScopes, |
| 431 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), | 435 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), |
| 432 | jkt: Schema.optionalKey(Schema.String), | 436 | jkt: Schema.optionalKey(Schema.String), |
| 437 | rtc: Schema.optionalKey(RuntimeMode), | ||
| 433 | iat: Schema.Number, | 438 | iat: Schema.Number, |
| 434 | exp: Schema.Number, | 439 | exp: Schema.Number, |
| 435 | }); | 440 | }); |
| ... | @@ -665,6 +670,7 @@ export const make = Effect.gen(function* () { | ... | @@ -665,6 +670,7 @@ export const make = Effect.gen(function* () { |
| 665 | scopes: input?.scopes ?? AuthStandardClientScopes, | 670 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 666 | method: input?.method ?? "browser-session-cookie", | 671 | method: input?.method ?? "browser-session-cookie", |
| 667 | ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), | 672 | ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), |
| 673 | ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}), | ||
| 668 | iat: issuedAt.epochMilliseconds, | 674 | iat: issuedAt.epochMilliseconds, |
| 669 | exp: expiresAt.epochMilliseconds, | 675 | exp: expiresAt.epochMilliseconds, |
| 670 | }; | 676 | }; |
| ... | @@ -839,6 +845,7 @@ export const make = Effect.gen(function* () { | ... | @@ -839,6 +845,7 @@ export const make = Effect.gen(function* () { |
| 839 | subject: claims.sub, | 845 | subject: claims.sub, |
| 840 | scopes: claims.scopes, | 846 | scopes: claims.scopes, |
| 841 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | 847 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 848 | ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), | ||
| 842 | } satisfies VerifiedSession; | 849 | } satisfies VerifiedSession; |
| 843 | }, | 850 | }, |
| 844 | ); | 851 | ); |
apps/server/src/auth/mcpOAuthHtml.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | /** | ||
| 2 | * The page for a sign-in link that cannot be trusted (unknown client or | ||
| 3 | * unregistered redirect). It is plain server HTML so it never depends on the | ||
| 4 | * web app, and it must not redirect anywhere. | ||
| 5 | */ | ||
| 6 | const escapeHtml = (value: string) => | ||
| 7 | value.replace( | ||
| 8 | /[&<>"']/gu, | ||
| 9 | (character) => | ||
| 10 | ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!, | ||
| 11 | ); | ||
| 12 | |||
| 13 | const STYLES = ` | ||
| 14 | :root { color-scheme: light dark; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | ||
| 15 | body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; background: #f6f7f9; color: #17191f; } | ||
| 16 | main { width: min(100%, 440px); } | ||
| 17 | h1 { margin: 0 0 8px; font-size: 20px; } | ||
| 18 | p { margin: 0 0 12px; line-height: 1.5; color: #4b5060; } | ||
| 19 | @media (prefers-color-scheme: dark) { | ||
| 20 | body { background: #0f1115; color: #e6e8ee; } | ||
| 21 | p { color: #a0a6b4; } | ||
| 22 | } | ||
| 23 | `; | ||
| 24 | |||
| 25 | const shell = (title: string, body: string) => `<!doctype html> | ||
| 26 | <html lang="en"> | ||
| 27 | <head> | ||
| 28 | <meta charset="utf-8" /> | ||
| 29 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| 30 | <meta name="color-scheme" content="light dark" /> | ||
| 31 | <title>${escapeHtml(title)}</title> | ||
| 32 | <style>${STYLES}</style> | ||
| 33 | </head> | ||
| 34 | <body> | ||
| 35 | <main>${body}</main> | ||
| 36 | </body> | ||
| 37 | </html>`; | ||
| 38 | |||
| 39 | export function renderErrorPage(description: string): string { | ||
| 40 | return shell( | ||
| 41 | "Sign-in failed", | ||
| 42 | `<h1>This sign-in cannot continue</h1> | ||
| 43 | <p>${escapeHtml(description)}</p> | ||
| 44 | <p>Close this page and start the sign-in again from your agent.</p>`, | ||
| 45 | ); | ||
| 46 | } | ||
apps/server/src/auth/mcpOAuthHttp.ts created+159| ... | @@ -0,0 +1,159 @@ | ||
| 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 | ); | ||
apps/server/src/mcp/McpHttpServer.test.ts+153-2| ... | @@ -3,7 +3,13 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ... | @@ -3,7 +3,13 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 3 | import { expect, it } from "@effect/vitest"; | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | import { NodeHttpServer } from "@effect/platform-node"; | 4 | import { NodeHttpServer } from "@effect/platform-node"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 6 | import { |
| 7 | EnvironmentId, | ||
| 8 | OrchestratorMcpFailure, | ||
| 9 | PreviewTabId, | ||
| 10 | ProviderInstanceId, | ||
| 11 | ThreadId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | 13 | import * as Deferred from "effect/Deferred"; |
| 8 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 15 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -11,14 +17,16 @@ import * as Layer from "effect/Layer"; | ... | @@ -11,14 +17,16 @@ import * as Layer from "effect/Layer"; |
| 11 | import * as Path from "effect/Path"; | 17 | import * as Path from "effect/Path"; |
| 12 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 13 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| 14 | import { McpProtocol, McpSchema, McpServer } from "effect/ai"; | 20 | import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; |
| 15 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/http"; | 21 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/http"; |
| 16 | 22 | ||
| 17 | import * as ProjectService from "../project/ProjectService.ts"; | 23 | import * as ProjectService from "../project/ProjectService.ts"; |
| 18 | import * as ServerConfig from "../config.ts"; | 24 | import * as ServerConfig from "../config.ts"; |
| 19 | import * as McpHttpServer from "./McpHttpServer.ts"; | 25 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 26 | import * as McpToolAccess from "./McpToolAccess.ts"; | ||
| 20 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; | 27 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; |
| 21 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 28 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 29 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; | ||
| 22 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | 30 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 23 | 31 | ||
| 24 | const environmentId = EnvironmentId.make("environment-mcp-test"); | 32 | const environmentId = EnvironmentId.make("environment-mcp-test"); |
| ... | @@ -356,6 +364,28 @@ it.effect.each([ | ... | @@ -356,6 +364,28 @@ it.effect.each([ |
| 356 | ).pipe(Effect.provide(layerTest)), | 364 | ).pipe(Effect.provide(layerTest)), |
| 357 | ); | 365 | ); |
| 358 | 366 | ||
| 367 | it.effect("refuses preview tools to a client outside a thread before they run", () => | ||
| 368 | Effect.gen(function* () { | ||
| 369 | const server = yield* McpServer.McpServer; | ||
| 370 | const readOnly = { | ||
| 371 | ...invocation, | ||
| 372 | thread: undefined, | ||
| 373 | requestNamespace: "client:session-1", | ||
| 374 | client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const }, | ||
| 375 | }; | ||
| 376 | const click = yield* server | ||
| 377 | .callTool({ name: "preview_click", arguments: { locator: "text=Send" } }) | ||
| 378 | .pipe( | ||
| 379 | Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly), | ||
| 380 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 381 | ); | ||
| 382 | expect(click.isError).toBe(true); | ||
| 383 | expect(click.content).toEqual([ | ||
| 384 | { type: "text", text: expect.stringContaining("needs an agent running inside T3 Code") }, | ||
| 385 | ]); | ||
| 386 | }).pipe(Effect.provide(layerTest)), | ||
| 387 | ); | ||
| 388 | |||
| 359 | it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => | 389 | it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => |
| 360 | Effect.gen(function* () { | 390 | Effect.gen(function* () { |
| 361 | const server = yield* McpServer.McpServer; | 391 | const server = yield* McpServer.McpServer; |
| ... | @@ -916,3 +946,124 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -916,3 +946,124 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 916 | }), | 946 | }), |
| 917 | ).pipe(Effect.provide(layerTest)), | 947 | ).pipe(Effect.provide(layerTest)), |
| 918 | ); | 948 | ); |
| 949 | |||
| 950 | it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => | ||
| 951 | Effect.scoped( | ||
| 952 | Effect.gen(function* () { | ||
| 953 | const providerToken = "providerTokenWithoutDots"; | ||
| 954 | const clientToken = "client-payload.client-signature"; | ||
| 955 | const providerScope: McpInvocationContext.McpInvocationScope = { | ||
| 956 | environmentId, | ||
| 957 | requestNamespace: "provider-session", | ||
| 958 | thread: { | ||
| 959 | threadId: ThreadId.make("thread-provider"), | ||
| 960 | providerSessionId: "provider-session", | ||
| 961 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 962 | }, | ||
| 963 | client: undefined, | ||
| 964 | capabilities: new Set(["orchestration"]), | ||
| 965 | issuedAt: 1, | ||
| 966 | }; | ||
| 967 | const clientScope: McpInvocationContext.McpInvocationScope = { | ||
| 968 | environmentId, | ||
| 969 | requestNamespace: "client:session-1", | ||
| 970 | thread: undefined, | ||
| 971 | client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, | ||
| 972 | capabilities: new Set(["orchestration"]), | ||
| 973 | issuedAt: 1, | ||
| 974 | }; | ||
| 975 | const seen: Array<McpInvocationContext.McpInvocationScope> = []; | ||
| 976 | const ProbeToolkit = Toolkit.make( | ||
| 977 | Tool.make("probe", { | ||
| 978 | description: "Reports the caller.", | ||
| 979 | success: Schema.Struct({ ok: Schema.Boolean }), | ||
| 980 | failure: OrchestratorMcpFailure, | ||
| 981 | failureMode: "return", | ||
| 982 | dependencies: [McpInvocationContext.McpInvocationContext], | ||
| 983 | }), | ||
| 984 | ); | ||
| 985 | const serverLayer = McpHttpServer.toolkitRegistration( | ||
| 986 | ProbeToolkit, | ||
| 987 | McpToolAccess.toLayer(ProbeToolkit, { | ||
| 988 | probe: McpToolAccess.reads(() => | ||
| 989 | McpInvocationContext.McpInvocationContext.pipe( | ||
| 990 | Effect.tap((scope) => Effect.sync(() => seen.push(scope))), | ||
| 991 | Effect.as({ ok: true }), | ||
| 992 | ), | ||
| 993 | ), | ||
| 994 | }), | ||
| 995 | ).pipe( | ||
| 996 | Layer.provideMerge(McpHttpServer.layerMcpTransport), | ||
| 997 | Layer.provide( | ||
| 998 | Layer.mock(McpSessionRegistry.McpSessionRegistry)({ | ||
| 999 | resolve: (token) => | ||
| 1000 | Effect.succeed( | ||
| 1001 | token === providerToken | ||
| 1002 | ? (providerScope as McpInvocationContext.McpThreadInvocationScope) | ||
| 1003 | : undefined, | ||
| 1004 | ), | ||
| 1005 | }), | ||
| 1006 | ), | ||
| 1007 | Layer.provide( | ||
| 1008 | Layer.succeed(McpHttpServer.McpClientAuthenticator, { | ||
| 1009 | authenticate: (request) => | ||
| 1010 | Effect.succeed( | ||
| 1011 | request.headers.authorization === `Bearer ${clientToken}` ? clientScope : undefined, | ||
| 1012 | ), | ||
| 1013 | }), | ||
| 1014 | ), | ||
| 1015 | ); | ||
| 1016 | yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( | ||
| 1017 | Layer.build, | ||
| 1018 | ); | ||
| 1019 | const httpClient = yield* HttpClient.HttpClient; | ||
| 1020 | const call = (token: string | undefined) => | ||
| 1021 | Effect.gen(function* () { | ||
| 1022 | const headers = { | ||
| 1023 | accept: "application/json, text/event-stream", | ||
| 1024 | ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), | ||
| 1025 | }; | ||
| 1026 | const initialize = yield* httpClient.post("/mcp", { | ||
| 1027 | headers, | ||
| 1028 | body: HttpBody.text( | ||
| 1029 | `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, | ||
| 1030 | "application/json", | ||
| 1031 | ), | ||
| 1032 | }); | ||
| 1033 | if (initialize.status !== 200) return initialize; | ||
| 1034 | return yield* httpClient.post("/mcp", { | ||
| 1035 | headers: { | ||
| 1036 | ...headers, | ||
| 1037 | "mcp-session-id": initialize.headers["mcp-session-id"]!, | ||
| 1038 | "mcp-protocol-version": "2025-06-18", | ||
| 1039 | }, | ||
| 1040 | body: HttpBody.text( | ||
| 1041 | `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"probe","arguments":{}}}`, | ||
| 1042 | "application/json", | ||
| 1043 | ), | ||
| 1044 | }); | ||
| 1045 | }); | ||
| 1046 | |||
| 1047 | expect((yield* call(providerToken)).status).toBe(200); | ||
| 1048 | expect((yield* call(clientToken)).status).toBe(200); | ||
| 1049 | expect(seen.map((scope) => scope.requestNamespace)).toEqual([ | ||
| 1050 | "provider-session", | ||
| 1051 | "client:session-1", | ||
| 1052 | ]); | ||
| 1053 | |||
| 1054 | const missing = yield* call(undefined); | ||
| 1055 | expect(missing.status).toBe(401); | ||
| 1056 | expect(missing.headers["www-authenticate"]).toMatch( | ||
| 1057 | /^Bearer resource_metadata="http:\/\/[^"]+\/\.well-known\/oauth-protected-resource\/mcp"$/, | ||
| 1058 | ); | ||
| 1059 | |||
| 1060 | const expiredClient = yield* call("stale-payload.stale-signature"); | ||
| 1061 | expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata="); | ||
| 1062 | expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"'); | ||
| 1063 | |||
| 1064 | const deadProvider = yield* call("deadProviderToken"); | ||
| 1065 | expect(deadProvider.status).toBe(401); | ||
| 1066 | expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"'); | ||
| 1067 | }), | ||
| 1068 | ).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1069 | ); |
apps/server/src/mcp/McpHttpServer.ts+93-40| ... | @@ -58,19 +58,62 @@ import { | ... | @@ -58,19 +58,62 @@ import { |
| 58 | import * as HtmlHandlers from "./toolkits/html/handlers.ts"; | 58 | import * as HtmlHandlers from "./toolkits/html/handlers.ts"; |
| 59 | import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; | 59 | import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; |
| 60 | 60 | ||
| 61 | const unauthorized = HttpServerResponse.jsonUnsafe( | 61 | /** Where an MCP client discovers how to sign in (RFC 9728), at this request's own origin. */ |
| 62 | { | 62 | const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => |
| 63 | error: "invalid_mcp_credential", | 63 | HttpServerRequest.toURL(request).pipe( |
| 64 | message: "A valid provider-scoped MCP bearer credential is required.", | 64 | Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), |
| 65 | }, | 65 | ); |
| 66 | { | 66 | |
| 67 | status: 401, | 67 | /** |
| 68 | headers: { | 68 | * Agents T3 Code launched carry a registry token and must never be sent into |
| 69 | "cache-control": "no-store", | 69 | * an OAuth flow when it dies: they cannot open a browser, and a sign-in |
| 70 | "www-authenticate": "Bearer", | 70 | * would mint a credential that outlives their session. Only a request that |
| 71 | * does not look like a provider token is pointed at the OAuth metadata. | ||
| 72 | */ | ||
| 73 | const unauthorized = (input: { | ||
| 74 | readonly request: HttpServerRequest.HttpServerRequest; | ||
| 75 | readonly presentedToken: boolean; | ||
| 76 | readonly offerOAuth: boolean; | ||
| 77 | }) => { | ||
| 78 | const metadataUrl = input.offerOAuth | ||
| 79 | ? Option.getOrUndefined(mcpResourceMetadataUrl(input.request)) | ||
| 80 | : undefined; | ||
| 81 | const challenge = [ | ||
| 82 | "Bearer", | ||
| 83 | [ | ||
| 84 | ...(metadataUrl === undefined ? [] : [`resource_metadata="${metadataUrl}"`]), | ||
| 85 | ...(input.presentedToken ? ['error="invalid_token"'] : []), | ||
| 86 | ].join(", "), | ||
| 87 | ] | ||
| 88 | .filter((part) => part.length > 0) | ||
| 89 | .join(" "); | ||
| 90 | return HttpServerResponse.jsonUnsafe( | ||
| 91 | { | ||
| 92 | error: "invalid_mcp_credential", | ||
| 93 | message: "A valid T3 Code MCP credential is required.", | ||
| 71 | }, | 94 | }, |
| 72 | }, | 95 | { |
| 73 | ); | 96 | status: 401, |
| 97 | headers: { | ||
| 98 | "cache-control": "no-store", | ||
| 99 | "www-authenticate": challenge, | ||
| 100 | }, | ||
| 101 | }, | ||
| 102 | ); | ||
| 103 | }; | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Resolves a bearer token that is not a provider-session token: an OAuth | ||
| 107 | * client signed in from outside T3. Undefined when the token is not one. | ||
| 108 | */ | ||
| 109 | export class McpClientAuthenticator extends Context.Service< | ||
| 110 | McpClientAuthenticator, | ||
| 111 | { | ||
| 112 | readonly authenticate: ( | ||
| 113 | request: HttpServerRequest.HttpServerRequest, | ||
| 114 | ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; | ||
| 115 | } | ||
| 116 | >()("t3/mcp/McpHttpServer/McpClientAuthenticator") {} | ||
| 74 | 117 | ||
| 75 | type AuthenticatedHttpEffect = Effect.Effect< | 118 | type AuthenticatedHttpEffect = Effect.Effect< |
| 76 | HttpServerResponse.HttpServerResponse, | 119 | HttpServerResponse.HttpServerResponse, |
| ... | @@ -98,33 +141,43 @@ export const normalizeMcpHttpResponse = ( | ... | @@ -98,33 +141,43 @@ export const normalizeMcpHttpResponse = ( |
| 98 | : response; | 141 | : response; |
| 99 | }; | 142 | }; |
| 100 | 143 | ||
| 101 | const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( | 144 | // Session tokens are `<payload>.<signature>`; registry tokens are a bare base64url secret. |
| 102 | Effect.map((registry): McpAuthMiddleware => | 145 | const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes("."); |
| 103 | Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { | 146 | |
| 104 | const request = yield* HttpServerRequest.HttpServerRequest; | 147 | const makeMcpAuthMiddleware = Effect.gen(function* () { |
| 105 | const authorization = request.headers.authorization; | 148 | const registry = yield* McpSessionRegistry.McpSessionRegistry; |
| 106 | const token = | 149 | const clients = yield* Effect.serviceOption(McpClientAuthenticator); |
| 107 | authorization?.startsWith("Bearer ") === true | 150 | return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { |
| 108 | ? authorization.slice("Bearer ".length).trim() | 151 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 109 | : ""; | 152 | const authorization = request.headers.authorization; |
| 110 | const invocation = yield* registry.resolve(token); | 153 | const token = |
| 111 | if (!invocation) { | 154 | authorization?.startsWith("Bearer ") === true |
| 112 | // Without this the only symptom of a dead credential is the agent | 155 | ? authorization.slice("Bearer ".length).trim() |
| 113 | // quietly losing the whole `t3-code` toolkit for the rest of its | 156 | : ""; |
| 114 | // session, with nothing on the server to explain why. | 157 | const invocation = |
| 115 | yield* Effect.logWarning("rejected MCP request with an unusable credential", { | 158 | (yield* registry.resolve(token)) ?? |
| 116 | reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", | 159 | (Option.isSome(clients) && token.length > 0 |
| 117 | }); | 160 | ? yield* clients.value.authenticate(request) |
| 118 | return unauthorized; | 161 | : undefined); |
| 119 | } | 162 | if (!invocation) { |
| 120 | return yield* httpEffect.pipe( | 163 | // Without this the only symptom of a dead credential is the agent |
| 121 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | 164 | // quietly losing the whole `t3-code` toolkit for the rest of its |
| 122 | Effect.map(normalizeMcpHttpResponse), | 165 | // session, with nothing on the server to explain why. |
| 123 | ); | 166 | yield* Effect.logWarning("rejected MCP request with an unusable credential", { |
| 124 | }), | 167 | reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", |
| 125 | ), | 168 | }); |
| 126 | Effect.withSpan("McpHttpServer.makeAuthMiddleware"), | 169 | return unauthorized({ |
| 127 | ); | 170 | request, |
| 171 | presentedToken: token.length > 0, | ||
| 172 | offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token), | ||
| 173 | }); | ||
| 174 | } | ||
| 175 | return yield* httpEffect.pipe( | ||
| 176 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | ||
| 177 | Effect.map(normalizeMcpHttpResponse), | ||
| 178 | ); | ||
| 179 | }) satisfies McpAuthMiddleware; | ||
| 180 | }).pipe(Effect.withSpan("McpHttpServer.makeAuthMiddleware")); | ||
| 128 | 181 | ||
| 129 | const layerMcpAuthMiddleware = HttpRouter.middleware<{ | 182 | const layerMcpAuthMiddleware = HttpRouter.middleware<{ |
| 130 | provides: McpInvocationContext.McpInvocationContext; | 183 | provides: McpInvocationContext.McpInvocationContext; |
| ... | @@ -787,7 +840,7 @@ export const layerDeviceToolkit = Layer.mergeAll( | ... | @@ -787,7 +840,7 @@ export const layerDeviceToolkit = Layer.mergeAll( |
| 787 | layerDeviceScreenshotRegistration, | 840 | layerDeviceScreenshotRegistration, |
| 788 | ); | 841 | ); |
| 789 | 842 | ||
| 790 | const layerMcpTransport = McpServer.layerHttp({ | 843 | export const layerMcpTransport = McpServer.layerHttp({ |
| 791 | name: "T3 Code", | 844 | name: "T3 Code", |
| 792 | version: packageJson.version, | 845 | version: packageJson.version, |
| 793 | path: "/mcp", | 846 | path: "/mcp", |
apps/server/src/mcp/McpInvocationContext.test.ts+1-1| ... | @@ -81,7 +81,7 @@ it.effect("refuses thread-owned capabilities to a caller signed in from outside | ... | @@ -81,7 +81,7 @@ it.effect("refuses thread-owned capabilities to a caller signed in from outside |
| 81 | environmentId: EnvironmentId.make("environment-1"), | 81 | environmentId: EnvironmentId.make("environment-1"), |
| 82 | requestNamespace: "client:session-1", | 82 | requestNamespace: "client:session-1", |
| 83 | thread: undefined, | 83 | thread: undefined, |
| 84 | client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" }, | 84 | client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, |
| 85 | capabilities: new Set(["preview", "orchestration"]), | 85 | capabilities: new Set(["preview", "orchestration"]), |
| 86 | issuedAt: 1, | 86 | issuedAt: 1, |
| 87 | }; | 87 | }; |
apps/server/src/mcp/McpInvocationContext.ts+11-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type AuthMcpClientAccess, | ||
| 2 | type EnvironmentId, | 3 | type EnvironmentId, |
| 3 | McpCapabilityUnavailableError, | 4 | McpCapabilityUnavailableError, |
| 4 | OrchestratorMcpFailure, | 5 | OrchestratorMcpFailure, |
| ... | @@ -30,9 +31,18 @@ export interface McpThreadCaller { | ... | @@ -30,9 +31,18 @@ export interface McpThreadCaller { |
| 30 | export interface McpClientCaller { | 31 | export interface McpClientCaller { |
| 31 | readonly sessionId: string; | 32 | readonly sessionId: string; |
| 32 | readonly label: string; | 33 | readonly label: string; |
| 33 | readonly runtimeModeCeiling: RuntimeMode; | 34 | /** Read only, or the most the threads it starts or changes may run with. */ |
| 35 | readonly access: AuthMcpClientAccess; | ||
| 34 | } | 36 | } |
| 35 | 37 | ||
| 38 | /** | ||
| 39 | * The runtime mode a client caller's writes are capped at. A read-only client | ||
| 40 | * never reaches a write (`McpToolAccess` refuses it first), so it maps to the | ||
| 41 | * lowest mode rather than to nothing. | ||
| 42 | */ | ||
| 43 | export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => | ||
| 44 | client === undefined || client.access === "read-only" ? "approval-required" : client.access; | ||
| 45 | |||
| 36 | /** | 46 | /** |
| 37 | * Who is calling and what they may do. Tool parameters choose the target | 47 | * Who is calling and what they may do. Tool parameters choose the target |
| 38 | * (thread, project); the caller sets the limits. A thread caller's omitted | 48 | * (thread, project); the caller sets the limits. A thread caller's omitted |
apps/server/src/mcp/McpToolAccess.race.test.ts+1-1| ... | @@ -66,7 +66,7 @@ const supervisedClient: McpInvocationContext.McpInvocationScope = { | ... | @@ -66,7 +66,7 @@ const supervisedClient: McpInvocationContext.McpInvocationScope = { |
| 66 | environmentId: EnvironmentId.make("environment"), | 66 | environmentId: EnvironmentId.make("environment"), |
| 67 | requestNamespace: "client:race", | 67 | requestNamespace: "client:race", |
| 68 | thread: undefined, | 68 | thread: undefined, |
| 69 | client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" }, | 69 | client: { sessionId: "race", label: "Claude Code", access: "approval-required" }, |
| 70 | capabilities: new Set(["orchestration"]), | 70 | capabilities: new Set(["orchestration"]), |
| 71 | issuedAt: 0, | 71 | issuedAt: 0, |
| 72 | }; | 72 | }; |
apps/server/src/mcp/McpToolAccess.test.ts+10-2| ... | @@ -48,12 +48,12 @@ const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationSco | ... | @@ -48,12 +48,12 @@ const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationSco |
| 48 | }); | 48 | }); |
| 49 | 49 | ||
| 50 | const clientCaller = ( | 50 | const clientCaller = ( |
| 51 | runtimeModeCeiling: RuntimeMode, | 51 | access: McpInvocationContext.McpClientCaller["access"], |
| 52 | ): McpInvocationContext.McpInvocationScope => ({ | 52 | ): McpInvocationContext.McpInvocationScope => ({ |
| 53 | environmentId: EnvironmentId.make("environment"), | 53 | environmentId: EnvironmentId.make("environment"), |
| 54 | requestNamespace: "client:session", | 54 | requestNamespace: "client:session", |
| 55 | thread: undefined, | 55 | thread: undefined, |
| 56 | client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling }, | 56 | client: { sessionId: "session", label: "Claude Code", access }, |
| 57 | capabilities: new Set(["orchestration"]), | 57 | capabilities: new Set(["orchestration"]), |
| 58 | issuedAt: 0, | 58 | issuedAt: 0, |
| 59 | }); | 59 | }); |
| ... | @@ -193,6 +193,7 @@ const fullAccess = threadCaller(fullAccessThreadId); | ... | @@ -193,6 +193,7 @@ const fullAccess = threadCaller(fullAccessThreadId); |
| 193 | const ended = threadCaller(endedThreadId); | 193 | const ended = threadCaller(endedThreadId); |
| 194 | const supervisedClient = clientCaller("approval-required"); | 194 | const supervisedClient = clientCaller("approval-required"); |
| 195 | const fullAccessClient = clientCaller("full-access"); | 195 | const fullAccessClient = clientCaller("full-access"); |
| 196 | const readOnlyClient = clientCaller("read-only"); | ||
| 196 | // A live full-access thread whose credential may use its browser but not control threads. | 197 | // A live full-access thread whose credential may use its browser but not control threads. |
| 197 | const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; | 198 | const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; |
| 198 | 199 | ||
| ... | @@ -200,6 +201,13 @@ it.effect.each([ | ... | @@ -200,6 +201,13 @@ it.effect.each([ |
| 200 | // Reads are open to every caller, even one whose turn ended. | 201 | // Reads are open to every caller, even one whose turn ended. |
| 201 | ["reads", ended, {}, "ran"], | 202 | ["reads", ended, {}, "ran"], |
| 202 | ["reads", supervisedClient, {}, "ran"], | 203 | ["reads", supervisedClient, {}, "ran"], |
| 204 | ["reads", readOnlyClient, {}, "ran"], | ||
| 205 | |||
| 206 | // A client approved for read-only access changes nothing. | ||
| 207 | ["writes", readOnlyClient, {}, "capability_denied"], | ||
| 208 | ["writes_threads", readOnlyClient, { threadId: supervisedThreadId }, "capability_denied"], | ||
| 209 | ["starts_threads", readOnlyClient, {}, "capability_denied"], | ||
| 210 | ["writes_environment", readOnlyClient, {}, "capability_denied"], | ||
| 203 | 211 | ||
| 204 | // What belongs to the calling thread needs one; changing it needs its live turn. | 212 | // What belongs to the calling thread needs one; changing it needs its live turn. |
| 205 | ["reads_as_caller", ended, {}, "ran"], | 213 | ["reads_as_caller", ended, {}, "ran"], |
apps/server/src/mcp/McpToolAccess.ts+23-2| ... | @@ -97,15 +97,36 @@ export class Declaration<out Handler> { | ... | @@ -97,15 +97,36 @@ export class Declaration<out Handler> { |
| 97 | } | 97 | } |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | /** A client approved for read-only access changes nothing. */ | ||
| 101 | const refuseReadOnlyClient = McpInvocationContext.McpInvocationContext.pipe( | ||
| 102 | Effect.flatMap((scope) => | ||
| 103 | scope.client?.access === "read-only" | ||
| 104 | ? Effect.fail( | ||
| 105 | new OrchestratorMcpFailure({ | ||
| 106 | code: "capability_denied", | ||
| 107 | message: | ||
| 108 | "This tool changes the environment, and this MCP client was approved for read-only access.", | ||
| 109 | }), | ||
| 110 | ) | ||
| 111 | : Effect.void, | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | |||
| 100 | /** | 115 | /** |
| 101 | * The caller of a tool that changes something. Tools that act for a | 116 | * The caller of a tool that changes something. Tools that act for a |
| 102 | * capability of their own (preview, device, worktree, pull requests) check it | 117 | * capability of their own (preview, device, worktree, pull requests) check it |
| 103 | * themselves. | 118 | * themselves. |
| 104 | */ | 119 | */ |
| 105 | const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller)); | 120 | const writingCaller = refuseReadOnlyClient.pipe( |
| 121 | Effect.andThen(loadCaller()), | ||
| 122 | Effect.tap(assertLiveCaller), | ||
| 123 | ); | ||
| 106 | 124 | ||
| 107 | /** The same, for tools whose only capability is controlling threads. */ | 125 | /** The same, for tools whose only capability is controlling threads. */ |
| 108 | const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller)); | 126 | const orchestratingCaller = refuseReadOnlyClient.pipe( |
| 127 | Effect.andThen(readCaller()), | ||
| 128 | Effect.tap(assertLiveCaller), | ||
| 129 | ); | ||
| 109 | 130 | ||
| 110 | const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( | 131 | const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( |
| 111 | Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), | 132 | Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), |
apps/server/src/mcp/OrchestratorMcpService.test.ts+20-1| ... | @@ -1603,7 +1603,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1603,7 +1603,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1603 | client: { | 1603 | client: { |
| 1604 | sessionId: "scheduled", | 1604 | sessionId: "scheduled", |
| 1605 | label: "Claude Code", | 1605 | label: "Claude Code", |
| 1606 | runtimeModeCeiling: "approval-required", | 1606 | access: "approval-required", |
| 1607 | }, | 1607 | }, |
| 1608 | capabilities: new Set(["orchestration"]), | 1608 | capabilities: new Set(["orchestration"]), |
| 1609 | issuedAt: 1, | 1609 | issuedAt: 1, |
| ... | @@ -1709,6 +1709,25 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1709,6 +1709,25 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1709 | }), | 1709 | }), |
| 1710 | ); | 1710 | ); |
| 1711 | 1711 | ||
| 1712 | it.effect("never shows a read-only client a webhook URL", () => | ||
| 1713 | Effect.gen(function* () { | ||
| 1714 | const upserted = yield* Ref.make(0); | ||
| 1715 | const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | ||
| 1716 | Effect.flatMap((mcp) => | ||
| 1717 | mcp.listScheduledTasks( | ||
| 1718 | { | ||
| 1719 | ...supervisedClient, | ||
| 1720 | client: { sessionId: "scheduled", label: "Claude Code", access: "read-only" }, | ||
| 1721 | }, | ||
| 1722 | { projectId }, | ||
| 1723 | ), | ||
| 1724 | ), | ||
| 1725 | Effect.provide(service([task({})], null, upserted)), | ||
| 1726 | ); | ||
| 1727 | assert.equal(listed.tasks[0]?.webhookUrl, undefined); | ||
| 1728 | }), | ||
| 1729 | ); | ||
| 1730 | |||
| 1712 | it.effect("checks a bound task against its thread's current modes", () => | 1731 | it.effect("checks a bound task against its thread's current modes", () => |
| 1713 | Effect.gen(function* () { | 1732 | Effect.gen(function* () { |
| 1714 | const upserted = yield* Ref.make(0); | 1733 | const upserted = yield* Ref.make(0); |
apps/server/src/mcp/OrchestratorMcpService.ts+8-5| ... | @@ -84,6 +84,7 @@ import * as ProjectService from "../project/ProjectService.ts"; | ... | @@ -84,6 +84,7 @@ import * as ProjectService from "../project/ProjectService.ts"; |
| 84 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 84 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 85 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 85 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 86 | import { | 86 | import { |
| 87 | clientRuntimeModeCeiling, | ||
| 87 | type McpInvocationScope, | 88 | type McpInvocationScope, |
| 88 | type McpThreadInvocationScope, | 89 | type McpThreadInvocationScope, |
| 89 | requireThreadScope, | 90 | requireThreadScope, |
| ... | @@ -908,7 +909,7 @@ const make = Effect.gen(function* () { | ... | @@ -908,7 +909,7 @@ const make = Effect.gen(function* () { |
| 908 | return { | 909 | return { |
| 909 | parent: undefined, | 910 | parent: undefined, |
| 910 | limits: { | 911 | limits: { |
| 911 | runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", | 912 | runtimeMode: clientRuntimeModeCeiling(scope.client), |
| 912 | interactionMode: "default", | 913 | interactionMode: "default", |
| 913 | } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, | 914 | } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, |
| 914 | } as const; | 915 | } as const; |
| ... | @@ -1400,8 +1401,9 @@ const make = Effect.gen(function* () { | ... | @@ -1400,8 +1401,9 @@ const make = Effect.gen(function* () { |
| 1400 | 1401 | ||
| 1401 | /** | 1402 | /** |
| 1402 | * A task as the caller may see it. Its webhook URL starts runs, so only a | 1403 | * A task as the caller may see it. Its webhook URL starts runs, so only a |
| 1403 | * caller that may start one sees it: a thread caller with a live turn, at | 1404 | * caller that may start one sees it: never a client approved for read-only |
| 1404 | * modes covering every mode the task runs at. | 1405 | * access, and a thread caller only with a live turn, at modes covering every |
| 1406 | * mode the task runs at. | ||
| 1405 | */ | 1407 | */ |
| 1406 | const summarizeScheduledTask = ( | 1408 | const summarizeScheduledTask = ( |
| 1407 | scope: McpInvocationScope, | 1409 | scope: McpInvocationScope, |
| ... | @@ -1416,8 +1418,9 @@ const make = Effect.gen(function* () { | ... | @@ -1416,8 +1418,9 @@ const make = Effect.gen(function* () { |
| 1416 | ) => | 1418 | ) => |
| 1417 | Effect.gen(function* () { | 1419 | Effect.gen(function* () { |
| 1418 | const live = | 1420 | const live = |
| 1419 | caller.parent === undefined || | 1421 | caller.parent === undefined |
| 1420 | Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); | 1422 | ? scope.client?.access !== "read-only" |
| 1423 | : Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); | ||
| 1421 | // This runs after a save, so a failed lookup of the bound thread hides | 1424 | // This runs after a save, so a failed lookup of the bound thread hides |
| 1422 | // the webhook URL instead of reporting a saved task as an error. | 1425 | // the webhook URL instead of reporting a saved task as an error. |
| 1423 | const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); | 1426 | const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); |
apps/server/src/mcp/threadAccess.ts+1-1| ... | @@ -76,7 +76,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { | ... | @@ -76,7 +76,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { |
| 76 | threads, | 76 | threads, |
| 77 | caller: undefined, | 77 | caller: undefined, |
| 78 | limits: { | 78 | limits: { |
| 79 | runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", | 79 | runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client), |
| 80 | interactionMode: "default", | 80 | interactionMode: "default", |
| 81 | }, | 81 | }, |
| 82 | } satisfies Caller; | 82 | } satisfies Caller; |
apps/server/src/mcp/toolkits/core.test.ts+72-2| ... | @@ -396,12 +396,12 @@ it.effect("resolves reused attachment references from stored metadata", () => | ... | @@ -396,12 +396,12 @@ it.effect("resolves reused attachment references from stored metadata", () => |
| 396 | ); | 396 | ); |
| 397 | 397 | ||
| 398 | const clientScope = ( | 398 | const clientScope = ( |
| 399 | runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access", | 399 | access: McpInvocationContext.McpClientCaller["access"], |
| 400 | ): McpInvocationContext.McpInvocationScope => ({ | 400 | ): McpInvocationContext.McpInvocationScope => ({ |
| 401 | environmentId: EnvironmentId.make("mcp-core-environment"), | 401 | environmentId: EnvironmentId.make("mcp-core-environment"), |
| 402 | requestNamespace: "client:session-1", | 402 | requestNamespace: "client:session-1", |
| 403 | thread: undefined, | 403 | thread: undefined, |
| 404 | client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling }, | 404 | client: { sessionId: "session-1", label: "Claude Code", access }, |
| 405 | issuedAt: 0, | 405 | issuedAt: 0, |
| 406 | capabilities: new Set(["orchestration", "worktree", "pull-requests"]), | 406 | capabilities: new Set(["orchestration", "worktree", "pull-requests"]), |
| 407 | }); | 407 | }); |
| ... | @@ -474,6 +474,76 @@ it.effect("a client caller targets any thread within its ceiling and cannot act | ... | @@ -474,6 +474,76 @@ it.effect("a client caller targets any thread within its ceiling and cannot act |
| 474 | ), | 474 | ), |
| 475 | ); | 475 | ); |
| 476 | 476 | ||
| 477 | it.effect("a read-only client reads threads and is refused every write before it runs", () => | ||
| 478 | Effect.gen(function* () { | ||
| 479 | const server = yield* McpServer.McpServer; | ||
| 480 | const call = (name: string, args: Record<string, unknown>) => | ||
| 481 | server | ||
| 482 | .callTool({ name, arguments: args }) | ||
| 483 | .pipe( | ||
| 484 | Effect.provideService( | ||
| 485 | McpInvocationContext.McpInvocationContext, | ||
| 486 | clientScope("read-only"), | ||
| 487 | ), | ||
| 488 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 489 | ); | ||
| 490 | |||
| 491 | const configuration = yield* call("t3_thread_configuration", { | ||
| 492 | threadId: "other-project-thread", | ||
| 493 | }); | ||
| 494 | expect(configuration.isError).toBe(false); | ||
| 495 | expect(configuration.structuredContent).toMatchObject({ runtimeMode: "auto" }); | ||
| 496 | |||
| 497 | const pinned = yield* call("t3_thread_organize", { | ||
| 498 | action: "pin", | ||
| 499 | threadId: "other-project-thread", | ||
| 500 | }); | ||
| 501 | expect(declaredFailure(pinned)).toMatchObject({ code: "capability_denied" }); | ||
| 502 | expect(dispatched).toEqual([]); | ||
| 503 | |||
| 504 | const configure = yield* call("t3_thread_configure", { | ||
| 505 | threadId: "other-project-thread", | ||
| 506 | modelSelection: { instanceId: "codex", model: "gpt-5" }, | ||
| 507 | }); | ||
| 508 | expect(declaredFailure(configure)).toMatchObject({ code: "capability_denied" }); | ||
| 509 | expect(dispatched).toEqual([]); | ||
| 510 | }).pipe( | ||
| 511 | Effect.provide( | ||
| 512 | McpHttpServer.layerThreadToolkit.pipe( | ||
| 513 | Layer.provideMerge(McpServer.McpServer.layer), | ||
| 514 | Layer.provide(NodeCrypto.layer), | ||
| 515 | Layer.provide( | ||
| 516 | Layer.mock(ThreadManagement.ThreadManagementService)({ | ||
| 517 | getThreadShell: () => | ||
| 518 | Effect.succeed({ | ||
| 519 | id: ThreadId.make("other-project-thread"), | ||
| 520 | projectId: "other-project", | ||
| 521 | deletedAt: null, | ||
| 522 | } as never), | ||
| 523 | getProjectThreadRecords: () => | ||
| 524 | Effect.succeed({ | ||
| 525 | thread: { | ||
| 526 | id: ThreadId.make("other-project-thread"), | ||
| 527 | projectId: "other-project", | ||
| 528 | modelSelection: { instanceId: "codex", model: "gpt-5" }, | ||
| 529 | runtimeMode: "auto", | ||
| 530 | interactionMode: "default", | ||
| 531 | deletedAt: null, | ||
| 532 | }, | ||
| 533 | } as never), | ||
| 534 | dispatch: () => | ||
| 535 | Effect.sync(() => { | ||
| 536 | dispatched.push("dispatch"); | ||
| 537 | return { sequence: 7 } as never; | ||
| 538 | }), | ||
| 539 | }), | ||
| 540 | ), | ||
| 541 | ), | ||
| 542 | ), | ||
| 543 | ), | ||
| 544 | ); | ||
| 545 | const dispatched: Array<string> = []; | ||
| 546 | |||
| 477 | it.effect("refuses act-as-caller tools to a client caller", () => | 547 | it.effect("refuses act-as-caller tools to a client caller", () => |
| 478 | Effect.gen(function* () { | 548 | Effect.gen(function* () { |
| 479 | const server = yield* McpServer.McpServer; | 549 | const server = yield* McpServer.McpServer; |
apps/server/src/mcp/toolkits/project/handlers.test.ts+1-1| ... | @@ -313,7 +313,7 @@ const clientLaunchHarness = (input: { | ... | @@ -313,7 +313,7 @@ const clientLaunchHarness = (input: { |
| 313 | client: { | 313 | client: { |
| 314 | sessionId: "session-1", | 314 | sessionId: "session-1", |
| 315 | label: "Claude Code", | 315 | label: "Claude Code", |
| 316 | runtimeModeCeiling: input.runtimeModeCeiling, | 316 | access: input.runtimeModeCeiling, |
| 317 | }, | 317 | }, |
| 318 | issuedAt: 0, | 318 | issuedAt: 0, |
| 319 | capabilities: new Set(["orchestration" as const]), | 319 | capabilities: new Set(["orchestration" as const]), |
apps/server/src/server.ts+4| ... | @@ -116,6 +116,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | ... | @@ -116,6 +116,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 116 | import * as WebhookRoute from "./scheduledTasks/webhookRoute.ts"; | 116 | import * as WebhookRoute from "./scheduledTasks/webhookRoute.ts"; |
| 117 | import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts"; | 117 | import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts"; |
| 118 | import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts"; | 118 | import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts"; |
| 119 | import * as McpOAuth from "./auth/McpOAuth.ts"; | ||
| 120 | import * as McpOAuthHttp from "./auth/mcpOAuthHttp.ts"; | ||
| 119 | import { | 121 | import { |
| 120 | relayHookBaseUrl, | 122 | relayHookBaseUrl, |
| 121 | ScheduledTaskWebhookOrigin, | 123 | ScheduledTaskWebhookOrigin, |
| ... | @@ -654,6 +656,7 @@ const layerMakeRoutes = Layer.mergeAll( | ... | @@ -654,6 +656,7 @@ const layerMakeRoutes = Layer.mergeAll( |
| 654 | Layer.mergeAll( | 656 | Layer.mergeAll( |
| 655 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | 657 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( |
| 656 | Layer.provide(AuthHttp.layer), | 658 | Layer.provide(AuthHttp.layer), |
| 659 | Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), | ||
| 657 | Layer.provide(CloudHttp.layer), | 660 | Layer.provide(CloudHttp.layer), |
| 658 | Layer.provide(OrchestrationHttp.layer), | 661 | Layer.provide(OrchestrationHttp.layer), |
| 659 | Layer.provide(PullRequestHttp.layer), | 662 | Layer.provide(PullRequestHttp.layer), |
| ... | @@ -677,6 +680,7 @@ const layerMakeRoutes = Layer.mergeAll( | ... | @@ -677,6 +680,7 @@ const layerMakeRoutes = Layer.mergeAll( |
| 677 | // what dispatch can actually serve. | 680 | // what dispatch can actually serve. |
| 678 | McpHttpServer.layer.pipe( | 681 | McpHttpServer.layer.pipe( |
| 679 | Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), | 682 | Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), |
| 683 | Layer.provide(McpOAuth.layerMcpClientAuthenticator), | ||
| 680 | ), | 684 | ), |
| 681 | ).pipe( | 685 | ).pipe( |
| 682 | // Both transports consume the same service instance, so caches single-flight across clients | 686 | // Both transports consume the same service instance, so caches single-flight across clients |
apps/web/src/components/auth/ConnectAgentSurface.tsx created+317| ... | @@ -0,0 +1,317 @@ | ||
| 1 | import { | ||
| 2 | type AuthMcpApprovalDecision, | ||
| 3 | type AuthMcpApprovalDetails, | ||
| 4 | AuthMcpApprovalError, | ||
| 5 | AuthMcpClientAccess, | ||
| 6 | type AuthMcpAuthorizationRequest, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | ||
| 9 | import { EyeIcon, type LucideIcon } from "lucide-react"; | ||
| 10 | import type * as Context from "effect/Context"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import { type ReactNode, useCallback, useEffect, useState } from "react"; | ||
| 14 | |||
| 15 | import { PrimaryEnvironmentHttpClient } from "~/environments/primary/httpClient"; | ||
| 16 | import { runPrimaryHttp } from "~/lib/runtime"; | ||
| 17 | import { cn } from "~/lib/utils"; | ||
| 18 | import { runtimeModeConfig } from "../chat/runtimeModeConfig"; | ||
| 19 | import { Alert, AlertDescription } from "../ui/alert"; | ||
| 20 | import { Button } from "../ui/button"; | ||
| 21 | import { Input } from "../ui/input"; | ||
| 22 | import { RadioGroup } from "../ui/radio-group"; | ||
| 23 | import { Spinner } from "../ui/spinner"; | ||
| 24 | import { AuthSurfaceShell } from "./AuthSurfaceShell"; | ||
| 25 | |||
| 26 | const accessConfig: Record< | ||
| 27 | AuthMcpClientAccess, | ||
| 28 | { readonly label: string; readonly description: string; readonly icon: LucideIcon } | ||
| 29 | > = { | ||
| 30 | "read-only": { | ||
| 31 | label: "Read only", | ||
| 32 | description: "Read projects and threads. Cannot start, message or change anything.", | ||
| 33 | icon: EyeIcon, | ||
| 34 | }, | ||
| 35 | ...runtimeModeConfig, | ||
| 36 | }; | ||
| 37 | |||
| 38 | type Loaded = | ||
| 39 | | { readonly status: "loading" } | ||
| 40 | | { readonly status: "invalid"; readonly message: string } | ||
| 41 | | { readonly status: "ready"; readonly details: AuthMcpApprovalDetails }; | ||
| 42 | |||
| 43 | const UNREACHABLE = "Could not reach this environment. Try again."; | ||
| 44 | const isApprovalError = Schema.is(AuthMcpApprovalError); | ||
| 45 | |||
| 46 | type Answer<A> = | ||
| 47 | | { readonly kind: "ok"; readonly value: A } | ||
| 48 | | { readonly kind: "redirect"; readonly url: string } | ||
| 49 | | { readonly kind: "error"; readonly message: string }; | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Runs an approval call. The server validates the agent's request again on | ||
| 53 | * every call and answers what the page asked for, a message to show, or a URL | ||
| 54 | * the browser must follow (an approval, a denial, or a protocol error the | ||
| 55 | * agent should receive). | ||
| 56 | */ | ||
| 57 | function runApproval<A>( | ||
| 58 | call: ( | ||
| 59 | client: Context.Service.Shape<typeof PrimaryEnvironmentHttpClient>, | ||
| 60 | ) => Effect.Effect<A | { readonly redirectTo: string }, unknown>, | ||
| 61 | ): Promise<Answer<A>> { | ||
| 62 | return runPrimaryHttp( | ||
| 63 | PrimaryEnvironmentHttpClient.pipe( | ||
| 64 | Effect.flatMap(call), | ||
| 65 | Effect.map((value): Answer<A> => | ||
| 66 | typeof value === "object" && value !== null && "redirectTo" in value | ||
| 67 | ? { kind: "redirect", url: value.redirectTo } | ||
| 68 | : { kind: "ok", value: value as A }, | ||
| 69 | ), | ||
| 70 | Effect.catch((error) => | ||
| 71 | Effect.succeed<Answer<A>>({ | ||
| 72 | kind: "error", | ||
| 73 | message: isApprovalError(error) ? error.message : UNREACHABLE, | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ), | ||
| 77 | ).catch((): Answer<A> => ({ kind: "error", message: UNREACHABLE })); | ||
| 78 | } | ||
| 79 | |||
| 80 | function readAuthorizationRequest(): AuthMcpAuthorizationRequest { | ||
| 81 | return Object.fromEntries(new URL(window.location.href).searchParams); | ||
| 82 | } | ||
| 83 | |||
| 84 | /** | ||
| 85 | * /connect-agent: where an outside agent's MCP sign-in lands after the server | ||
| 86 | * checks the request. The user picks the most the agent may allow, then | ||
| 87 | * approves with a pairing code, or in one click when this browser is already | ||
| 88 | * signed in to the environment as an administrator. | ||
| 89 | */ | ||
| 90 | /** | ||
| 91 | * Whether this browser's session may approve `access` without a pairing code. | ||
| 92 | * A session may only grant access whose scopes it holds itself. | ||
| 93 | */ | ||
| 94 | function oneClickApproves(details: AuthMcpApprovalDetails, access: AuthMcpClientAccess) { | ||
| 95 | return details.csrfToken !== undefined && (details.oneClickAccess ?? []).includes(access); | ||
| 96 | } | ||
| 97 | |||
| 98 | export function ConnectAgentSurface() { | ||
| 99 | const [authorization] = useState(readAuthorizationRequest); | ||
| 100 | const [loaded, setLoaded] = useState<Loaded>({ status: "loading" }); | ||
| 101 | const [access, setAccess] = useState<AuthMcpClientAccess>("read-only"); | ||
| 102 | const [pairingCode, setPairingCode] = useState(""); | ||
| 103 | const [errorMessage, setErrorMessage] = useState(""); | ||
| 104 | const [pending, setPending] = useState<"approve" | "deny" | null>(null); | ||
| 105 | |||
| 106 | useEffect(() => { | ||
| 107 | let cancelled = false; | ||
| 108 | void runApproval((client) => client.mcpOAuth.approval({ payload: authorization })).then( | ||
| 109 | (answer) => { | ||
| 110 | if (cancelled) return; | ||
| 111 | if (answer.kind === "redirect") { | ||
| 112 | window.location.replace(answer.url); | ||
| 113 | return; | ||
| 114 | } | ||
| 115 | setLoaded( | ||
| 116 | answer.kind === "error" | ||
| 117 | ? { status: "invalid", message: answer.message } | ||
| 118 | : { status: "ready", details: answer.value }, | ||
| 119 | ); | ||
| 120 | }, | ||
| 121 | ); | ||
| 122 | return () => { | ||
| 123 | cancelled = true; | ||
| 124 | }; | ||
| 125 | }, [authorization]); | ||
| 126 | |||
| 127 | const decide = useCallback( | ||
| 128 | async (choice: "approve" | "deny") => { | ||
| 129 | if (loaded.status !== "ready") return; | ||
| 130 | setPending(choice); | ||
| 131 | setErrorMessage(""); | ||
| 132 | const { csrfToken } = loaded.details; | ||
| 133 | const decision: AuthMcpApprovalDecision = | ||
| 134 | choice === "deny" | ||
| 135 | ? { _tag: "deny" } | ||
| 136 | : csrfToken !== undefined && oneClickApproves(loaded.details, access) | ||
| 137 | ? { _tag: "browser-session", access, csrfToken } | ||
| 138 | : { _tag: "pairing-code", access, code: pairingCode.trim() }; | ||
| 139 | const answer = await runApproval((client) => | ||
| 140 | client.mcpOAuth.decision({ payload: { authorization, decision } }), | ||
| 141 | ); | ||
| 142 | if (answer.kind === "redirect") { | ||
| 143 | window.location.replace(answer.url); | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | setPending(null); | ||
| 147 | setErrorMessage(answer.kind === "error" ? answer.message : "The sign-in could not continue."); | ||
| 148 | }, | ||
| 149 | [access, authorization, loaded, pairingCode], | ||
| 150 | ); | ||
| 151 | |||
| 152 | if (loaded.status === "loading") { | ||
| 153 | return ( | ||
| 154 | <AuthSurfaceShell> | ||
| 155 | <ConnectAgentHeading | ||
| 156 | title="Checking the sign-in request" | ||
| 157 | description="One moment while this environment verifies the agent's request." | ||
| 158 | /> | ||
| 159 | <Spinner className="mt-6" size="lg" tone="muted" /> | ||
| 160 | </AuthSurfaceShell> | ||
| 161 | ); | ||
| 162 | } | ||
| 163 | |||
| 164 | if (loaded.status === "invalid") { | ||
| 165 | return ( | ||
| 166 | <AuthSurfaceShell> | ||
| 167 | <ConnectAgentHeading title="This sign-in cannot continue" description={loaded.message} /> | ||
| 168 | <p className="mt-4 text-sm text-muted-foreground"> | ||
| 169 | Close this page and start the sign-in again from your agent. | ||
| 170 | </p> | ||
| 171 | </AuthSurfaceShell> | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | |||
| 175 | const { details } = loaded; | ||
| 176 | const oneClick = oneClickApproves(details, access); | ||
| 177 | const canApprove = pending === null && (oneClick || pairingCode.trim().length > 0); | ||
| 178 | |||
| 179 | return ( | ||
| 180 | <AuthSurfaceShell> | ||
| 181 | <ConnectAgentHeading | ||
| 182 | title={`Connect ${details.clientName}`} | ||
| 183 | description={ | ||
| 184 | <> | ||
| 185 | This agent wants to use the threads in every project on{" "} | ||
| 186 | <span className="font-medium text-foreground">{details.environmentHost}</span>. | ||
| 187 | </> | ||
| 188 | } | ||
| 189 | /> | ||
| 190 | <p className="mt-2 text-xs text-muted-foreground"> | ||
| 191 | The name is chosen by the agent. Approval returns to {details.redirectHost} on the computer | ||
| 192 | that opened this page. Only approve a sign-in you just started. | ||
| 193 | </p> | ||
| 194 | |||
| 195 | <form | ||
| 196 | className="mt-6 space-y-5" | ||
| 197 | onSubmit={(event) => { | ||
| 198 | event.preventDefault(); | ||
| 199 | if (canApprove) void decide("approve"); | ||
| 200 | }} | ||
| 201 | > | ||
| 202 | <div className="space-y-2"> | ||
| 203 | <span id="connect-agent-access-label" className="text-sm font-medium"> | ||
| 204 | What it may do | ||
| 205 | </span> | ||
| 206 | <RadioGroup | ||
| 207 | aria-labelledby="connect-agent-access-label" | ||
| 208 | value={access} | ||
| 209 | onValueChange={(value) => setAccess(value as AuthMcpClientAccess)} | ||
| 210 | > | ||
| 211 | {AuthMcpClientAccess.literals.map((option) => ( | ||
| 212 | <AccessOption key={option} access={option} selected={option === access} /> | ||
| 213 | ))} | ||
| 214 | </RadioGroup> | ||
| 215 | <p className="text-xs text-muted-foreground"> | ||
| 216 | Beyond read only, it can start, message and stop threads, and none of them can run with | ||
| 217 | more than the mode you pick. | ||
| 218 | </p> | ||
| 219 | </div> | ||
| 220 | |||
| 221 | {oneClick ? null : ( | ||
| 222 | <div className="space-y-2"> | ||
| 223 | <label className="text-sm font-medium" htmlFor="connect-agent-pairing-code"> | ||
| 224 | Pairing code | ||
| 225 | </label> | ||
| 226 | <Input | ||
| 227 | id="connect-agent-pairing-code" | ||
| 228 | autoCapitalize="none" | ||
| 229 | autoComplete="one-time-code" | ||
| 230 | autoCorrect="off" | ||
| 231 | disabled={pending !== null} | ||
| 232 | nativeInput | ||
| 233 | onChange={(event) => setPairingCode(event.currentTarget.value)} | ||
| 234 | placeholder="Paste a one-time pairing code" | ||
| 235 | spellCheck={false} | ||
| 236 | value={pairingCode} | ||
| 237 | /> | ||
| 238 | <p className="text-xs text-muted-foreground"> | ||
| 239 | Create one in Settings → Connections, or run <code>t3 auth pairing create</code> on | ||
| 240 | this machine. | ||
| 241 | </p> | ||
| 242 | </div> | ||
| 243 | )} | ||
| 244 | |||
| 245 | {errorMessage ? ( | ||
| 246 | <Alert variant="error"> | ||
| 247 | <AlertDescription>{errorMessage}</AlertDescription> | ||
| 248 | </Alert> | ||
| 249 | ) : null} | ||
| 250 | |||
| 251 | <div className="flex flex-wrap gap-2"> | ||
| 252 | <Button disabled={!canApprove} type="submit"> | ||
| 253 | {pending === "approve" ? "Approving…" : "Approve"} | ||
| 254 | </Button> | ||
| 255 | <Button | ||
| 256 | disabled={pending !== null} | ||
| 257 | onClick={() => void decide("deny")} | ||
| 258 | type="button" | ||
| 259 | variant="outline" | ||
| 260 | > | ||
| 261 | {pending === "deny" ? "Denying…" : "Deny"} | ||
| 262 | </Button> | ||
| 263 | </div> | ||
| 264 | </form> | ||
| 265 | </AuthSurfaceShell> | ||
| 266 | ); | ||
| 267 | } | ||
| 268 | |||
| 269 | function ConnectAgentHeading({ | ||
| 270 | title, | ||
| 271 | description, | ||
| 272 | }: { | ||
| 273 | readonly title: string; | ||
| 274 | readonly description: ReactNode; | ||
| 275 | }) { | ||
| 276 | return ( | ||
| 277 | <> | ||
| 278 | <p className="text-3xs font-semibold tracking-widest text-primary uppercase">Agent sign-in</p> | ||
| 279 | <h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1> | ||
| 280 | <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{description}</p> | ||
| 281 | </> | ||
| 282 | ); | ||
| 283 | } | ||
| 284 | |||
| 285 | function AccessOption({ | ||
| 286 | access, | ||
| 287 | selected, | ||
| 288 | }: { | ||
| 289 | readonly access: AuthMcpClientAccess; | ||
| 290 | readonly selected: boolean; | ||
| 291 | }) { | ||
| 292 | const { label, description, icon: Icon } = accessConfig[access]; | ||
| 293 | return ( | ||
| 294 | <RadioPrimitive.Root | ||
| 295 | value={access} | ||
| 296 | className={cn( | ||
| 297 | "flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", | ||
| 298 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 299 | selected | ||
| 300 | ? "border-primary bg-background ring-2 ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" | ||
| 301 | : "border-border bg-background hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", | ||
| 302 | )} | ||
| 303 | > | ||
| 304 | <Icon | ||
| 305 | aria-hidden | ||
| 306 | className={cn( | ||
| 307 | "mt-0.5 size-4 shrink-0", | ||
| 308 | selected ? "text-primary" : "text-muted-foreground", | ||
| 309 | )} | ||
| 310 | /> | ||
| 311 | <span className="min-w-0"> | ||
| 312 | <span className="block text-sm font-medium text-foreground">{label}</span> | ||
| 313 | <span className="block text-xs text-muted-foreground">{description}</span> | ||
| 314 | </span> | ||
| 315 | </RadioPrimitive.Root> | ||
| 316 | ); | ||
| 317 | } | ||
apps/web/src/routeTree.gen.ts+21| ... | @@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome' | ... | @@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome' |
| 13 | import { Route as UsageRouteImport } from './routes/usage' | 13 | import { Route as UsageRouteImport } from './routes/usage' |
| 14 | import { Route as SettingsRouteImport } from './routes/settings' | 14 | import { Route as SettingsRouteImport } from './routes/settings' |
| 15 | import { Route as PairRouteImport } from './routes/pair' | 15 | import { Route as PairRouteImport } from './routes/pair' |
| 16 | import { Route as ConnectAgentRouteImport } from './routes/connect-agent' | ||
| 16 | import { Route as ConnectRouteImport } from './routes/connect' | 17 | import { Route as ConnectRouteImport } from './routes/connect' |
| 17 | import { Route as ChatRouteImport } from './routes/_chat' | 18 | import { Route as ChatRouteImport } from './routes/_chat' |
| 18 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 19 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| ... | @@ -55,6 +56,11 @@ const PairRoute = PairRouteImport.update({ | ... | @@ -55,6 +56,11 @@ const PairRoute = PairRouteImport.update({ |
| 55 | path: '/pair', | 56 | path: '/pair', |
| 56 | getParentRoute: () => rootRouteImport, | 57 | getParentRoute: () => rootRouteImport, |
| 57 | } as any) | 58 | } as any) |
| 59 | const ConnectAgentRoute = ConnectAgentRouteImport.update({ | ||
| 60 | id: '/connect-agent', | ||
| 61 | path: '/connect-agent', | ||
| 62 | getParentRoute: () => rootRouteImport, | ||
| 63 | } as any) | ||
| 58 | const ConnectRoute = ConnectRouteImport.update({ | 64 | const ConnectRoute = ConnectRouteImport.update({ |
| 59 | id: '/connect', | 65 | id: '/connect', |
| 60 | path: '/connect', | 66 | path: '/connect', |
| ... | @@ -165,6 +171,7 @@ const ChatEnvironmentIdThreadIdRoute = | ... | @@ -165,6 +171,7 @@ const ChatEnvironmentIdThreadIdRoute = |
| 165 | export interface FileRoutesByFullPath { | 171 | export interface FileRoutesByFullPath { |
| 166 | '/': typeof ChatIndexRoute | 172 | '/': typeof ChatIndexRoute |
| 167 | '/connect': typeof ConnectRoute | 173 | '/connect': typeof ConnectRoute |
| 174 | '/connect-agent': typeof ConnectAgentRoute | ||
| 168 | '/pair': typeof PairRoute | 175 | '/pair': typeof PairRoute |
| 169 | '/settings': typeof SettingsRouteWithChildren | 176 | '/settings': typeof SettingsRouteWithChildren |
| 170 | '/usage': typeof UsageRoute | 177 | '/usage': typeof UsageRoute |
| ... | @@ -190,6 +197,7 @@ export interface FileRoutesByFullPath { | ... | @@ -190,6 +197,7 @@ export interface FileRoutesByFullPath { |
| 190 | } | 197 | } |
| 191 | export interface FileRoutesByTo { | 198 | export interface FileRoutesByTo { |
| 192 | '/connect': typeof ConnectRoute | 199 | '/connect': typeof ConnectRoute |
| 200 | '/connect-agent': typeof ConnectAgentRoute | ||
| 193 | '/pair': typeof PairRoute | 201 | '/pair': typeof PairRoute |
| 194 | '/settings': typeof SettingsRouteWithChildren | 202 | '/settings': typeof SettingsRouteWithChildren |
| 195 | '/usage': typeof UsageRoute | 203 | '/usage': typeof UsageRoute |
| ... | @@ -218,6 +226,7 @@ export interface FileRoutesById { | ... | @@ -218,6 +226,7 @@ export interface FileRoutesById { |
| 218 | __root__: typeof rootRouteImport | 226 | __root__: typeof rootRouteImport |
| 219 | '/_chat': typeof ChatRouteWithChildren | 227 | '/_chat': typeof ChatRouteWithChildren |
| 220 | '/connect': typeof ConnectRoute | 228 | '/connect': typeof ConnectRoute |
| 229 | '/connect-agent': typeof ConnectAgentRoute | ||
| 221 | '/pair': typeof PairRoute | 230 | '/pair': typeof PairRoute |
| 222 | '/settings': typeof SettingsRouteWithChildren | 231 | '/settings': typeof SettingsRouteWithChildren |
| 223 | '/usage': typeof UsageRoute | 232 | '/usage': typeof UsageRoute |
| ... | @@ -247,6 +256,7 @@ export interface FileRouteTypes { | ... | @@ -247,6 +256,7 @@ export interface FileRouteTypes { |
| 247 | fullPaths: | 256 | fullPaths: |
| 248 | | '/' | 257 | | '/' |
| 249 | | '/connect' | 258 | | '/connect' |
| 259 | | '/connect-agent' | ||
| 250 | | '/pair' | 260 | | '/pair' |
| 251 | | '/settings' | 261 | | '/settings' |
| 252 | | '/usage' | 262 | | '/usage' |
| ... | @@ -272,6 +282,7 @@ export interface FileRouteTypes { | ... | @@ -272,6 +282,7 @@ export interface FileRouteTypes { |
| 272 | fileRoutesByTo: FileRoutesByTo | 282 | fileRoutesByTo: FileRoutesByTo |
| 273 | to: | 283 | to: |
| 274 | | '/connect' | 284 | | '/connect' |
| 285 | | '/connect-agent' | ||
| 275 | | '/pair' | 286 | | '/pair' |
| 276 | | '/settings' | 287 | | '/settings' |
| 277 | | '/usage' | 288 | | '/usage' |
| ... | @@ -299,6 +310,7 @@ export interface FileRouteTypes { | ... | @@ -299,6 +310,7 @@ export interface FileRouteTypes { |
| 299 | | '__root__' | 310 | | '__root__' |
| 300 | | '/_chat' | 311 | | '/_chat' |
| 301 | | '/connect' | 312 | | '/connect' |
| 313 | | '/connect-agent' | ||
| 302 | | '/pair' | 314 | | '/pair' |
| 303 | | '/settings' | 315 | | '/settings' |
| 304 | | '/usage' | 316 | | '/usage' |
| ... | @@ -327,6 +339,7 @@ export interface FileRouteTypes { | ... | @@ -327,6 +339,7 @@ export interface FileRouteTypes { |
| 327 | export interface RootRouteChildren { | 339 | export interface RootRouteChildren { |
| 328 | ChatRoute: typeof ChatRouteWithChildren | 340 | ChatRoute: typeof ChatRouteWithChildren |
| 329 | ConnectRoute: typeof ConnectRoute | 341 | ConnectRoute: typeof ConnectRoute |
| 342 | ConnectAgentRoute: typeof ConnectAgentRoute | ||
| 330 | PairRoute: typeof PairRoute | 343 | PairRoute: typeof PairRoute |
| 331 | SettingsRoute: typeof SettingsRouteWithChildren | 344 | SettingsRoute: typeof SettingsRouteWithChildren |
| 332 | UsageRoute: typeof UsageRoute | 345 | UsageRoute: typeof UsageRoute |
| ... | @@ -364,6 +377,13 @@ declare module '@tanstack/react-router' { | ... | @@ -364,6 +377,13 @@ declare module '@tanstack/react-router' { |
| 364 | preLoaderRoute: typeof PairRouteImport | 377 | preLoaderRoute: typeof PairRouteImport |
| 365 | parentRoute: typeof rootRouteImport | 378 | parentRoute: typeof rootRouteImport |
| 366 | } | 379 | } |
| 380 | '/connect-agent': { | ||
| 381 | id: '/connect-agent' | ||
| 382 | path: '/connect-agent' | ||
| 383 | fullPath: '/connect-agent' | ||
| 384 | preLoaderRoute: typeof ConnectAgentRouteImport | ||
| 385 | parentRoute: typeof rootRouteImport | ||
| 386 | } | ||
| 367 | '/connect': { | 387 | '/connect': { |
| 368 | id: '/connect' | 388 | id: '/connect' |
| 369 | path: '/connect' | 389 | path: '/connect' |
| ... | @@ -571,6 +591,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( | ... | @@ -571,6 +591,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
| 571 | const rootRouteChildren: RootRouteChildren = { | 591 | const rootRouteChildren: RootRouteChildren = { |
| 572 | ChatRoute: ChatRouteWithChildren, | 592 | ChatRoute: ChatRouteWithChildren, |
| 573 | ConnectRoute: ConnectRoute, | 593 | ConnectRoute: ConnectRoute, |
| 594 | ConnectAgentRoute: ConnectAgentRoute, | ||
| 574 | PairRoute: PairRoute, | 595 | PairRoute: PairRoute, |
| 575 | SettingsRoute: SettingsRouteWithChildren, | 596 | SettingsRoute: SettingsRouteWithChildren, |
| 576 | UsageRoute: UsageRoute, | 597 | UsageRoute: UsageRoute, |
apps/web/src/routes/__root.tsx+1-1| ... | @@ -161,7 +161,7 @@ function RootRouteView() { | ... | @@ -161,7 +161,7 @@ function RootRouteView() { |
| 161 | }; | 161 | }; |
| 162 | }, [pathname]); | 162 | }, [pathname]); |
| 163 | 163 | ||
| 164 | if (pathname === "/pair" || pathname === "/connect") { | 164 | if (pathname === "/pair" || pathname === "/connect" || pathname === "/connect-agent") { |
| 165 | return ( | 165 | return ( |
| 166 | <> | 166 | <> |
| 167 | <DocumentTitleSync /> | 167 | <DocumentTitleSync /> |
apps/web/src/routes/connect-agent.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { ConnectAgentSurface } from "../components/auth/ConnectAgentSurface"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/connect-agent")({ | ||
| 6 | component: ConnectAgentSurface, | ||
| 7 | }); | ||
apps/web/vite.config.ts+5-2| ... | @@ -9,7 +9,10 @@ import "vite-plus/test/config"; | ... | @@ -9,7 +9,10 @@ import "vite-plus/test/config"; |
| 9 | import { defineConfig, type Connect, type Plugin } from "vite-plus"; | 9 | import { defineConfig, type Connect, type Plugin } from "vite-plus"; |
| 10 | import pkg from "./package.json" with { type: "json" }; | 10 | import pkg from "./package.json" with { type: "json" }; |
| 11 | 11 | ||
| 12 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; | 12 | import { |
| 13 | DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES, | ||
| 14 | DEV_PROXIED_PATH_PREFIXES, | ||
| 15 | } from "@t3tools/shared/devProxy"; | ||
| 13 | 16 | ||
| 14 | import { loadRepoEnv } from "../../scripts/lib/public-config"; | 17 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 15 | import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; | 18 | import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; |
| ... | @@ -249,7 +252,7 @@ export default defineConfig(() => { | ... | @@ -249,7 +252,7 @@ export default defineConfig(() => { |
| 249 | prefix, | 252 | prefix, |
| 250 | { | 253 | { |
| 251 | target: devProxyTarget, | 254 | target: devProxyTarget, |
| 252 | changeOrigin: true, | 255 | changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix), |
| 253 | ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), | 256 | ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), |
| 254 | }, | 257 | }, |
| 255 | ]), | 258 | ]), |
docs/internals/environment-auth.md+29-2| ... | @@ -21,8 +21,35 @@ authority. | ... | @@ -21,8 +21,35 @@ authority. |
| 21 | Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session | 21 | Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session |
| 22 | model. DPoP binds a token to a client's proof key; an invalid proof must fail | 22 | model. DPoP binds a token to a client's proof key; an invalid proof must fail |
| 23 | rather than fall back to bearer authentication. The OAuth token-exchange | 23 | rather than fall back to bearer authentication. The OAuth token-exchange |
| 24 | vocabulary gives these grants a familiar meaning, but the environment does not | 24 | vocabulary gives these grants a familiar meaning. |
| 25 | implement a general-purpose OAuth authorization server. | 25 | |
| 26 | ### MCP clients are a separate audience | ||
| 27 | |||
| 28 | Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth | ||
| 29 | authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)). | ||
| 30 | It accepts only loopback redirect URIs: an HTTPS redirect would let anyone send | ||
| 31 | the owner an approval link that delivers the code to their own server. Client | ||
| 32 | registration is stateless, so an unauthenticated caller cannot grow server | ||
| 33 | state. Approval spends a one-time pairing code, or uses a browser session with | ||
| 34 | `access:write`; proof-bound T3 Connect codes are refused without being spent. | ||
| 35 | |||
| 36 | The user grants either read-only access or a runtime-mode ceiling, not a | ||
| 37 | scope list: MCP tools are all orchestration, and `orchestration:operate` | ||
| 38 | alone would let an agent start a thread in full access and act through it. | ||
| 39 | The result is an ordinary session with subject `mcp-client`. A read-only | ||
| 40 | grant holds `orchestration:read` alone. On `/mcp` it passes only tools | ||
| 41 | declared as reads in [McpToolAccess](../../apps/server/src/mcp/McpToolAccess.ts), | ||
| 42 | where every tool must declare who may call it to compile. Any other grant adds | ||
| 43 | `orchestration:operate` and a signed ceiling. Only `/mcp` accepts these sessions. Every other HTTP and WebSocket | ||
| 44 | path rejects that subject, because the RPC surface would let the agent act | ||
| 45 | above its ceiling. Inside MCP the credential sets the limits and tool | ||
| 46 | parameters only pick targets; see | ||
| 47 | [threadAccess](../../apps/server/src/mcp/threadAccess.ts). | ||
| 48 | |||
| 49 | Issuer and resource URLs come from the request's Host and | ||
| 50 | `X-Forwarded-Proto`, so one server answers over loopback, Tailscale Serve and a | ||
| 51 | T3 Connect tunnel. A proxy that rewrites Host or drops the protocol header | ||
| 52 | breaks sign-in. | ||
| 26 | 53 | ||
| 27 | Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated | 54 | Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated |
| 28 | HTTP so long-lived tokens stay out of socket URLs. Browser sessions can | 55 | HTTP so long-lived tokens stay out of socket URLs. Browser sessions can |
packages/contracts/src/auth.ts+180| ... | @@ -353,3 +353,183 @@ export const AuthSessionState = Schema.Struct({ | ... | @@ -353,3 +353,183 @@ export const AuthSessionState = Schema.Struct({ |
| 353 | expiresAt: Schema.optionalKey(Schema.DateTimeUtc), | 353 | expiresAt: Schema.optionalKey(Schema.DateTimeUtc), |
| 354 | }); | 354 | }); |
| 355 | export type AuthSessionState = typeof AuthSessionState.Type; | 355 | export type AuthSessionState = typeof AuthSessionState.Type; |
| 356 | |||
| 357 | /** | ||
| 358 | * What an agent signed in through MCP OAuth may do, least to most: only read, | ||
| 359 | * or act on threads that never run above the given runtime mode. | ||
| 360 | */ | ||
| 361 | export const AuthMcpClientAccess = Schema.Literals([ | ||
| 362 | "read-only", | ||
| 363 | "approval-required", | ||
| 364 | "auto-accept-edits", | ||
| 365 | "auto", | ||
| 366 | "full-access", | ||
| 367 | ]); | ||
| 368 | export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type; | ||
| 369 | |||
| 370 | /** RFC 9728 metadata for an environment's `/mcp` resource. */ | ||
| 371 | export const AuthMcpProtectedResourceMetadata = Schema.Struct({ | ||
| 372 | resource: Schema.String, | ||
| 373 | authorization_servers: Schema.Array(Schema.String), | ||
| 374 | scopes_supported: Schema.Array(AuthEnvironmentScope), | ||
| 375 | bearer_methods_supported: Schema.Array(Schema.Literal("header")), | ||
| 376 | resource_name: Schema.String, | ||
| 377 | }); | ||
| 378 | export type AuthMcpProtectedResourceMetadata = typeof AuthMcpProtectedResourceMetadata.Type; | ||
| 379 | |||
| 380 | /** RFC 8414 metadata for the authorization server MCP clients sign in through. */ | ||
| 381 | export const AuthMcpAuthorizationServerMetadata = Schema.Struct({ | ||
| 382 | issuer: Schema.String, | ||
| 383 | authorization_endpoint: Schema.String, | ||
| 384 | token_endpoint: Schema.String, | ||
| 385 | registration_endpoint: Schema.String, | ||
| 386 | response_types_supported: Schema.Array(Schema.Literal("code")), | ||
| 387 | grant_types_supported: Schema.Array(Schema.Literal("authorization_code")), | ||
| 388 | code_challenge_methods_supported: Schema.Array(Schema.Literal("S256")), | ||
| 389 | token_endpoint_auth_methods_supported: Schema.Array(Schema.Literal("none")), | ||
| 390 | scopes_supported: Schema.Array(AuthEnvironmentScope), | ||
| 391 | authorization_response_iss_parameter_supported: Schema.Boolean, | ||
| 392 | }); | ||
| 393 | export type AuthMcpAuthorizationServerMetadata = typeof AuthMcpAuthorizationServerMetadata.Type; | ||
| 394 | |||
| 395 | /** RFC 7591 client metadata. Fields the server does not use are dropped. */ | ||
| 396 | export const AuthMcpClientRegistration = Schema.Struct({ | ||
| 397 | client_name: Schema.optionalKey(Schema.String), | ||
| 398 | redirect_uris: Schema.optionalKey(Schema.Array(Schema.String)), | ||
| 399 | token_endpoint_auth_method: Schema.optionalKey(Schema.String), | ||
| 400 | }); | ||
| 401 | export type AuthMcpClientRegistration = typeof AuthMcpClientRegistration.Type; | ||
| 402 | |||
| 403 | export const AuthMcpRegisteredClient = Schema.Struct({ | ||
| 404 | client_id: Schema.String, | ||
| 405 | client_name: Schema.String, | ||
| 406 | redirect_uris: Schema.Array(Schema.String), | ||
| 407 | grant_types: Schema.Array(Schema.Literal("authorization_code")), | ||
| 408 | response_types: Schema.Array(Schema.Literal("code")), | ||
| 409 | token_endpoint_auth_method: Schema.Literal("none"), | ||
| 410 | }).pipe(HttpApiSchema.status(201)); | ||
| 411 | export type AuthMcpRegisteredClient = typeof AuthMcpRegisteredClient.Type; | ||
| 412 | |||
| 413 | /** RFC 7591 §3.2.2 registration error. */ | ||
| 414 | export class AuthMcpRegistrationError extends Schema.Error<AuthMcpRegistrationError>( | ||
| 415 | "AuthMcpRegistrationError", | ||
| 416 | )( | ||
| 417 | { | ||
| 418 | error: Schema.Literals(["invalid_client_metadata", "invalid_redirect_uri"]), | ||
| 419 | error_description: Schema.String, | ||
| 420 | }, | ||
| 421 | { httpApiStatus: 400 }, | ||
| 422 | ) { | ||
| 423 | override get message(): string { | ||
| 424 | return this.error_description; | ||
| 425 | } | ||
| 426 | } | ||
| 427 | |||
| 428 | /** | ||
| 429 | * An agent's authorization request, as the approval page received it in its | ||
| 430 | * URL. Every field is checked by the server, so all are optional here. | ||
| 431 | */ | ||
| 432 | export const AuthMcpAuthorizationRequest = Schema.Struct({ | ||
| 433 | response_type: Schema.optionalKey(Schema.String), | ||
| 434 | client_id: Schema.optionalKey(Schema.String), | ||
| 435 | redirect_uri: Schema.optionalKey(Schema.String), | ||
| 436 | code_challenge: Schema.optionalKey(Schema.String), | ||
| 437 | code_challenge_method: Schema.optionalKey(Schema.String), | ||
| 438 | state: Schema.optionalKey(Schema.String), | ||
| 439 | resource: Schema.optionalKey(Schema.String), | ||
| 440 | }); | ||
| 441 | export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Type; | ||
| 442 | |||
| 443 | /** | ||
| 444 | * What the approval page needs to show for an MCP OAuth sign-in. The server | ||
| 445 | * has already validated the request; nothing here is trusted by the client | ||
| 446 | * except for display. | ||
| 447 | */ | ||
| 448 | export const AuthMcpApprovalDetails = Schema.Struct({ | ||
| 449 | /** Self-declared by the client, so shown as such. */ | ||
| 450 | clientName: Schema.String, | ||
| 451 | /** Where the code goes: always a loopback address on the browser's machine. */ | ||
| 452 | redirectHost: Schema.String, | ||
| 453 | environmentHost: Schema.String, | ||
| 454 | /** Present when this browser's session may approve without a pairing code. */ | ||
| 455 | csrfToken: Schema.optionalKey(Schema.String), | ||
| 456 | /** | ||
| 457 | * What that session may approve in one click: only access whose scopes it | ||
| 458 | * holds. Anything else still needs a pairing code. Absent with `csrfToken`. | ||
| 459 | */ | ||
| 460 | oneClickAccess: Schema.optionalKey(Schema.Array(AuthMcpClientAccess)), | ||
| 461 | }); | ||
| 462 | export type AuthMcpApprovalDetails = typeof AuthMcpApprovalDetails.Type; | ||
| 463 | |||
| 464 | /** | ||
| 465 | * Where the approval page sends the browser next: back to the agent with a | ||
| 466 | * code, a denial, or a protocol error the agent should receive. | ||
| 467 | */ | ||
| 468 | export const AuthMcpApprovalRedirect = Schema.Struct({ | ||
| 469 | redirectTo: Schema.String, | ||
| 470 | }); | ||
| 471 | export type AuthMcpApprovalRedirect = typeof AuthMcpApprovalRedirect.Type; | ||
| 472 | |||
| 473 | export const AuthMcpApprovalDecision = Schema.Union([ | ||
| 474 | Schema.TaggedStruct("deny", {}), | ||
| 475 | Schema.TaggedStruct("pairing-code", { | ||
| 476 | access: AuthMcpClientAccess, | ||
| 477 | code: TrimmedNonEmptyString, | ||
| 478 | }), | ||
| 479 | /** One click, for a browser session that may approve (see `csrfToken`). */ | ||
| 480 | Schema.TaggedStruct("browser-session", { | ||
| 481 | access: AuthMcpClientAccess, | ||
| 482 | csrfToken: Schema.String, | ||
| 483 | }), | ||
| 484 | ]); | ||
| 485 | export type AuthMcpApprovalDecision = typeof AuthMcpApprovalDecision.Type; | ||
| 486 | |||
| 487 | export const AuthMcpApprovalDecisionRequest = Schema.Struct({ | ||
| 488 | authorization: AuthMcpAuthorizationRequest, | ||
| 489 | decision: AuthMcpApprovalDecision, | ||
| 490 | }); | ||
| 491 | export type AuthMcpApprovalDecisionRequest = typeof AuthMcpApprovalDecisionRequest.Type; | ||
| 492 | |||
| 493 | /** A problem the approval page shows the user without redirecting anywhere. */ | ||
| 494 | export class AuthMcpApprovalError extends Schema.TaggedError<AuthMcpApprovalError>()( | ||
| 495 | "AuthMcpApprovalError", | ||
| 496 | { message: Schema.String }, | ||
| 497 | { httpApiStatus: 400 }, | ||
| 498 | ) {} | ||
| 499 | |||
| 500 | /** RFC 6749 §4.1.3 token request. Every field is checked by the server. */ | ||
| 501 | export const AuthMcpTokenRequest = Schema.Struct({ | ||
| 502 | grant_type: Schema.optionalKey(Schema.String), | ||
| 503 | code: Schema.optionalKey(Schema.String), | ||
| 504 | redirect_uri: Schema.optionalKey(Schema.String), | ||
| 505 | client_id: Schema.optionalKey(Schema.String), | ||
| 506 | code_verifier: Schema.optionalKey(Schema.String), | ||
| 507 | resource: Schema.optionalKey(Schema.String), | ||
| 508 | }).pipe(HttpApiSchema.asFormUrlEncoded()); | ||
| 509 | export type AuthMcpTokenRequest = typeof AuthMcpTokenRequest.Type; | ||
| 510 | |||
| 511 | export const AuthMcpTokenResult = Schema.Struct({ | ||
| 512 | access_token: Schema.String, | ||
| 513 | token_type: Schema.Literal("Bearer"), | ||
| 514 | expires_in: Schema.Number, | ||
| 515 | scope: Schema.String, | ||
| 516 | }); | ||
| 517 | export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type; | ||
| 518 | |||
| 519 | /** RFC 6749 §5.2 token error. */ | ||
| 520 | export class AuthMcpTokenError extends Schema.Error<AuthMcpTokenError>("AuthMcpTokenError")( | ||
| 521 | { | ||
| 522 | error: Schema.Literals([ | ||
| 523 | "invalid_request", | ||
| 524 | "invalid_client", | ||
| 525 | "invalid_grant", | ||
| 526 | "unsupported_grant_type", | ||
| 527 | ]), | ||
| 528 | error_description: Schema.String, | ||
| 529 | }, | ||
| 530 | { httpApiStatus: 400 }, | ||
| 531 | ) { | ||
| 532 | override get message(): string { | ||
| 533 | return this.error_description; | ||
| 534 | } | ||
| 535 | } |
packages/contracts/src/environmentHttp.ts+66| ... | @@ -15,6 +15,19 @@ import { | ... | @@ -15,6 +15,19 @@ import { |
| 15 | AuthBrowserSessionResult, | 15 | AuthBrowserSessionResult, |
| 16 | AuthClientSession, | 16 | AuthClientSession, |
| 17 | AuthCreatePairingCredentialInput, | 17 | AuthCreatePairingCredentialInput, |
| 18 | AuthMcpApprovalDecisionRequest, | ||
| 19 | AuthMcpApprovalDetails, | ||
| 20 | AuthMcpApprovalError, | ||
| 21 | AuthMcpApprovalRedirect, | ||
| 22 | AuthMcpAuthorizationRequest, | ||
| 23 | AuthMcpAuthorizationServerMetadata, | ||
| 24 | AuthMcpClientRegistration, | ||
| 25 | AuthMcpProtectedResourceMetadata, | ||
| 26 | AuthMcpRegisteredClient, | ||
| 27 | AuthMcpRegistrationError, | ||
| 28 | AuthMcpTokenError, | ||
| 29 | AuthMcpTokenRequest, | ||
| 30 | AuthMcpTokenResult, | ||
| 18 | AuthPairingCredentialResult, | 31 | AuthPairingCredentialResult, |
| 19 | AuthPairingLink, | 32 | AuthPairingLink, |
| 20 | AuthRevokeClientSessionInput, | 33 | AuthRevokeClientSessionInput, |
| ... | @@ -514,6 +527,58 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") | ... | @@ -514,6 +527,58 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") |
| 514 | }).middleware(EnvironmentAuthenticatedAuth), | 527 | }).middleware(EnvironmentAuthenticatedAuth), |
| 515 | ) {} | 528 | ) {} |
| 516 | 529 | ||
| 530 | /** | ||
| 531 | * The OAuth authorization server outside agents use to sign in to `/mcp`. | ||
| 532 | * `authorize` is where the agent sends the browser: it answers a redirect to | ||
| 533 | * the web app's approval page, or a plain error page for a request that names | ||
| 534 | * an unverified client or redirect, so the response is not a schema. | ||
| 535 | */ | ||
| 536 | class EnvironmentMcpOAuthHttpApi extends HttpApiGroup.make("mcpOAuth") | ||
| 537 | .add( | ||
| 538 | HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { | ||
| 539 | success: AuthMcpProtectedResourceMetadata, | ||
| 540 | }), | ||
| 541 | ) | ||
| 542 | .add( | ||
| 543 | HttpApiEndpoint.get("mcpProtectedResource", "/.well-known/oauth-protected-resource/mcp", { | ||
| 544 | success: AuthMcpProtectedResourceMetadata, | ||
| 545 | }), | ||
| 546 | ) | ||
| 547 | .add( | ||
| 548 | HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { | ||
| 549 | success: AuthMcpAuthorizationServerMetadata, | ||
| 550 | }), | ||
| 551 | ) | ||
| 552 | .add( | ||
| 553 | HttpApiEndpoint.post("register", "/oauth/mcp/register", { | ||
| 554 | payload: AuthMcpClientRegistration, | ||
| 555 | success: AuthMcpRegisteredClient, | ||
| 556 | error: AuthMcpRegistrationError, | ||
| 557 | }), | ||
| 558 | ) | ||
| 559 | .add(HttpApiEndpoint.get("authorize", "/oauth/mcp/authorize")) | ||
| 560 | .add( | ||
| 561 | HttpApiEndpoint.post("approval", "/oauth/mcp/approval", { | ||
| 562 | payload: AuthMcpAuthorizationRequest, | ||
| 563 | success: [AuthMcpApprovalDetails, AuthMcpApprovalRedirect], | ||
| 564 | error: AuthMcpApprovalError, | ||
| 565 | }), | ||
| 566 | ) | ||
| 567 | .add( | ||
| 568 | HttpApiEndpoint.post("decision", "/oauth/mcp/decision", { | ||
| 569 | payload: AuthMcpApprovalDecisionRequest, | ||
| 570 | success: AuthMcpApprovalRedirect, | ||
| 571 | error: AuthMcpApprovalError, | ||
| 572 | }), | ||
| 573 | ) | ||
| 574 | .add( | ||
| 575 | HttpApiEndpoint.post("token", "/oauth/mcp/token", { | ||
| 576 | payload: AuthMcpTokenRequest, | ||
| 577 | success: AuthMcpTokenResult, | ||
| 578 | error: AuthMcpTokenError, | ||
| 579 | }), | ||
| 580 | ) {} | ||
| 581 | |||
| 517 | const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ | 582 | const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ |
| 518 | threadId: ThreadId, | 583 | threadId: ThreadId, |
| 519 | }); | 584 | }); |
| ... | @@ -684,6 +749,7 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks") | ... | @@ -684,6 +749,7 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks") |
| 684 | export class EnvironmentHttpApi extends HttpApi.make("environment") | 749 | export class EnvironmentHttpApi extends HttpApi.make("environment") |
| 685 | .add(EnvironmentMetadataHttpApi) | 750 | .add(EnvironmentMetadataHttpApi) |
| 686 | .add(EnvironmentAuthHttpApi) | 751 | .add(EnvironmentAuthHttpApi) |
| 752 | .add(EnvironmentMcpOAuthHttpApi) | ||
| 687 | .add(EnvironmentOrchestrationHttpApi) | 753 | .add(EnvironmentOrchestrationHttpApi) |
| 688 | .add(EnvironmentPullRequestsHttpApi) | 754 | .add(EnvironmentPullRequestsHttpApi) |
| 689 | .add(EnvironmentProjectsHttpApi) | 755 | .add(EnvironmentProjectsHttpApi) |
packages/shared/src/devProxy.ts+12-1| ... | @@ -8,7 +8,18 @@ | ... | @@ -8,7 +8,18 @@ |
| 8 | * prefix only Vite knows gets answered with index.html; a prefix only the | 8 | * prefix only Vite knows gets answered with index.html; a prefix only the |
| 9 | * server knows redirect-loops through the proxy. | 9 | * server knows redirect-loops through the proxy. |
| 10 | */ | 10 | */ |
| 11 | export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; | 11 | export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const; |
| 12 | |||
| 13 | /** | ||
| 14 | * Prefixes the proxy must forward with the browser's own Host. MCP OAuth | ||
| 15 | * derives its issuer and resource URLs from the request, and a client | ||
| 16 | * rejects metadata naming a different origin than the one it fetched. | ||
| 17 | */ | ||
| 18 | export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([ | ||
| 19 | "/oauth", | ||
| 20 | "/.well-known", | ||
| 21 | "/mcp", | ||
| 22 | ]); | ||
| 12 | 23 | ||
| 13 | export function isDevProxiedPath(pathname: string): boolean { | 24 | export function isDevProxiedPath(pathname: string): boolean { |
| 14 | return DEV_PROXIED_PATH_PREFIXES.some( | 25 | return DEV_PROXIED_PATH_PREFIXES.some( |