| author | |
| committer | |
| log | dc0869b605f60c2c8afa924035a81c8f98b29385 |
| tree | 26827865bbdb5ddbf80a987f48bf6a102c550c45 |
| parent | 0f21fcbb6dbabb6d2185a73edf6b865fd64c6019 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 20 | import * as Option from "effect/Option"; |
| 21 | 21 | import * as References from "effect/References"; |
| 22 | 22 | import * as Schema from "effect/Schema"; |
| 23 | import * as Terminal from "effect/Terminal"; | |
| 24 | 23 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 25 | 24 | import { |
| 26 | 25 | FetchHttpClient, |
| ... | ... | @@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe( |
| 64 | 63 | const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); |
| 65 | 64 | |
| 66 | 65 | const 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 | 67 | Flag.withDefault(false), |
| 69 | 68 | ); |
| 70 | 69 | |
| 71 | 70 | /** |
| 72 | 71 | * 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 | */ |
| 75 | 75 | export const headlessSessionConfig = Config.all({ |
| 76 | 76 | sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), |
| ... | ... | @@ -79,20 +79,21 @@ export const headlessSessionConfig = Config.all({ |
| 79 | 79 | Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)), |
| 80 | 80 | ); |
| 81 | 81 | |
| 82 | const 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 | ); | |
| 82 | const showDeviceAuthorizationPrompt = (prompt: CliTokenManager.DeviceAuthorizationPrompt) => | |
| 83 | Console.log(formatDeviceAuthorizationPrompt(prompt)); | |
| 88 | 84 | |
| 89 | function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { | |
| 85 | function formatDeviceAuthorizationPrompt( | |
| 86 | prompt: CliTokenManager.DeviceAuthorizationPrompt, | |
| 87 | ): string { | |
| 88 | const minutes = Math.max(1, Math.round(Duration.toMinutes(prompt.expiresIn))); | |
| 90 | 89 | return [ |
| 91 | 90 | "Headless authorization", |
| 92 | 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 | 97 | ].join("\n"); |
| 97 | 98 | } |
| 98 | 99 | |
| ... | ... | @@ -110,7 +111,7 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { |
| 110 | 111 | yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below."); |
| 111 | 112 | } |
| 112 | 113 | // 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 | 115 | const existing = yield* tokens.getExisting.pipe( |
| 115 | 116 | Effect.catchTag("CloudCliCredentialRefreshError", () => |
| 116 | 117 | Console.log( |
| ... | ... | @@ -121,13 +122,11 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { |
| 121 | 122 | if (Option.isSome(existing)) { |
| 122 | 123 | return existing.value.identity ?? null; |
| 123 | 124 | } |
| 124 | const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin( | |
| 125 | promptForOutOfBandOAuthCode, | |
| 125 | const { token, identity } = yield* CliTokenManager.deviceAuthorizationLogin( | |
| 126 | showDeviceAuthorizationPrompt, | |
| 126 | 127 | ).pipe( |
| 127 | 128 | 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) | |
| 131 | 130 | ? cause |
| 132 | 131 | : new CliTokenManager.CloudCliAuthorizationError({ cause }), |
| 133 | 132 | ), |
apps/server/src/cloud/CliTokenManager.test.ts+186-109| ... | ... | @@ -1,21 +1,21 @@ |
| 1 | import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | |
| 2 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 2 | import { assert, it } from "@effect/vitest"; |
| 4 | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Crypto from "effect/Crypto"; | |
| 6 | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Duration from "effect/Duration"; | |
| 7 | 6 | import * as Effect from "effect/Effect"; |
| 8 | 7 | import * as Encoding from "effect/Encoding"; |
| 8 | import * as Fiber from "effect/Fiber"; | |
| 9 | 9 | import * as Layer from "effect/Layer"; |
| 10 | 10 | import * as Option from "effect/Option"; |
| 11 | 11 | import * as Queue from "effect/Queue"; |
| 12 | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | import * as Terminal from "effect/Terminal"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | 15 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 15 | 16 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| 16 | 17 | |
| 17 | 18 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 18 | import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts"; | |
| 19 | 19 | |
| 20 | 20 | // pk_test_<base64 of "clerk.example.test$"> |
| 21 | 21 | const TEST_ENV = { |
| ... | ... | @@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString( |
| 54 | 54 | ); |
| 55 | 55 | const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson); |
| 56 | 56 | |
| 57 | const 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 | ||
| 85 | 57 | const provideTestEnv = Effect.provide( |
| 86 | 58 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), |
| 87 | 59 | ); |
| 88 | 60 | |
| 89 | 61 | const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); |
| 90 | 62 | |
| 91 | class PromptRejectedError extends Schema.TaggedError<PromptRejectedError>()("PromptRejectedError", { | |
| 92 | message: Schema.String, | |
| 93 | }) {} | |
| 94 | ||
| 95 | 63 | const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) => |
| 96 | 64 | Terminal.make({ |
| 97 | 65 | columns: Effect.succeed(80), |
| ... | ... | @@ -144,106 +112,215 @@ it.effect("finishes normally when the browser callback wins", () => |
| 144 | 112 | }), |
| 145 | 113 | ); |
| 146 | 114 | |
| 147 | it.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 | ); | |
| 115 | interface 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 | ||
| 121 | const 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 | ||
| 130 | const oauthError = (error: string) => ({ status: 400, body: JSON.stringify({ error }) }); | |
| 131 | const 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 | ||
| 142 | const 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 | ||
| 166 | const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) => | |
| 167 | requests.filter((request) => request.url.endsWith("/oauth/token")); | |
| 164 | 168 | |
| 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}$/); | |
| 169 | it.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> = []; | |
| 171 | 177 | |
| 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 | 195 | assert.equal(token.accessToken, "access-token-1"); |
| 173 | 196 | assert.equal(token.refreshToken, "refresh-token-1"); |
| 174 | 197 | assert.equal(token.identity, "theo@example.test"); |
| 175 | // The id_token's email claim is surfaced so connect can show the account. | |
| 176 | 198 | assert.equal(identity, "theo@example.test"); |
| 177 | 199 | |
| 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, | |
| 186 | 227 | ); |
| 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); | |
| 194 | 239 | }), |
| 195 | 240 | ); |
| 196 | 241 | |
| 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 | 243 | 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"); | |
| 214 | 264 | }), |
| 215 | 265 | ); |
| 216 | 266 | |
| 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 | 268 | 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)), | |
| 230 | 297 | provideTestEnv, |
| 298 | Effect.flip, | |
| 299 | Effect.forkChild, | |
| 231 | 300 | ); |
| 301 | yield* TestClock.adjust(Duration.seconds(600)); | |
| 302 | const result = yield* Fiber.join(fiber); | |
| 232 | 303 | |
| 233 | assert.isNull(identity); | |
| 234 | assert.lengthOf(requests, 1); | |
| 304 | assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError); | |
| 235 | 305 | }), |
| 236 | 306 | ); |
| 237 | 307 | |
| 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 | 309 | Effect.gen(function* () { |
| 240 | const requests: Array<RecordedTokenRequest> = []; | |
| 310 | const server: DeviceFlowServer = { | |
| 311 | requests: [], | |
| 312 | tokenReplies: [oauthError("invalid_client")], | |
| 313 | }; | |
| 241 | 314 | |
| 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); | |
| 245 | 323 | |
| 246 | assert.lengthOf(requests, 0); | |
| 247 | 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 | 18 | import * as Semaphore from "effect/Semaphore"; |
| 19 | 19 | import * as Terminal from "effect/Terminal"; |
| 20 | 20 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 21 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; | |
| 21 | 22 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; |
| 22 | 23 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| 23 | 24 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 24 | 25 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 25 | 26 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 26 | 27 | |
| 27 | import { | |
| 28 | buildConnectAuthorizeRequestUrl, | |
| 29 | checkConnectAuthCode, | |
| 30 | connectCallbackUrl, | |
| 31 | } from "@t3tools/shared/connectAuth"; | |
| 28 | import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth"; | |
| 32 | 29 | |
| 33 | 30 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 34 | 31 | import * as ExternalLauncher from "../process/externalLauncher.ts"; |
| ... | ... | @@ -42,6 +39,11 @@ import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; |
| 42 | 39 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; |
| 43 | 40 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); |
| 44 | 41 | const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); |
| 42 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; | |
| 43 | // RFC 8628 defaults, used only when Clerk omits the field. | |
| 44 | const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5); | |
| 45 | // RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval". | |
| 46 | const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5); | |
| 45 | 47 | const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; |
| 46 | 48 | |
| 47 | 49 | function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { |
| ... | ... | @@ -136,6 +138,20 @@ const OAuthTokenResponse = Schema.Struct({ |
| 136 | 138 | token_type: Schema.String, |
| 137 | 139 | }); |
| 138 | 140 | |
| 141 | const OAuthErrorResponse = Schema.Struct({ | |
| 142 | error: Schema.String, | |
| 143 | error_description: Schema.optional(Schema.String), | |
| 144 | }); | |
| 145 | ||
| 146 | const 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 | ||
| 139 | 155 | const OidcIdentityClaimsJson = Schema.fromJsonString( |
| 140 | 156 | Schema.Struct({ |
| 141 | 157 | email: Schema.optional(Schema.String), |
| ... | ... | @@ -209,12 +225,22 @@ export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudC |
| 209 | 225 | } |
| 210 | 226 | } |
| 211 | 227 | |
| 228 | export 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 | ||
| 212 | 237 | export const CloudCliTokenManagerError = Schema.Union([ |
| 213 | 238 | CloudCliCredentialRemovalError, |
| 214 | 239 | CloudCliCredentialRefreshError, |
| 215 | 240 | CloudCliCredentialReadError, |
| 216 | 241 | CloudCliAuthorizationError, |
| 217 | 242 | CloudCliAuthorizationTimeoutError, |
| 243 | CloudCliAuthorizationDeniedError, | |
| 218 | 244 | ]); |
| 219 | 245 | export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; |
| 220 | 246 | |
| ... | ... | @@ -241,29 +267,36 @@ function bytesToString(value: Uint8Array): string { |
| 241 | 267 | return new TextDecoder().decode(value); |
| 242 | 268 | } |
| 243 | 269 | |
| 244 | const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( | |
| 245 | metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">, | |
| 270 | const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* ( | |
| 271 | response: HttpClientResponse.HttpClientResponse, | |
| 246 | 272 | params: Record<string, string>, |
| 247 | 273 | ) { |
| 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); | |
| 254 | 275 | const now = yield* Clock.currentTimeMillis; |
| 255 | const identity = idTokenIdentity(response.id_token); | |
| 276 | const identity = idTokenIdentity(body.id_token); | |
| 256 | 277 | return { |
| 257 | 278 | 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, | |
| 261 | 282 | ...(identity === null ? {} : { identity }), |
| 262 | 283 | } satisfies PersistedToken, |
| 263 | 284 | identity, |
| 264 | 285 | }; |
| 265 | 286 | }); |
| 266 | 287 | |
| 288 | const 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 | ||
| 267 | 300 | const makePkceRequest = Effect.gen(function* () { |
| 268 | 301 | const crypto = yield* Crypto.Crypto; |
| 269 | 302 | const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); |
| ... | ... | @@ -274,61 +307,118 @@ const makePkceRequest = Effect.gen(function* () { |
| 274 | 307 | return { verifier, challenge, state }; |
| 275 | 308 | }); |
| 276 | 309 | |
| 277 | export interface OutOfBandOAuthPromptInput { | |
| 278 | readonly authorizeUrl: string; | |
| 279 | readonly validate: (value: string) => Effect.Effect<string, string>; | |
| 310 | export interface DeviceAuthorizationPrompt { | |
| 311 | readonly verificationUri: string; | |
| 312 | readonly verificationUriComplete: string | undefined; | |
| 313 | readonly userCode: string; | |
| 314 | readonly expiresIn: Duration.Duration; | |
| 280 | 315 | } |
| 281 | 316 | |
| 317 | const 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 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. | |
| 288 | 326 | */ |
| 289 | export 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), | |
| 327 | const 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, | |
| 322 | 336 | 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 | } | |
| 325 | 376 | }); |
| 326 | 377 | |
| 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 | */ | |
| 385 | export 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 | 418 | /** @public Service construction is part of the canonical Effect module API. */ |
| 328 | 419 | export 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. | |
| 332 | 422 | const crypto = yield* Crypto.Crypto; |
| 333 | 423 | const httpClient = yield* HttpClient.HttpClient; |
| 334 | 424 | const services = Context.make(Crypto.Crypto, crypto).pipe( |
| ... | ... | @@ -461,7 +551,7 @@ export const make = Effect.gen(function* () { |
| 461 | 551 | Effect.gen(function* () { |
| 462 | 552 | // A stored credential that can't be read or refreshed (corrupt, revoked, |
| 463 | 553 | // 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 | |
| 465 | 555 | // authorization. |
| 466 | 556 | const token = yield* getExistingNoLock().pipe( |
| 467 | 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 | 91 | |
| 92 | 92 | assert.deepEqual(config, { |
| 93 | 93 | tokenEndpoint: "https://clerk.example.test/oauth/token", |
| 94 | deviceAuthorizationEndpoint: "https://clerk.example.test/oauth/device_authorization", | |
| 94 | 95 | clientId: "oauth_client_embedded", |
| 95 | 96 | loopbackPort: 34338, |
| 96 | 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 | 152 | /** |
| 153 | 153 | * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes |
| 154 | 154 | * through the hosted /connect page, which builds the authorize URL after a |
| 155 | * Clerk session exists (see CliTokenManager.login). Only the token endpoint | |
| 156 | * is contacted directly. | |
| 155 | * Clerk session exists (see CliTokenManager.login). The token endpoint and, | |
| 156 | * for headless hosts, the device authorization endpoint are contacted | |
| 157 | * directly. | |
| 157 | 158 | */ |
| 158 | 159 | export interface CloudCliOAuthConfig { |
| 159 | 160 | readonly tokenEndpoint: string; |
| 161 | readonly deviceAuthorizationEndpoint: string; | |
| 160 | 162 | readonly clientId: string; |
| 161 | 163 | readonly loopbackPort: number; |
| 162 | 164 | readonly redirectUri: string; |
| ... | ... | @@ -195,6 +197,7 @@ export function makeCloudCliOAuthConfig({ |
| 195 | 197 | (clerkFrontendApiUrl) => |
| 196 | 198 | ({ |
| 197 | 199 | tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, |
| 200 | deviceAuthorizationEndpoint: `${clerkFrontendApiUrl}/oauth/device_authorization`, | |
| 198 | 201 | clientId, |
| 199 | 202 | loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT, |
| 200 | 203 | redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT), |
apps/web/src/cloud/connectCliAuth.test.ts+17-41| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | buildConnectCliClerkAuthorizeUrl, |
| 5 | 5 | connectCliSignInRedirectUrl, |
| 6 | 6 | hasConnectCliAuthConfig, |
| 7 | readConnectCliCallbackResult, | |
| 8 | 7 | } from "./connectCliAuth"; |
| 9 | 8 | |
| 10 | 9 | // Any pk_test_* key decodes to <base64 hostname>.clerk.accounts.dev. |
| ... | ... | @@ -25,48 +24,34 @@ describe("connectCliAuth", () => { |
| 25 | 24 | expect(hasConnectCliAuthConfig()).toBe(true); |
| 26 | 25 | }); |
| 27 | 26 | |
| 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 | 28 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); |
| 30 | 29 | vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); |
| 31 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://nightly.app.t3.codes"); | |
| 32 | 30 | |
| 33 | 31 | const authorizeUrl = buildConnectCliClerkAuthorizeUrl({ |
| 34 | 32 | state: "state-1", |
| 35 | 33 | challenge: "challenge-1", |
| 34 | loopbackPort: 34338, | |
| 36 | 35 | }); |
| 37 | 36 | expect(authorizeUrl).not.toBeNull(); |
| 38 | 37 | |
| 39 | 38 | const url = new URL(authorizeUrl!); |
| 40 | 39 | expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev"); |
| 41 | 40 | 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"); | |
| 45 | 42 | expect(url.searchParams.get("state")).toBe("state-1"); |
| 46 | 43 | expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); |
| 47 | 44 | expect(url.searchParams.get("code_challenge_method")).toBe("S256"); |
| 48 | 45 | }); |
| 49 | 46 | |
| 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 | 47 | it("returns null when the CLI OAuth client id is not configured", () => { |
| 67 | 48 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); |
| 68 | 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 | 55 | ).toBeNull(); |
| 71 | 56 | }); |
| 72 | 57 | |
| ... | ... | @@ -74,9 +59,10 @@ describe("connectCliAuth", () => { |
| 74 | 59 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); |
| 75 | 60 | vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); |
| 76 | 61 | |
| 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 | 64 | const redirectUrl = connectCliSignInRedirectUrl( |
| 79 | { state: "state-1", challenge: "challenge-1" }, | |
| 65 | { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 }, | |
| 80 | 66 | connectUrl, |
| 81 | 67 | ); |
| 82 | 68 | |
| ... | ... | @@ -87,23 +73,13 @@ describe("connectCliAuth", () => { |
| 87 | 73 | it("falls back to the current URL when the authorize URL cannot be built", () => { |
| 88 | 74 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); |
| 89 | 75 | |
| 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 | 78 | 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, | |
| 100 | 82 | ), |
| 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); | |
| 108 | 84 | }); |
| 109 | 85 | }); |
apps/web/src/cloud/connectCliAuth.ts+6-55| ... | ... | @@ -1,17 +1,14 @@ |
| 1 | 1 | import { |
| 2 | 2 | buildConnectClerkAuthorizeUrl, |
| 3 | connectCallbackUrl, | |
| 4 | 3 | connectLoopbackRedirectUri, |
| 5 | 4 | CONNECT_OAUTH_SCOPES, |
| 6 | 5 | type ConnectAuthorizeRequest, |
| 7 | 6 | } from "@t3tools/shared/connectAuth"; |
| 8 | 7 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 9 | 8 | |
| 10 | import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing"; | |
| 9 | import { isHostedStaticApp } from "../hostedPairing"; | |
| 11 | 10 | import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; |
| 12 | 11 | |
| 13 | const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; | |
| 14 | ||
| 15 | 12 | function resolveConnectCliOAuthClientId(): string | null { |
| 16 | 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 | 20 | } |
| 24 | 21 | |
| 25 | 22 | /** |
| 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 | |
| 27 | 24 | * deployment (the same bundle ships inside local instances) and needs the |
| 28 | 25 | * Clerk CLI OAuth client configured at build time. |
| 29 | 26 | */ |
| ... | ... | @@ -33,13 +30,9 @@ export function connectCliAuthRoutesEnabled(): boolean { |
| 33 | 30 | |
| 34 | 31 | /** |
| 35 | 32 | * 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. | |
| 43 | 36 | */ |
| 44 | 37 | export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { |
| 45 | 38 | const { clerkPublishableKey } = resolveCloudPublicConfig(); |
| ... | ... | @@ -50,10 +43,7 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques |
| 50 | 43 | return buildConnectClerkAuthorizeUrl({ |
| 51 | 44 | authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, |
| 52 | 45 | clientId, |
| 53 | redirectUri: | |
| 54 | request.loopbackPort === undefined | |
| 55 | ? connectCallbackUrl(configuredHostedAppUrl()) | |
| 56 | : connectLoopbackRedirectUri(request.loopbackPort), | |
| 46 | redirectUri: connectLoopbackRedirectUri(request.loopbackPort), | |
| 57 | 47 | scopes: CONNECT_OAUTH_SCOPES, |
| 58 | 48 | state: request.state, |
| 59 | 49 | challenge: request.challenge, |
| ... | ... | @@ -76,42 +66,3 @@ export function connectCliSignInRedirectUrl( |
| 76 | 66 | ): string { |
| 77 | 67 | return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; |
| 78 | 68 | } |
| 79 | ||
| 80 | export 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 | */ | |
| 95 | export 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 | ||
| 103 | export interface ConnectCliCallbackResult { | |
| 104 | readonly code: string; | |
| 105 | readonly state: string; | |
| 106 | } | |
| 107 | ||
| 108 | export 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 | import { useAuth, useClerk, useUser } from "@clerk/react"; | |
| 2 | import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | |
| 1 | import { useAuth, useClerk } from "@clerk/react"; | |
| 2 | import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | |
| 3 | 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | buildConnectCliClerkAuthorizeUrl, |
| 7 | 7 | connectCliSignInRedirectUrl, |
| 8 | readConnectCliAuthState, | |
| 9 | readConnectCliCallbackResult, | |
| 10 | rememberConnectCliAuthState, | |
| 11 | 8 | } from "../../cloud/connectCliAuth"; |
| 12 | 9 | import { isElectron } from "../../env"; |
| 13 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 14 | 10 | import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; |
| 15 | 11 | import { resolveClerkSignInProps } from "../clerk/authRedirect"; |
| 16 | 12 | import { Button } from "../ui/button"; |
| ... | ... | @@ -45,10 +41,10 @@ const invalidLinkMessage = { |
| 45 | 41 | } as const; |
| 46 | 42 | |
| 47 | 43 | /** |
| 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. | |
| 52 | 48 | */ |
| 53 | 49 | export function ConnectCliAuthorizeSurface() { |
| 54 | 50 | const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href))); |
| ... | ... | @@ -61,9 +57,6 @@ export function ConnectCliAuthorizeSurface() { |
| 61 | 57 | if (!request) { |
| 62 | 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 | 60 | clerk.openSignIn( |
| 68 | 61 | resolveClerkSignInProps( |
| 69 | 62 | connectCliSignInRedirectUrl(request, window.location.href), |
| ... | ... | @@ -88,7 +81,6 @@ export function ConnectCliAuthorizeSurface() { |
| 88 | 81 | return; |
| 89 | 82 | } |
| 90 | 83 | redirecting.current = true; |
| 91 | rememberConnectCliAuthState(request.state); | |
| 92 | 84 | window.location.assign(authorizeUrl); |
| 93 | 85 | }, [isLoaded, isSignedIn, openSignIn, request]); |
| 94 | 86 | |
| ... | ... | @@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() { |
| 103 | 95 | return ( |
| 104 | 96 | <AuthSurfaceShell> |
| 105 | 97 | <ConnectCliAuthMessage |
| 106 | eyebrow={ | |
| 107 | request.loopbackPort === undefined | |
| 108 | ? "Step 1 of 2 · Browser authorization" | |
| 109 | : "Browser authorization" | |
| 110 | } | |
| 98 | eyebrow="Browser authorization" | |
| 111 | 99 | title="Connecting your terminal" |
| 112 | 100 | description={ |
| 113 | 101 | isSignedIn |
| ... | ... | @@ -125,85 +113,3 @@ export function ConnectCliAuthorizeSurface() { |
| 125 | 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 | */ | |
| 133 | export 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 | 10 | |
| 11 | 11 | export type HostedAppChannel = "latest" | "nightly"; |
| 12 | 12 | |
| 13 | export function configuredHostedAppUrl(): string { | |
| 13 | function configuredHostedAppUrl(): string { | |
| 14 | 14 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; |
| 15 | 15 | } |
| 16 | 16 |
apps/web/src/routeTree.gen.ts-21| ... | ... | @@ -29,7 +29,6 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne |
| 29 | 29 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 30 | 30 | import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' |
| 31 | 31 | import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' |
| 32 | import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' | |
| 33 | 32 | import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' |
| 34 | 33 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 35 | 34 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| ... | ... | @@ -134,11 +133,6 @@ const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ |
| 134 | 133 | path: '/projects/$projectKey', |
| 135 | 134 | getParentRoute: () => rootRouteImport, |
| 136 | 135 | } as any) |
| 137 | const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ | |
| 138 | id: '/connect_/callback', | |
| 139 | path: '/connect/callback', | |
| 140 | getParentRoute: () => rootRouteImport, | |
| 141 | } as any) | |
| 142 | 136 | const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ |
| 143 | 137 | id: '/pull-requests', |
| 144 | 138 | path: '/pull-requests', |
| ... | ... | @@ -164,7 +158,6 @@ export interface FileRoutesByFullPath { |
| 164 | 158 | '/usage': typeof UsageRoute |
| 165 | 159 | '/welcome': typeof WelcomeRoute |
| 166 | 160 | '/pull-requests': typeof ChatPullRequestsRoute |
| 167 | '/connect/callback': typeof ConnectCallbackRoute | |
| 168 | 161 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| 169 | 162 | '/settings/appearance': typeof SettingsAppearanceRoute |
| 170 | 163 | '/settings/archived': typeof SettingsArchivedRoute |
| ... | ... | @@ -188,7 +181,6 @@ export interface FileRoutesByTo { |
| 188 | 181 | '/usage': typeof UsageRoute |
| 189 | 182 | '/welcome': typeof WelcomeRoute |
| 190 | 183 | '/pull-requests': typeof ChatPullRequestsRoute |
| 191 | '/connect/callback': typeof ConnectCallbackRoute | |
| 192 | 184 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| 193 | 185 | '/settings/appearance': typeof SettingsAppearanceRoute |
| 194 | 186 | '/settings/archived': typeof SettingsArchivedRoute |
| ... | ... | @@ -215,7 +207,6 @@ export interface FileRoutesById { |
| 215 | 207 | '/usage': typeof UsageRoute |
| 216 | 208 | '/welcome': typeof WelcomeRoute |
| 217 | 209 | '/_chat/pull-requests': typeof ChatPullRequestsRoute |
| 218 | '/connect_/callback': typeof ConnectCallbackRoute | |
| 219 | 210 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| 220 | 211 | '/settings/appearance': typeof SettingsAppearanceRoute |
| 221 | 212 | '/settings/archived': typeof SettingsArchivedRoute |
| ... | ... | @@ -243,7 +234,6 @@ export interface FileRouteTypes { |
| 243 | 234 | | '/usage' |
| 244 | 235 | | '/welcome' |
| 245 | 236 | | '/pull-requests' |
| 246 | | '/connect/callback' | |
| 247 | 237 | | '/projects/$projectKey' |
| 248 | 238 | | '/settings/appearance' |
| 249 | 239 | | '/settings/archived' |
| ... | ... | @@ -267,7 +257,6 @@ export interface FileRouteTypes { |
| 267 | 257 | | '/usage' |
| 268 | 258 | | '/welcome' |
| 269 | 259 | | '/pull-requests' |
| 270 | | '/connect/callback' | |
| 271 | 260 | | '/projects/$projectKey' |
| 272 | 261 | | '/settings/appearance' |
| 273 | 262 | | '/settings/archived' |
| ... | ... | @@ -293,7 +282,6 @@ export interface FileRouteTypes { |
| 293 | 282 | | '/usage' |
| 294 | 283 | | '/welcome' |
| 295 | 284 | | '/_chat/pull-requests' |
| 296 | | '/connect_/callback' | |
| 297 | 285 | | '/projects/$projectKey' |
| 298 | 286 | | '/settings/appearance' |
| 299 | 287 | | '/settings/archived' |
| ... | ... | @@ -319,7 +307,6 @@ export interface RootRouteChildren { |
| 319 | 307 | SettingsRoute: typeof SettingsRouteWithChildren |
| 320 | 308 | UsageRoute: typeof UsageRoute |
| 321 | 309 | WelcomeRoute: typeof WelcomeRoute |
| 322 | ConnectCallbackRoute: typeof ConnectCallbackRoute | |
| 323 | 310 | ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute |
| 324 | 311 | } |
| 325 | 312 | |
| ... | ... | @@ -465,13 +452,6 @@ declare module '@tanstack/react-router' { |
| 465 | 452 | preLoaderRoute: typeof ProjectsProjectKeyRouteImport |
| 466 | 453 | 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 | 455 | '/_chat/pull-requests': { |
| 476 | 456 | id: '/_chat/pull-requests' |
| 477 | 457 | path: '/pull-requests' |
| ... | ... | @@ -553,7 +533,6 @@ const rootRouteChildren: RootRouteChildren = { |
| 553 | 533 | SettingsRoute: SettingsRouteWithChildren, |
| 554 | 534 | UsageRoute: UsageRoute, |
| 555 | 535 | WelcomeRoute: WelcomeRoute, |
| 556 | ConnectCallbackRoute: ConnectCallbackRoute, | |
| 557 | 536 | ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, |
| 558 | 537 | } |
| 559 | 538 | export const routeTree = rootRouteImport |
apps/web/src/routes/__root.tsx+1-1| ... | ... | @@ -151,7 +151,7 @@ function RootRouteView() { |
| 151 | 151 | }; |
| 152 | 152 | }, [pathname]); |
| 153 | 153 | |
| 154 | if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) { | |
| 154 | if (pathname === "/pair" || pathname === "/connect") { | |
| 155 | 155 | return ( |
| 156 | 156 | <> |
| 157 | 157 | <DocumentTitleSync /> |
apps/web/src/routes/connect_.callback.tsx deleted-13| ... | ... | @@ -1,13 +0,0 @@ |
| 1 | import { createFileRoute, redirect } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; | |
| 4 | import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface"; | |
| 5 | ||
| 6 | export 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 | 72 | OAuth tokens; requiring a JWT template for the CLI would reject valid logins. |
| 73 | 73 | The CLI is a public OAuth client using PKCE and stores no client secret. |
| 74 | 74 | |
| 75 | CLI authorization starts on the hosted `/connect` page so sign-in completes | |
| 76 | before entering Clerk's authorize endpoint. Sending a signed-out browser | |
| 77 | straight to that endpoint loses the authorize parameters during the sign-in | |
| 78 | redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves | |
| 79 | PKCE and state for both loopback and pasted-code callbacks. SSH and headless | |
| 80 | sessions use the pasted-code flow because the browser cannot ordinarily reach a | |
| 81 | listener on the remote machine. | |
| 75 | Loopback CLI authorization starts on the hosted `/connect` page so sign-in | |
| 76 | completes before entering Clerk's authorize endpoint. Sending a signed-out | |
| 77 | browser straight to that endpoint loses the authorize parameters during the | |
| 78 | sign-in redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) | |
| 79 | preserves PKCE and state for the loopback callback. | |
| 80 | ||
| 81 | SSH and headless sessions use Clerk's OAuth device authorization grant because | |
| 82 | the browser cannot ordinarily reach a listener on the remote machine. The CLI | |
| 83 | polls Clerk's token endpoint directly while the user approves a short code on | |
| 84 | Clerk's hosted device page; the hosted app plays no part and there is no | |
| 85 | redirect URI or PKCE. The grant must be enabled on the CLI OAuth application | |
| 86 | or 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 | 39 | In Clerk's OAuth applications settings: |
| 40 | 40 | |
| 41 | 41 | 1. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE. |
| 42 | 2. 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. | |
| 45 | 3. Enable the `openid`, `profile`, and `email` scopes. | |
| 46 | 4. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release | |
| 42 | 2. Allow the redirect URI `http://127.0.0.1:34338/callback`. | |
| 43 | 3. Enable the `openid`, `profile`, `email`, and `offline_access` scopes. | |
| 44 | 4. 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. | |
| 47 | 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release | |
| 47 | 48 | build environments. |
| 48 | 49 | |
| 49 | 50 | ## 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 | 21 | reachable. |
| 22 | 22 | |
| 23 | 23 | On your other device, sign in to the same T3 Connect account and choose the |
| 24 | environment. Over SSH, the CLI prints a browser link and accepts the returned | |
| 25 | authorization code, so you do not need to forward an OAuth callback port. | |
| 24 | environment. Over SSH, the CLI prints a browser link and a short code. Open the | |
| 25 | link on any device, confirm the code matches, and approve. The CLI continues on | |
| 26 | its own, so you do not need to forward an OAuth callback port. | |
| 26 | 27 | |
| 27 | 28 | T3 Connect renews access credentials when needed without disconnecting a healthy |
| 28 | 29 | connection. 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 | 3 | import { |
| 4 | 4 | buildConnectAuthorizeRequestUrl, |
| 5 | 5 | buildConnectClerkAuthorizeUrl, |
| 6 | connectCallbackUrl, | |
| 7 | 6 | connectLoopbackRedirectUri, |
| 8 | encodeConnectAuthCode, | |
| 9 | parseConnectAuthCode, | |
| 10 | 7 | readConnectAuthorizeRequest, |
| 11 | 8 | } from "./connectAuth.ts"; |
| 12 | 9 | |
| 13 | 10 | describe("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 | 12 | const url = buildConnectAuthorizeRequestUrl({ |
| 16 | 13 | hostedAppUrl: "https://app.t3.codes", |
| 17 | 14 | state: "q7mK9xV2pL4nR8sT6wYzAQ", |
| 18 | 15 | challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", |
| 16 | loopbackPort: 34338, | |
| 19 | 17 | }); |
| 20 | 18 | const parsed = new URL(url); |
| 21 | 19 | |
| ... | ... | @@ -25,33 +23,22 @@ describe("connectAuth", () => { |
| 25 | 23 | expect(readConnectAuthorizeRequest(parsed)).toEqual({ |
| 26 | 24 | state: "q7mK9xV2pL4nR8sT6wYzAQ", |
| 27 | 25 | challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", |
| 26 | loopbackPort: 34338, | |
| 28 | 27 | }); |
| 28 | expect(connectLoopbackRedirectUri(34338)).toBe("http://127.0.0.1:34338/callback"); | |
| 29 | 29 | }); |
| 30 | 30 | |
| 31 | it("rejects authorize requests missing state or challenge", () => { | |
| 31 | it("rejects authorize requests missing state, challenge, or port", () => { | |
| 32 | 32 | expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull(); |
| 33 | 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 | 35 | ).toBeNull(); |
| 36 | 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 | 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 | }); |
| 56 | 43 | |
| 57 | 44 | it("rejects authorize requests whose loopback port is corrupted", () => { |
| ... | ... | @@ -68,8 +55,8 @@ describe("connectAuth", () => { |
| 68 | 55 | buildConnectClerkAuthorizeUrl({ |
| 69 | 56 | authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize", |
| 70 | 57 | 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"], | |
| 73 | 60 | state: "state-1", |
| 74 | 61 | challenge: "challenge-1", |
| 75 | 62 | }), |
| ... | ... | @@ -78,27 +65,11 @@ describe("connectAuth", () => { |
| 78 | 65 | expect(url.origin).toBe("https://clerk.t3.codes"); |
| 79 | 66 | expect(url.pathname).toBe("/oauth/authorize"); |
| 80 | 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 | 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 | 71 | expect(url.searchParams.get("state")).toBe("state-1"); |
| 85 | 72 | expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); |
| 86 | 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 | 3 | const CONNECT_AUTH_STATE_PARAM = "state"; |
| 4 | 4 | const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; |
| 5 | 5 | const CONNECT_AUTH_PORT_PARAM = "port"; |
| 6 | const CONNECT_AUTH_CODE_SEPARATOR = "."; | |
| 7 | 6 | const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; |
| 8 | 7 | |
| 9 | 8 | const CONNECT_AUTHORIZE_PATH = "/connect"; |
| 10 | const CONNECT_CALLBACK_PATH = "/connect/callback"; | |
| 11 | 9 | |
| 12 | 10 | /** |
| 13 | 11 | * 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 | 15 | export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; |
| 18 | 16 | |
| 19 | 17 | /** |
| 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. | |
| 22 | 21 | */ |
| 23 | export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; | |
| 22 | export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const; | |
| 24 | 23 | |
| 25 | 24 | export interface ConnectAuthorizeRequest { |
| 26 | 25 | readonly state: string; |
| 27 | 26 | readonly challenge: string; |
| 28 | 27 | /** |
| 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. | |
| 32 | 30 | */ |
| 33 | readonly loopbackPort?: number; | |
| 31 | readonly loopbackPort: number; | |
| 34 | 32 | } |
| 35 | 33 | |
| 36 | 34 | /** |
| ... | ... | @@ -38,26 +36,25 @@ export interface ConnectAuthorizeRequest { |
| 38 | 36 | * `code_challenge` ride the fragment so they never reach the hosted app's |
| 39 | 37 | * server or CDN logs; neither is a secret. |
| 40 | 38 | * |
| 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 | |
| 42 | 40 | * Clerk's /oauth/authorize directly: a signed-out browser sent straight to |
| 43 | 41 | * /oauth/authorize goes through Clerk's sign-in redirect, which does not |
| 44 | 42 | * reliably preserve the authorize query parameters (state, response_type, |
| 45 | 43 | * 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 | */ |
| 48 | 47 | export function buildConnectAuthorizeRequestUrl(input: { |
| 49 | 48 | readonly hostedAppUrl: string; |
| 50 | 49 | readonly state: string; |
| 51 | 50 | readonly challenge: string; |
| 52 | readonly loopbackPort?: number; | |
| 51 | readonly loopbackPort: number; | |
| 53 | 52 | }): string { |
| 54 | 53 | const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); |
| 55 | 54 | url.hash = new URLSearchParams([ |
| 56 | 55 | [CONNECT_AUTH_STATE_PARAM, input.state], |
| 57 | 56 | [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)], | |
| 61 | 58 | ]).toString(); |
| 62 | 59 | return url.toString(); |
| 63 | 60 | } |
| ... | ... | @@ -66,18 +63,8 @@ export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | |
| 66 | 63 | const params = readHashParams(url); |
| 67 | 64 | const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? ""; |
| 68 | 65 | 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) { | |
| 81 | 68 | return null; |
| 82 | 69 | } |
| 83 | 70 | return { state, challenge, loopbackPort }; |
| ... | ... | @@ -99,10 +86,6 @@ export function connectLoopbackRedirectUri(port: number): string { |
| 99 | 86 | return `http://127.0.0.1:${port}${CONNECT_LOOPBACK_CALLBACK_PATH}`; |
| 100 | 87 | } |
| 101 | 88 | |
| 102 | export function connectCallbackUrl(hostedAppUrl: string): string { | |
| 103 | return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString(); | |
| 104 | } | |
| 105 | ||
| 106 | 89 | export function buildConnectClerkAuthorizeUrl(input: { |
| 107 | 90 | readonly authorizationEndpoint: string; |
| 108 | 91 | readonly clientId: string; |
| ... | ... | @@ -121,50 +104,3 @@ export function buildConnectClerkAuthorizeUrl(input: { |
| 121 | 104 | url.searchParams.set("code_challenge_method", "S256"); |
| 122 | 105 | return url.toString(); |
| 123 | 106 | } |
| 124 | ||
| 125 | export 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 | */ | |
| 137 | export 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 | */ | |
| 147 | export 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 | ||
| 161 | export 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 | } |