| author | |
| committer | |
| log | 10f39eb9ac80c9a4b7f5097575dd2addc3b6f631 |
| tree | c89aabd93a6b1c4d5aa295e8642a75b5692cb1bf |
| parent | 365aa87982a4d81cc8e0c085e8d1a40ca7daecdc |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 143 insertions(+), 56 deletions(-)
apps/server/src/auth/McpOAuth.test.ts+86-17| ... | ... | @@ -180,12 +180,20 @@ it.live("derives discovery metadata from the origin the client reached", () => |
| 180 | 180 | ), |
| 181 | 181 | ); |
| 182 | 182 | |
| 183 | it.live("registers only loopback clients and never redirects for an unverified client", () => | |
| 183 | it.live("registers loopback and https clients and never redirects for an unverified client", () => | |
| 184 | 184 | withRoutes((handler) => |
| 185 | 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" }); | |
| 186 | for (const refused of [ | |
| 187 | "http://bot.example/callback", | |
| 188 | "https://user:pass@bot.example/callback", | |
| 189 | "https://bot.example/callback#", | |
| 190 | "http://localhost/callback#", | |
| 191 | "javascript:alert(1)", | |
| 192 | ]) { | |
| 193 | const response = yield* register(handler, refused); | |
| 194 | expect(response.status).toBe(400); | |
| 195 | expect(yield* json<unknown>(response)).toMatchObject({ error: "invalid_redirect_uri" }); | |
| 196 | } | |
| 189 | 197 | |
| 190 | 198 | const registered = yield* register(handler); |
| 191 | 199 | expect(registered.status).toBe(201); |
| ... | ... | @@ -308,6 +316,70 @@ it.live("signs in with a pairing code and issues a token only /mcp accepts", () |
| 308 | 316 | ), |
| 309 | 317 | ); |
| 310 | 318 | |
| 319 | it.live("signs in a hosted agent with an https callback that asked for a client secret", () => | |
| 320 | withRoutes((handler, auth) => | |
| 321 | Effect.gen(function* () { | |
| 322 | const callbackUrl = "https://bot.example/oauth/callback"; | |
| 323 | const registered = yield* handler( | |
| 324 | at("/oauth/mcp/register", { | |
| 325 | method: "POST", | |
| 326 | headers: { "content-type": "application/json" }, | |
| 327 | body: encodeJson({ | |
| 328 | client_name: "Hosted bot", | |
| 329 | redirect_uris: [callbackUrl], | |
| 330 | token_endpoint_auth_method: "client_secret_post", | |
| 331 | }), | |
| 332 | }), | |
| 333 | ); | |
| 334 | expect(registered.status).toBe(201); | |
| 335 | const client = (yield* json<unknown>(registered)) as { | |
| 336 | client_id: string; | |
| 337 | token_endpoint_auth_method: string; | |
| 338 | }; | |
| 339 | // Registered as a public client: PKCE, not a secret, proves it at the token endpoint. | |
| 340 | expect(client.token_endpoint_auth_method).toBe("none"); | |
| 341 | ||
| 342 | const params = authorizeParams(client.client_id, callbackUrl); | |
| 343 | const details = yield* handler(postJson("/oauth/mcp/approval", params)).pipe( | |
| 344 | Effect.flatMap(json<Record<string, unknown>>), | |
| 345 | ); | |
| 346 | expect(details).toMatchObject({ clientName: "Hosted bot", redirectHost: "bot.example" }); | |
| 347 | ||
| 348 | const pairing = yield* auth.issuePairingCredential(); | |
| 349 | const approved = yield* decide(handler, params, { | |
| 350 | _tag: "pairing-code", | |
| 351 | access: "approval-required", | |
| 352 | code: pairing.credential, | |
| 353 | }); | |
| 354 | const callback = new URL(approved.redirectTo!); | |
| 355 | expect(`${callback.origin}${callback.pathname}`).toBe(callbackUrl); | |
| 356 | ||
| 357 | const tokenResponse = yield* handler( | |
| 358 | at( | |
| 359 | "/oauth/mcp/token", | |
| 360 | form({ | |
| 361 | grant_type: "authorization_code", | |
| 362 | code: callback.searchParams.get("code")!, | |
| 363 | redirect_uri: callbackUrl, | |
| 364 | client_id: client.client_id, | |
| 365 | // Sent by a client that asked for one; there is none to check. | |
| 366 | client_secret: "unused", | |
| 367 | code_verifier: verifier, | |
| 368 | }), | |
| 369 | ), | |
| 370 | ); | |
| 371 | expect(tokenResponse.status).toBe(200); | |
| 372 | const token = (yield* json<unknown>(tokenResponse)) as { access_token: string }; | |
| 373 | const signedIn = yield* auth.authenticateMcpClient( | |
| 374 | HttpServerRequest.fromWeb( | |
| 375 | at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }), | |
| 376 | ), | |
| 377 | ); | |
| 378 | expect(signedIn).toMatchObject({ label: "Hosted bot", access: "approval-required" }); | |
| 379 | }), | |
| 380 | ), | |
| 381 | ); | |
| 382 | ||
| 311 | 383 | it.live("rejects a wrong PKCE verifier and spends the code", () => |
| 312 | 384 | withRoutes((handler, auth) => |
| 313 | 385 | Effect.gen(function* () { |
| ... | ... | @@ -489,17 +561,14 @@ it.live("one-click approves only access the browser session holds the scopes for |
| 489 | 561 | ), |
| 490 | 562 | ); |
| 491 | 563 | |
| 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); | |
| 564 | it("matches loopback redirects on everything but the port, and https redirects exactly", () => { | |
| 565 | const matches = McpOAuth.redirectMatches; | |
| 566 | expect(matches("http://localhost/callback", "http://localhost:9/callback")).toBe(true); | |
| 567 | expect(matches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback")).toBe(true); | |
| 568 | expect(matches("http://localhost/callback", "http://127.0.0.1/callback")).toBe(false); | |
| 569 | expect(matches("http://localhost/callback", "https://localhost/callback")).toBe(false); | |
| 570 | expect(matches("https://bot.example/cb", "https://bot.example/cb")).toBe(true); | |
| 571 | expect(matches("https://bot.example/cb", "https://bot.example:8443/cb")).toBe(false); | |
| 572 | expect(matches("https://bot.example/cb", "https://bot.example/cb?x=1")).toBe(false); | |
| 573 | expect(matches("https://bot.example/cb", "https://evil.example/cb")).toBe(false); | |
| 505 | 574 | }); |
apps/server/src/auth/McpOAuth.ts+29-33| ... | ... | @@ -45,9 +45,10 @@ import { |
| 45 | 45 | * Every URL is derived from the request's own origin, so the same server |
| 46 | 46 | * answers correctly over loopback, Tailscale Serve and a T3 Connect tunnel. |
| 47 | 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. | |
| 48 | * an unauthenticated caller cannot grow server state. Redirects go to a | |
| 49 | * loopback address (a CLI agent on the user's machine) or any https address | |
| 50 | * (a hosted agent); the approval page names where access goes, and the user | |
| 51 | * decides. | |
| 51 | 52 | */ |
| 52 | 53 | |
| 53 | 54 | const SIGNING_SECRET_NAME = "mcp-oauth-signing-key"; |
| ... | ... | @@ -105,29 +106,32 @@ export const authorizationServerMetadata = ( |
| 105 | 106 | authorization_response_iss_parameter_supported: true, |
| 106 | 107 | }); |
| 107 | 108 | |
| 108 | const parseLoopbackRedirect = (value: string): URL | undefined => { | |
| 109 | if (value.length > MAX_REDIRECT_URI_LENGTH) return undefined; | |
| 109 | /** A redirect URI an MCP client may register: loopback http, or any https. */ | |
| 110 | const parseRedirect = (value: string): URL | undefined => { | |
| 111 | // No fragment, not even an empty one, which `URL.hash` reports as "" (RFC 6749 §3.1.2). | |
| 112 | if (value.length > MAX_REDIRECT_URI_LENGTH || value.includes("#")) return undefined; | |
| 110 | 113 | try { |
| 111 | 114 | 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; | |
| 115 | const allowed = | |
| 116 | url.protocol === "https:" || | |
| 117 | (url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname)); | |
| 118 | return allowed && url.username === "" && url.password === "" ? url : undefined; | |
| 119 | 119 | } catch { |
| 120 | 120 | return undefined; |
| 121 | 121 | } |
| 122 | 122 | }; |
| 123 | 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); | |
| 124 | /** | |
| 125 | * Whether a presented redirect is one the client registered. Loopback | |
| 126 | * redirects match on everything but the port (RFC 8252 §7.3), since CLI | |
| 127 | * agents listen on whatever port is free; https redirects match exactly. | |
| 128 | */ | |
| 129 | export const redirectMatches = (registered: string, presented: string): boolean => { | |
| 130 | const left = parseRedirect(registered); | |
| 131 | const right = parseRedirect(presented); | |
| 132 | if (left === undefined || right === undefined) return false; | |
| 133 | if (left.protocol === "https:" || right.protocol === "https:") return left.href === right.href; | |
| 128 | 134 | return ( |
| 129 | left !== undefined && | |
| 130 | right !== undefined && | |
| 131 | 135 | left.hostname === right.hostname && |
| 132 | 136 | left.pathname === right.pathname && |
| 133 | 137 | left.search === right.search |
| ... | ... | @@ -320,24 +324,17 @@ const make = Effect.gen(function* () { |
| 320 | 324 | error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`, |
| 321 | 325 | }); |
| 322 | 326 | } |
| 323 | if (!redirectUris.every((uri) => parseLoopbackRedirect(uri) !== undefined)) { | |
| 327 | if (!redirectUris.every((uri) => parseRedirect(uri) !== undefined)) { | |
| 324 | 328 | return yield* new AuthMcpRegistrationError({ |
| 325 | 329 | error: "invalid_redirect_uri", |
| 326 | 330 | 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.", | |
| 331 | "Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1].", | |
| 337 | 332 | }); |
| 338 | 333 | } |
| 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. | |
| 334 | // Requested grant types, scopes and token endpoint auth methods are ignored rather | |
| 335 | // than rejected: RFC 7591 lets the server answer with what it supports. Claude Code | |
| 336 | // asks for refresh_token, and hosted agents may ask for a client secret; every client | |
| 337 | // is registered as public and proves itself with PKCE instead. | |
| 341 | 338 | return { clientId: signClientId(name, redirectUris), name, redirectUris }; |
| 342 | 339 | }); |
| 343 | 340 | |
| ... | ... | @@ -352,8 +349,7 @@ const make = Effect.gen(function* () { |
| 352 | 349 | const redirectUri = request.redirect_uri; |
| 353 | 350 | if ( |
| 354 | 351 | redirectUri === undefined || |
| 355 | parseLoopbackRedirect(redirectUri) === undefined || | |
| 356 | !client.redirectUris.some((registered) => loopbackRedirectMatches(registered, redirectUri)) | |
| 352 | !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri)) | |
| 357 | 353 | ) { |
| 358 | 354 | return yield* new McpOAuthPageError({ |
| 359 | 355 | description: "This sign-in link sends you to an address the app did not register.", |
apps/web/src/components/auth/ConnectAgentSurface.tsx+18-2| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | type AuthMcpAuthorizationRequest, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 9 | import { isLoopbackHost } from "@t3tools/shared/preview"; | |
| 9 | 10 | import { EyeIcon, type LucideIcon } from "lucide-react"; |
| 10 | 11 | import type * as Context from "effect/Context"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -35,6 +36,11 @@ const accessConfig: Record< |
| 35 | 36 | ...runtimeModeConfig, |
| 36 | 37 | }; |
| 37 | 38 | |
| 39 | /** A CLI agent's callback is a loopback address; a hosted agent's is its own server. */ | |
| 40 | function redirectsToThisComputer(redirectHost: string): boolean { | |
| 41 | return isLoopbackHost(new URL(`http://${redirectHost}`).hostname); | |
| 42 | } | |
| 43 | ||
| 38 | 44 | type Loaded = |
| 39 | 45 | | { readonly status: "loading" } |
| 40 | 46 | | { readonly status: "invalid"; readonly message: string } |
| ... | ... | @@ -188,8 +194,18 @@ export function ConnectAgentSurface() { |
| 188 | 194 | } |
| 189 | 195 | /> |
| 190 | 196 | <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. | |
| 197 | {redirectsToThisComputer(details.redirectHost) ? ( | |
| 198 | <> | |
| 199 | The name is chosen by the agent. Approval returns to {details.redirectHost} on the | |
| 200 | computer that opened this page. Only approve a sign-in you just started. | |
| 201 | </> | |
| 202 | ) : ( | |
| 203 | <> | |
| 204 | The name is chosen by the agent. Approval gives access to whoever runs{" "} | |
| 205 | <span className="font-medium text-foreground">{details.redirectHost}</span>. Only | |
| 206 | approve a sign-in you just started there. | |
| 207 | </> | |
| 208 | )} | |
| 193 | 209 | </p> |
| 194 | 210 | |
| 195 | 211 | <form |
docs/internals/environment-auth.md+6-3| ... | ... | @@ -27,9 +27,12 @@ vocabulary gives these grants a familiar meaning. |
| 27 | 27 | |
| 28 | 28 | Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth |
| 29 | 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 | |
| 30 | It accepts loopback redirect URIs for agents on the user's machine and any | |
| 31 | HTTPS redirect for hosted agents (ChatGPT, bots). An HTTPS redirect means a | |
| 32 | link someone else sends the owner can deliver access to that someone, so the | |
| 33 | approval page names the host access goes to and approving stays the owner's | |
| 34 | call. Every client is public and proves itself with PKCE; a client that asks | |
| 35 | for a secret is registered without one. Client registration is stateless, so an unauthenticated caller cannot grow server | |
| 33 | 36 | state. Approval spends a one-time pairing code, or uses a browser session with |
| 34 | 37 | `access:write`; proof-bound T3 Connect codes are refused without being spent. |
| 35 | 38 |
packages/contracts/src/auth.ts+4-1| ... | ... | @@ -562,7 +562,10 @@ export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Typ |
| 562 | 562 | export const AuthMcpApprovalDetails = Schema.Struct({ |
| 563 | 563 | /** Self-declared by the client, so shown as such. */ |
| 564 | 564 | clientName: Schema.String, |
| 565 | /** Where the code goes: always a loopback address on the browser's machine. */ | |
| 565 | /** | |
| 566 | * Where the code goes: a loopback address on the browser's machine (a CLI | |
| 567 | * agent), or an https host (a hosted agent). | |
| 568 | */ | |
| 566 | 569 | redirectHost: Schema.String, |
| 567 | 570 | environmentHost: Schema.String, |
| 568 | 571 | /** Present when this browser's session may approve without a pairing code. */ |