From 41e519a879ac180c43f0ea391c351b2c2bf97485 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 12 Aug 2026 01:13:42 -0700 Subject: [PATCH] fix(connect): preserve CLI OAuth parameters through browser sign-in - Route CLI authorization through the hosted connect page - Return loopback OAuth codes directly to the waiting CLI --- apps/server/src/cloud/CliTokenManager.ts | 16 +++--- apps/server/src/cloud/publicConfig.test.ts | 3 +- apps/server/src/cloud/publicConfig.ts | 20 +++++-- apps/web/src/cloud/connectCliAuth.test.ts | 16 ++++++ apps/web/src/cloud/connectCliAuth.ts | 11 +++- .../cloud/ConnectCliAuthSurface.tsx | 12 +++-- docs/internals/t3-connect.md | 14 +++-- packages/shared/src/connectAuth.test.ts | 26 +++++++++ packages/shared/src/connectAuth.ts | 54 +++++++++++++++++-- 9 files changed, 147 insertions(+), 25 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index f01599bb96fa..b0867b62f6c8 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -26,7 +26,6 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { buildConnectAuthorizeRequestUrl, - buildConnectClerkAuthorizeUrl, checkConnectAuthCode, connectCallbackUrl, } from "@t3tools/shared/connectAuth"; @@ -367,6 +366,7 @@ export const make = Effect.gen(function* () { const login = Effect.fn("cloud.cli_token.login")(function* () { const metadata = yield* cloudCliOAuthConfig; + const hostedAppUrl = yield* hostedAppUrlConfig; const { verifier, challenge, state } = yield* makePkceRequest; const callback = yield* Deferred.make(); const callbackRoute = HttpRouter.add( @@ -392,19 +392,21 @@ export const make = Effect.gen(function* () { Layer.provide( NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", - port: 34338, + port: metadata.loopbackPort, disablePreemptiveShutdown: true, }), ), Layer.build, ); - const authorizationUrl = buildConnectClerkAuthorizeUrl({ - authorizationEndpoint: metadata.authorizationEndpoint, - clientId: metadata.clientId, - redirectUri: metadata.redirectUri, - scopes: metadata.scopes, + // The hosted /connect page establishes a Clerk session before forwarding + // the request to /oauth/authorize with the loopback redirect URI. Sending + // a signed-out browser to /oauth/authorize directly loses the authorize + // parameters across Clerk's sign-in redirect (#5051). + const authorizationUrl = buildConnectAuthorizeRequestUrl({ + hostedAppUrl, state, challenge, + loopbackPort: metadata.loopbackPort, }); yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); const authorization = yield* waitForLoopbackAuthorization({ diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 96a8a1b8b8a3..f8324f9478ed 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -90,9 +90,9 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public }).pipe(provideEnv({})); assert.deepEqual(config, { - authorizationEndpoint: "https://clerk.example.test/oauth/authorize", tokenEndpoint: "https://clerk.example.test/oauth/token", clientId: "oauth_client_embedded", + loopbackPort: 34338, redirectUri: "http://127.0.0.1:34338/callback", scopes: ["openid", "profile", "email"], }); @@ -111,7 +111,6 @@ it.effect("prefers runtime Clerk OAuth config overrides over statically injected }), ); - assert.equal(config.authorizationEndpoint, "https://runtime.example.test/oauth/authorize"); assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); assert.equal(config.clientId, "oauth_client_runtime"); }), diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index e5fb9bd1697d..e977d7cfdf0d 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,4 +1,8 @@ -import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; +import { + connectLoopbackRedirectUri, + CONNECT_OAUTH_SCOPES, + DEFAULT_HOSTED_APP_URL, +} from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; @@ -14,7 +18,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; -const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; +const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338; const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; function validateRelayUrl(value: string) { @@ -145,10 +149,16 @@ function makePublicValueConfig(name: string, fallback: string) { ); } +/** + * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes + * through the hosted /connect page, which builds the authorize URL after a + * Clerk session exists (see CliTokenManager.login). Only the token endpoint + * is contacted directly. + */ export interface CloudCliOAuthConfig { - readonly authorizationEndpoint: string; readonly tokenEndpoint: string; readonly clientId: string; + readonly loopbackPort: number; readonly redirectUri: string; readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES; } @@ -184,10 +194,10 @@ export function makeCloudCliOAuthConfig({ Effect.map( (clerkFrontendApiUrl) => ({ - authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, clientId, - redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, + loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT, + redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT), scopes: CLOUD_CLI_OAUTH_SCOPES, }) satisfies CloudCliOAuthConfig, ), diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 61d854eb6ab3..59b443a49d93 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -46,6 +46,22 @@ describe("connectCliAuth", () => { expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); + it("redirects straight to the CLI's loopback listener when the request carries a port", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const authorizeUrl = buildConnectCliClerkAuthorizeUrl({ + state: "state-1", + challenge: "challenge-1", + loopbackPort: 34338, + }); + expect(authorizeUrl).not.toBeNull(); + + const url = new URL(authorizeUrl!); + expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback"); + expect(url.searchParams.get("state")).toBe("state-1"); + }); + it("returns null when the CLI OAuth client id is not configured", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); expect( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 849319dcebef..969215d97ad3 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -1,6 +1,7 @@ import { buildConnectClerkAuthorizeUrl, connectCallbackUrl, + connectLoopbackRedirectUri, CONNECT_OAUTH_SCOPES, type ConnectAuthorizeRequest, } from "@t3tools/shared/connectAuth"; @@ -34,6 +35,11 @@ export function connectCliAuthRoutesEnabled(): boolean { * Builds the Clerk authorize URL for a CLI-initiated connect request. The * state is mirrored into sessionStorage so the callback page can verify the * response matches a request this browser actually started. + * + * A request carrying a loopback port came from a CLI with a local callback + * listener: the authorization code must return to `127.0.0.1` directly, so + * the hosted callback page never sees it. Clerk enforces its registered + * redirect URI allowlist either way. */ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { const { clerkPublishableKey } = resolveCloudPublicConfig(); @@ -44,7 +50,10 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques return buildConnectClerkAuthorizeUrl({ authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, clientId, - redirectUri: connectCallbackUrl(configuredHostedAppUrl()), + redirectUri: + request.loopbackPort === undefined + ? connectCallbackUrl(configuredHostedAppUrl()) + : connectLoopbackRedirectUri(request.loopbackPort), scopes: CONNECT_OAUTH_SCOPES, state: request.state, challenge: request.challenge, diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index b216f5433166..e47d8ddf7f7c 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -44,8 +44,10 @@ const invalidLinkMessage = { } as const; /** - * /connect: the URL a headless CLI prints. Waits for a Clerk session, then - * forwards the CLI's PKCE request to Clerk's authorize endpoint. + * /connect: the URL the CLI prints for both flows. Waits for a Clerk session, + * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a + * loopback redirect URI when the request carries a port, so the code returns + * straight to the waiting CLI, and the hosted callback page otherwise. */ export function ConnectCliAuthorizeSurface() { const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); @@ -85,7 +87,11 @@ export function ConnectCliAuthorizeSurface() { return ( OAuth applications**: 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build environment to the generated public client ID. -The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's -`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth -handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. +Both CLI flows start at the hosted `/connect` page (`buildConnectAuthorizeRequestUrl` in +`packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request +to Clerk's `/oauth/authorize`. The CLI never opens `/oauth/authorize` directly: a signed-out browser +sent there goes through Clerk's sign-in redirect, which drops the authorize query parameters and +fails the flow with `unsupported_response_type` or an empty `state` (#5051). The loopback flow marks +the request with a `port` fragment parameter so the hosted page asks Clerk to redirect the +authorization code straight to `http://127.0.0.1:/callback`; the out-of-band flow omits it and +uses the hosted `/connect/callback` page instead. The CLI derives Clerk's frontend API URL from the +publishable key and calls only the `/oauth/token` endpoint directly. The relay is not involved in +the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an +environment link. The connect command group is: diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts index 9ffef3936bbf..275a958f274d 100644 --- a/packages/shared/src/connectAuth.test.ts +++ b/packages/shared/src/connectAuth.test.ts @@ -4,6 +4,7 @@ import { buildConnectAuthorizeRequestUrl, buildConnectClerkAuthorizeUrl, connectCallbackUrl, + connectLoopbackRedirectUri, encodeConnectAuthCode, parseConnectAuthCode, readConnectAuthorizeRequest, @@ -37,6 +38,31 @@ describe("connectAuth", () => { ).toBeNull(); }); + it("round-trips the loopback port through the authorize URL fragment", () => { + const url = buildConnectAuthorizeRequestUrl({ + hostedAppUrl: "https://app.t3.codes", + state: "state-1", + challenge: "challenge-1", + loopbackPort: 34338, + }); + + expect(readConnectAuthorizeRequest(new URL(url))).toEqual({ + state: "state-1", + challenge: "challenge-1", + loopbackPort: 34338, + }); + expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback"); + }); + + it("rejects authorize requests whose loopback port is corrupted", () => { + for (const port of ["", "abc", "-1", "0", "65536", "34338x", "34 38"]) { + const url = new URL( + `https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=${encodeURIComponent(port)}`, + ); + expect(readConnectAuthorizeRequest(url), port).toBeNull(); + } + }); + it("builds a PKCE authorize URL against the Clerk endpoint", () => { const url = new URL( buildConnectClerkAuthorizeUrl({ diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts index 8d849d77ae6d..e2a2af106640 100644 --- a/packages/shared/src/connectAuth.ts +++ b/packages/shared/src/connectAuth.ts @@ -2,7 +2,9 @@ import { readHashParams } from "./remote.ts"; const CONNECT_AUTH_STATE_PARAM = "state"; const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; +const CONNECT_AUTH_PORT_PARAM = "port"; const CONNECT_AUTH_CODE_SEPARATOR = "."; +const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; const CONNECT_AUTHORIZE_PATH = "/connect"; const CONNECT_CALLBACK_PATH = "/connect/callback"; @@ -23,22 +25,39 @@ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; export interface ConnectAuthorizeRequest { readonly state: string; readonly challenge: string; + /** + * Present when a loopback CLI initiated the request: the hosted /connect + * page then asks Clerk to redirect the authorization code straight to + * `http://127.0.0.1:/callback` instead of the hosted callback page. + */ + readonly loopbackPort?: number; } /** - * The URL a headless CLI prints for the user to open on a machine with a - * browser. `state` and `code_challenge` ride the fragment so they never reach - * the hosted app's server or CDN logs; neither is a secret. + * The URL the CLI prints for the user to open in a browser. `state` and + * `code_challenge` ride the fragment so they never reach the hosted app's + * server or CDN logs; neither is a secret. + * + * Both CLI flows route through the hosted /connect page rather than hitting + * Clerk's /oauth/authorize directly: a signed-out browser sent straight to + * /oauth/authorize goes through Clerk's sign-in redirect, which does not + * reliably preserve the authorize query parameters (state, response_type, + * code_challenge). The hosted page waits for a Clerk session first, then + * forwards the request with the parameters intact. */ export function buildConnectAuthorizeRequestUrl(input: { readonly hostedAppUrl: string; readonly state: string; readonly challenge: string; + readonly loopbackPort?: number; }): string { const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); url.hash = new URLSearchParams([ [CONNECT_AUTH_STATE_PARAM, input.state], [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], + ...(input.loopbackPort === undefined + ? [] + : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]), ]).toString(); return url.toString(); } @@ -50,7 +69,34 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | if (!state || !challenge) { return null; } - return { state, challenge }; + const port = params.get(CONNECT_AUTH_PORT_PARAM); + if (port === null) { + return { state, challenge }; + } + // A present-but-invalid port means the link was corrupted; reject the whole + // request rather than silently downgrading a loopback flow to the + // out-of-band one, which would strand the waiting CLI. + const loopbackPort = parseLoopbackPort(port.trim()); + if (loopbackPort === null) { + return null; + } + return { state, challenge, loopbackPort }; +} + +function parseLoopbackPort(value: string): number | null { + if (!/^\d{1,5}$/.test(value)) { + return null; + } + const port = Number(value); + return port >= 1 && port <= 65535 ? port : null; +} + +/** + * Redirect URI for the CLI's local callback listener. Must stay in sync with + * the redirect URI registered on the Clerk CLI OAuth application. + */ +export function connectLoopbackRedirectUri(port: number): string { + return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`; } export function connectCallbackUrl(hostedAppUrl: string): string {