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", () =>
180180 ),
181181);
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", () =>
184184 withRoutes((handler) =>
185185 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 }
189197
190198 const registered = yield* register(handler);
191199 expect(registered.status).toBe(201);
......@@ -308,6 +316,70 @@ it.live("signs in with a pairing code and issues a token only /mcp accepts", ()
308316 ),
309317);
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
311383it.live("rejects a wrong PKCE verifier and spends the code", () =>
312384 withRoutes((handler, auth) =>
313385 Effect.gen(function* () {
......@@ -489,17 +561,14 @@ it.live("one-click approves only access the browser session holds the scopes for
489561 ),
490562);
491563
492it("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);
564it("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);
505574});
apps/server/src/auth/McpOAuth.ts+29-33
......@@ -45,9 +45,10 @@ import {
4545 * Every URL is derived from the request's own origin, so the same server
4646 * answers correctly over loopback, Tailscale Serve and a T3 Connect tunnel.
4747 * 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.
5152 */
5253
5354const SIGNING_SECRET_NAME = "mcp-oauth-signing-key";
......@@ -105,29 +106,32 @@ export const authorizationServerMetadata = (
105106 authorization_response_iss_parameter_supported: true,
106107});
107108
108const 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. */
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;
110113 try {
111114 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;
119119 } catch {
120120 return undefined;
121121 }
122122};
123123
124/** RFC 8252 §7.3: loopback redirects match on everything but the port. */
125export 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 */
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;
128134 return (
129 left !== undefined &&
130 right !== undefined &&
131135 left.hostname === right.hostname &&
132136 left.pathname === right.pathname &&
133137 left.search === right.search
......@@ -320,24 +324,17 @@ const make = Effect.gen(function* () {
320324 error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`,
321325 });
322326 }
323 if (!redirectUris.every((uri) => parseLoopbackRedirect(uri) !== undefined)) {
327 if (!redirectUris.every((uri) => parseRedirect(uri) !== undefined)) {
324328 return yield* new AuthMcpRegistrationError({
325329 error: "invalid_redirect_uri",
326330 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].",
337332 });
338333 }
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.
341338 return { clientId: signClientId(name, redirectUris), name, redirectUris };
342339 });
343340
......@@ -352,8 +349,7 @@ const make = Effect.gen(function* () {
352349 const redirectUri = request.redirect_uri;
353350 if (
354351 redirectUri === undefined ||
355 parseLoopbackRedirect(redirectUri) === undefined ||
356 !client.redirectUris.some((registered) => loopbackRedirectMatches(registered, redirectUri))
352 !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri))
357353 ) {
358354 return yield* new McpOAuthPageError({
359355 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 {
66 type AuthMcpAuthorizationRequest,
77} from "@t3tools/contracts";
88import { Radio as RadioPrimitive } from "@base-ui/react/radio";
9import { isLoopbackHost } from "@t3tools/shared/preview";
910import { EyeIcon, type LucideIcon } from "lucide-react";
1011import type * as Context from "effect/Context";
1112import * as Effect from "effect/Effect";
......@@ -35,6 +36,11 @@ const accessConfig: Record<
3536 ...runtimeModeConfig,
3637};
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
3844type Loaded =
3945 | { readonly status: "loading" }
4046 | { readonly status: "invalid"; readonly message: string }
......@@ -188,8 +194,18 @@ export function ConnectAgentSurface() {
188194 }
189195 />
190196 <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 )}
193209 </p>
194210
195211 <form
docs/internals/environment-auth.md+6-3
......@@ -27,9 +27,12 @@ vocabulary gives these grants a familiar meaning.
2727
2828Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth
2929authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)).
30It accepts only loopback redirect URIs: an HTTPS redirect would let anyone send
31the owner an approval link that delivers the code to their own server. Client
32registration is stateless, so an unauthenticated caller cannot grow server
30It accepts loopback redirect URIs for agents on the user's machine and any
31HTTPS redirect for hosted agents (ChatGPT, bots). An HTTPS redirect means a
32link 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
3336state. Approval spends a one-time pairing code, or uses a browser session with
3437`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
562562export const AuthMcpApprovalDetails = Schema.Struct({
563563 /** Self-declared by the client, so shown as such. */
564564 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 */
566569 redirectHost: Schema.String,
567570 environmentHost: Schema.String,
568571 /** Present when this browser's session may approve without a pairing code. */