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";...@@ -26,7 +26,6 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2626
27import {27import {
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* () {
367366
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({}));
9191
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 );
113113
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 @@
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";
2import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";6import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
3import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";7import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
4import * as Config from "effect/Config";8import * 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;
14declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined;18declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined;
15declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;19declare 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;
18const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;22const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
1923
20function validateRelayUrl(value: string) {24function 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}
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 */
148export interface CloudCliOAuthConfig {158export 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 });
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
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 @@
1import {1import {
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. The35 * Builds the Clerk authorize URL for a CLI-initiated connect request. The
35 * state is mirrored into sessionStorage so the callback page can verify the36 * 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 */
38export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null {44export 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;
4545
46/**46/**
47 * /connect: the URL a headless CLI prints. Waits for a Clerk session, then47 * /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 */
50export function ConnectCliAuthorizeSurface() {52export 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 <ConnectCliAuthMessage89 <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 isSignedIn97 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**:
905. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build905. 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.
9292
93The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's93Both 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 OAuth94`packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request
95handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link.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
97The connect command group is:105The connect command group is:
98106
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 });
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
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";
22
3const CONNECT_AUTH_STATE_PARAM = "state";3const CONNECT_AUTH_STATE_PARAM = "state";
4const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";4const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";
5const CONNECT_AUTH_PORT_PARAM = "port";
5const CONNECT_AUTH_CODE_SEPARATOR = ".";6const CONNECT_AUTH_CODE_SEPARATOR = ".";
7const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback";
68
7const CONNECT_AUTHORIZE_PATH = "/connect";9const CONNECT_AUTHORIZE_PATH = "/connect";
8const CONNECT_CALLBACK_PATH = "/connect/callback";10const 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;
23export interface ConnectAuthorizeRequest {25export 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}
2735
28/**36/**
29 * The URL a headless CLI prints for the user to open on a machine with a37 * 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 reach38 * `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 */
33export function buildConnectAuthorizeRequestUrl(input: {48export 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
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}`;
54}100}
55101
56export function connectCallbackUrl(hostedAppUrl: string): string {102export function connectCallbackUrl(hostedAppUrl: string): string {