authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-12 12:58:34+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 12:58:34+02:00
log849bac8946c40420174b4187e36fcf17b5ea7cc4
tree478f2f39269d637319a167aa5ba0b4b45a9ec890
parent6fd088af9f77933745c8ecb93dda98fa0938ea4d
signature Signed by PGP key B5690EEEBB952194

fix(connect): preserve CLI OAuth parameters through browser sign-in (#6285)


9 files changed, 147 insertions(+), 25 deletions(-)

apps/server/src/cloud/CliTokenManager.ts+9-7
......@@ -26,7 +26,6 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2626
2727import {
2828 buildConnectAuthorizeRequestUrl,
29 buildConnectClerkAuthorizeUrl,
3029 checkConnectAuthCode,
3130 connectCallbackUrl,
3231} from "@t3tools/shared/connectAuth";
......@@ -367,6 +366,7 @@ export const make = Effect.gen(function* () {
367366
368367 const login = Effect.fn("cloud.cli_token.login")(function* () {
369368 const metadata = yield* cloudCliOAuthConfig;
369 const hostedAppUrl = yield* hostedAppUrlConfig;
370370 const { verifier, challenge, state } = yield* makePkceRequest;
371371 const callback = yield* Deferred.make<string>();
372372 const callbackRoute = HttpRouter.add(
......@@ -392,19 +392,21 @@ export const make = Effect.gen(function* () {
392392 Layer.provide(
393393 NodeHttpServer.layer(NodeHttp.createServer, {
394394 host: "127.0.0.1",
395 port: 34338,
395 port: metadata.loopbackPort,
396396 disablePreemptiveShutdown: true,
397397 }),
398398 ),
399399 Layer.build,
400400 );
401 const authorizationUrl = buildConnectClerkAuthorizeUrl({
402 authorizationEndpoint: metadata.authorizationEndpoint,
403 clientId: metadata.clientId,
404 redirectUri: metadata.redirectUri,
405 scopes: metadata.scopes,
401 // The hosted /connect page establishes a Clerk session before forwarding
402 // the request to /oauth/authorize with the loopback redirect URI. Sending
403 // a signed-out browser to /oauth/authorize directly loses the authorize
404 // parameters across Clerk's sign-in redirect (#5051).
405 const authorizationUrl = buildConnectAuthorizeRequestUrl({
406 hostedAppUrl,
406407 state,
407408 challenge,
409 loopbackPort: metadata.loopbackPort,
408410 });
409411 yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl));
410412 const authorization = yield* waitForLoopbackAuthorization({
apps/server/src/cloud/publicConfig.test.ts+1-2
......@@ -90,9 +90,9 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public
9090 }).pipe(provideEnv({}));
9191
9292 assert.deepEqual(config, {
93 authorizationEndpoint: "https://clerk.example.test/oauth/authorize",
9493 tokenEndpoint: "https://clerk.example.test/oauth/token",
9594 clientId: "oauth_client_embedded",
95 loopbackPort: 34338,
9696 redirectUri: "http://127.0.0.1:34338/callback",
9797 scopes: ["openid", "profile", "email"],
9898 });
......@@ -111,7 +111,6 @@ it.effect("prefers runtime Clerk OAuth config overrides over statically injected
111111 }),
112112 );
113113
114 assert.equal(config.authorizationEndpoint, "https://runtime.example.test/oauth/authorize");
115114 assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token");
116115 assert.equal(config.clientId, "oauth_client_runtime");
117116 }),
apps/server/src/cloud/publicConfig.ts+15-5
......@@ -1,4 +1,8 @@
1import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
1import {
2 connectLoopbackRedirectUri,
3 CONNECT_OAUTH_SCOPES,
4 DEFAULT_HOSTED_APP_URL,
5} from "@t3tools/shared/connectAuth";
26import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
37import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
48import * as Config from "effect/Config";
......@@ -14,7 +18,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined;
1418declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined;
1519declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;
1620
17const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
21const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338;
1822const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
1923
2024function validateRelayUrl(value: string) {
......@@ -145,10 +149,16 @@ function makePublicValueConfig(name: string, fallback: string) {
145149 );
146150}
147151
152/**
153 * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes
154 * through the hosted /connect page, which builds the authorize URL after a
155 * Clerk session exists (see CliTokenManager.login). Only the token endpoint
156 * is contacted directly.
157 */
148158export interface CloudCliOAuthConfig {
149 readonly authorizationEndpoint: string;
150159 readonly tokenEndpoint: string;
151160 readonly clientId: string;
161 readonly loopbackPort: number;
152162 readonly redirectUri: string;
153163 readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES;
154164}
......@@ -184,10 +194,10 @@ export function makeCloudCliOAuthConfig({
184194 Effect.map(
185195 (clerkFrontendApiUrl) =>
186196 ({
187 authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`,
188197 tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`,
189198 clientId,
190 redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI,
199 loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT,
200 redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT),
191201 scopes: CLOUD_CLI_OAUTH_SCOPES,
192202 }) satisfies CloudCliOAuthConfig,
193203 ),
apps/web/src/cloud/connectCliAuth.test.ts+16
......@@ -46,6 +46,22 @@ describe("connectCliAuth", () => {
4646 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
4747 });
4848
49 it("redirects straight to the CLI's loopback listener when the request carries a port", () => {
50 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
51 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
52
53 const authorizeUrl = buildConnectCliClerkAuthorizeUrl({
54 state: "state-1",
55 challenge: "challenge-1",
56 loopbackPort: 34338,
57 });
58 expect(authorizeUrl).not.toBeNull();
59
60 const url = new URL(authorizeUrl!);
61 expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback");
62 expect(url.searchParams.get("state")).toBe("state-1");
63 });
64
4965 it("returns null when the CLI OAuth client id is not configured", () => {
5066 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
5167 expect(
apps/web/src/cloud/connectCliAuth.ts+10-1
......@@ -1,6 +1,7 @@
11import {
22 buildConnectClerkAuthorizeUrl,
33 connectCallbackUrl,
4 connectLoopbackRedirectUri,
45 CONNECT_OAUTH_SCOPES,
56 type ConnectAuthorizeRequest,
67} from "@t3tools/shared/connectAuth";
......@@ -34,6 +35,11 @@ export function connectCliAuthRoutesEnabled(): boolean {
3435 * Builds the Clerk authorize URL for a CLI-initiated connect request. The
3536 * state is mirrored into sessionStorage so the callback page can verify the
3637 * response matches a request this browser actually started.
38 *
39 * A request carrying a loopback port came from a CLI with a local callback
40 * listener: the authorization code must return to `127.0.0.1` directly, so
41 * the hosted callback page never sees it. Clerk enforces its registered
42 * redirect URI allowlist either way.
3743 */
3844export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null {
3945 const { clerkPublishableKey } = resolveCloudPublicConfig();
......@@ -44,7 +50,10 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques
4450 return buildConnectClerkAuthorizeUrl({
4551 authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`,
4652 clientId,
47 redirectUri: connectCallbackUrl(configuredHostedAppUrl()),
53 redirectUri:
54 request.loopbackPort === undefined
55 ? connectCallbackUrl(configuredHostedAppUrl())
56 : connectLoopbackRedirectUri(request.loopbackPort),
4857 scopes: CONNECT_OAUTH_SCOPES,
4958 state: request.state,
5059 challenge: request.challenge,
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+9-3
......@@ -44,8 +44,10 @@ const invalidLinkMessage = {
4444} as const;
4545
4646/**
47 * /connect: the URL a headless CLI prints. Waits for a Clerk session, then
48 * forwards the CLI's PKCE request to Clerk's authorize endpoint.
47 * /connect: the URL the CLI prints for both flows. Waits for a Clerk session,
48 * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a
49 * loopback redirect URI when the request carries a port, so the code returns
50 * straight to the waiting CLI, and the hosted callback page otherwise.
4951 */
5052export function ConnectCliAuthorizeSurface() {
5153 const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href)));
......@@ -85,7 +87,11 @@ export function ConnectCliAuthorizeSurface() {
8587 return (
8688 <AuthSurfaceShell>
8789 <ConnectCliAuthMessage
88 eyebrow="Step 1 of 2 · Browser authorization"
90 eyebrow={
91 request.loopbackPort === undefined
92 ? "Step 1 of 2 · Browser authorization"
93 : "Browser authorization"
94 }
8995 title="Connecting your terminal"
9096 description={
9197 isSignedIn
docs/internals/t3-connect.md+11-3
......@@ -90,9 +90,17 @@ In **Clerk Dashboard > OAuth applications**:
90905. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build
9191 environment to the generated public client ID.
9292
93The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's
94`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth
95handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link.
93Both CLI flows start at the hosted `/connect` page (`buildConnectAuthorizeRequestUrl` in
94`packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request
95to Clerk's `/oauth/authorize`. The CLI never opens `/oauth/authorize` directly: a signed-out browser
96sent there goes through Clerk's sign-in redirect, which drops the authorize query parameters and
97fails the flow with `unsupported_response_type` or an empty `state` (#5051). The loopback flow marks
98the request with a `port` fragment parameter so the hosted page asks Clerk to redirect the
99authorization code straight to `http://127.0.0.1:<port>/callback`; the out-of-band flow omits it and
100uses the hosted `/connect/callback` page instead. The CLI derives Clerk's frontend API URL from the
101publishable key and calls only the `/oauth/token` endpoint directly. The relay is not involved in
102the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an
103environment link.
96104
97105The connect command group is:
98106
packages/shared/src/connectAuth.test.ts+26
......@@ -4,6 +4,7 @@ import {
44 buildConnectAuthorizeRequestUrl,
55 buildConnectClerkAuthorizeUrl,
66 connectCallbackUrl,
7 connectLoopbackRedirectUri,
78 encodeConnectAuthCode,
89 parseConnectAuthCode,
910 readConnectAuthorizeRequest,
......@@ -37,6 +38,31 @@ describe("connectAuth", () => {
3738 ).toBeNull();
3839 });
3940
41 it("round-trips the loopback port through the authorize URL fragment", () => {
42 const url = buildConnectAuthorizeRequestUrl({
43 hostedAppUrl: "https://app.t3.codes",
44 state: "state-1",
45 challenge: "challenge-1",
46 loopbackPort: 34338,
47 });
48
49 expect(readConnectAuthorizeRequest(new URL(url))).toEqual({
50 state: "state-1",
51 challenge: "challenge-1",
52 loopbackPort: 34338,
53 });
54 expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback");
55 });
56
57 it("rejects authorize requests whose loopback port is corrupted", () => {
58 for (const port of ["", "abc", "-1", "0", "65536", "34338x", "34 38"]) {
59 const url = new URL(
60 `https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=${encodeURIComponent(port)}`,
61 );
62 expect(readConnectAuthorizeRequest(url), port).toBeNull();
63 }
64 });
65
4066 it("builds a PKCE authorize URL against the Clerk endpoint", () => {
4167 const url = new URL(
4268 buildConnectClerkAuthorizeUrl({
packages/shared/src/connectAuth.ts+50-4
......@@ -2,7 +2,9 @@ import { readHashParams } from "./remote.ts";
22
33const CONNECT_AUTH_STATE_PARAM = "state";
44const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";
5const CONNECT_AUTH_PORT_PARAM = "port";
56const CONNECT_AUTH_CODE_SEPARATOR = ".";
7const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback";
68
79const CONNECT_AUTHORIZE_PATH = "/connect";
810const CONNECT_CALLBACK_PATH = "/connect/callback";
......@@ -23,22 +25,39 @@ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
2325export interface ConnectAuthorizeRequest {
2426 readonly state: string;
2527 readonly challenge: string;
28 /**
29 * Present when a loopback CLI initiated the request: the hosted /connect
30 * page then asks Clerk to redirect the authorization code straight to
31 * `http://127.0.0.1:<port>/callback` instead of the hosted callback page.
32 */
33 readonly loopbackPort?: number;
2634}
2735
2836/**
29 * The URL a headless CLI prints for the user to open on a machine with a
30 * browser. `state` and `code_challenge` ride the fragment so they never reach
31 * the hosted app's server or CDN logs; neither is a secret.
37 * The URL the CLI prints for the user to open in a browser. `state` and
38 * `code_challenge` ride the fragment so they never reach the hosted app's
39 * server or CDN logs; neither is a secret.
40 *
41 * Both CLI flows route through the hosted /connect page rather than hitting
42 * Clerk's /oauth/authorize directly: a signed-out browser sent straight to
43 * /oauth/authorize goes through Clerk's sign-in redirect, which does not
44 * reliably preserve the authorize query parameters (state, response_type,
45 * code_challenge). The hosted page waits for a Clerk session first, then
46 * forwards the request with the parameters intact.
3247 */
3348export function buildConnectAuthorizeRequestUrl(input: {
3449 readonly hostedAppUrl: string;
3550 readonly state: string;
3651 readonly challenge: string;
52 readonly loopbackPort?: number;
3753}): string {
3854 const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl);
3955 url.hash = new URLSearchParams([
4056 [CONNECT_AUTH_STATE_PARAM, input.state],
4157 [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge],
58 ...(input.loopbackPort === undefined
59 ? []
60 : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]),
4261 ]).toString();
4362 return url.toString();
4463}
......@@ -50,7 +69,34 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest |
5069 if (!state || !challenge) {
5170 return null;
5271 }
53 return { state, challenge };
72 const port = params.get(CONNECT_AUTH_PORT_PARAM);
73 if (port === null) {
74 return { state, challenge };
75 }
76 // A present-but-invalid port means the link was corrupted; reject the whole
77 // request rather than silently downgrading a loopback flow to the
78 // out-of-band one, which would strand the waiting CLI.
79 const loopbackPort = parseLoopbackPort(port.trim());
80 if (loopbackPort === null) {
81 return null;
82 }
83 return { state, challenge, loopbackPort };
84}
85
86function parseLoopbackPort(value: string): number | null {
87 if (!/^\d{1,5}$/.test(value)) {
88 return null;
89 }
90 const port = Number(value);
91 return port >= 1 && port <= 65535 ? port : null;
92}
93
94/**
95 * Redirect URI for the CLI's local callback listener. Must stay in sync with
96 * the redirect URI registered on the Clerk CLI OAuth application.
97 */
98export function connectLoopbackRedirectUri(port: number): string {
99 return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`;
54100}
55101
56102export function connectCallbackUrl(hostedAppUrl: string): string {