authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 22:16:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 22:16:29-07:00
log10f39eb9ac80c9a4b7f5097575dd2addc3b6f631
treec89aabd93a6b1c4d5aa295e8642a75b5692cb1bf
parent365aa87982a4d81cc8e0c085e8d1a40ca7daecdc
signature Signed by PGP key B5690EEEBB952194

feat(server): hosted agents like ChatGPT can sign in to the T3 MCP server (#16718)

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,12 +180,20 @@ it.live("derives discovery metadata from the origin the client reached", () =>
180 ),180 ),
181);181);
182182
183it.live("registers only loopback clients and never redirects for an unverified client", () =>183it.live("registers loopback and https clients and never redirects for an unverified client", () =>
184 withRoutes((handler) =>184 withRoutes((handler) =>
185 Effect.gen(function* () {185 Effect.gen(function* () {
186 const remote = yield* register(handler, "https://attacker.example/callback");186 for (const refused of [
187 expect(remote.status).toBe(400);187 "http://bot.example/callback",
188 expect(yield* json<unknown>(remote)).toMatchObject({ error: "invalid_redirect_uri" });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 }
189197
190 const registered = yield* register(handler);198 const registered = yield* register(handler);
191 expect(registered.status).toBe(201);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,6 +316,70 @@ it.live("signs in with a pairing code and issues a token only /mcp accepts", ()
308 ),316 ),
309);317);
310318
319it.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
311it.live("rejects a wrong PKCE verifier and spends the code", () =>383it.live("rejects a wrong PKCE verifier and spends the code", () =>
312 withRoutes((handler, auth) =>384 withRoutes((handler, auth) =>
313 Effect.gen(function* () {385 Effect.gen(function* () {
...@@ -489,17 +561,14 @@ it.live("one-click approves only access the browser session holds the scopes for...@@ -489,17 +561,14 @@ it.live("one-click approves only access the browser session holds the scopes for
489 ),561 ),
490);562);
491563
492it("matches loopback redirects on everything but the port", () => {564it("matches loopback redirects on everything but the port, and https redirects exactly", () => {
493 expect(565 const matches = McpOAuth.redirectMatches;
494 McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://localhost:9/callback"),566 expect(matches("http://localhost/callback", "http://localhost:9/callback")).toBe(true);
495 ).toBe(true);567 expect(matches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback")).toBe(true);
496 expect(568 expect(matches("http://localhost/callback", "http://127.0.0.1/callback")).toBe(false);
497 McpOAuth.loopbackRedirectMatches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback"),569 expect(matches("http://localhost/callback", "https://localhost/callback")).toBe(false);
498 ).toBe(true);570 expect(matches("https://bot.example/cb", "https://bot.example/cb")).toBe(true);
499 expect(571 expect(matches("https://bot.example/cb", "https://bot.example:8443/cb")).toBe(false);
500 McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://127.0.0.1/callback"),572 expect(matches("https://bot.example/cb", "https://bot.example/cb?x=1")).toBe(false);
501 ).toBe(false);573 expect(matches("https://bot.example/cb", "https://evil.example/cb")).toBe(false);
502 expect(
503 McpOAuth.loopbackRedirectMatches("http://localhost/callback", "https://localhost/callback"),
504 ).toBe(false);
505});574});
apps/server/src/auth/McpOAuth.ts+29-33
...@@ -45,9 +45,10 @@ import {...@@ -45,9 +45,10 @@ import {
45 * Every URL is derived from the request's own origin, so the same server45 * 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.46 * answers correctly over loopback, Tailscale Serve and a T3 Connect tunnel.
47 * Client registration is stateless: a client id is its signed metadata, so47 * Client registration is stateless: a client id is its signed metadata, so
48 * an unauthenticated caller cannot grow server state. Only loopback redirect48 * an unauthenticated caller cannot grow server state. Redirects go to a
49 * URIs are accepted; an https redirect would let anyone mail the owner an49 * loopback address (a CLI agent on the user's machine) or any https address
50 * approval link that delivers the code to their own server.50 * (a hosted agent); the approval page names where access goes, and the user
51 * decides.
51 */52 */
5253
53const SIGNING_SECRET_NAME = "mcp-oauth-signing-key";54const SIGNING_SECRET_NAME = "mcp-oauth-signing-key";
...@@ -105,29 +106,32 @@ export const authorizationServerMetadata = (...@@ -105,29 +106,32 @@ export const authorizationServerMetadata = (
105 authorization_response_iss_parameter_supported: true,106 authorization_response_iss_parameter_supported: true,
106});107});
107108
108const parseLoopbackRedirect = (value: string): URL | undefined => {109/** A redirect URI an MCP client may register: loopback http, or any https. */
109 if (value.length > MAX_REDIRECT_URI_LENGTH) return undefined;110const parseRedirect = (value: string): URL | undefined => {
111 // No fragment, not even an empty one, which `URL.hash` reports as "" (RFC 6749 §3.1.2).
112 if (value.length > MAX_REDIRECT_URI_LENGTH || value.includes("#")) return undefined;
110 try {113 try {
111 const url = new URL(value);114 const url = new URL(value);
112 return url.protocol === "http:" &&115 const allowed =
113 LOOPBACK_HOSTNAMES.has(url.hostname) &&116 url.protocol === "https:" ||
114 url.username === "" &&117 (url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname));
115 url.password === "" &&118 return allowed && url.username === "" && url.password === "" ? url : undefined;
116 url.hash === ""
117 ? url
118 : undefined;
119 } catch {119 } catch {
120 return undefined;120 return undefined;
121 }121 }
122};122};
123123
124/** RFC 8252 §7.3: loopback redirects match on everything but the port. */124/**
125export const loopbackRedirectMatches = (registered: string, presented: string): boolean => {125 * Whether a presented redirect is one the client registered. Loopback
126 const left = parseLoopbackRedirect(registered);126 * redirects match on everything but the port (RFC 8252 §7.3), since CLI
127 const right = parseLoopbackRedirect(presented);127 * agents listen on whatever port is free; https redirects match exactly.
128 */
129export const redirectMatches = (registered: string, presented: string): boolean => {
130 const left = parseRedirect(registered);
131 const right = parseRedirect(presented);
132 if (left === undefined || right === undefined) return false;
133 if (left.protocol === "https:" || right.protocol === "https:") return left.href === right.href;
128 return (134 return (
129 left !== undefined &&
130 right !== undefined &&
131 left.hostname === right.hostname &&135 left.hostname === right.hostname &&
132 left.pathname === right.pathname &&136 left.pathname === right.pathname &&
133 left.search === right.search137 left.search === right.search
...@@ -320,24 +324,17 @@ const make = Effect.gen(function* () {...@@ -320,24 +324,17 @@ const make = Effect.gen(function* () {
320 error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`,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 return yield* new AuthMcpRegistrationError({328 return yield* new AuthMcpRegistrationError({
325 error: "invalid_redirect_uri",329 error: "invalid_redirect_uri",
326 error_description:330 error_description:
327 "Only http://localhost, 127.0.0.1 or [::1] redirect URIs are accepted.",331 "Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1].",
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 });332 });
338 }333 }
339 // Requested grant types and scopes are ignored rather than rejected: RFC 7591 lets334 // Requested grant types, scopes and token endpoint auth methods are ignored rather
340 // the server answer with what it supports, and Claude Code asks for refresh_token.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 return { clientId: signClientId(name, redirectUris), name, redirectUris };338 return { clientId: signClientId(name, redirectUris), name, redirectUris };
342 });339 });
343340
...@@ -352,8 +349,7 @@ const make = Effect.gen(function* () {...@@ -352,8 +349,7 @@ const make = Effect.gen(function* () {
352 const redirectUri = request.redirect_uri;349 const redirectUri = request.redirect_uri;
353 if (350 if (
354 redirectUri === undefined ||351 redirectUri === undefined ||
355 parseLoopbackRedirect(redirectUri) === undefined ||352 !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri))
356 !client.redirectUris.some((registered) => loopbackRedirectMatches(registered, redirectUri))
357 ) {353 ) {
358 return yield* new McpOAuthPageError({354 return yield* new McpOAuthPageError({
359 description: "This sign-in link sends you to an address the app did not register.",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 +6,7 @@ import {
6 type AuthMcpAuthorizationRequest,6 type AuthMcpAuthorizationRequest,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { Radio as RadioPrimitive } from "@base-ui/react/radio";8import { Radio as RadioPrimitive } from "@base-ui/react/radio";
9import { isLoopbackHost } from "@t3tools/shared/preview";
9import { EyeIcon, type LucideIcon } from "lucide-react";10import { EyeIcon, type LucideIcon } from "lucide-react";
10import type * as Context from "effect/Context";11import type * as Context from "effect/Context";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
...@@ -35,6 +36,11 @@ const accessConfig: Record<...@@ -35,6 +36,11 @@ const accessConfig: Record<
35 ...runtimeModeConfig,36 ...runtimeModeConfig,
36};37};
3738
39/** A CLI agent's callback is a loopback address; a hosted agent's is its own server. */
40function redirectsToThisComputer(redirectHost: string): boolean {
41 return isLoopbackHost(new URL(`http://${redirectHost}`).hostname);
42}
43
38type Loaded =44type Loaded =
39 | { readonly status: "loading" }45 | { readonly status: "loading" }
40 | { readonly status: "invalid"; readonly message: string }46 | { readonly status: "invalid"; readonly message: string }
...@@ -188,8 +194,18 @@ export function ConnectAgentSurface() {...@@ -188,8 +194,18 @@ export function ConnectAgentSurface() {
188 }194 }
189 />195 />
190 <p className="mt-2 text-xs text-muted-foreground">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 computer197 {redirectsToThisComputer(details.redirectHost) ? (
192 that opened this page. Only approve a sign-in you just started.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 </p>209 </p>
194210
195 <form211 <form
docs/internals/environment-auth.md+6-3
...@@ -27,9 +27,12 @@ vocabulary gives these grants a familiar meaning....@@ -27,9 +27,12 @@ vocabulary gives these grants a familiar meaning.
2727
28Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth28Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth
29authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)).29authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)).
30It accepts only loopback redirect URIs: an HTTPS redirect would let anyone send30It accepts loopback redirect URIs for agents on the user's machine and any
31the owner an approval link that delivers the code to their own server. Client31HTTPS redirect for hosted agents (ChatGPT, bots). An HTTPS redirect means a
32registration is stateless, so an unauthenticated caller cannot grow server32link someone else sends the owner can deliver access to that someone, so the
33approval page names the host access goes to and approving stays the owner's
34call. Every client is public and proves itself with PKCE; a client that asks
35for a secret is registered without one. Client registration is stateless, so an unauthenticated caller cannot grow server
33state. Approval spends a one-time pairing code, or uses a browser session with36state. 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.37`access:write`; proof-bound T3 Connect codes are refused without being spent.
3538
packages/contracts/src/auth.ts+4-1
...@@ -562,7 +562,10 @@ export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Typ...@@ -562,7 +562,10 @@ export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Typ
562export const AuthMcpApprovalDetails = Schema.Struct({562export const AuthMcpApprovalDetails = Schema.Struct({
563 /** Self-declared by the client, so shown as such. */563 /** Self-declared by the client, so shown as such. */
564 clientName: Schema.String,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 redirectHost: Schema.String,569 redirectHost: Schema.String,
567 environmentHost: Schema.String,570 environmentHost: Schema.String,
568 /** Present when this browser's session may approve without a pairing code. */571 /** Present when this browser's session may approve without a pairing code. */