| 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,7 +26,6 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 26 | 26 | ||
| 27 | import { | 27 | import { |
| 28 | buildConnectAuthorizeRequestUrl, | 28 | buildConnectAuthorizeRequestUrl, |
| 29 | buildConnectClerkAuthorizeUrl, | ||
| 30 | checkConnectAuthCode, | 29 | checkConnectAuthCode, |
| 31 | connectCallbackUrl, | 30 | connectCallbackUrl, |
| 32 | } from "@t3tools/shared/connectAuth"; | 31 | } from "@t3tools/shared/connectAuth"; |
| ... | @@ -367,6 +366,7 @@ export const make = Effect.gen(function* () { | ... | @@ -367,6 +366,7 @@ export const make = Effect.gen(function* () { |
| 367 | 366 | ||
| 368 | const login = Effect.fn("cloud.cli_token.login")(function* () { | 367 | const login = Effect.fn("cloud.cli_token.login")(function* () { |
| 369 | const metadata = yield* cloudCliOAuthConfig; | 368 | const metadata = yield* cloudCliOAuthConfig; |
| 369 | const hostedAppUrl = yield* hostedAppUrlConfig; | ||
| 370 | const { verifier, challenge, state } = yield* makePkceRequest; | 370 | const { verifier, challenge, state } = yield* makePkceRequest; |
| 371 | const callback = yield* Deferred.make<string>(); | 371 | const callback = yield* Deferred.make<string>(); |
| 372 | const callbackRoute = HttpRouter.add( | 372 | const callbackRoute = HttpRouter.add( |
| ... | @@ -392,19 +392,21 @@ export const make = Effect.gen(function* () { | ... | @@ -392,19 +392,21 @@ export const make = Effect.gen(function* () { |
| 392 | Layer.provide( | 392 | Layer.provide( |
| 393 | NodeHttpServer.layer(NodeHttp.createServer, { | 393 | NodeHttpServer.layer(NodeHttp.createServer, { |
| 394 | host: "127.0.0.1", | 394 | host: "127.0.0.1", |
| 395 | port: 34338, | 395 | port: metadata.loopbackPort, |
| 396 | disablePreemptiveShutdown: true, | 396 | disablePreemptiveShutdown: true, |
| 397 | }), | 397 | }), |
| 398 | ), | 398 | ), |
| 399 | Layer.build, | 399 | Layer.build, |
| 400 | ); | 400 | ); |
| 401 | const authorizationUrl = buildConnectClerkAuthorizeUrl({ | 401 | // The hosted /connect page establishes a Clerk session before forwarding |
| 402 | authorizationEndpoint: metadata.authorizationEndpoint, | 402 | // the request to /oauth/authorize with the loopback redirect URI. Sending |
| 403 | clientId: metadata.clientId, | 403 | // a signed-out browser to /oauth/authorize directly loses the authorize |
| 404 | redirectUri: metadata.redirectUri, | 404 | // parameters across Clerk's sign-in redirect (#5051). |
| 405 | scopes: metadata.scopes, | 405 | const authorizationUrl = buildConnectAuthorizeRequestUrl({ |
| 406 | hostedAppUrl, | ||
| 406 | state, | 407 | state, |
| 407 | challenge, | 408 | challenge, |
| 409 | loopbackPort: metadata.loopbackPort, | ||
| 408 | }); | 410 | }); |
| 409 | yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); | 411 | yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); |
| 410 | const authorization = yield* waitForLoopbackAuthorization({ | 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,9 +90,9 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public |
| 90 | }).pipe(provideEnv({})); | 90 | }).pipe(provideEnv({})); |
| 91 | 91 | ||
| 92 | assert.deepEqual(config, { | 92 | assert.deepEqual(config, { |
| 93 | authorizationEndpoint: "https://clerk.example.test/oauth/authorize", | ||
| 94 | tokenEndpoint: "https://clerk.example.test/oauth/token", | 93 | tokenEndpoint: "https://clerk.example.test/oauth/token", |
| 95 | clientId: "oauth_client_embedded", | 94 | clientId: "oauth_client_embedded", |
| 95 | loopbackPort: 34338, | ||
| 96 | redirectUri: "http://127.0.0.1:34338/callback", | 96 | redirectUri: "http://127.0.0.1:34338/callback", |
| 97 | scopes: ["openid", "profile", "email"], | 97 | scopes: ["openid", "profile", "email"], |
| 98 | }); | 98 | }); |
| ... | @@ -111,7 +111,6 @@ it.effect("prefers runtime Clerk OAuth config overrides over statically injected | ... | @@ -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 | assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); | 114 | assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); |
| 116 | assert.equal(config.clientId, "oauth_client_runtime"); | 115 | assert.equal(config.clientId, "oauth_client_runtime"); |
| 117 | }), | 116 | }), |
apps/server/src/cloud/publicConfig.ts+15-5| ... | @@ -1,4 +1,8 @@ | ... | @@ -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 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; | 6 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | 7 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 4 | import * as Config from "effect/Config"; | 8 | import * as Config from "effect/Config"; |
| ... | @@ -14,7 +18,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; | ... | @@ -14,7 +18,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; |
| 14 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; | 18 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; |
| 15 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; | 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 | const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; | 22 | const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; |
| 19 | 23 | ||
| 20 | function validateRelayUrl(value: string) { | 24 | function validateRelayUrl(value: string) { |
| ... | @@ -145,10 +149,16 @@ function makePublicValueConfig(name: string, fallback: 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 | export interface CloudCliOAuthConfig { | 158 | export interface CloudCliOAuthConfig { |
| 149 | readonly authorizationEndpoint: string; | ||
| 150 | readonly tokenEndpoint: string; | 159 | readonly tokenEndpoint: string; |
| 151 | readonly clientId: string; | 160 | readonly clientId: string; |
| 161 | readonly loopbackPort: number; | ||
| 152 | readonly redirectUri: string; | 162 | readonly redirectUri: string; |
| 153 | readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES; | 163 | readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES; |
| 154 | } | 164 | } |
| ... | @@ -184,10 +194,10 @@ export function makeCloudCliOAuthConfig({ | ... | @@ -184,10 +194,10 @@ export function makeCloudCliOAuthConfig({ |
| 184 | Effect.map( | 194 | Effect.map( |
| 185 | (clerkFrontendApiUrl) => | 195 | (clerkFrontendApiUrl) => |
| 186 | ({ | 196 | ({ |
| 187 | authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, | ||
| 188 | tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, | 197 | tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, |
| 189 | clientId, | 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 | scopes: CLOUD_CLI_OAUTH_SCOPES, | 201 | scopes: CLOUD_CLI_OAUTH_SCOPES, |
| 192 | }) satisfies CloudCliOAuthConfig, | 202 | }) satisfies CloudCliOAuthConfig, |
| 193 | ), | 203 | ), |
apps/web/src/cloud/connectCliAuth.test.ts+16| ... | @@ -46,6 +46,22 @@ describe("connectCliAuth", () => { | ... | @@ -46,6 +46,22 @@ describe("connectCliAuth", () => { |
| 46 | expect(url.searchParams.get("code_challenge_method")).toBe("S256"); | 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 | it("returns null when the CLI OAuth client id is not configured", () => { | 65 | it("returns null when the CLI OAuth client id is not configured", () => { |
| 50 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); | 66 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); |
| 51 | expect( | 67 | expect( |
apps/web/src/cloud/connectCliAuth.ts+10-1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | buildConnectClerkAuthorizeUrl, | 2 | buildConnectClerkAuthorizeUrl, |
| 3 | connectCallbackUrl, | 3 | connectCallbackUrl, |
| 4 | connectLoopbackRedirectUri, | ||
| 4 | CONNECT_OAUTH_SCOPES, | 5 | CONNECT_OAUTH_SCOPES, |
| 5 | type ConnectAuthorizeRequest, | 6 | type ConnectAuthorizeRequest, |
| 6 | } from "@t3tools/shared/connectAuth"; | 7 | } from "@t3tools/shared/connectAuth"; |
| ... | @@ -34,6 +35,11 @@ export function connectCliAuthRoutesEnabled(): boolean { | ... | @@ -34,6 +35,11 @@ export function connectCliAuthRoutesEnabled(): boolean { |
| 34 | * Builds the Clerk authorize URL for a CLI-initiated connect request. The | 35 | * Builds the Clerk authorize URL for a CLI-initiated connect request. The |
| 35 | * state is mirrored into sessionStorage so the callback page can verify the | 36 | * state is mirrored into sessionStorage so the callback page can verify the |
| 36 | * response matches a request this browser actually started. | 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 | export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { | 44 | export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { |
| 39 | const { clerkPublishableKey } = resolveCloudPublicConfig(); | 45 | const { clerkPublishableKey } = resolveCloudPublicConfig(); |
| ... | @@ -44,7 +50,10 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques | ... | @@ -44,7 +50,10 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques |
| 44 | return buildConnectClerkAuthorizeUrl({ | 50 | return buildConnectClerkAuthorizeUrl({ |
| 45 | authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, | 51 | authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, |
| 46 | clientId, | 52 | clientId, |
| 47 | redirectUri: connectCallbackUrl(configuredHostedAppUrl()), | 53 | redirectUri: |
| 54 | request.loopbackPort === undefined | ||
| 55 | ? connectCallbackUrl(configuredHostedAppUrl()) | ||
| 56 | : connectLoopbackRedirectUri(request.loopbackPort), | ||
| 48 | scopes: CONNECT_OAUTH_SCOPES, | 57 | scopes: CONNECT_OAUTH_SCOPES, |
| 49 | state: request.state, | 58 | state: request.state, |
| 50 | challenge: request.challenge, | 59 | challenge: request.challenge, |
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+9-3| ... | @@ -44,8 +44,10 @@ const invalidLinkMessage = { | ... | @@ -44,8 +44,10 @@ const invalidLinkMessage = { |
| 44 | } as const; | 44 | } as const; |
| 45 | 45 | ||
| 46 | /** | 46 | /** |
| 47 | * /connect: the URL a headless CLI prints. Waits for a Clerk session, then | 47 | * /connect: the URL the CLI prints for both flows. Waits for a Clerk session, |
| 48 | * forwards the CLI's PKCE request to Clerk's authorize endpoint. | 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 | export function ConnectCliAuthorizeSurface() { | 52 | export function ConnectCliAuthorizeSurface() { |
| 51 | const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); | 53 | const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); |
| ... | @@ -85,7 +87,11 @@ export function ConnectCliAuthorizeSurface() { | ... | @@ -85,7 +87,11 @@ export function ConnectCliAuthorizeSurface() { |
| 85 | return ( | 87 | return ( |
| 86 | <AuthSurfaceShell> | 88 | <AuthSurfaceShell> |
| 87 | <ConnectCliAuthMessage | 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 | title="Connecting your terminal" | 95 | title="Connecting your terminal" |
| 90 | description={ | 96 | description={ |
| 91 | isSignedIn | 97 | isSignedIn |
docs/internals/t3-connect.md+11-3| ... | @@ -90,9 +90,17 @@ In **Clerk Dashboard > OAuth applications**: | ... | @@ -90,9 +90,17 @@ In **Clerk Dashboard > OAuth applications**: |
| 90 | 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build | 90 | 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build |
| 91 | environment to the generated public client ID. | 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 | 93 | Both CLI flows start at the hosted `/connect` page (`buildConnectAuthorizeRequestUrl` in |
| 94 | `/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth | 94 | `packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request |
| 95 | handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. | 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 | The connect command group is: | 105 | The connect command group is: |
| 98 | 106 |
packages/shared/src/connectAuth.test.ts+26| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | buildConnectAuthorizeRequestUrl, | 4 | buildConnectAuthorizeRequestUrl, |
| 5 | buildConnectClerkAuthorizeUrl, | 5 | buildConnectClerkAuthorizeUrl, |
| 6 | connectCallbackUrl, | 6 | connectCallbackUrl, |
| 7 | connectLoopbackRedirectUri, | ||
| 7 | encodeConnectAuthCode, | 8 | encodeConnectAuthCode, |
| 8 | parseConnectAuthCode, | 9 | parseConnectAuthCode, |
| 9 | readConnectAuthorizeRequest, | 10 | readConnectAuthorizeRequest, |
| ... | @@ -37,6 +38,31 @@ describe("connectAuth", () => { | ... | @@ -37,6 +38,31 @@ describe("connectAuth", () => { |
| 37 | ).toBeNull(); | 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 | it("builds a PKCE authorize URL against the Clerk endpoint", () => { | 66 | it("builds a PKCE authorize URL against the Clerk endpoint", () => { |
| 41 | const url = new URL( | 67 | const url = new URL( |
| 42 | buildConnectClerkAuthorizeUrl({ | 68 | buildConnectClerkAuthorizeUrl({ |
packages/shared/src/connectAuth.ts+50-4| ... | @@ -2,7 +2,9 @@ import { readHashParams } from "./remote.ts"; | ... | @@ -2,7 +2,9 @@ import { readHashParams } from "./remote.ts"; |
| 2 | 2 | ||
| 3 | const CONNECT_AUTH_STATE_PARAM = "state"; | 3 | const CONNECT_AUTH_STATE_PARAM = "state"; |
| 4 | const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; | 4 | const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; |
| 5 | const CONNECT_AUTH_PORT_PARAM = "port"; | ||
| 5 | const CONNECT_AUTH_CODE_SEPARATOR = "."; | 6 | const CONNECT_AUTH_CODE_SEPARATOR = "."; |
| 7 | const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; | ||
| 6 | 8 | ||
| 7 | const CONNECT_AUTHORIZE_PATH = "/connect"; | 9 | const CONNECT_AUTHORIZE_PATH = "/connect"; |
| 8 | const CONNECT_CALLBACK_PATH = "/connect/callback"; | 10 | const CONNECT_CALLBACK_PATH = "/connect/callback"; |
| ... | @@ -23,22 +25,39 @@ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; | ... | @@ -23,22 +25,39 @@ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; |
| 23 | export interface ConnectAuthorizeRequest { | 25 | export interface ConnectAuthorizeRequest { |
| 24 | readonly state: string; | 26 | readonly state: string; |
| 25 | readonly challenge: string; | 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 | 37 | * The URL the CLI prints for the user to open in a browser. `state` and |
| 30 | * browser. `state` and `code_challenge` ride the fragment so they never reach | 38 | * `code_challenge` ride the fragment so they never reach the hosted app's |
| 31 | * the hosted app's server or CDN logs; neither is a secret. | 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 | export function buildConnectAuthorizeRequestUrl(input: { | 48 | export function buildConnectAuthorizeRequestUrl(input: { |
| 34 | readonly hostedAppUrl: string; | 49 | readonly hostedAppUrl: string; |
| 35 | readonly state: string; | 50 | readonly state: string; |
| 36 | readonly challenge: string; | 51 | readonly challenge: string; |
| 52 | readonly loopbackPort?: number; | ||
| 37 | }): string { | 53 | }): string { |
| 38 | const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); | 54 | const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); |
| 39 | url.hash = new URLSearchParams([ | 55 | url.hash = new URLSearchParams([ |
| 40 | [CONNECT_AUTH_STATE_PARAM, input.state], | 56 | [CONNECT_AUTH_STATE_PARAM, input.state], |
| 41 | [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], | 57 | [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], |
| 58 | ...(input.loopbackPort === undefined | ||
| 59 | ? [] | ||
| 60 | : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]), | ||
| 42 | ]).toString(); | 61 | ]).toString(); |
| 43 | return url.toString(); | 62 | return url.toString(); |
| 44 | } | 63 | } |
| ... | @@ -50,7 +69,34 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | | ... | @@ -50,7 +69,34 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | |
| 50 | if (!state || !challenge) { | 69 | if (!state || !challenge) { |
| 51 | return null; | 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 | export function connectCallbackUrl(hostedAppUrl: string): string { | 102 | export function connectCallbackUrl(hostedAppUrl: string): string { |