1import { readHashParams } from "./remote.ts";
2
3const CONNECT_AUTH_STATE_PARAM = "state";
4const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";
5const CONNECT_AUTH_PORT_PARAM = "port";
6const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback";
7
8const CONNECT_AUTHORIZE_PATH = "/connect";
9
10/**
11 * The CLI prints URLs against this origin and the web bundle uses it to
12 * decide whether it is the hosted deployment — the two must agree, so the
13 * default lives here.
14 */
15export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
16
17/**
18 * Requested at authorize time by the hosted page and by the CLI's device
19 * authorization request; keep both sides on this single definition.
20 * `offline_access` asks Clerk for the refresh token the CLI relies on.
21 */
22export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const;
23
24export interface ConnectAuthorizeRequest {
25 readonly state: string;
26 readonly challenge: string;
27 /**
28 * The hosted /connect page asks Clerk to redirect the authorization code
29 * straight to `http://127.0.0.1:<port>/callback` on the waiting CLI.
30 */
31 readonly loopbackPort: number;
32}
33
34/**
35 * The URL the CLI prints for the user to open in a browser. `state` and
36 * `code_challenge` ride the fragment so they never reach the hosted app's
37 * server or CDN logs; neither is a secret.
38 *
39 * The CLI routes through the hosted /connect page rather than hitting
40 * Clerk's /oauth/authorize directly: a signed-out browser sent straight to
41 * /oauth/authorize goes through Clerk's sign-in redirect, which does not
42 * reliably preserve the authorize query parameters (state, response_type,
43 * code_challenge). The hosted page waits for a Clerk session first, then
44 * forwards the request with the parameters intact. Headless hosts use the
45 * OAuth device authorization grant instead and never involve this page.
46 */
47export function buildConnectAuthorizeRequestUrl(input: {
48 readonly hostedAppUrl: string;
49 readonly state: string;
50 readonly challenge: string;
51 readonly loopbackPort: number;
52}): string {
53 const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl);
54 url.hash = new URLSearchParams([
55 [CONNECT_AUTH_STATE_PARAM, input.state],
56 [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge],
57 [CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)],
58 ]).toString();
59 return url.toString();
60}
61
62export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | null {
63 const params = readHashParams(url);
64 const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? "";
65 const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? "";
66 const loopbackPort = parseLoopbackPort(params.get(CONNECT_AUTH_PORT_PARAM)?.trim() ?? "");
67 if (!state || !challenge || loopbackPort === null) {
68 return null;
69 }
70 return { state, challenge, loopbackPort };
71}
72
73function parseLoopbackPort(value: string): number | null {
74 if (!/^\d{1,5}$/.test(value)) {
75 return null;
76 }
77 const port = Number(value);
78 return port >= 1 && port <= 65535 ? port : null;
79}
80
81/**
82 * Redirect URI for the CLI's local callback listener. Must stay in sync with
83 * the redirect URI registered on the Clerk CLI OAuth application.
84 */
85export function connectLoopbackRedirectUri(port: number): string {
86 return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`;
87}
88
89export function buildConnectClerkAuthorizeUrl(input: {
90 readonly authorizationEndpoint: string;
91 readonly clientId: string;
92 readonly redirectUri: string;
93 readonly scopes: ReadonlyArray<string>;
94 readonly state: string;
95 readonly challenge: string;
96}): string {
97 const url = new URL(input.authorizationEndpoint);
98 url.searchParams.set("client_id", input.clientId);
99 url.searchParams.set("redirect_uri", input.redirectUri);
100 url.searchParams.set("response_type", "code");
101 url.searchParams.set("scope", input.scopes.join(" "));
102 url.searchParams.set("state", input.state);
103 url.searchParams.set("code_challenge", input.challenge);
104 url.searchParams.set("code_challenge_method", "S256");
105 return url.toString();
106}