| author | |
| committer | |
| log | 849bac8946c40420174b4187e36fcf17b5ea7cc4 |
| tree | 478f2f39269d637319a167aa5ba0b4b45a9ec890 |
| parent | 6fd088af9f77933745c8ecb93dda98fa0938ea4d |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 26 | 26 | |
| 27 | 27 | import { |
| 28 | 28 | buildConnectAuthorizeRequestUrl, |
| 29 | buildConnectClerkAuthorizeUrl, | |
| 30 | 29 | checkConnectAuthCode, |
| 31 | 30 | connectCallbackUrl, |
| 32 | 31 | } from "@t3tools/shared/connectAuth"; |
| ... | ... | @@ -367,6 +366,7 @@ export const make = Effect.gen(function* () { |
| 367 | 366 | |
| 368 | 367 | const login = Effect.fn("cloud.cli_token.login")(function* () { |
| 369 | 368 | const metadata = yield* cloudCliOAuthConfig; |
| 369 | const hostedAppUrl = yield* hostedAppUrlConfig; | |
| 370 | 370 | const { verifier, challenge, state } = yield* makePkceRequest; |
| 371 | 371 | const callback = yield* Deferred.make<string>(); |
| 372 | 372 | const callbackRoute = HttpRouter.add( |
| ... | ... | @@ -392,19 +392,21 @@ export const make = Effect.gen(function* () { |
| 392 | 392 | Layer.provide( |
| 393 | 393 | NodeHttpServer.layer(NodeHttp.createServer, { |
| 394 | 394 | host: "127.0.0.1", |
| 395 | port: 34338, | |
| 395 | port: metadata.loopbackPort, | |
| 396 | 396 | disablePreemptiveShutdown: true, |
| 397 | 397 | }), |
| 398 | 398 | ), |
| 399 | 399 | Layer.build, |
| 400 | 400 | ); |
| 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, | |
| 406 | 407 | state, |
| 407 | 408 | challenge, |
| 409 | loopbackPort: metadata.loopbackPort, | |
| 408 | 410 | }); |
| 409 | 411 | yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); |
| 410 | 412 | 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 |
| 90 | 90 | }).pipe(provideEnv({})); |
| 91 | 91 | |
| 92 | 92 | assert.deepEqual(config, { |
| 93 | authorizationEndpoint: "https://clerk.example.test/oauth/authorize", | |
| 94 | 93 | tokenEndpoint: "https://clerk.example.test/oauth/token", |
| 95 | 94 | clientId: "oauth_client_embedded", |
| 95 | loopbackPort: 34338, | |
| 96 | 96 | redirectUri: "http://127.0.0.1:34338/callback", |
| 97 | 97 | scopes: ["openid", "profile", "email"], |
| 98 | 98 | }); |
| ... | ... | @@ -111,7 +111,6 @@ it.effect("prefers runtime Clerk OAuth config overrides over statically injected |
| 111 | 111 | }), |
| 112 | 112 | ); |
| 113 | 113 | |
| 114 | assert.equal(config.authorizationEndpoint, "https://runtime.example.test/oauth/authorize"); | |
| 115 | 114 | assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); |
| 116 | 115 | assert.equal(config.clientId, "oauth_client_runtime"); |
| 117 | 116 | }), |
apps/server/src/cloud/publicConfig.ts+15-5| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; | |
| 1 | import { | |
| 2 | connectLoopbackRedirectUri, | |
| 3 | CONNECT_OAUTH_SCOPES, | |
| 4 | DEFAULT_HOSTED_APP_URL, | |
| 5 | } from "@t3tools/shared/connectAuth"; | |
| 2 | 6 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 3 | 7 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 4 | 8 | import * as Config from "effect/Config"; |
| ... | ... | @@ -14,7 +18,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; |
| 14 | 18 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; |
| 15 | 19 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; |
| 16 | 20 | |
| 17 | const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; | |
| 21 | const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338; | |
| 18 | 22 | const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; |
| 19 | 23 | |
| 20 | 24 | function validateRelayUrl(value: string) { |
| ... | ... | @@ -145,10 +149,16 @@ function makePublicValueConfig(name: string, fallback: string) { |
| 145 | 149 | ); |
| 146 | 150 | } |
| 147 | 151 | |
| 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 | */ | |
| 148 | 158 | export interface CloudCliOAuthConfig { |
| 149 | readonly authorizationEndpoint: string; | |
| 150 | 159 | readonly tokenEndpoint: string; |
| 151 | 160 | readonly clientId: string; |
| 161 | readonly loopbackPort: number; | |
| 152 | 162 | readonly redirectUri: string; |
| 153 | 163 | readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES; |
| 154 | 164 | } |
| ... | ... | @@ -184,10 +194,10 @@ export function makeCloudCliOAuthConfig({ |
| 184 | 194 | Effect.map( |
| 185 | 195 | (clerkFrontendApiUrl) => |
| 186 | 196 | ({ |
| 187 | authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, | |
| 188 | 197 | tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, |
| 189 | 198 | clientId, |
| 190 | redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, | |
| 199 | loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT, | |
| 200 | redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT), | |
| 191 | 201 | scopes: CLOUD_CLI_OAUTH_SCOPES, |
| 192 | 202 | }) satisfies CloudCliOAuthConfig, |
| 193 | 203 | ), |
apps/web/src/cloud/connectCliAuth.test.ts+16| ... | ... | @@ -46,6 +46,22 @@ describe("connectCliAuth", () => { |
| 46 | 46 | expect(url.searchParams.get("code_challenge_method")).toBe("S256"); |
| 47 | 47 | }); |
| 48 | 48 | |
| 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 | ||
| 49 | 65 | it("returns null when the CLI OAuth client id is not configured", () => { |
| 50 | 66 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); |
| 51 | 67 | expect( |
apps/web/src/cloud/connectCliAuth.ts+10-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | buildConnectClerkAuthorizeUrl, |
| 3 | 3 | connectCallbackUrl, |
| 4 | connectLoopbackRedirectUri, | |
| 4 | 5 | CONNECT_OAUTH_SCOPES, |
| 5 | 6 | type ConnectAuthorizeRequest, |
| 6 | 7 | } from "@t3tools/shared/connectAuth"; |
| ... | ... | @@ -34,6 +35,11 @@ export function connectCliAuthRoutesEnabled(): boolean { |
| 34 | 35 | * Builds the Clerk authorize URL for a CLI-initiated connect request. The |
| 35 | 36 | * state is mirrored into sessionStorage so the callback page can verify the |
| 36 | 37 | * 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. | |
| 37 | 43 | */ |
| 38 | 44 | export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { |
| 39 | 45 | const { clerkPublishableKey } = resolveCloudPublicConfig(); |
| ... | ... | @@ -44,7 +50,10 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques |
| 44 | 50 | return buildConnectClerkAuthorizeUrl({ |
| 45 | 51 | authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, |
| 46 | 52 | clientId, |
| 47 | redirectUri: connectCallbackUrl(configuredHostedAppUrl()), | |
| 53 | redirectUri: | |
| 54 | request.loopbackPort === undefined | |
| 55 | ? connectCallbackUrl(configuredHostedAppUrl()) | |
| 56 | : connectLoopbackRedirectUri(request.loopbackPort), | |
| 48 | 57 | scopes: CONNECT_OAUTH_SCOPES, |
| 49 | 58 | state: request.state, |
| 50 | 59 | challenge: request.challenge, |
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+9-3| ... | ... | @@ -44,8 +44,10 @@ const invalidLinkMessage = { |
| 44 | 44 | } as const; |
| 45 | 45 | |
| 46 | 46 | /** |
| 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. | |
| 49 | 51 | */ |
| 50 | 52 | export function ConnectCliAuthorizeSurface() { |
| 51 | 53 | const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); |
| ... | ... | @@ -85,7 +87,11 @@ export function ConnectCliAuthorizeSurface() { |
| 85 | 87 | return ( |
| 86 | 88 | <AuthSurfaceShell> |
| 87 | 89 | <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 | } | |
| 89 | 95 | title="Connecting your terminal" |
| 90 | 96 | description={ |
| 91 | 97 | isSignedIn |
docs/internals/t3-connect.md+11-3| ... | ... | @@ -90,9 +90,17 @@ In **Clerk Dashboard > OAuth applications**: |
| 90 | 90 | 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build |
| 91 | 91 | environment to the generated public client ID. |
| 92 | 92 | |
| 93 | The 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 | |
| 95 | handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. | |
| 93 | Both 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 | |
| 95 | to Clerk's `/oauth/authorize`. The CLI never opens `/oauth/authorize` directly: a signed-out browser | |
| 96 | sent there goes through Clerk's sign-in redirect, which drops the authorize query parameters and | |
| 97 | fails the flow with `unsupported_response_type` or an empty `state` (#5051). The loopback flow marks | |
| 98 | the request with a `port` fragment parameter so the hosted page asks Clerk to redirect the | |
| 99 | authorization code straight to `http://127.0.0.1:<port>/callback`; the out-of-band flow omits it and | |
| 100 | uses the hosted `/connect/callback` page instead. The CLI derives Clerk's frontend API URL from the | |
| 101 | publishable key and calls only the `/oauth/token` endpoint directly. The relay is not involved in | |
| 102 | the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an | |
| 103 | environment link. | |
| 96 | 104 | |
| 97 | 105 | The connect command group is: |
| 98 | 106 |
packages/shared/src/connectAuth.test.ts+26| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | buildConnectAuthorizeRequestUrl, |
| 5 | 5 | buildConnectClerkAuthorizeUrl, |
| 6 | 6 | connectCallbackUrl, |
| 7 | connectLoopbackRedirectUri, | |
| 7 | 8 | encodeConnectAuthCode, |
| 8 | 9 | parseConnectAuthCode, |
| 9 | 10 | readConnectAuthorizeRequest, |
| ... | ... | @@ -37,6 +38,31 @@ describe("connectAuth", () => { |
| 37 | 38 | ).toBeNull(); |
| 38 | 39 | }); |
| 39 | 40 | |
| 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 | ||
| 40 | 66 | it("builds a PKCE authorize URL against the Clerk endpoint", () => { |
| 41 | 67 | const url = new URL( |
| 42 | 68 | buildConnectClerkAuthorizeUrl({ |
packages/shared/src/connectAuth.ts+50-4| ... | ... | @@ -2,7 +2,9 @@ import { readHashParams } from "./remote.ts"; |
| 2 | 2 | |
| 3 | 3 | const CONNECT_AUTH_STATE_PARAM = "state"; |
| 4 | 4 | const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; |
| 5 | const CONNECT_AUTH_PORT_PARAM = "port"; | |
| 5 | 6 | const CONNECT_AUTH_CODE_SEPARATOR = "."; |
| 7 | const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; | |
| 6 | 8 | |
| 7 | 9 | const CONNECT_AUTHORIZE_PATH = "/connect"; |
| 8 | 10 | const CONNECT_CALLBACK_PATH = "/connect/callback"; |
| ... | ... | @@ -23,22 +25,39 @@ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; |
| 23 | 25 | export interface ConnectAuthorizeRequest { |
| 24 | 26 | readonly state: string; |
| 25 | 27 | 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; | |
| 26 | 34 | } |
| 27 | 35 | |
| 28 | 36 | /** |
| 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. | |
| 32 | 47 | */ |
| 33 | 48 | export function buildConnectAuthorizeRequestUrl(input: { |
| 34 | 49 | readonly hostedAppUrl: string; |
| 35 | 50 | readonly state: string; |
| 36 | 51 | readonly challenge: string; |
| 52 | readonly loopbackPort?: number; | |
| 37 | 53 | }): string { |
| 38 | 54 | const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); |
| 39 | 55 | url.hash = new URLSearchParams([ |
| 40 | 56 | [CONNECT_AUTH_STATE_PARAM, input.state], |
| 41 | 57 | [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], |
| 58 | ...(input.loopbackPort === undefined | |
| 59 | ? [] | |
| 60 | : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]), | |
| 42 | 61 | ]).toString(); |
| 43 | 62 | return url.toString(); |
| 44 | 63 | } |
| ... | ... | @@ -50,7 +69,34 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | |
| 50 | 69 | if (!state || !challenge) { |
| 51 | 70 | return null; |
| 52 | 71 | } |
| 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 | ||
| 86 | function 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 | */ | |
| 98 | export function connectLoopbackRedirectUri(port: number): string { | |
| 99 | return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`; | |
| 54 | 100 | } |
| 55 | 101 | |
| 56 | 102 | export function connectCallbackUrl(hostedAppUrl: string): string { |