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";...@@ -20,7 +20,6 @@ import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";20import * as Option from "effect/Option";
21import * as References from "effect/References";21import * as References from "effect/References";
22import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
23import * as Terminal from "effect/Terminal";
24import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";23import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
25import {24import {
26 FetchHttpClient,25 FetchHttpClient,
...@@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe(...@@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe(
64const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);63const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
6564
66const headlessFlag = Flag.boolean("headless").pipe(65const 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."),
68 Flag.withDefault(false),67 Flag.withDefault(false),
69);68);
7069
71/**70/**
72 * Inside an SSH session there is no local browser to complete the loopback71 * 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.
74 */74 */
75export const headlessSessionConfig = Config.all({75export const headlessSessionConfig = Config.all({
76 sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),76 sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),
...@@ -79,20 +79,21 @@ export const headlessSessionConfig = Config.all({...@@ -79,20 +79,21 @@ export const headlessSessionConfig = Config.all({
79 Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),79 Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
80);80);
8181
82const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")(82const showDeviceAuthorizationPrompt = (prompt: CliTokenManager.DeviceAuthorizationPrompt) =>
83 function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) {83 Console.log(formatDeviceAuthorizationPrompt(prompt));
84 yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl));
85 return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate }));
86 },
87);
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)));
90 return [89 return [
91 "Headless authorization",90 "Headless authorization",
92 "Open this URL on a device with a browser:",91 "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}`,
94 "",95 "",
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.`,
96 ].join("\n");97 ].join("\n");
97}98}
9899
...@@ -110,7 +111,7 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {...@@ -110,7 +111,7 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
110 yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");111 yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");
111 }112 }
112 // A stored credential whose refresh fails (revoked, expired grant) must113 // 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.
114 const existing = yield* tokens.getExisting.pipe(115 const existing = yield* tokens.getExisting.pipe(
115 Effect.catchTag("CloudCliCredentialRefreshError", () =>116 Effect.catchTag("CloudCliCredentialRefreshError", () =>
116 Console.log(117 Console.log(
...@@ -121,13 +122,11 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {...@@ -121,13 +122,11 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
121 if (Option.isSome(existing)) {122 if (Option.isSome(existing)) {
122 return existing.value.identity ?? null;123 return existing.value.identity ?? null;
123 }124 }
124 const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(125 const { token, identity } = yield* CliTokenManager.deviceAuthorizationLogin(
125 promptForOutOfBandOAuthCode,126 showDeviceAuthorizationPrompt,
126 ).pipe(127 ).pipe(
127 Effect.mapError((cause) =>128 Effect.mapError((cause) =>
128 // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI129 isCloudCliTokenManagerError(cause)
129 // cancels quietly instead of dumping an authorization error.
130 Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
131 ? cause130 ? cause
132 : new CliTokenManager.CloudCliAuthorizationError({ cause }),131 : new CliTokenManager.CloudCliAuthorizationError({ cause }),
133 ),132 ),
apps/server/src/cloud/CliTokenManager.test.ts+186-109
...@@ -1,21 +1,21 @@...@@ -1,21 +1,21 @@
1import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
2import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";3import * as ConfigProvider from "effect/ConfigProvider";
5import * as Crypto from "effect/Crypto";
6import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
8import * as Encoding from "effect/Encoding";7import * as Encoding from "effect/Encoding";
8import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";10import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";11import * as Queue from "effect/Queue";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import * as Terminal from "effect/Terminal";13import * as Terminal from "effect/Terminal";
14import * as TestClock from "effect/testing/TestClock";
14import * as HttpClient from "effect/unstable/http/HttpClient";15import * as HttpClient from "effect/unstable/http/HttpClient";
15import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";16import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1617
17import * as CliTokenManager from "./CliTokenManager.ts";18import * as CliTokenManager from "./CliTokenManager.ts";
18import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts";
1919
20// pk_test_<base64 of "clerk.example.test$">20// pk_test_<base64 of "clerk.example.test$">
21const TEST_ENV = {21const TEST_ENV = {
...@@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString(...@@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString(
54);54);
55const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson);55const 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
85const provideTestEnv = Effect.provide(57const provideTestEnv = Effect.provide(
86 ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),58 ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),
87);59);
8860
89const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);61const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);
9062
91class PromptRejectedError extends Schema.TaggedError<PromptRejectedError>()("PromptRejectedError", {
92 message: Schema.String,
93}) {}
94
95const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) =>63const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) =>
96 Terminal.make({64 Terminal.make({
97 columns: Effect.succeed(80),65 columns: Effect.succeed(80),
...@@ -144,106 +112,215 @@ it.effect("finishes normally when the browser callback wins", () =>...@@ -144,106 +112,215 @@ it.effect("finishes normally when the browser callback wins", () =>
144 }),112 }),
145);113);
146114
147it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => {115interface DeviceFlowServer {
148 it.effect("prints a hosted authorize URL and exchanges the out-of-band code with PKCE", () =>116 readonly requests: Array<RecordedTokenRequest>;
149 Effect.gen(function* () {117 /** Token endpoint replies, consumed in order; the last one repeats. */
150 const requests: Array<RecordedTokenRequest> = [];118 readonly tokenReplies: Array<{ readonly status: number; readonly body: string }>;
151 let seenAuthorizeUrl = "";119}
152120
153 const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(121const DEVICE_AUTHORIZATION_BODY = JSON.stringify({
154 ({ authorizeUrl, validate }: OutOfBandOAuthPromptInput) =>122 device_code: "device-code-1",
155 Effect.gen(function* () {123 user_code: "BCDF-GHJK",
156 seenAuthorizeUrl = authorizeUrl;124 verification_uri: "https://accounts.example.test/device",
157 const request = readConnectAuthorizeRequest(new URL(authorizeUrl));125 verification_uri_complete: "https://accounts.example.test/device?user_code=BCDF-GHJK",
158 assert.isNotNull(request);126 expires_in: 600,
159 return yield* validate(`clerk-code-123.${request!.state}`).pipe(127 interval: 5,
160 Effect.mapError((message) => new PromptRejectedError({ message })),128});
161 );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" },
162 }),160 }),
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);169it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => {
166 assert.equal(authorizeUrl.origin, "https://hosted.example.test");170 it.effect("requests a device code, shows it, and polls until Clerk grants the token", () =>
167 assert.equal(authorizeUrl.pathname, "/connect");171 Effect.gen(function* () {
168 const request = readConnectAuthorizeRequest(authorizeUrl);172 const server: DeviceFlowServer = {
169 assert.isNotNull(request);173 requests: [],
170 assert.match(request!.state, /^[A-Za-z0-9_-]{22}$/);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 ]);
172 assert.equal(token.accessToken, "access-token-1");195 assert.equal(token.accessToken, "access-token-1");
173 assert.equal(token.refreshToken, "refresh-token-1");196 assert.equal(token.refreshToken, "refresh-token-1");
174 assert.equal(token.identity, "theo@example.test");197 assert.equal(token.identity, "theo@example.test");
175 // The id_token's email claim is surfaced so connect can show the account.
176 assert.equal(identity, "theo@example.test");198 assert.equal(identity, "theo@example.test");
177199
178 assert.lengthOf(requests, 1);200 const authorization = server.requests[0]!;
179 const exchange = requests[0]!;201 assert.equal(authorization.url, "https://clerk.example.test/oauth/device_authorization");
180 assert.equal(exchange.url, "https://clerk.example.test/oauth/token");202 assert.equal(authorization.params.get("client_id"), "oauth_client_test");
181 assert.equal(exchange.params.get("grant_type"), "authorization_code");203 assert.equal(authorization.params.get("scope"), "openid profile email offline_access");
182 assert.equal(exchange.params.get("code"), "clerk-code-123");204
183 assert.equal(205 const polls = tokenRequests(server.requests);
184 exchange.params.get("redirect_uri"),206 assert.lengthOf(polls, 2);
185 "https://hosted.example.test/connect/callback",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,
186 );227 );
187 assert.equal(exchange.params.get("client_id"), "oauth_client_test");228
188 // The verifier must hash to the challenge advertised in the authorize URL.229 yield* TestClock.adjust(Duration.seconds(4));
189 const verifier = exchange.params.get("code_verifier");230 assert.lengthOf(tokenRequests(server.requests), 0);
190 assert.isNotNull(verifier);231 yield* TestClock.adjust(Duration.seconds(1));
191 const crypto = yield* Crypto.Crypto;232 assert.lengthOf(tokenRequests(server.requests), 1);
192 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier!));233 // slow_down widens the 5s interval to 10s.
193 assert.equal(Encoding.encodeBase64Url(digest), request!.challenge);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);
194 }),239 }),
195 );240 );
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", () =>
198 Effect.gen(function* () {243 Effect.gen(function* () {
199 const requests: Array<RecordedTokenRequest> = [];244 const server: DeviceFlowServer = {
200245 requests: [],
201 const validationErrors: Array<string> = [];246 tokenReplies: [{ status: 503, body: "upstream unavailable" }, tokenGranted],
202 const result = yield* CliTokenManager.outOfBandOAuthLogin(247 };
203 ({ validate }: OutOfBandOAuthPromptInput) =>248
204 validate("clerk-code-123.wrong-state").pipe(249 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
205 Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))),250 Effect.provide(makeDeviceFlowLayer(server)),
206 Effect.mapError((message) => new PromptRejectedError({ message })),251 provideTestEnv,
207 ),252 Effect.forkChild,
208 ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);253 );
209254
210 assert.lengthOf(requests, 0);255 yield* TestClock.adjust(Duration.seconds(5));
211 assert.lengthOf(validationErrors, 1);256 assert.lengthOf(tokenRequests(server.requests), 1);
212 assert.include(validationErrors[0], "different connect request");257 // The 5xx widens the 5s interval to 10s before the retry.
213 assert.instanceOf(result, PromptRejectedError);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");
214 }),264 }),
215 );265 );
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", () =>
218 Effect.gen(function* () {268 Effect.gen(function* () {
219 const requests: Array<RecordedTokenRequest> = [];269 const server: DeviceFlowServer = {
220 const malformedIdToken = `header.${Encoding.encodeBase64Url("not-json")}.signature`;270 requests: [],
221271 tokenReplies: [oauthError("access_denied")],
222 const { identity } = yield* CliTokenManager.outOfBandOAuthLogin(272 };
223 ({ authorizeUrl }: OutOfBandOAuthPromptInput) => {273
224 const request = readConnectAuthorizeRequest(new URL(authorizeUrl));274 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
225 assert.isNotNull(request);275 Effect.provide(makeDeviceFlowLayer(server)),
226 return Effect.succeed(`clerk-code-123.${request!.state}`);276 provideTestEnv,
227 },277 Effect.flip,
228 ).pipe(278 Effect.forkChild,
229 Effect.provide(makeTokenEndpointLayer(requests, { idToken: malformedIdToken })),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)),
230 provideTestEnv,297 provideTestEnv,
298 Effect.flip,
299 Effect.forkChild,
231 );300 );
301 yield* TestClock.adjust(Duration.seconds(600));
302 const result = yield* Fiber.join(fiber);
232303
233 assert.isNull(identity);304 assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError);
234 assert.lengthOf(requests, 1);
235 }),305 }),
236 );306 );
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", () =>
239 Effect.gen(function* () {309 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(() =>315 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
243 Effect.succeed("not-a-connect-code"),316 Effect.provide(makeDeviceFlowLayer(server)),
244 ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);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);
247 assert.isTrue(isAuthorizationError(result));324 assert.isTrue(isAuthorizationError(result));
248 }),325 }),
249 );326 );
apps/server/src/cloud/CliTokenManager.ts+154-64
...@@ -18,17 +18,14 @@ import * as Schema from "effect/Schema";...@@ -18,17 +18,14 @@ import * as Schema from "effect/Schema";
18import * as Semaphore from "effect/Semaphore";18import * as Semaphore from "effect/Semaphore";
19import * as Terminal from "effect/Terminal";19import * as Terminal from "effect/Terminal";
20import * as HttpClient from "effect/unstable/http/HttpClient";20import * as HttpClient from "effect/unstable/http/HttpClient";
21import * as HttpClientError from "effect/unstable/http/HttpClientError";
21import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";22import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
22import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";23import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
23import * as HttpRouter from "effect/unstable/http/HttpRouter";24import * as HttpRouter from "effect/unstable/http/HttpRouter";
24import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";25import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
25import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";26import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2627
27import {28import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth";
28 buildConnectAuthorizeRequestUrl,
29 checkConnectAuthCode,
30 connectCallbackUrl,
31} from "@t3tools/shared/connectAuth";
3229
33import * as ServerSecretStore from "../auth/ServerSecretStore.ts";30import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
34import * as ExternalLauncher from "../process/externalLauncher.ts";31import * as ExternalLauncher from "../process/externalLauncher.ts";
...@@ -42,6 +39,11 @@ import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";...@@ -42,6 +39,11 @@ import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
42const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";39const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
43const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);40const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
44const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));41const 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);
45const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;47const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
4648
47function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {49function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
...@@ -136,6 +138,20 @@ const OAuthTokenResponse = Schema.Struct({...@@ -136,6 +138,20 @@ const OAuthTokenResponse = Schema.Struct({
136 token_type: Schema.String,138 token_type: Schema.String,
137});139});
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
139const OidcIdentityClaimsJson = Schema.fromJsonString(155const OidcIdentityClaimsJson = Schema.fromJsonString(
140 Schema.Struct({156 Schema.Struct({
141 email: Schema.optional(Schema.String),157 email: Schema.optional(Schema.String),
...@@ -209,12 +225,22 @@ export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudC...@@ -209,12 +225,22 @@ export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudC
209 }225 }
210}226}
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
212export const CloudCliTokenManagerError = Schema.Union([237export const CloudCliTokenManagerError = Schema.Union([
213 CloudCliCredentialRemovalError,238 CloudCliCredentialRemovalError,
214 CloudCliCredentialRefreshError,239 CloudCliCredentialRefreshError,
215 CloudCliCredentialReadError,240 CloudCliCredentialReadError,
216 CloudCliAuthorizationError,241 CloudCliAuthorizationError,
217 CloudCliAuthorizationTimeoutError,242 CloudCliAuthorizationTimeoutError,
243 CloudCliAuthorizationDeniedError,
218]);244]);
219export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type;245export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type;
220246
...@@ -241,29 +267,36 @@ function bytesToString(value: Uint8Array): string {...@@ -241,29 +267,36 @@ function bytesToString(value: Uint8Array): string {
241 return new TextDecoder().decode(value);267 return new TextDecoder().decode(value);
242}268}
243269
244const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (270const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* (
245 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,271 response: HttpClientResponse.HttpClientResponse,
246 params: Record<string, string>,272 params: Record<string, string>,
247) {273) {
248 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);274 const body = yield* HttpClientResponse.schemaBodyJson(OAuthTokenResponse)(response);
249 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
250 HttpClientRequest.bodyUrlParams(params),
251 httpClient.execute,
252 Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),
253 );
254 const now = yield* Clock.currentTimeMillis;275 const now = yield* Clock.currentTimeMillis;
255 const identity = idTokenIdentity(response.id_token);276 const identity = idTokenIdentity(body.id_token);
256 return {277 return {
257 token: {278 token: {
258 accessToken: response.access_token,279 accessToken: body.access_token,
259 refreshToken: response.refresh_token ?? params.refresh_token ?? "",280 refreshToken: body.refresh_token ?? params.refresh_token ?? "",
260 expiresAtEpochMs: now + response.expires_in * 1_000,281 expiresAtEpochMs: now + body.expires_in * 1_000,
261 ...(identity === null ? {} : { identity }),282 ...(identity === null ? {} : { identity }),
262 } satisfies PersistedToken,283 } satisfies PersistedToken,
263 identity,284 identity,
264 };285 };
265});286});
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
267const makePkceRequest = Effect.gen(function* () {300const makePkceRequest = Effect.gen(function* () {
268 const crypto = yield* Crypto.Crypto;301 const crypto = yield* Crypto.Crypto;
269 const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));302 const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
...@@ -274,61 +307,118 @@ const makePkceRequest = Effect.gen(function* () {...@@ -274,61 +307,118 @@ const makePkceRequest = Effect.gen(function* () {
274 return { verifier, challenge, state };307 return { verifier, challenge, state };
275});308});
276309
277export interface OutOfBandOAuthPromptInput {310export interface DeviceAuthorizationPrompt {
278 readonly authorizeUrl: string;311 readonly verificationUri: string;
279 readonly validate: (value: string) => Effect.Effect<string, string>;312 readonly verificationUriComplete: string | undefined;
313 readonly userCode: string;
314 readonly expiresIn: Duration.Duration;
280}315}
281316
317const isTransportError = (error: unknown) =>
318 HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError";
319
282/**320/**
283 * Out-of-band OAuth for machines without a local browser (SSH). The user321 * Polls Clerk's token endpoint until the user approves or denies the device
284 * opens the hosted /connect URL elsewhere, signs in, and enters the displayed322 * request in the browser (RFC 8628 §3.4/3.5). `authorization_pending` keeps
285 * code in this terminal. The PKCE verifier never leaves this process, so the323 * waiting, while `slow_down` and transient failures widen the interval before
286 * authorization code is useless to an observer, and the state bundled into324 * the next tick; the caller bounds the whole loop with the device code's
287 * the blob preserves the loopback flow's CSRF check.325 * lifetime.
288 */326 */
289export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* <327const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* (
290 E,328 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint" | "clientId">,
291 R,329 deviceCode: string,
292>(promptForCode: (input: OutOfBandOAuthPromptInput) => Effect.Effect<string, E, R>) {330 initialInterval: Duration.Duration,
293 const metadata = yield* cloudCliOAuthConfig;331) {
294 const hostedAppUrl = yield* hostedAppUrlConfig;332 const httpClient = yield* HttpClient.HttpClient;
295 const { verifier, challenge, state } = yield* makePkceRequest;333 const params = {
296334 grant_type: DEVICE_CODE_GRANT_TYPE,
297 const authorizationCode = yield* promptForCode({335 device_code: deviceCode,
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),
322 client_id: metadata.clientId,336 client_id: metadata.clientId,
323 code_verifier: verifier,337 };
324 });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 }
325});376});
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
327/** @public Service construction is part of the canonical Effect module API. */418/** @public Service construction is part of the canonical Effect module API. */
328export const make = Effect.gen(function* () {419export const make = Effect.gen(function* () {
329 // Capture exactly the services the login/refresh flows need at build time420 // 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), not421 // not the whole ambient context.
331 // the whole ambient context.
332 const crypto = yield* Crypto.Crypto;422 const crypto = yield* Crypto.Crypto;
333 const httpClient = yield* HttpClient.HttpClient;423 const httpClient = yield* HttpClient.HttpClient;
334 const services = Context.make(Crypto.Crypto, crypto).pipe(424 const services = Context.make(Crypto.Crypto, crypto).pipe(
...@@ -461,7 +551,7 @@ export const make = Effect.gen(function* () {...@@ -461,7 +551,7 @@ export const make = Effect.gen(function* () {
461 Effect.gen(function* () {551 Effect.gen(function* () {
462 // A stored credential that can't be read or refreshed (corrupt, revoked,552 // A stored credential that can't be read or refreshed (corrupt, revoked,
463 // expired grant) must fall through to a fresh login rather than dead-end553 // expired grant) must fall through to a fresh login rather than dead-end
464 // the command — authorizeCli applies the same fallback to out-of-band554 // the command — authorizeCli applies the same fallback to device
465 // authorization.555 // authorization.
466 const token = yield* getExistingNoLock().pipe(556 const token = yield* getExistingNoLock().pipe(
467 Effect.orElseSucceed(() => Option.none<PersistedToken>()),557 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...@@ -91,10 +91,11 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public
9191
92 assert.deepEqual(config, {92 assert.deepEqual(config, {
93 tokenEndpoint: "https://clerk.example.test/oauth/token",93 tokenEndpoint: "https://clerk.example.test/oauth/token",
94 deviceAuthorizationEndpoint: "https://clerk.example.test/oauth/device_authorization",
94 clientId: "oauth_client_embedded",95 clientId: "oauth_client_embedded",
95 loopbackPort: 34338,96 loopbackPort: 34338,
96 redirectUri: "http://127.0.0.1:34338/callback",97 redirectUri: "http://127.0.0.1:34338/callback",
97 scopes: ["openid", "profile", "email"],98 scopes: ["openid", "profile", "email", "offline_access"],
98 });99 });
99 }),100 }),
100);101);
apps/server/src/cloud/publicConfig.ts+5-2
...@@ -152,11 +152,13 @@ function makePublicValueConfig(name: string, fallback: string) {...@@ -152,11 +152,13 @@ function makePublicValueConfig(name: string, fallback: string) {
152/**152/**
153 * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes153 * 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 a154 * through the hosted /connect page, which builds the authorize URL after a
155 * Clerk session exists (see CliTokenManager.login). Only the token endpoint155 * Clerk session exists (see CliTokenManager.login). The token endpoint and,
156 * is contacted directly.156 * for headless hosts, the device authorization endpoint are contacted
157 * directly.
157 */158 */
158export interface CloudCliOAuthConfig {159export interface CloudCliOAuthConfig {
159 readonly tokenEndpoint: string;160 readonly tokenEndpoint: string;
161 readonly deviceAuthorizationEndpoint: string;
160 readonly clientId: string;162 readonly clientId: string;
161 readonly loopbackPort: number;163 readonly loopbackPort: number;
162 readonly redirectUri: string;164 readonly redirectUri: string;
...@@ -195,6 +197,7 @@ export function makeCloudCliOAuthConfig({...@@ -195,6 +197,7 @@ export function makeCloudCliOAuthConfig({
195 (clerkFrontendApiUrl) =>197 (clerkFrontendApiUrl) =>
196 ({198 ({
197 tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`,199 tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`,
200 deviceAuthorizationEndpoint: `${clerkFrontendApiUrl}/oauth/device_authorization`,
198 clientId,201 clientId,
199 loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT,202 loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT,
200 redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT),203 redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT),
apps/web/src/cloud/connectCliAuth.test.ts+17-41
...@@ -4,7 +4,6 @@ import {...@@ -4,7 +4,6 @@ import {
4 buildConnectCliClerkAuthorizeUrl,4 buildConnectCliClerkAuthorizeUrl,
5 connectCliSignInRedirectUrl,5 connectCliSignInRedirectUrl,
6 hasConnectCliAuthConfig,6 hasConnectCliAuthConfig,
7 readConnectCliCallbackResult,
8} from "./connectCliAuth";7} from "./connectCliAuth";
98
10// Any pk_test_* key decodes to <base64 hostname>.clerk.accounts.dev.9// Any pk_test_* key decodes to <base64 hostname>.clerk.accounts.dev.
...@@ -25,48 +24,34 @@ describe("connectCliAuth", () => {...@@ -25,48 +24,34 @@ describe("connectCliAuth", () => {
25 expect(hasConnectCliAuthConfig()).toBe(true);24 expect(hasConnectCliAuthConfig()).toBe(true);
26 });25 });
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", () => {
29 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);28 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
30 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");29 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
31 vi.stubEnv("VITE_HOSTED_APP_URL", "https://nightly.app.t3.codes");
3230
33 const authorizeUrl = buildConnectCliClerkAuthorizeUrl({31 const authorizeUrl = buildConnectCliClerkAuthorizeUrl({
34 state: "state-1",32 state: "state-1",
35 challenge: "challenge-1",33 challenge: "challenge-1",
34 loopbackPort: 34338,
36 });35 });
37 expect(authorizeUrl).not.toBeNull();36 expect(authorizeUrl).not.toBeNull();
3837
39 const url = new URL(authorizeUrl!);38 const url = new URL(authorizeUrl!);
40 expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev");39 expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev");
41 expect(url.pathname).toBe("/oauth/authorize");40 expect(url.pathname).toBe("/oauth/authorize");
42 expect(url.searchParams.get("redirect_uri")).toBe(41 expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback");
43 "https://nightly.app.t3.codes/connect/callback",
44 );
45 expect(url.searchParams.get("state")).toBe("state-1");42 expect(url.searchParams.get("state")).toBe("state-1");
46 expect(url.searchParams.get("code_challenge")).toBe("challenge-1");43 expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
47 expect(url.searchParams.get("code_challenge_method")).toBe("S256");44 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
48 });45 });
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
66 it("returns null when the CLI OAuth client id is not configured", () => {47 it("returns null when the CLI OAuth client id is not configured", () => {
67 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);48 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
68 expect(49 expect(
69 buildConnectCliClerkAuthorizeUrl({ state: "state-1", challenge: "challenge-1" }),50 buildConnectCliClerkAuthorizeUrl({
51 state: "state-1",
52 challenge: "challenge-1",
53 loopbackPort: 34338,
54 }),
70 ).toBeNull();55 ).toBeNull();
71 });56 });
7257
...@@ -74,9 +59,10 @@ describe("connectCliAuth", () => {...@@ -74,9 +59,10 @@ describe("connectCliAuth", () => {
74 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);59 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
75 vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");60 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";
78 const redirectUrl = connectCliSignInRedirectUrl(64 const redirectUrl = connectCliSignInRedirectUrl(
79 { state: "state-1", challenge: "challenge-1" },65 { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 },
80 connectUrl,66 connectUrl,
81 );67 );
8268
...@@ -87,23 +73,13 @@ describe("connectCliAuth", () => {...@@ -87,23 +73,13 @@ describe("connectCliAuth", () => {
87 it("falls back to the current URL when the authorize URL cannot be built", () => {73 it("falls back to the current URL when the authorize URL cannot be built", () => {
88 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);74 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";
91 expect(78 expect(
92 connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl),79 connectCliSignInRedirectUrl(
93 ).toBe(connectUrl);80 { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 },
94 });81 connectUrl,
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"),
100 ),82 ),
101 ).toEqual({ code: "abc", state: "state-1" });83 ).toBe(connectUrl);
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();
108 });84 });
109});85});
apps/web/src/cloud/connectCliAuth.ts+6-55
...@@ -1,17 +1,14 @@...@@ -1,17 +1,14 @@
1import {1import {
2 buildConnectClerkAuthorizeUrl,2 buildConnectClerkAuthorizeUrl,
3 connectCallbackUrl,
4 connectLoopbackRedirectUri,3 connectLoopbackRedirectUri,
5 CONNECT_OAUTH_SCOPES,4 CONNECT_OAUTH_SCOPES,
6 type ConnectAuthorizeRequest,5 type ConnectAuthorizeRequest,
7} from "@t3tools/shared/connectAuth";6} from "@t3tools/shared/connectAuth";
8import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";7import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
98
10import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing";9import { isHostedStaticApp } from "../hostedPairing";
11import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig";10import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig";
1211
13const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state";
14
15function resolveConnectCliOAuthClientId(): string | null {12function resolveConnectCliOAuthClientId(): string | null {
16 return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined);13 return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined);
17}14}
...@@ -23,7 +20,7 @@ export function hasConnectCliAuthConfig(): boolean {...@@ -23,7 +20,7 @@ export function hasConnectCliAuthConfig(): boolean {
23}20}
2421
25/**22/**
26 * Gate for the /connect routes: the CLI handshake only exists on the hosted23 * Gate for the /connect route: the CLI handshake only exists on the hosted
27 * deployment (the same bundle ships inside local instances) and needs the24 * deployment (the same bundle ships inside local instances) and needs the
28 * Clerk CLI OAuth client configured at build time.25 * Clerk CLI OAuth client configured at build time.
29 */26 */
...@@ -33,13 +30,9 @@ export function connectCliAuthRoutesEnabled(): boolean {...@@ -33,13 +30,9 @@ export function connectCliAuthRoutesEnabled(): boolean {
3330
34/**31/**
35 * Builds the Clerk authorize URL for a CLI-initiated connect request. The32 * Builds the Clerk authorize URL for a CLI-initiated connect request. The
36 * state is mirrored into sessionStorage so the callback page can verify the33 * authorization code returns to the CLI's `127.0.0.1` listener directly, so
37 * response matches a request this browser actually started.34 * this page never sees it. Clerk enforces its registered redirect URI
38 *35 * allowlist either way.
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.
43 */36 */
44export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null {37export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null {
45 const { clerkPublishableKey } = resolveCloudPublicConfig();38 const { clerkPublishableKey } = resolveCloudPublicConfig();
...@@ -50,10 +43,7 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques...@@ -50,10 +43,7 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques
50 return buildConnectClerkAuthorizeUrl({43 return buildConnectClerkAuthorizeUrl({
51 authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`,44 authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`,
52 clientId,45 clientId,
53 redirectUri:46 redirectUri: connectLoopbackRedirectUri(request.loopbackPort),
54 request.loopbackPort === undefined
55 ? connectCallbackUrl(configuredHostedAppUrl())
56 : connectLoopbackRedirectUri(request.loopbackPort),
57 scopes: CONNECT_OAUTH_SCOPES,47 scopes: CONNECT_OAUTH_SCOPES,
58 state: request.state,48 state: request.state,
59 challenge: request.challenge,49 challenge: request.challenge,
...@@ -76,42 +66,3 @@ export function connectCliSignInRedirectUrl(...@@ -76,42 +66,3 @@ export function connectCliSignInRedirectUrl(
76): string {66): string {
77 return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref;67 return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref;
78}68}
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 @@...@@ -1,16 +1,12 @@
1import { useAuth, useClerk, useUser } from "@clerk/react";1import { useAuth, useClerk } from "@clerk/react";
2import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";2import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
3import { useCallback, useEffect, useRef, useState } from "react";3import { useCallback, useEffect, useRef, useState } from "react";
44
5import {5import {
6 buildConnectCliClerkAuthorizeUrl,6 buildConnectCliClerkAuthorizeUrl,
7 connectCliSignInRedirectUrl,7 connectCliSignInRedirectUrl,
8 readConnectCliAuthState,
9 readConnectCliCallbackResult,
10 rememberConnectCliAuthState,
11} from "../../cloud/connectCliAuth";8} from "../../cloud/connectCliAuth";
12import { isElectron } from "../../env";9import { isElectron } from "../../env";
13import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
14import { AuthSurfaceShell } from "../auth/AuthSurfaceShell";10import { AuthSurfaceShell } from "../auth/AuthSurfaceShell";
15import { resolveClerkSignInProps } from "../clerk/authRedirect";11import { resolveClerkSignInProps } from "../clerk/authRedirect";
16import { Button } from "../ui/button";12import { Button } from "../ui/button";
...@@ -45,10 +41,10 @@ const invalidLinkMessage = {...@@ -45,10 +41,10 @@ const invalidLinkMessage = {
45} as const;41} as const;
4642
47/**43/**
48 * /connect: the URL the CLI prints for both flows. Waits for a Clerk session,44 * /connect: the URL the CLI prints for the loopback flow. Waits for a Clerk
49 * then forwards the CLI's PKCE request to Clerk's authorize endpoint — with a45 * session, then forwards the CLI's PKCE request to Clerk's authorize endpoint
50 * loopback redirect URI when the request carries a port, so the code returns46 * with the loopback redirect URI so the code returns straight to the waiting
51 * straight to the waiting CLI, and the hosted callback page otherwise.47 * CLI. Headless hosts use Clerk's device authorization page instead.
52 */48 */
53export function ConnectCliAuthorizeSurface() {49export function ConnectCliAuthorizeSurface() {
54 const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href)));50 const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href)));
...@@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() {...@@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() {
61 if (!request) {57 if (!request) {
62 return;58 return;
63 }59 }
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);
67 clerk.openSignIn(60 clerk.openSignIn(
68 resolveClerkSignInProps(61 resolveClerkSignInProps(
69 connectCliSignInRedirectUrl(request, window.location.href),62 connectCliSignInRedirectUrl(request, window.location.href),
...@@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() {...@@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() {
88 return;81 return;
89 }82 }
90 redirecting.current = true;83 redirecting.current = true;
91 rememberConnectCliAuthState(request.state);
92 window.location.assign(authorizeUrl);84 window.location.assign(authorizeUrl);
93 }, [isLoaded, isSignedIn, openSignIn, request]);85 }, [isLoaded, isSignedIn, openSignIn, request]);
9486
...@@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() {...@@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() {
103 return (95 return (
104 <AuthSurfaceShell>96 <AuthSurfaceShell>
105 <ConnectCliAuthMessage97 <ConnectCliAuthMessage
106 eyebrow={98 eyebrow="Browser authorization"
107 request.loopbackPort === undefined
108 ? "Step 1 of 2 · Browser authorization"
109 : "Browser authorization"
110 }
111 title="Connecting your terminal"99 title="Connecting your terminal"
112 description={100 description={
113 isSignedIn101 isSignedIn
...@@ -125,85 +113,3 @@ export function ConnectCliAuthorizeSurface() {...@@ -125,85 +113,3 @@ export function ConnectCliAuthorizeSurface() {
125 </AuthSurfaceShell>113 </AuthSurfaceShell>
126 );114 );
127}115}
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 {...@@ -10,7 +10,7 @@ export interface HostedPairingRequest {
1010
11export type HostedAppChannel = "latest" | "nightly";11export type HostedAppChannel = "latest" | "nightly";
1212
13export function configuredHostedAppUrl(): string {13function configuredHostedAppUrl(): string {
14 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;14 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
15}15}
1616
apps/web/src/routeTree.gen.ts-21
...@@ -29,7 +29,6 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne...@@ -29,7 +29,6 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne
29import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'29import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
30import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'30import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
31import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'31import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'
32import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
33import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests'32import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests'
34import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'33import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
35import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'34import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
...@@ -134,11 +133,6 @@ const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({...@@ -134,11 +133,6 @@ const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({
134 path: '/projects/$projectKey',133 path: '/projects/$projectKey',
135 getParentRoute: () => rootRouteImport,134 getParentRoute: () => rootRouteImport,
136} as any)135} as any)
137const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
138 id: '/connect_/callback',
139 path: '/connect/callback',
140 getParentRoute: () => rootRouteImport,
141} as any)
142const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({136const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({
143 id: '/pull-requests',137 id: '/pull-requests',
144 path: '/pull-requests',138 path: '/pull-requests',
...@@ -164,7 +158,6 @@ export interface FileRoutesByFullPath {...@@ -164,7 +158,6 @@ export interface FileRoutesByFullPath {
164 '/usage': typeof UsageRoute158 '/usage': typeof UsageRoute
165 '/welcome': typeof WelcomeRoute159 '/welcome': typeof WelcomeRoute
166 '/pull-requests': typeof ChatPullRequestsRoute160 '/pull-requests': typeof ChatPullRequestsRoute
167 '/connect/callback': typeof ConnectCallbackRoute
168 '/projects/$projectKey': typeof ProjectsProjectKeyRoute161 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
169 '/settings/appearance': typeof SettingsAppearanceRoute162 '/settings/appearance': typeof SettingsAppearanceRoute
170 '/settings/archived': typeof SettingsArchivedRoute163 '/settings/archived': typeof SettingsArchivedRoute
...@@ -188,7 +181,6 @@ export interface FileRoutesByTo {...@@ -188,7 +181,6 @@ export interface FileRoutesByTo {
188 '/usage': typeof UsageRoute181 '/usage': typeof UsageRoute
189 '/welcome': typeof WelcomeRoute182 '/welcome': typeof WelcomeRoute
190 '/pull-requests': typeof ChatPullRequestsRoute183 '/pull-requests': typeof ChatPullRequestsRoute
191 '/connect/callback': typeof ConnectCallbackRoute
192 '/projects/$projectKey': typeof ProjectsProjectKeyRoute184 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
193 '/settings/appearance': typeof SettingsAppearanceRoute185 '/settings/appearance': typeof SettingsAppearanceRoute
194 '/settings/archived': typeof SettingsArchivedRoute186 '/settings/archived': typeof SettingsArchivedRoute
...@@ -215,7 +207,6 @@ export interface FileRoutesById {...@@ -215,7 +207,6 @@ export interface FileRoutesById {
215 '/usage': typeof UsageRoute207 '/usage': typeof UsageRoute
216 '/welcome': typeof WelcomeRoute208 '/welcome': typeof WelcomeRoute
217 '/_chat/pull-requests': typeof ChatPullRequestsRoute209 '/_chat/pull-requests': typeof ChatPullRequestsRoute
218 '/connect_/callback': typeof ConnectCallbackRoute
219 '/projects/$projectKey': typeof ProjectsProjectKeyRoute210 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
220 '/settings/appearance': typeof SettingsAppearanceRoute211 '/settings/appearance': typeof SettingsAppearanceRoute
221 '/settings/archived': typeof SettingsArchivedRoute212 '/settings/archived': typeof SettingsArchivedRoute
...@@ -243,7 +234,6 @@ export interface FileRouteTypes {...@@ -243,7 +234,6 @@ export interface FileRouteTypes {
243 | '/usage'234 | '/usage'
244 | '/welcome'235 | '/welcome'
245 | '/pull-requests'236 | '/pull-requests'
246 | '/connect/callback'
247 | '/projects/$projectKey'237 | '/projects/$projectKey'
248 | '/settings/appearance'238 | '/settings/appearance'
249 | '/settings/archived'239 | '/settings/archived'
...@@ -267,7 +257,6 @@ export interface FileRouteTypes {...@@ -267,7 +257,6 @@ export interface FileRouteTypes {
267 | '/usage'257 | '/usage'
268 | '/welcome'258 | '/welcome'
269 | '/pull-requests'259 | '/pull-requests'
270 | '/connect/callback'
271 | '/projects/$projectKey'260 | '/projects/$projectKey'
272 | '/settings/appearance'261 | '/settings/appearance'
273 | '/settings/archived'262 | '/settings/archived'
...@@ -293,7 +282,6 @@ export interface FileRouteTypes {...@@ -293,7 +282,6 @@ export interface FileRouteTypes {
293 | '/usage'282 | '/usage'
294 | '/welcome'283 | '/welcome'
295 | '/_chat/pull-requests'284 | '/_chat/pull-requests'
296 | '/connect_/callback'
297 | '/projects/$projectKey'285 | '/projects/$projectKey'
298 | '/settings/appearance'286 | '/settings/appearance'
299 | '/settings/archived'287 | '/settings/archived'
...@@ -319,7 +307,6 @@ export interface RootRouteChildren {...@@ -319,7 +307,6 @@ export interface RootRouteChildren {
319 SettingsRoute: typeof SettingsRouteWithChildren307 SettingsRoute: typeof SettingsRouteWithChildren
320 UsageRoute: typeof UsageRoute308 UsageRoute: typeof UsageRoute
321 WelcomeRoute: typeof WelcomeRoute309 WelcomeRoute: typeof WelcomeRoute
322 ConnectCallbackRoute: typeof ConnectCallbackRoute
323 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute310 ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute
324}311}
325312
...@@ -465,13 +452,6 @@ declare module '@tanstack/react-router' {...@@ -465,13 +452,6 @@ declare module '@tanstack/react-router' {
465 preLoaderRoute: typeof ProjectsProjectKeyRouteImport452 preLoaderRoute: typeof ProjectsProjectKeyRouteImport
466 parentRoute: typeof rootRouteImport453 parentRoute: typeof rootRouteImport
467 }454 }
468 '/connect_/callback': {
469 id: '/connect_/callback'
470 path: '/connect/callback'
471 fullPath: '/connect/callback'
472 preLoaderRoute: typeof ConnectCallbackRouteImport
473 parentRoute: typeof rootRouteImport
474 }
475 '/_chat/pull-requests': {455 '/_chat/pull-requests': {
476 id: '/_chat/pull-requests'456 id: '/_chat/pull-requests'
477 path: '/pull-requests'457 path: '/pull-requests'
...@@ -553,7 +533,6 @@ const rootRouteChildren: RootRouteChildren = {...@@ -553,7 +533,6 @@ const rootRouteChildren: RootRouteChildren = {
553 SettingsRoute: SettingsRouteWithChildren,533 SettingsRoute: SettingsRouteWithChildren,
554 UsageRoute: UsageRoute,534 UsageRoute: UsageRoute,
555 WelcomeRoute: WelcomeRoute,535 WelcomeRoute: WelcomeRoute,
556 ConnectCallbackRoute: ConnectCallbackRoute,
557 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,536 ProjectsProjectKeyRoute: ProjectsProjectKeyRoute,
558}537}
559export const routeTree = rootRouteImport538export const routeTree = rootRouteImport
apps/web/src/routes/__root.tsx+1-1
...@@ -151,7 +151,7 @@ function RootRouteView() {...@@ -151,7 +151,7 @@ function RootRouteView() {
151 };151 };
152 }, [pathname]);152 }, [pathname]);
153153
154 if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) {154 if (pathname === "/pair" || pathname === "/connect") {
155 return (155 return (
156 <>156 <>
157 <DocumentTitleSync />157 <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...@@ -72,10 +72,15 @@ different credentials. The relay accepts both session-template JWTs and CLI
72OAuth tokens; requiring a JWT template for the CLI would reject valid logins.72OAuth tokens; requiring a JWT template for the CLI would reject valid logins.
73The CLI is a public OAuth client using PKCE and stores no client secret.73The 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 completes75Loopback CLI authorization starts on the hosted `/connect` page so sign-in
76before entering Clerk's authorize endpoint. Sending a signed-out browser76completes before entering Clerk's authorize endpoint. Sending a signed-out
77straight to that endpoint loses the authorize parameters during the sign-in77browser straight to that endpoint loses the authorize parameters during the
78redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves78sign-in redirect. The [shared flow](../../packages/shared/src/connectAuth.ts)
79PKCE and state for both loopback and pasted-code callbacks. SSH and headless79preserves PKCE and state for the loopback callback.
80sessions use the pasted-code flow because the browser cannot ordinarily reach a80
81listener on the remote machine.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 `....@@ -39,11 +39,12 @@ depend on. The deploy wrapper writes the resulting relay URL back to the root `.
39In Clerk's OAuth applications settings:39In Clerk's OAuth applications settings:
4040
411. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE.411. 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` and422. Allow the redirect URI `http://127.0.0.1:34338/callback`.
43 `https://app.t3.codes/connect/callback`. A custom `T3CODE_HOSTED_APP_URL` needs its own433. Enable the `openid`, `profile`, `email`, and `offline_access` scopes.
44 `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect.444. Enable **Device authorization grant** on the application. Headless and SSH authorization use
453. Enable the `openid`, `profile`, and `email` scopes.45 it, and Clerk only advertises the device endpoint once it is on. The feature is in beta and
464. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release46 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
47 build environments.48 build environments.
4849
49## JWT template50## 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...@@ -21,8 +21,9 @@ server with `npx t3 serve`. Saving your sign-in alone does not make the machine
21reachable.21reachable.
2222
23On your other device, sign in to the same T3 Connect account and choose the23On 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 returned24environment. Over SSH, the CLI prints a browser link and a short code. Open the
25authorization code, so you do not need to forward an OAuth callback port.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
27T3 Connect renews access credentials when needed without disconnecting a healthy28T3 Connect renews access credentials when needed without disconnecting a healthy
28connection. Pull request diffs and provider settings keep working after the29connection. 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";...@@ -3,19 +3,17 @@ import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 buildConnectAuthorizeRequestUrl,4 buildConnectAuthorizeRequestUrl,
5 buildConnectClerkAuthorizeUrl,5 buildConnectClerkAuthorizeUrl,
6 connectCallbackUrl,
7 connectLoopbackRedirectUri,6 connectLoopbackRedirectUri,
8 encodeConnectAuthCode,
9 parseConnectAuthCode,
10 readConnectAuthorizeRequest,7 readConnectAuthorizeRequest,
11} from "./connectAuth.ts";8} from "./connectAuth.ts";
129
13describe("connectAuth", () => {10describe("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", () => {
15 const url = buildConnectAuthorizeRequestUrl({12 const url = buildConnectAuthorizeRequestUrl({
16 hostedAppUrl: "https://app.t3.codes",13 hostedAppUrl: "https://app.t3.codes",
17 state: "q7mK9xV2pL4nR8sT6wYzAQ",14 state: "q7mK9xV2pL4nR8sT6wYzAQ",
18 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",15 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
16 loopbackPort: 34338,
19 });17 });
20 const parsed = new URL(url);18 const parsed = new URL(url);
2119
...@@ -25,33 +23,22 @@ describe("connectAuth", () => {...@@ -25,33 +23,22 @@ describe("connectAuth", () => {
25 expect(readConnectAuthorizeRequest(parsed)).toEqual({23 expect(readConnectAuthorizeRequest(parsed)).toEqual({
26 state: "q7mK9xV2pL4nR8sT6wYzAQ",24 state: "q7mK9xV2pL4nR8sT6wYzAQ",
27 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",25 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
26 loopbackPort: 34338,
28 });27 });
28 expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback");
29 });29 });
3030
31 it("rejects authorize requests missing state or challenge", () => {31 it("rejects authorize requests missing state, challenge, or port", () => {
32 expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull();32 expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull();
33 expect(33 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")),
35 ).toBeNull();35 ).toBeNull();
36 expect(36 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")),
38 ).toBeNull();41 ).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");
55 });42 });
5643
57 it("rejects authorize requests whose loopback port is corrupted", () => {44 it("rejects authorize requests whose loopback port is corrupted", () => {
...@@ -68,8 +55,8 @@ describe("connectAuth", () => {...@@ -68,8 +55,8 @@ describe("connectAuth", () => {
68 buildConnectClerkAuthorizeUrl({55 buildConnectClerkAuthorizeUrl({
69 authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize",56 authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize",
70 clientId: "oauthapp_123",57 clientId: "oauthapp_123",
71 redirectUri: connectCallbackUrl("https://app.t3.codes"),58 redirectUri: connectLoopbackRedirectUri(34338),
72 scopes: ["openid", "profile", "email"],59 scopes: ["openid", "profile", "email", "offline_access"],
73 state: "state-1",60 state: "state-1",
74 challenge: "challenge-1",61 challenge: "challenge-1",
75 }),62 }),
...@@ -78,27 +65,11 @@ describe("connectAuth", () => {...@@ -78,27 +65,11 @@ describe("connectAuth", () => {
78 expect(url.origin).toBe("https://clerk.t3.codes");65 expect(url.origin).toBe("https://clerk.t3.codes");
79 expect(url.pathname).toBe("/oauth/authorize");66 expect(url.pathname).toBe("/oauth/authorize");
80 expect(url.searchParams.get("client_id")).toBe("oauthapp_123");67 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");
82 expect(url.searchParams.get("response_type")).toBe("code");69 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");
84 expect(url.searchParams.get("state")).toBe("state-1");71 expect(url.searchParams.get("state")).toBe("state-1");
85 expect(url.searchParams.get("code_challenge")).toBe("challenge-1");72 expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
86 expect(url.searchParams.get("code_challenge_method")).toBe("S256");73 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
87 });74 });
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 });
104});75});
packages/shared/src/connectAuth.ts+14-78
...@@ -3,11 +3,9 @@ import { readHashParams } from "./remote.ts";...@@ -3,11 +3,9 @@ import { readHashParams } from "./remote.ts";
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_PORT_PARAM = "port";
6const CONNECT_AUTH_CODE_SEPARATOR = ".";
7const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback";6const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback";
87
9const CONNECT_AUTHORIZE_PATH = "/connect";8const CONNECT_AUTHORIZE_PATH = "/connect";
10const CONNECT_CALLBACK_PATH = "/connect/callback";
119
12/**10/**
13 * The CLI prints URLs against this origin and the web bundle uses it to11 * The CLI prints URLs against this origin and the web bundle uses it to
...@@ -17,20 +15,20 @@ const CONNECT_CALLBACK_PATH = "/connect/callback";...@@ -17,20 +15,20 @@ const CONNECT_CALLBACK_PATH = "/connect/callback";
17export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";15export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
1816
19/**17/**
20 * Requested at authorize time by the hosted page and honored by the CLI's18 * Requested at authorize time by the hosted page and by the CLI's device
21 * token exchange; keep both sides on this single definition.19 * authorization request; keep both sides on this single definition.
20 * `offline_access` asks Clerk for the refresh token the CLI relies on.
22 */21 */
23export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const;22export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const;
2423
25export interface ConnectAuthorizeRequest {24export interface ConnectAuthorizeRequest {
26 readonly state: string;25 readonly state: string;
27 readonly challenge: string;26 readonly challenge: string;
28 /**27 /**
29 * Present when a loopback CLI initiated the request: the hosted /connect28 * The hosted /connect page asks Clerk to redirect the authorization code
30 * page then asks Clerk to redirect the authorization code straight to29 * straight to `http://127.0.0.1:<port>/callback` on the waiting CLI.
31 * `http://127.0.0.1:<port>/callback` instead of the hosted callback page.
32 */30 */
33 readonly loopbackPort?: number;31 readonly loopbackPort: number;
34}32}
3533
36/**34/**
...@@ -38,26 +36,25 @@ export interface ConnectAuthorizeRequest {...@@ -38,26 +36,25 @@ export interface ConnectAuthorizeRequest {
38 * `code_challenge` ride the fragment so they never reach the hosted app's36 * `code_challenge` ride the fragment so they never reach the hosted app's
39 * server or CDN logs; neither is a secret.37 * server or CDN logs; neither is a secret.
40 *38 *
41 * Both CLI flows route through the hosted /connect page rather than hitting39 * The CLI routes through the hosted /connect page rather than hitting
42 * Clerk's /oauth/authorize directly: a signed-out browser sent straight to40 * Clerk's /oauth/authorize directly: a signed-out browser sent straight to
43 * /oauth/authorize goes through Clerk's sign-in redirect, which does not41 * /oauth/authorize goes through Clerk's sign-in redirect, which does not
44 * reliably preserve the authorize query parameters (state, response_type,42 * reliably preserve the authorize query parameters (state, response_type,
45 * code_challenge). The hosted page waits for a Clerk session first, then43 * 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.
47 */46 */
48export function buildConnectAuthorizeRequestUrl(input: {47export function buildConnectAuthorizeRequestUrl(input: {
49 readonly hostedAppUrl: string;48 readonly hostedAppUrl: string;
50 readonly state: string;49 readonly state: string;
51 readonly challenge: string;50 readonly challenge: string;
52 readonly loopbackPort?: number;51 readonly loopbackPort: number;
53}): string {52}): string {
54 const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl);53 const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl);
55 url.hash = new URLSearchParams([54 url.hash = new URLSearchParams([
56 [CONNECT_AUTH_STATE_PARAM, input.state],55 [CONNECT_AUTH_STATE_PARAM, input.state],
57 [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge],56 [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge],
58 ...(input.loopbackPort === undefined57 [CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)],
59 ? []
60 : [[CONNECT_AUTH_PORT_PARAM, String(input.loopbackPort)] as [string, string]]),
61 ]).toString();58 ]).toString();
62 return url.toString();59 return url.toString();
63}60}
...@@ -66,18 +63,8 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest |...@@ -66,18 +63,8 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest |
66 const params = readHashParams(url);63 const params = readHashParams(url);
67 const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? "";64 const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? "";
68 const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? "";65 const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? "";
69 if (!state || !challenge) {66 const loopbackPort = parseLoopbackPort(params.get(CONNECT_AUTH_PORT_PARAM)?.trim() ?? "");
70 return null;67 if (!state || !challenge || loopbackPort === 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) {
81 return null;68 return null;
82 }69 }
83 return { state, challenge, loopbackPort };70 return { state, challenge, loopbackPort };
...@@ -99,10 +86,6 @@ export function connectLoopbackRedirectUri(port: number): string {...@@ -99,10 +86,6 @@ export function connectLoopbackRedirectUri(port: number): string {
99 return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`;86 return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`;
100}87}
10188
102export function connectCallbackUrl(hostedAppUrl: string): string {
103 return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString();
104}
105
106export function buildConnectClerkAuthorizeUrl(input: {89export function buildConnectClerkAuthorizeUrl(input: {
107 readonly authorizationEndpoint: string;90 readonly authorizationEndpoint: string;
108 readonly clientId: string;91 readonly clientId: string;
...@@ -121,50 +104,3 @@ export function buildConnectClerkAuthorizeUrl(input: {...@@ -121,50 +104,3 @@ export function buildConnectClerkAuthorizeUrl(input: {
121 url.searchParams.set("code_challenge_method", "S256");104 url.searchParams.set("code_challenge_method", "S256");
122 return url.toString();105 return url.toString();
123}106}
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}