Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/server/src/cloud/CliTokenManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";

import {
buildConnectAuthorizeRequestUrl,
buildConnectClerkAuthorizeUrl,
checkConnectAuthCode,
connectCallbackUrl,
} from "@t3tools/shared/connectAuth";
Expand Down Expand Up @@ -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<string>();
const callbackRoute = HttpRouter.add(
Expand All @@ -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({
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/cloud/publicConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
Expand All @@ -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");
}),
Expand Down
20 changes: 15 additions & 5 deletions apps/server/src/cloud/publicConfig.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
),
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/cloud/connectCliAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/cloud/connectCliAuth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
buildConnectClerkAuthorizeUrl,
connectCallbackUrl,
connectLoopbackRedirectUri,
CONNECT_OAUTH_SCOPES,
type ConnectAuthorizeRequest,
} from "@t3tools/shared/connectAuth";
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/cloud/ConnectCliAuthSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -85,7 +87,11 @@ export function ConnectCliAuthorizeSurface() {
return (
<AuthSurfaceShell>
<ConnectCliAuthMessage
eyebrow="Step 1 of 2 · Browser authorization"
eyebrow={
request.loopbackPort === undefined
? "Step 1 of 2 · Browser authorization"
: "Browser authorization"
}
title="Connecting your terminal"
description={
isSignedIn
Expand Down
14 changes: 11 additions & 3 deletions docs/internals/t3-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,17 @@ In **Clerk Dashboard > 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:<port>/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:

Expand Down
26 changes: 26 additions & 0 deletions packages/shared/src/connectAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildConnectAuthorizeRequestUrl,
buildConnectClerkAuthorizeUrl,
connectCallbackUrl,
connectLoopbackRedirectUri,
encodeConnectAuthCode,
parseConnectAuthCode,
readConnectAuthorizeRequest,
Expand Down Expand Up @@ -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({
Expand Down
54 changes: 50 additions & 4 deletions packages/shared/src/connectAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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:<port>/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();
}
Expand All @@ -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 {
Expand Down
Loading