authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 15:09:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 15:09:28-07:00
logdc0869b605f60c2c8afa924035a81c8f98b29385
tree26827865bbdb5ddbf80a987f48bf6a102c550c45
parent0f21fcbb6dbabb6d2185a73edf6b865fd64c6019
signature Signed by PGP key B5690EEEBB952194

feat(server): use Clerk's device authorization grant for headless connect login (#11794)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

17 files changed, 445 insertions(+), 562 deletions(-)

apps/server/src/cli/connect.ts+17-18
......@@ -20,7 +20,6 @@ import * as Layer from "effect/Layer";
2020import * as Option from "effect/Option";
2121import * as References from "effect/References";
2222import * as Schema from "effect/Schema";
23import * as Terminal from "effect/Terminal";
2423import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
2524import {
2625 FetchHttpClient,
......@@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe(
6463const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
6564
6665const headlessFlag = Flag.boolean("headless").pipe(
67 Flag.withDescription("Authorize without a local browser using out-of-band OAuth."),
66 Flag.withDescription("Authorize without a local browser using the OAuth device flow."),
6867 Flag.withDefault(false),
6968);
7069
7170/**
7271 * Inside an SSH session there is no local browser to complete the loopback
73 * OAuth callback, so out-of-band OAuth is the only flow that can work.
72 * OAuth callback, so the device authorization grant is the only flow that
73 * can work.
7474 */
7575export const headlessSessionConfig = Config.all({
7676 sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),
......@@ -79,20 +79,21 @@ export const headlessSessionConfig = Config.all({
7979 Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
8080);
8181
82const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")(
83 function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) {
84 yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl));
85 return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate }));
86 },
87);
82const showDeviceAuthorizationPrompt = (prompt: CliTokenManager.DeviceAuthorizationPrompt) =>
83 Console.log(formatDeviceAuthorizationPrompt(prompt));
8884
89function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string {
85function formatDeviceAuthorizationPrompt(
86 prompt: CliTokenManager.DeviceAuthorizationPrompt,
87): string {
88 const minutes = Math.max(1, Math.round(Duration.toMinutes(prompt.expiresIn)));
9089 return [
9190 "Headless authorization",
9291 "Open this URL on a device with a browser:",
93 ` ${authorizeUrl}`,
92 ` ${prompt.verificationUriComplete ?? prompt.verificationUri}`,
93 "",
94 `Confirm this code when asked: ${prompt.userCode}`,
9495 "",
95 "After signing in, return here and enter the code shown in your browser.",
96 `Waiting for approval (expires in ${minutes} min). Press Ctrl+C to cancel.`,
9697 ].join("\n");
9798}
9899
......@@ -110,7 +111,7 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
110111 yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");
111112 }
112113 // A stored credential whose refresh fails (revoked, expired grant) must
113 // fall through to a fresh out-of-band authorization, not dead-end the command.
114 // fall through to a fresh device authorization, not dead-end the command.
114115 const existing = yield* tokens.getExisting.pipe(
115116 Effect.catchTag("CloudCliCredentialRefreshError", () =>
116117 Console.log(
......@@ -121,13 +122,11 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
121122 if (Option.isSome(existing)) {
122123 return existing.value.identity ?? null;
123124 }
124 const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
125 promptForOutOfBandOAuthCode,
125 const { token, identity } = yield* CliTokenManager.deviceAuthorizationLogin(
126 showDeviceAuthorizationPrompt,
126127 ).pipe(
127128 Effect.mapError((cause) =>
128 // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI
129 // cancels quietly instead of dumping an authorization error.
130 Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
129 isCloudCliTokenManagerError(cause)
131130 ? cause
132131 : new CliTokenManager.CloudCliAuthorizationError({ cause }),
133132 ),
apps/server/src/cloud/CliTokenManager.test.ts+186-109
......@@ -1,21 +1,21 @@
1import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
21import * as NodeServices from "@effect/platform-node/NodeServices";
32import { assert, it } from "@effect/vitest";
43import * as ConfigProvider from "effect/ConfigProvider";
5import * as Crypto from "effect/Crypto";
64import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";
76import * as Effect from "effect/Effect";
87import * as Encoding from "effect/Encoding";
8import * as Fiber from "effect/Fiber";
99import * as Layer from "effect/Layer";
1010import * as Option from "effect/Option";
1111import * as Queue from "effect/Queue";
1212import * as Schema from "effect/Schema";
1313import * as Terminal from "effect/Terminal";
14import * as TestClock from "effect/testing/TestClock";
1415import * as HttpClient from "effect/unstable/http/HttpClient";
1516import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1617
1718import * as CliTokenManager from "./CliTokenManager.ts";
18import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts";
1919
2020// pk_test_<base64 of "clerk.example.test$">
2121const TEST_ENV = {
......@@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString(
5454);
5555const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson);
5656
57const makeTokenEndpointLayer = (
58 requests: Array<RecordedTokenRequest>,
59 options?: { readonly idToken?: string },
60) =>
61 Layer.succeed(
62 HttpClient.HttpClient,
63 HttpClient.make((request) =>
64 Effect.sync(() => {
65 const body =
66 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
67 requests.push({ url: request.url, params: new URLSearchParams(body) });
68 return HttpClientResponse.fromWeb(
69 request,
70 new Response(
71 encodeTestTokenResponse({
72 access_token: "access-token-1",
73 refresh_token: "refresh-token-1",
74 id_token: options?.idToken ?? idTokenWithEmail,
75 expires_in: 3600,
76 token_type: "bearer",
77 }),
78 { status: 200, headers: { "content-type": "application/json" } },
79 ),
80 );
81 }),
82 ),
83 );
84
8557const provideTestEnv = Effect.provide(
8658 ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),
8759);
8860
8961const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);
9062
91class PromptRejectedError extends Schema.TaggedError<PromptRejectedError>()("PromptRejectedError", {
92 message: Schema.String,
93}) {}
94
9563const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) =>
9664 Terminal.make({
9765 columns: Effect.succeed(80),
......@@ -144,106 +112,215 @@ it.effect("finishes normally when the browser callback wins", () =>
144112 }),
145113);
146114
147it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => {
148 it.effect("prints a hosted authorize URL and exchanges the out-of-band code with PKCE", () =>
149 Effect.gen(function* () {
150 const requests: Array<RecordedTokenRequest> = [];
151 let seenAuthorizeUrl = "";
152
153 const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
154 ({ authorizeUrl, validate }: OutOfBandOAuthPromptInput) =>
155 Effect.gen(function* () {
156 seenAuthorizeUrl = authorizeUrl;
157 const request = readConnectAuthorizeRequest(new URL(authorizeUrl));
158 assert.isNotNull(request);
159 return yield* validate(`clerk-code-123.${request!.state}`).pipe(
160 Effect.mapError((message) => new PromptRejectedError({ message })),
161 );
115interface DeviceFlowServer {
116 readonly requests: Array<RecordedTokenRequest>;
117 /** Token endpoint replies, consumed in order; the last one repeats. */
118 readonly tokenReplies: Array<{ readonly status: number; readonly body: string }>;
119}
120
121const DEVICE_AUTHORIZATION_BODY = JSON.stringify({
122 device_code: "device-code-1",
123 user_code: "BCDF-GHJK",
124 verification_uri: "https://accounts.example.test/device",
125 verification_uri_complete: "https://accounts.example.test/device?user_code=BCDF-GHJK",
126 expires_in: 600,
127 interval: 5,
128});
129
130const oauthError = (error: string) => ({ status: 400, body: JSON.stringify({ error }) });
131const tokenGranted = {
132 status: 200,
133 body: encodeTestTokenResponse({
134 access_token: "access-token-1",
135 refresh_token: "refresh-token-1",
136 id_token: idTokenWithEmail,
137 expires_in: 3600,
138 token_type: "bearer",
139 }),
140};
141
142const makeDeviceFlowLayer = (server: DeviceFlowServer) =>
143 Layer.succeed(
144 HttpClient.HttpClient,
145 HttpClient.make((request) =>
146 Effect.sync(() => {
147 const body =
148 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
149 server.requests.push({ url: request.url, params: new URLSearchParams(body) });
150 const reply = request.url.endsWith("/oauth/device_authorization")
151 ? { status: 200, body: DEVICE_AUTHORIZATION_BODY }
152 : ((server.tokenReplies.length > 1
153 ? server.tokenReplies.shift()
154 : server.tokenReplies[0]) ?? oauthError("invalid_grant"));
155 return HttpClientResponse.fromWeb(
156 request,
157 new Response(reply.body, {
158 status: reply.status,
159 headers: { "content-type": "application/json" },
162160 }),
163 ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv);
161 );
162 }),
163 ),
164 );
165
166const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) =>
167 requests.filter((request) => request.url.endsWith("/oauth/token"));
164168
165 const authorizeUrl = new URL(seenAuthorizeUrl);
166 assert.equal(authorizeUrl.origin, "https://hosted.example.test");
167 assert.equal(authorizeUrl.pathname, "/connect");
168 const request = readConnectAuthorizeRequest(authorizeUrl);
169 assert.isNotNull(request);
170 assert.match(request!.state, /^[A-Za-z0-9_-]{22}$/);
169it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => {
170 it.effect("requests a device code, shows it, and polls until Clerk grants the token", () =>
171 Effect.gen(function* () {
172 const server: DeviceFlowServer = {
173 requests: [],
174 tokenReplies: [oauthError("authorization_pending"), tokenGranted],
175 };
176 const prompts: Array<CliTokenManager.DeviceAuthorizationPrompt> = [];
171177
178 const fiber = yield* CliTokenManager.deviceAuthorizationLogin((prompt) =>
179 Effect.sync(() => {
180 prompts.push(prompt);
181 }),
182 ).pipe(Effect.provide(makeDeviceFlowLayer(server)), provideTestEnv, Effect.forkChild);
183
184 yield* TestClock.adjust(Duration.seconds(10));
185 const { token, identity } = yield* Fiber.join(fiber);
186
187 assert.deepEqual(prompts, [
188 {
189 verificationUri: "https://accounts.example.test/device",
190 verificationUriComplete: "https://accounts.example.test/device?user_code=BCDF-GHJK",
191 userCode: "BCDF-GHJK",
192 expiresIn: Duration.seconds(600),
193 },
194 ]);
172195 assert.equal(token.accessToken, "access-token-1");
173196 assert.equal(token.refreshToken, "refresh-token-1");
174197 assert.equal(token.identity, "theo@example.test");
175 // The id_token's email claim is surfaced so connect can show the account.
176198 assert.equal(identity, "theo@example.test");
177199
178 assert.lengthOf(requests, 1);
179 const exchange = requests[0]!;
180 assert.equal(exchange.url, "https://clerk.example.test/oauth/token");
181 assert.equal(exchange.params.get("grant_type"), "authorization_code");
182 assert.equal(exchange.params.get("code"), "clerk-code-123");
183 assert.equal(
184 exchange.params.get("redirect_uri"),
185 "https://hosted.example.test/connect/callback",
200 const authorization = server.requests[0]!;
201 assert.equal(authorization.url, "https://clerk.example.test/oauth/device_authorization");
202 assert.equal(authorization.params.get("client_id"), "oauth_client_test");
203 assert.equal(authorization.params.get("scope"), "openid profile email offline_access");
204
205 const polls = tokenRequests(server.requests);
206 assert.lengthOf(polls, 2);
207 for (const poll of polls) {
208 assert.equal(poll.url, "https://clerk.example.test/oauth/token");
209 assert.equal(poll.params.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code");
210 assert.equal(poll.params.get("device_code"), "device-code-1");
211 assert.equal(poll.params.get("client_id"), "oauth_client_test");
212 }
213 }),
214 );
215
216 it.effect("waits the advertised interval between polls and backs off on slow_down", () =>
217 Effect.gen(function* () {
218 const server: DeviceFlowServer = {
219 requests: [],
220 tokenReplies: [oauthError("slow_down"), oauthError("authorization_pending")],
221 };
222
223 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
224 Effect.provide(makeDeviceFlowLayer(server)),
225 provideTestEnv,
226 Effect.forkChild,
186227 );
187 assert.equal(exchange.params.get("client_id"), "oauth_client_test");
188 // The verifier must hash to the challenge advertised in the authorize URL.
189 const verifier = exchange.params.get("code_verifier");
190 assert.isNotNull(verifier);
191 const crypto = yield* Crypto.Crypto;
192 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier!));
193 assert.equal(Encoding.encodeBase64Url(digest), request!.challenge);
228
229 yield* TestClock.adjust(Duration.seconds(4));
230 assert.lengthOf(tokenRequests(server.requests), 0);
231 yield* TestClock.adjust(Duration.seconds(1));
232 assert.lengthOf(tokenRequests(server.requests), 1);
233 // slow_down widens the 5s interval to 10s.
234 yield* TestClock.adjust(Duration.seconds(9));
235 assert.lengthOf(tokenRequests(server.requests), 1);
236 yield* TestClock.adjust(Duration.seconds(1));
237 assert.lengthOf(tokenRequests(server.requests), 2);
238 yield* Fiber.interrupt(fiber);
194239 }),
195240 );
196241
197 it.effect("rejects out-of-band codes whose state does not match the request", () =>
242 it.effect("backs off after a transient upstream failure and keeps polling", () =>
198243 Effect.gen(function* () {
199 const requests: Array<RecordedTokenRequest> = [];
200
201 const validationErrors: Array<string> = [];
202 const result = yield* CliTokenManager.outOfBandOAuthLogin(
203 ({ validate }: OutOfBandOAuthPromptInput) =>
204 validate("clerk-code-123.wrong-state").pipe(
205 Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))),
206 Effect.mapError((message) => new PromptRejectedError({ message })),
207 ),
208 ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);
209
210 assert.lengthOf(requests, 0);
211 assert.lengthOf(validationErrors, 1);
212 assert.include(validationErrors[0], "different connect request");
213 assert.instanceOf(result, PromptRejectedError);
244 const server: DeviceFlowServer = {
245 requests: [],
246 tokenReplies: [{ status: 503, body: "upstream unavailable" }, tokenGranted],
247 };
248
249 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
250 Effect.provide(makeDeviceFlowLayer(server)),
251 provideTestEnv,
252 Effect.forkChild,
253 );
254
255 yield* TestClock.adjust(Duration.seconds(5));
256 assert.lengthOf(tokenRequests(server.requests), 1);
257 // The 5xx widens the 5s interval to 10s before the retry.
258 yield* TestClock.adjust(Duration.seconds(9));
259 assert.lengthOf(tokenRequests(server.requests), 1);
260 yield* TestClock.adjust(Duration.seconds(1));
261 const { token } = yield* Fiber.join(fiber);
262 assert.lengthOf(tokenRequests(server.requests), 2);
263 assert.equal(token.accessToken, "access-token-1");
214264 }),
215265 );
216266
217 it.effect("ignores an id_token whose claims are not valid JSON", () =>
267 it.effect("fails with a denied error when the user rejects the request", () =>
218268 Effect.gen(function* () {
219 const requests: Array<RecordedTokenRequest> = [];
220 const malformedIdToken = `header.${Encoding.encodeBase64Url("not-json")}.signature`;
221
222 const { identity } = yield* CliTokenManager.outOfBandOAuthLogin(
223 ({ authorizeUrl }: OutOfBandOAuthPromptInput) => {
224 const request = readConnectAuthorizeRequest(new URL(authorizeUrl));
225 assert.isNotNull(request);
226 return Effect.succeed(`clerk-code-123.${request!.state}`);
227 },
228 ).pipe(
229 Effect.provide(makeTokenEndpointLayer(requests, { idToken: malformedIdToken })),
269 const server: DeviceFlowServer = {
270 requests: [],
271 tokenReplies: [oauthError("access_denied")],
272 };
273
274 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
275 Effect.provide(makeDeviceFlowLayer(server)),
276 provideTestEnv,
277 Effect.flip,
278 Effect.forkChild,
279 );
280 yield* TestClock.adjust(Duration.seconds(5));
281 const result = yield* Fiber.join(fiber);
282
283 assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationDeniedError);
284 assert.lengthOf(tokenRequests(server.requests), 1);
285 }),
286 );
287
288 it.effect("times out once the device code lifetime elapses", () =>
289 Effect.gen(function* () {
290 const server: DeviceFlowServer = {
291 requests: [],
292 tokenReplies: [oauthError("authorization_pending")],
293 };
294
295 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
296 Effect.provide(makeDeviceFlowLayer(server)),
230297 provideTestEnv,
298 Effect.flip,
299 Effect.forkChild,
231300 );
301 yield* TestClock.adjust(Duration.seconds(600));
302 const result = yield* Fiber.join(fiber);
232303
233 assert.isNull(identity);
234 assert.lengthOf(requests, 1);
304 assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError);
235305 }),
236306 );
237307
238 it.effect("fails without touching the token endpoint when the prompt returns garbage", () =>
308 it.effect("surfaces other OAuth errors as authorization failures", () =>
239309 Effect.gen(function* () {
240 const requests: Array<RecordedTokenRequest> = [];
310 const server: DeviceFlowServer = {
311 requests: [],
312 tokenReplies: [oauthError("invalid_client")],
313 };
241314
242 const result = yield* CliTokenManager.outOfBandOAuthLogin(() =>
243 Effect.succeed("not-a-connect-code"),
244 ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);
315 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
316 Effect.provide(makeDeviceFlowLayer(server)),
317 provideTestEnv,
318 Effect.flip,
319 Effect.forkChild,
320 );
321 yield* TestClock.adjust(Duration.seconds(5));
322 const result = yield* Fiber.join(fiber);
245323
246 assert.lengthOf(requests, 0);
247324 assert.isTrue(isAuthorizationError(result));
248325 }),
249326 );
apps/server/src/cloud/CliTokenManager.ts+154-64
......@@ -18,17 +18,14 @@ import * as Schema from "effect/Schema";
1818import * as Semaphore from "effect/Semaphore";
1919import * as Terminal from "effect/Terminal";
2020import * as HttpClient from "effect/unstable/http/HttpClient";
21import * as HttpClientError from "effect/unstable/http/HttpClientError";
2122import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
2223import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
2324import * as HttpRouter from "effect/unstable/http/HttpRouter";
2425import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
2526import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2627
27import {
28 buildConnectAuthorizeRequestUrl,
29 checkConnectAuthCode,
30 connectCallbackUrl,
31} from "@t3tools/shared/connectAuth";
28import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth";
3229
3330import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3431import * as ExternalLauncher from "../process/externalLauncher.ts";
......@@ -42,6 +39,11 @@ import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
4239const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
4340const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
4441const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
42const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
43// RFC 8628 defaults, used only when Clerk omits the field.
44const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5);
45// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval".
46const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5);
4547const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
4648
4749function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
......@@ -136,6 +138,20 @@ const OAuthTokenResponse = Schema.Struct({
136138 token_type: Schema.String,
137139});
138140
141const OAuthErrorResponse = Schema.Struct({
142 error: Schema.String,
143 error_description: Schema.optional(Schema.String),
144});
145
146const DeviceAuthorizationResponse = Schema.Struct({
147 device_code: Schema.String,
148 user_code: Schema.String,
149 verification_uri: Schema.String,
150 verification_uri_complete: Schema.optional(Schema.String),
151 expires_in: Schema.Number,
152 interval: Schema.optional(Schema.Number),
153});
154
139155const OidcIdentityClaimsJson = Schema.fromJsonString(
140156 Schema.Struct({
141157 email: Schema.optional(Schema.String),
......@@ -209,12 +225,22 @@ export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudC
209225 }
210226}
211227
228export class CloudCliAuthorizationDeniedError extends Schema.TaggedError<CloudCliAuthorizationDeniedError>()(
229 "CloudCliAuthorizationDeniedError",
230 {},
231) {
232 override get message(): string {
233 return "T3 Connect authorization was denied in the browser.";
234 }
235}
236
212237export const CloudCliTokenManagerError = Schema.Union([
213238 CloudCliCredentialRemovalError,
214239 CloudCliCredentialRefreshError,
215240 CloudCliCredentialReadError,
216241 CloudCliAuthorizationError,
217242 CloudCliAuthorizationTimeoutError,
243 CloudCliAuthorizationDeniedError,
218244]);
219245export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type;
220246
......@@ -241,29 +267,36 @@ function bytesToString(value: Uint8Array): string {
241267 return new TextDecoder().decode(value);
242268}
243269
244const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
245 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,
270const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* (
271 response: HttpClientResponse.HttpClientResponse,
246272 params: Record<string, string>,
247273) {
248 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
249 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
250 HttpClientRequest.bodyUrlParams(params),
251 httpClient.execute,
252 Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),
253 );
274 const body = yield* HttpClientResponse.schemaBodyJson(OAuthTokenResponse)(response);
254275 const now = yield* Clock.currentTimeMillis;
255 const identity = idTokenIdentity(response.id_token);
276 const identity = idTokenIdentity(body.id_token);
256277 return {
257278 token: {
258 accessToken: response.access_token,
259 refreshToken: response.refresh_token ?? params.refresh_token ?? "",
260 expiresAtEpochMs: now + response.expires_in * 1_000,
279 accessToken: body.access_token,
280 refreshToken: body.refresh_token ?? params.refresh_token ?? "",
281 expiresAtEpochMs: now + body.expires_in * 1_000,
261282 ...(identity === null ? {} : { identity }),
262283 } satisfies PersistedToken,
263284 identity,
264285 };
265286});
266287
288const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
289 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,
290 params: Record<string, string>,
291) {
292 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
293 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
294 HttpClientRequest.bodyUrlParams(params),
295 httpClient.execute,
296 );
297 return yield* readTokenResponse(response, params);
298});
299
267300const makePkceRequest = Effect.gen(function* () {
268301 const crypto = yield* Crypto.Crypto;
269302 const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
......@@ -274,61 +307,118 @@ const makePkceRequest = Effect.gen(function* () {
274307 return { verifier, challenge, state };
275308});
276309
277export interface OutOfBandOAuthPromptInput {
278 readonly authorizeUrl: string;
279 readonly validate: (value: string) => Effect.Effect<string, string>;
310export interface DeviceAuthorizationPrompt {
311 readonly verificationUri: string;
312 readonly verificationUriComplete: string | undefined;
313 readonly userCode: string;
314 readonly expiresIn: Duration.Duration;
280315}
281316
317const isTransportError = (error: unknown) =>
318 HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError";
319
282320/**
283 * Out-of-band OAuth for machines without a local browser (SSH). The user
284 * opens the hosted /connect URL elsewhere, signs in, and enters the displayed
285 * code in this terminal. The PKCE verifier never leaves this process, so the
286 * authorization code is useless to an observer, and the state bundled into
287 * the blob preserves the loopback flow's CSRF check.
321 * Polls Clerk's token endpoint until the user approves or denies the device
322 * request in the browser (RFC 8628 §3.4/3.5). `authorization_pending` keeps
323 * waiting, while `slow_down` and transient failures widen the interval before
324 * the next tick; the caller bounds the whole loop with the device code's
325 * lifetime.
288326 */
289export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* <
290 E,
291 R,
292>(promptForCode: (input: OutOfBandOAuthPromptInput) => Effect.Effect<string, E, R>) {
293 const metadata = yield* cloudCliOAuthConfig;
294 const hostedAppUrl = yield* hostedAppUrlConfig;
295 const { verifier, challenge, state } = yield* makePkceRequest;
296
297 const authorizationCode = yield* promptForCode({
298 authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }),
299 validate: (value) => {
300 const checked = checkConnectAuthCode(value, state);
301 return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value);
302 },
303 }).pipe(
304 // Clerk authorization codes expire on this horizon anyway; matching the
305 // loopback flow's timeout turns an abandoned prompt into a clear error.
306 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
307 Effect.catchTag("TimeoutError", (cause) =>
308 Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
309 ),
310 );
311 // promptForCode is caller-supplied, so re-check the returned value rather
312 // than trusting that the prompt ran validate.
313 const authCode = checkConnectAuthCode(authorizationCode, state);
314 if (typeof authCode === "string") {
315 return yield* new CloudCliAuthorizationError({ cause: authCode });
316 }
317
318 return yield* exchangeToken(metadata, {
319 grant_type: "authorization_code",
320 code: authCode.code,
321 redirect_uri: connectCallbackUrl(hostedAppUrl),
327const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* (
328 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint" | "clientId">,
329 deviceCode: string,
330 initialInterval: Duration.Duration,
331) {
332 const httpClient = yield* HttpClient.HttpClient;
333 const params = {
334 grant_type: DEVICE_CODE_GRANT_TYPE,
335 device_code: deviceCode,
322336 client_id: metadata.clientId,
323 code_verifier: verifier,
324 });
337 };
338 let interval = initialInterval;
339 while (true) {
340 yield* Effect.sleep(interval);
341 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
342 HttpClientRequest.bodyUrlParams(params),
343 httpClient.execute,
344 Effect.map(Option.some),
345 Effect.catchIf(isTransportError, () => Effect.succeedNone),
346 );
347 // Transport failures and upstream 5xx are transient while the device code
348 // is still valid. RFC 8628 §3.5 asks clients to back off before retrying,
349 // so widen the interval like slow_down; drain the body so the connection
350 // returns to the pool for the next poll.
351 if (Option.isNone(response) || response.value.status >= 500) {
352 if (Option.isSome(response)) yield* Effect.ignore(response.value.text);
353 interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT);
354 continue;
355 }
356 if (response.value.status >= 200 && response.value.status < 300) {
357 return yield* readTokenResponse(response.value, params);
358 }
359 const failure = yield* HttpClientResponse.schemaBodyJson(OAuthErrorResponse)(response.value);
360 switch (failure.error) {
361 case "authorization_pending":
362 continue;
363 case "slow_down":
364 interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT);
365 continue;
366 case "expired_token":
367 return yield* new CloudCliAuthorizationTimeoutError({ cause: failure });
368 case "access_denied":
369 return yield* new CloudCliAuthorizationDeniedError();
370 default:
371 return yield* new CloudCliAuthorizationError({
372 cause: failure.error_description ?? failure.error,
373 });
374 }
375 }
325376});
326377
378/**
379 * OAuth device authorization grant for machines without a local browser
380 * (SSH). Clerk issues a short user code; the user approves it on Clerk's
381 * hosted device page from any browser while this process polls the token
382 * endpoint. Nothing is typed into the terminal and no redirect URI is
383 * involved, so the hosted app plays no part in this flow.
384 */
385export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_authorization_login")(
386 function* <E, R>(showPrompt: (prompt: DeviceAuthorizationPrompt) => Effect.Effect<void, E, R>) {
387 const metadata = yield* cloudCliOAuthConfig;
388 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
389 const authorization = yield* HttpClientRequest.post(metadata.deviceAuthorizationEndpoint).pipe(
390 HttpClientRequest.bodyUrlParams({
391 client_id: metadata.clientId,
392 scope: metadata.scopes.join(" "),
393 }),
394 httpClient.execute,
395 Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)),
396 );
397 // Clerk's advertised lifetime and interval are authoritative.
398 const expiresIn = Duration.seconds(authorization.expires_in);
399 const interval =
400 authorization.interval === undefined
401 ? DEVICE_AUTHORIZATION_DEFAULT_INTERVAL
402 : Duration.seconds(authorization.interval);
403 yield* showPrompt({
404 verificationUri: authorization.verification_uri,
405 verificationUriComplete: authorization.verification_uri_complete,
406 userCode: authorization.user_code,
407 expiresIn,
408 });
409 return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe(
410 Effect.timeout(expiresIn),
411 Effect.catchTag("TimeoutError", (cause) =>
412 Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
413 ),
414 );
415 },
416);
417
327418/** @public Service construction is part of the canonical Effect module API. */
328419export const make = Effect.gen(function* () {
329 // Capture exactly the services the login/refresh flows need at build time
330 // (matching the behavior before the out-of-band flow captured the instances), not
331 // the whole ambient context.
420 // Capture exactly the services the login/refresh flows need at build time,
421 // not the whole ambient context.
332422 const crypto = yield* Crypto.Crypto;
333423 const httpClient = yield* HttpClient.HttpClient;
334424 const services = Context.make(Crypto.Crypto, crypto).pipe(
......@@ -461,7 +551,7 @@ export const make = Effect.gen(function* () {
461551 Effect.gen(function* () {
462552 // A stored credential that can't be read or refreshed (corrupt, revoked,
463553 // expired grant) must fall through to a fresh login rather than dead-end
464 // the command — authorizeCli applies the same fallback to out-of-band
554 // the command — authorizeCli applies the same fallback to device
465555 // authorization.
466556 const token = yield* getExistingNoLock().pipe(
467557 Effect.orElseSucceed(() => Option.none<PersistedToken>()),
apps/server/src/cloud/publicConfig.test.ts+2-1
......@@ -91,10 +91,11 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public
9191
9292 assert.deepEqual(config, {
9393 tokenEndpoint: "https://clerk.example.test/oauth/token",
94 deviceAuthorizationEndpoint: "https://clerk.example.test/oauth/device_authorization",
9495 clientId: "oauth_client_embedded",
9596 loopbackPort: 34338,
9697 redirectUri: "http://127.0.0.1:34338/callback",
97 scopes: ["openid", "profile", "email"],
98 scopes: ["openid", "profile", "email", "offline_access"],
9899 });
99100 }),
100101);
apps/server/src/cloud/publicConfig.ts+5-2
......@@ -152,11 +152,13 @@ function makePublicValueConfig(name: string, fallback: string) {
152152/**
153153 * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes
154154 * 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.
155 * Clerk session exists (see CliTokenManager.login). The token endpoint and,
156 * for headless hosts, the device authorization endpoint are contacted
157 * directly.
157158 */
158159export interface CloudCliOAuthConfig {
159160 readonly tokenEndpoint: string;
161 readonly deviceAuthorizationEndpoint: string;
160162 readonly clientId: string;
161163 readonly loopbackPort: number;
162164 readonly redirectUri: string;
......@@ -195,6 +197,7 @@ export function makeCloudCliOAuthConfig({
195197 (clerkFrontendApiUrl) =>
196198 ({
197199 tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`,
200 deviceAuthorizationEndpoint: `${clerkFrontendApiUrl}/oauth/device_authorization`,
198201 clientId,
199202 loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT,
200203 redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT),
apps/web/src/cloud/connectCliAuth.test.ts+17-41
......@@ -4,7 +4,6 @@ import {
44 buildConnectCliClerkAuthorizeUrl,
55 connectCliSignInRedirectUrl,
66 hasConnectCliAuthConfig,
7 readConnectCliCallbackResult,
87} from "./connectCliAuth";
98
109// Any pk_test_* key decodes to <base64 hostname>.clerk.accounts.dev.
......@@ -25,48 +24,34 @@ describe("connectCliAuth", () => {
2524 expect(hasConnectCliAuthConfig()).toBe(true);
2625 });
2726
28 it("builds the Clerk authorize URL with the configured hosted origin's callback", () => {
27 it("builds a PKCE authorize URL that redirects to the CLI's loopback listener", () => {
2928 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
3029 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
31 vi.stubEnv("VITE_HOSTED_APP_URL", "https://nightly.app.t3.codes");
3230
3331 const authorizeUrl = buildConnectCliClerkAuthorizeUrl({
3432 state: "state-1",
3533 challenge: "challenge-1",
34 loopbackPort: 34338,
3635 });
3736 expect(authorizeUrl).not.toBeNull();
3837
3938 const url = new URL(authorizeUrl!);
4039 expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev");
4140 expect(url.pathname).toBe("/oauth/authorize");
42 expect(url.searchParams.get("redirect_uri")).toBe(
43 "https://nightly.app.t3.codes/connect/callback",
44 );
41 expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback");
4542 expect(url.searchParams.get("state")).toBe("state-1");
4643 expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
4744 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
4845 });
4946
50 it("redirects straight to the CLI's loopback listener when the request carries a port", () => {
51 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
52 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
53
54 const authorizeUrl = buildConnectCliClerkAuthorizeUrl({
55 state: "state-1",
56 challenge: "challenge-1",
57 loopbackPort: 34338,
58 });
59 expect(authorizeUrl).not.toBeNull();
60
61 const url = new URL(authorizeUrl!);
62 expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback");
63 expect(url.searchParams.get("state")).toBe("state-1");
64 });
65
6647 it("returns null when the CLI OAuth client id is not configured", () => {
6748 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
6849 expect(
69 buildConnectCliClerkAuthorizeUrl({ state: "state-1", challenge: "challenge-1" }),
50 buildConnectCliClerkAuthorizeUrl({
51 state: "state-1",
52 challenge: "challenge-1",
53 loopbackPort: 34338,
54 }),
7055 ).toBeNull();
7156 });
7257
......@@ -74,9 +59,10 @@ describe("connectCliAuth", () => {
7459 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
7560 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
7661
77 const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1";
62 const connectUrl =
63 "https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=34338";
7864 const redirectUrl = connectCliSignInRedirectUrl(
79 { state: "state-1", challenge: "challenge-1" },
65 { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 },
8066 connectUrl,
8167 );
8268
......@@ -87,23 +73,13 @@ describe("connectCliAuth", () => {
8773 it("falls back to the current URL when the authorize URL cannot be built", () => {
8874 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
8975
90 const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1";
76 const connectUrl =
77 "https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=34338";
9178 expect(
92 connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl),
93 ).toBe(connectUrl);
94 });
95
96 it("reads the code and state Clerk echoes back to the callback", () => {
97 expect(
98 readConnectCliCallbackResult(
99 new URL("https://app.t3.codes/connect/callback?code=abc&state=state-1"),
79 connectCliSignInRedirectUrl(
80 { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 },
81 connectUrl,
10082 ),
101 ).toEqual({ code: "abc", state: "state-1" });
102 expect(
103 readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?code=abc")),
104 ).toBeNull();
105 expect(
106 readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?state=s")),
107 ).toBeNull();
83 ).toBe(connectUrl);
10884 });
10985});
apps/web/src/cloud/connectCliAuth.ts+6-55
......@@ -1,17 +1,14 @@
11import {
22 buildConnectClerkAuthorizeUrl,
3 connectCallbackUrl,
43 connectLoopbackRedirectUri,
54 CONNECT_OAUTH_SCOPES,
65 type ConnectAuthorizeRequest,
76} from "@t3tools/shared/connectAuth";
87import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
98
10import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing";
9import { isHostedStaticApp } from "../hostedPairing";
1110import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig";
1211
13const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state";
14
1512function resolveConnectCliOAuthClientId(): string | null {
1613 return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined);
1714}
......@@ -23,7 +20,7 @@ export function hasConnectCliAuthConfig(): boolean {
2320}
2421
2522/**
26 * Gate for the /connect routes: the CLI handshake only exists on the hosted
23 * Gate for the /connect route: the CLI handshake only exists on the hosted
2724 * deployment (the same bundle ships inside local instances) and needs the
2825 * Clerk CLI OAuth client configured at build time.
2926 */
......@@ -33,13 +30,9 @@ export function connectCliAuthRoutesEnabled(): boolean {
3330
3431/**
3532 * Builds the Clerk authorize URL for a CLI-initiated connect request. The
36 * state is mirrored into sessionStorage so the callback page can verify the
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.
33 * authorization code returns to the CLI's `127.0.0.1` listener directly, so
34 * this page never sees it. Clerk enforces its registered redirect URI
35 * allowlist either way.
4336 */
4437export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null {
4538 const { clerkPublishableKey } = resolveCloudPublicConfig();
......@@ -50,10 +43,7 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques
5043 return buildConnectClerkAuthorizeUrl({
5144 authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`,
5245 clientId,
53 redirectUri:
54 request.loopbackPort === undefined
55 ? connectCallbackUrl(configuredHostedAppUrl())
56 : connectLoopbackRedirectUri(request.loopbackPort),
46 redirectUri: connectLoopbackRedirectUri(request.loopbackPort),
5747 scopes: CONNECT_OAUTH_SCOPES,
5848 state: request.state,
5949 challenge: request.challenge,
......@@ -76,42 +66,3 @@ export function connectCliSignInRedirectUrl(
7666): string {
7767 return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref;
7868}
79
80export function rememberConnectCliAuthState(state: string): void {
81 try {
82 window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state);
83 } catch {
84 // Session storage can be unavailable (e.g. blocked). The callback page
85 // then falls back to trusting the state Clerk echoed back.
86 }
87}
88
89/**
90 * Read-only on purpose: this runs during render, where a removal would be
91 * consumed by React's double-invoked/discarded renders (StrictMode) and
92 * silently disable the state check. The value is not a secret and is
93 * overwritten by the next /connect visit.
94 */
95export function readConnectCliAuthState(): string | null {
96 try {
97 return window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY);
98 } catch {
99 return null;
100 }
101}
102
103export interface ConnectCliCallbackResult {
104 readonly code: string;
105 readonly state: string;
106}
107
108export function readConnectCliCallbackResult(
109 url: URL = new URL(window.location.href),
110): ConnectCliCallbackResult | null {
111 const code = url.searchParams.get("code")?.trim() ?? "";
112 const state = url.searchParams.get("state")?.trim() ?? "";
113 if (!code || !state) {
114 return null;
115 }
116 return { code, state };
117}
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+7-101
......@@ -1,16 +1,12 @@
1import { useAuth, useClerk, useUser } from "@clerk/react";
2import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
1import { useAuth, useClerk } from "@clerk/react";
2import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
33import { useCallback, useEffect, useRef, useState } from "react";
44
55import {
66 buildConnectCliClerkAuthorizeUrl,
77 connectCliSignInRedirectUrl,
8 readConnectCliAuthState,
9 readConnectCliCallbackResult,
10 rememberConnectCliAuthState,
118} from "../../cloud/connectCliAuth";
129import { isElectron } from "../../env";
13import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
1410import { AuthSurfaceShell } from "../auth/AuthSurfaceShell";
1511import { resolveClerkSignInProps } from "../clerk/authRedirect";
1612import { Button } from "../ui/button";
......@@ -45,10 +41,10 @@ const invalidLinkMessage = {
4541} as const;
4642
4743/**
48 * /connect: the URL the CLI prints for both flows. Waits for a Clerk session,
49 * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a
50 * loopback redirect URI when the request carries a port, so the code returns
51 * straight to the waiting CLI, and the hosted callback page otherwise.
44 * /connect: the URL the CLI prints for the loopback flow. Waits for a Clerk
45 * session, then forwards the CLI's PKCE request to Clerk's authorize endpoint
46 * with the loopback redirect URI so the code returns straight to the waiting
47 * CLI. Headless hosts use Clerk's device authorization page instead.
5248 */
5349export function ConnectCliAuthorizeSurface() {
5450 const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href)));
......@@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() {
6157 if (!request) {
6258 return;
6359 }
64 // Clerk redirects to the authorize endpoint itself once sign-in completes,
65 // so the callback's state check has to be armed before handing off.
66 rememberConnectCliAuthState(request.state);
6760 clerk.openSignIn(
6861 resolveClerkSignInProps(
6962 connectCliSignInRedirectUrl(request, window.location.href),
......@@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() {
8881 return;
8982 }
9083 redirecting.current = true;
91 rememberConnectCliAuthState(request.state);
9284 window.location.assign(authorizeUrl);
9385 }, [isLoaded, isSignedIn, openSignIn, request]);
9486
......@@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() {
10395 return (
10496 <AuthSurfaceShell>
10597 <ConnectCliAuthMessage
106 eyebrow={
107 request.loopbackPort === undefined
108 ? "Step 1 of 2 · Browser authorization"
109 : "Browser authorization"
110 }
98 eyebrow="Browser authorization"
11199 title="Connecting your terminal"
112100 description={
113101 isSignedIn
......@@ -125,85 +113,3 @@ export function ConnectCliAuthorizeSurface() {
125113 </AuthSurfaceShell>
126114 );
127115}
128
129/**
130 * /connect/callback: Clerk's redirect target. Shows the one-time code the
131 * user enters in the waiting terminal.
132 */
133export function ConnectCliCallbackSurface() {
134 const [result] = useState(readConnectCliCallbackResult);
135 const [expectedState] = useState(readConnectCliAuthState);
136 const { user } = useUser();
137 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" });
138
139 if (!result) {
140 return (
141 <AuthSurfaceShell>
142 <ConnectCliAuthMessage
143 eyebrow="Step 2 of 2 · Terminal handoff"
144 title="Authorization did not complete"
145 description="No authorization code was returned. Re-run `t3 connect` in your terminal and try again."
146 />
147 </AuthSurfaceShell>
148 );
149 }
150
151 // Fail closed: the legitimate callback always lands in the same browser
152 // that visited /connect (which recorded the state), so a missing or
153 // mismatched state means this page was reached some other way — the CSRF
154 // shape the state parameter exists to stop. Refuse to display a code.
155 if (expectedState === null || expectedState !== result.state) {
156 return (
157 <AuthSurfaceShell>
158 <ConnectCliAuthMessage
159 eyebrow="Step 2 of 2 · Terminal handoff"
160 title="This code belongs to a different request"
161 description="This authorization response does not match a connect request started in this browser. Re-run `t3 connect` in your terminal and open the freshly printed URL in this browser."
162 />
163 </AuthSurfaceShell>
164 );
165 }
166
167 const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null;
168 const authCode = encodeConnectAuthCode(result);
169
170 return (
171 <AuthSurfaceShell>
172 <ConnectCliAuthMessage
173 eyebrow="Step 2 of 2 · Terminal handoff"
174 title="Almost connected"
175 description={
176 accountLabel
177 ? `Enter this code in your waiting terminal to connect it as ${accountLabel}.`
178 : "Enter this code in your waiting terminal to finish connecting."
179 }
180 />
181
182 <div className="mt-6 overflow-hidden rounded-xl border border-border/80 bg-background/65">
183 <div className="flex items-center justify-between border-b border-border/70 px-4 py-2.5">
184 <span className="text-[10px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">
185 One-time authorization code
186 </span>
187 <span className="font-mono text-[10px] text-muted-foreground">expires shortly</span>
188 </div>
189 <code
190 className="block p-4 font-mono text-sm leading-relaxed break-all select-all"
191 data-testid="connect-auth-code"
192 >
193 {authCode}
194 </code>
195 </div>
196
197 <div className="mt-4 flex items-center gap-3">
198 <Button type="button" onClick={() => copyToClipboard(authCode)}>
199 {isCopied ? "Copied!" : "Copy authorization code"}
200 </Button>
201 </div>
202
203 <p className="mt-6 text-xs leading-relaxed text-muted-foreground">
204 Only enter this code in a terminal session you started yourself. Anyone holding it can link
205 their machine to your T3 Connect account while it is valid.
206 </p>
207 </AuthSurfaceShell>
208 );
209}
apps/web/src/hostedPairing.ts+1-1
......@@ -10,7 +10,7 @@ export interface HostedPairingRequest {
1010
1111export type HostedAppChannel = "latest" | "nightly";
1212
13export function configuredHostedAppUrl(): string {
13function configuredHostedAppUrl(): string {
1414 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
1515}
1616
apps/web/src/routeTree.gen.ts-21
......@@ -29,7 +29,6 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne
2929import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
3030import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
3131import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'
32import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
3332import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests'
3433import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
3534import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
......@@ -134,11 +133,6 @@ const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({
134133 path: '/projects/$projectKey',
135134 getParentRoute: () => rootRouteImport,
136135} as any)
137const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
138 id: '/connect_/callback',
139 path: '/connect/callback',
140 getParentRoute: () => rootRouteImport,
141} as any)
142136const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({
143137 id: '/pull-requests',
144138 path: '/pull-requests',
......@@ -164,7 +158,6 @@ export interface FileRoutesByFullPath {
164158 '/usage': typeof UsageRoute
165159 '/welcome': typeof WelcomeRoute
166160 '/pull-requests': typeof ChatPullRequestsRoute
167 '/connect/callback': typeof ConnectCallbackRoute
168161 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
169162 '/settings/appearance': typeof SettingsAppearanceRoute
170163 '/settings/archived': typeof SettingsArchivedRoute
......@@ -188,7 +181,6 @@ export interface FileRoutesByTo {
188181 '/usage': typeof UsageRoute
189182 '/welcome': typeof WelcomeRoute
190183 '/pull-requests': typeof ChatPullRequestsRoute
191 '/connect/callback': typeof ConnectCallbackRoute
192184 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
193185 '/settings/appearance': typeof SettingsAppearanceRoute
194186 '/settings/archived': typeof SettingsArchivedRoute
......@@ -215,7 +207,6 @@ export interface FileRoutesById {
215207 '/usage': typeof UsageRoute
216208 '/welcome': typeof WelcomeRoute
217209 '/_chat/pull-requests': typeof ChatPullRequestsRoute
218 '/connect_/callback': typeof ConnectCallbackRoute
219210 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
220211 '/settings/appearance': typeof SettingsAppearanceRoute
221212 '/settings/archived': typeof SettingsArchivedRoute
......@@ -243,7 +234,6 @@ export interface FileRouteTypes {
243234 | '/usage'
244235 | '/welcome'
245236 | '/pull-requests'
246 | '/connect/callback'
247237 | '/projects/$projectKey'
248238 | '/settings/appearance'
249239 | '/settings/archived'
......@@ -267,7 +257,6 @@ export interface FileRouteTypes {
267257 | '/usage'
268258 | '/welcome'
269259 | '/pull-requests'
270 | '/connect/callback'
271260 | '/projects/$projectKey'
272261 | '/settings/appearance'
273262 | '/settings/archived'
......@@ -293,7 +282,6 @@ export interface FileRouteTypes {
293282 | '/usage'
294283 | '/welcome'
295284 | '/_chat/pull-requests'
296 | '/connect_/callback'
297285 | '/projects/$projectKey'
298286 | '/settings/appearance'
299287 | '/settings/archived'
......@@ -319,7 +307,6 @@ export interface RootRouteChildren {
319307 SettingsRoute: typeof SettingsRouteWithChildren
320308 UsageRoute: typeof UsageRoute
321309 WelcomeRoute: typeof WelcomeRoute
322 ConnectCallbackRoute: typeof ConnectCallbackRoute
323310 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
324311}
325312
......@@ -465,13 +452,6 @@ declare module '@tanstack/react-router' {
465452 preLoaderRoute: typeof ProjectsProjectKeyRouteImport
466453 parentRoute: typeof rootRouteImport
467454 }
468 '/connect_/callback': {
469 id: '/connect_/callback'
470 path: '/connect/callback'
471 fullPath: '/connect/callback'
472 preLoaderRoute: typeof ConnectCallbackRouteImport
473 parentRoute: typeof rootRouteImport
474 }
475455 '/_chat/pull-requests': {
476456 id: '/_chat/pull-requests'
477457 path: '/pull-requests'
......@@ -553,7 +533,6 @@ const rootRouteChildren: RootRouteChildren = {
553533 SettingsRoute: SettingsRouteWithChildren,
554534 UsageRoute: UsageRoute,
555535 WelcomeRoute: WelcomeRoute,
556 ConnectCallbackRoute: ConnectCallbackRoute,
557536 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
558537}
559538export const routeTree = rootRouteImport
apps/web/src/routes/__root.tsx+1-1
......@@ -151,7 +151,7 @@ function RootRouteView() {
151151 };
152152 }, [pathname]);
153153
154 if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) {
154 if (pathname === "/pair" || pathname === "/connect") {
155155 return (
156156 <>
157157 <DocumentTitleSync />
apps/web/src/routes/connect_.callback.tsx deleted-13
......@@ -1,13 +0,0 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth";
4import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface";
5
6export const Route = createFileRoute("/connect_/callback")({
7 beforeLoad: () => {
8 if (!connectCliAuthRoutesEnabled()) {
9 throw redirect({ to: "/", replace: true });
10 }
11 },
12 component: ConnectCliCallbackSurface,
13});
docs/internals/t3-connect.md+12-7
......@@ -72,10 +72,15 @@ different credentials. The relay accepts both session-template JWTs and CLI
7272OAuth tokens; requiring a JWT template for the CLI would reject valid logins.
7373The CLI is a public OAuth client using PKCE and stores no client secret.
7474
75CLI authorization starts on the hosted `/connect` page so sign-in completes
76before entering Clerk's authorize endpoint. Sending a signed-out browser
77straight to that endpoint loses the authorize parameters during the sign-in
78redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves
79PKCE and state for both loopback and pasted-code callbacks. SSH and headless
80sessions use the pasted-code flow because the browser cannot ordinarily reach a
81listener on the remote machine.
75Loopback CLI authorization starts on the hosted `/connect` page so sign-in
76completes before entering Clerk's authorize endpoint. Sending a signed-out
77browser straight to that endpoint loses the authorize parameters during the
78sign-in redirect. The [shared flow](../../packages/shared/src/connectAuth.ts)
79preserves PKCE and state for the loopback callback.
80
81SSH and headless sessions use Clerk's OAuth device authorization grant because
82the browser cannot ordinarily reach a listener on the remote machine. The CLI
83polls Clerk's token endpoint directly while the user approves a short code on
84Clerk's hosted device page; the hosted app plays no part and there is no
85redirect URI or PKCE. The grant must be enabled on the CLI OAuth application
86or the device endpoint returns an error before any prompt is shown.
docs/operations/connect-setup.md+6-5
......@@ -39,11 +39,12 @@ depend on. The deploy wrapper writes the resulting relay URL back to the root `.
3939In Clerk's OAuth applications settings:
4040
41411. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE.
422. Allow both redirect URIs: `http://127.0.0.1:34338/callback` and
43 `https://app.t3.codes/connect/callback`. A custom `T3CODE_HOSTED_APP_URL` needs its own
44 `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect.
453. Enable the `openid`, `profile`, and `email` scopes.
464. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release
422. Allow the redirect URI `http://127.0.0.1:34338/callback`.
433. Enable the `openid`, `profile`, `email`, and `offline_access` scopes.
444. Enable **Device authorization grant** on the application. Headless and SSH authorization use
45 it, and Clerk only advertises the device endpoint once it is on. The feature is in beta and
46 Clerk enables it per account on request.
475. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release
4748 build environments.
4849
4950## JWT template
docs/user/remote-access.md+3-2
......@@ -21,8 +21,9 @@ server with `npx t3 serve`. Saving your sign-in alone does not make the machine
2121reachable.
2222
2323On your other device, sign in to the same T3 Connect account and choose the
24environment. Over SSH, the CLI prints a browser link and accepts the returned
25authorization code, so you do not need to forward an OAuth callback port.
24environment. Over SSH, the CLI prints a browser link and a short code. Open the
25link on any device, confirm the code matches, and approve. The CLI continues on
26its own, so you do not need to forward an OAuth callback port.
2627
2728T3 Connect renews access credentials when needed without disconnecting a healthy
2829connection. Pull request diffs and provider settings keep working after the
packages/shared/src/connectAuth.test.ts+14-43
......@@ -3,19 +3,17 @@ import { describe, expect, it } from "vite-plus/test";
33import {
44 buildConnectAuthorizeRequestUrl,
55 buildConnectClerkAuthorizeUrl,
6 connectCallbackUrl,
76 connectLoopbackRedirectUri,
8 encodeConnectAuthCode,
9 parseConnectAuthCode,
107 readConnectAuthorizeRequest,
118} from "./connectAuth.ts";
129
1310describe("connectAuth", () => {
14 it("round-trips state and challenge through the authorize URL fragment", () => {
11 it("round-trips state, challenge, and loopback port through the authorize URL fragment", () => {
1512 const url = buildConnectAuthorizeRequestUrl({
1613 hostedAppUrl: "https://app.t3.codes",
1714 state: "q7mK9xV2pL4nR8sT6wYzAQ",
1815 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
16 loopbackPort: 34338,
1917 });
2018 const parsed = new URL(url);
2119
......@@ -25,33 +23,22 @@ describe("connectAuth", () => {
2523 expect(readConnectAuthorizeRequest(parsed)).toEqual({
2624 state: "q7mK9xV2pL4nR8sT6wYzAQ",
2725 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
26 loopbackPort: 34338,
2827 });
28 expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback");
2929 });
3030
31 it("rejects authorize requests missing state or challenge", () => {
31 it("rejects authorize requests missing state, challenge, or port", () => {
3232 expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull();
3333 expect(
34 readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc")),
34 readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc&port=34338")),
3535 ).toBeNull();
3636 expect(
37 readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc")),
37 readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc&port=34338")),
38 ).toBeNull();
39 expect(
40 readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc&challenge=abc")),
3841 ).toBeNull();
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");
5542 });
5643
5744 it("rejects authorize requests whose loopback port is corrupted", () => {
......@@ -68,8 +55,8 @@ describe("connectAuth", () => {
6855 buildConnectClerkAuthorizeUrl({
6956 authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize",
7057 clientId: "oauthapp_123",
71 redirectUri: connectCallbackUrl("https://app.t3.codes"),
72 scopes: ["openid", "profile", "email"],
58 redirectUri: connectLoopbackRedirectUri(34338),
59 scopes: ["openid", "profile", "email", "offline_access"],
7360 state: "state-1",
7461 challenge: "challenge-1",
7562 }),
......@@ -78,27 +65,11 @@ describe("connectAuth", () => {
7865 expect(url.origin).toBe("https://clerk.t3.codes");
7966 expect(url.pathname).toBe("/oauth/authorize");
8067 expect(url.searchParams.get("client_id")).toBe("oauthapp_123");
81 expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback");
68 expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback");
8269 expect(url.searchParams.get("response_type")).toBe("code");
83 expect(url.searchParams.get("scope")).toBe("openid profile email");
70 expect(url.searchParams.get("scope")).toBe("openid profile email offline_access");
8471 expect(url.searchParams.get("state")).toBe("state-1");
8572 expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
8673 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
8774 });
88
89 it("round-trips the out-of-band authorization code and preserves dots inside it", () => {
90 const blob = encodeConnectAuthCode({ code: "az9.code.chunk", state: "state-uuid" });
91 expect(parseConnectAuthCode(blob)).toEqual({ code: "az9.code.chunk", state: "state-uuid" });
92 expect(parseConnectAuthCode(` ${blob}\n`)).toEqual({
93 code: "az9.code.chunk",
94 state: "state-uuid",
95 });
96 });
97
98 it("rejects malformed out-of-band authorization codes", () => {
99 expect(parseConnectAuthCode("")).toBeNull();
100 expect(parseConnectAuthCode("no-separator")).toBeNull();
101 expect(parseConnectAuthCode(".leading")).toBeNull();
102 expect(parseConnectAuthCode("trailing.")).toBeNull();
103 });
10475});
packages/shared/src/connectAuth.ts+14-78
......@@ -3,11 +3,9 @@ import { readHashParams } from "./remote.ts";
33const CONNECT_AUTH_STATE_PARAM = "state";
44const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";
55const CONNECT_AUTH_PORT_PARAM = "port";
6const CONNECT_AUTH_CODE_SEPARATOR = ".";
76const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback";
87
98const CONNECT_AUTHORIZE_PATH = "/connect";
10const CONNECT_CALLBACK_PATH = "/connect/callback";
119
1210/**
1311 * The CLI prints URLs against this origin and the web bundle uses it to
......@@ -17,20 +15,20 @@ const CONNECT_CALLBACK_PATH = "/connect/callback";
1715export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
1816
1917/**
20 * Requested at authorize time by the hosted page and honored by the CLI's
21 * token exchange; keep both sides on this single definition.
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.
2221 */
23export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
22export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const;
2423
2524export interface ConnectAuthorizeRequest {
2625 readonly state: string;
2726 readonly challenge: string;
2827 /**
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.
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.
3230 */
33 readonly loopbackPort?: number;
31 readonly loopbackPort: number;
3432}
3533
3634/**
......@@ -38,26 +36,25 @@ export interface ConnectAuthorizeRequest {
3836 * `code_challenge` ride the fragment so they never reach the hosted app's
3937 * server or CDN logs; neither is a secret.
4038 *
41 * Both CLI flows route through the hosted /connect page rather than hitting
39 * The CLI routes through the hosted /connect page rather than hitting
4240 * Clerk's /oauth/authorize directly: a signed-out browser sent straight to
4341 * /oauth/authorize goes through Clerk's sign-in redirect, which does not
4442 * reliably preserve the authorize query parameters (state, response_type,
4543 * code_challenge). The hosted page waits for a Clerk session first, then
46 * forwards the request with the parameters intact.
44 * forwards the request with the parameters intact. Headless hosts use the
45 * OAuth device authorization grant instead and never involve this page.
4746 */
4847export function buildConnectAuthorizeRequestUrl(input: {
4948 readonly hostedAppUrl: string;
5049 readonly state: string;
5150 readonly challenge: string;
52 readonly loopbackPort?: number;
51 readonly loopbackPort: number;
5352}): string {
5453 const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl);
5554 url.hash = new URLSearchParams([
5655 [CONNECT_AUTH_STATE_PARAM, input.state],
5756 [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge],
58 ...(input.loopbackPort === undefined
59 ? []
60 : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]),
57 [CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)],
6158 ]).toString();
6259 return url.toString();
6360}
......@@ -66,18 +63,8 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest |
6663 const params = readHashParams(url);
6764 const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? "";
6865 const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? "";
69 if (!state || !challenge) {
70 return null;
71 }
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) {
66 const loopbackPort = parseLoopbackPort(params.get(CONNECT_AUTH_PORT_PARAM)?.trim() ?? "");
67 if (!state || !challenge || loopbackPort === null) {
8168 return null;
8269 }
8370 return { state, challenge, loopbackPort };
......@@ -99,10 +86,6 @@ export function connectLoopbackRedirectUri(port: number): string {
9986 return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`;
10087}
10188
102export function connectCallbackUrl(hostedAppUrl: string): string {
103 return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString();
104}
105
10689export function buildConnectClerkAuthorizeUrl(input: {
10790 readonly authorizationEndpoint: string;
10891 readonly clientId: string;
......@@ -121,50 +104,3 @@ export function buildConnectClerkAuthorizeUrl(input: {
121104 url.searchParams.set("code_challenge_method", "S256");
122105 return url.toString();
123106}
124
125export interface ConnectAuthCode {
126 readonly code: string;
127 readonly state: string;
128}
129
130/**
131 * The single blob the hosted callback page displays and the CLI accepts.
132 * Bundling `state` with the authorization code lets the CLI keep the loopback
133 * flow's CSRF check without any backend: it verifies the returned state
134 * matches the one it generated. Clerk authorization codes and the CLI's
135 * base64url states never contain ".".
136 */
137export function encodeConnectAuthCode(input: ConnectAuthCode): string {
138 return `${input.code}${CONNECT_AUTH_CODE_SEPARATOR}${input.state}`;
139}
140
141/**
142 * Validates an out-of-band authorization code against the state of the request this process
143 * generated. Returns the parsed code or a user-facing error message; both
144 * the prompt's live validation and the authoritative post-prompt check go
145 * through here so they cannot drift.
146 */
147export function checkConnectAuthCode(
148 blob: string,
149 expectedState: string,
150): ConnectAuthCode | string {
151 const parsed = parseConnectAuthCode(blob);
152 if (parsed === null) {
153 return "That does not look like a T3 Connect code. Copy the full code.";
154 }
155 if (parsed.state !== expectedState) {
156 return "That code belongs to a different connect request. Open the URL above and try again.";
157 }
158 return parsed;
159}
160
161export function parseConnectAuthCode(blob: string): ConnectAuthCode | null {
162 const trimmed = blob.trim();
163 const separatorIndex = trimmed.lastIndexOf(CONNECT_AUTH_CODE_SEPARATOR);
164 if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) {
165 return null;
166 }
167 const code = trimmed.slice(0, separatorIndex);
168 const state = trimmed.slice(separatorIndex + 1);
169 return { code, state };
170}