| 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,7 +20,6 @@ import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; | 20 | import * as Option from "effect/Option"; |
| 21 | import * as References from "effect/References"; | 21 | import * as References from "effect/References"; |
| 22 | import * as Schema from "effect/Schema"; | 22 | import * as Schema from "effect/Schema"; |
| 23 | import * as Terminal from "effect/Terminal"; | ||
| 24 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; | 23 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 25 | import { | 24 | import { |
| 26 | FetchHttpClient, | 25 | FetchHttpClient, |
| ... | @@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe( | ... | @@ -64,13 +63,14 @@ const jsonFlag = Flag.boolean("json").pipe( |
| 64 | const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); | 63 | const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); |
| 65 | 64 | ||
| 66 | const headlessFlag = Flag.boolean("headless").pipe( | 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 | Flag.withDefault(false), | 67 | Flag.withDefault(false), |
| 69 | ); | 68 | ); |
| 70 | 69 | ||
| 71 | /** | 70 | /** |
| 72 | * Inside an SSH session there is no local browser to complete the loopback | 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 | export const headlessSessionConfig = Config.all({ | 75 | export 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 | ); |
| 81 | 81 | ||
| 82 | const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")( | 82 | const 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 | ); | ||
| 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 | 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 | } |
| 98 | 99 | ||
| ... | @@ -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) must | 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 | 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 CLI | 129 | isCloudCliTokenManagerError(cause) |
| 129 | // cancels quietly instead of dumping an authorization error. | ||
| 130 | Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause) | ||
| 131 | ? cause | 130 | ? 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 @@ |
| 1 | import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Duration from "effect/Duration"; | ||
| 7 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 8 | import * as Encoding from "effect/Encoding"; | 7 | import * as Encoding from "effect/Encoding"; |
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 11 | import * as Queue from "effect/Queue"; | 11 | import * as Queue from "effect/Queue"; |
| 12 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as Terminal from "effect/Terminal"; | 13 | import * as Terminal from "effect/Terminal"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; | ||
| 14 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 15 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 15 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | 16 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| 16 | 17 | ||
| 17 | import * as CliTokenManager from "./CliTokenManager.ts"; | 18 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 18 | import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts"; | ||
| 19 | 19 | ||
| 20 | // pk_test_<base64 of "clerk.example.test$"> | 20 | // pk_test_<base64 of "clerk.example.test$"> |
| 21 | const TEST_ENV = { | 21 | const TEST_ENV = { |
| ... | @@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString( | ... | @@ -54,44 +54,12 @@ const TestTokenResponseJson = Schema.fromJsonString( |
| 54 | ); | 54 | ); |
| 55 | const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson); | 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 | const provideTestEnv = Effect.provide( | 57 | const provideTestEnv = Effect.provide( |
| 86 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), | 58 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), |
| 87 | ); | 59 | ); |
| 88 | 60 | ||
| 89 | const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); | 61 | const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); |
| 90 | 62 | ||
| 91 | class PromptRejectedError extends Schema.TaggedError<PromptRejectedError>()("PromptRejectedError", { | ||
| 92 | message: Schema.String, | ||
| 93 | }) {} | ||
| 94 | |||
| 95 | const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) => | 63 | const 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 | ); |
| 146 | 114 | ||
| 147 | it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => { | 115 | interface 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 | } |
| 152 | 120 | ||
| 153 | const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin( | 121 | const 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 | |
| 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); | 169 | it.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> = []; | ||
| 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 | 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"); |
| 177 | 199 | ||
| 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 | ); |
| 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 | Effect.gen(function* () { | 243 | Effect.gen(function* () { |
| 199 | const requests: Array<RecordedTokenRequest> = []; | 244 | const server: DeviceFlowServer = { |
| 200 | 245 | 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 | ); |
| 209 | 254 | ||
| 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 | ); |
| 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 | 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: [], |
| 221 | 271 | 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); | ||
| 232 | 303 | ||
| 233 | assert.isNull(identity); | 304 | assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError); |
| 234 | assert.lengthOf(requests, 1); | ||
| 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 | Effect.gen(function* () { | 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(() => | 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); | ||
| 245 | 323 | ||
| 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"; |
| 18 | import * as Semaphore from "effect/Semaphore"; | 18 | import * as Semaphore from "effect/Semaphore"; |
| 19 | import * as Terminal from "effect/Terminal"; | 19 | import * as Terminal from "effect/Terminal"; |
| 20 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 20 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 21 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; | ||
| 21 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; | 22 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; |
| 22 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | 23 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| 23 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | 24 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 24 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 25 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 25 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | 26 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 26 | 27 | ||
| 27 | import { | 28 | import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth"; |
| 28 | buildConnectAuthorizeRequestUrl, | ||
| 29 | checkConnectAuthCode, | ||
| 30 | connectCallbackUrl, | ||
| 31 | } from "@t3tools/shared/connectAuth"; | ||
| 32 | 29 | ||
| 33 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 30 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 34 | import * as ExternalLauncher from "../process/externalLauncher.ts"; | 31 | import * as ExternalLauncher from "../process/externalLauncher.ts"; |
| ... | @@ -42,6 +39,11 @@ import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; | ... | @@ -42,6 +39,11 @@ import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; |
| 42 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; | 39 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; |
| 43 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); | 40 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); |
| 44 | const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); | 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 | const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; | 47 | const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; |
| 46 | 48 | ||
| 47 | function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { | 49 | function 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 | }); |
| 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 | const OidcIdentityClaimsJson = Schema.fromJsonString( | 155 | const 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 | } |
| 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 | export const CloudCliTokenManagerError = Schema.Union([ | 237 | export 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 | ]); |
| 219 | export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; | 245 | export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; |
| 220 | 246 | ||
| ... | @@ -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 | } |
| 243 | 269 | ||
| 244 | const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( | 270 | const 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 | }); |
| 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 | const makePkceRequest = Effect.gen(function* () { | 300 | const 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 | }); |
| 276 | 309 | ||
| 277 | export interface OutOfBandOAuthPromptInput { | 310 | export 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 | } |
| 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 | 321 | * 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 displayed | 322 | * 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 the | 323 | * waiting, while `slow_down` and transient failures widen the interval before |
| 286 | * authorization code is useless to an observer, and the state bundled into | 324 | * 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 | */ |
| 289 | export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* < | 327 | const 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 = { |
| 296 | 334 | 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 | }); |
| 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 | /** @public Service construction is part of the canonical Effect module API. */ | 418 | /** @public Service construction is part of the canonical Effect module API. */ |
| 328 | export const make = Effect.gen(function* () { | 419 | export const make = Effect.gen(function* () { |
| 329 | // Capture exactly the services the login/refresh flows need at build time | 420 | // 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 | 421 | // 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-end | 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 | // 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 |
| 91 | 91 | ||
| 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 goes | 153 | * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes |
| 154 | * through the hosted /connect page, which builds the authorize URL after a | 154 | * through the hosted /connect page, which builds the authorize URL after a |
| 155 | * Clerk session exists (see CliTokenManager.login). Only the token endpoint | 155 | * 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 | */ |
| 158 | export interface CloudCliOAuthConfig { | 159 | export 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"; |
| 9 | 8 | ||
| 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 | }); |
| 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 | 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"); | ||
| 32 | 30 | ||
| 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(); |
| 38 | 37 | ||
| 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 | }); |
| 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 | 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 | }); |
| 72 | 57 | ||
| ... | @@ -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"); |
| 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 | 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 | ); |
| 82 | 68 | ||
| ... | @@ -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); |
| 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 | 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 8 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; | 7 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 9 | 8 | ||
| 10 | import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing"; | 9 | import { isHostedStaticApp } from "../hostedPairing"; |
| 11 | import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; | 10 | import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; |
| 12 | 11 | ||
| 13 | const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; | ||
| 14 | |||
| 15 | function resolveConnectCliOAuthClientId(): string | null { | 12 | function 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 | } |
| 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 | * deployment (the same bundle ships inside local instances) and needs the | 24 | * 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 { |
| 33 | 30 | ||
| 34 | /** | 31 | /** |
| 35 | * Builds the Clerk authorize URL for a CLI-initiated connect request. The | 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 | 33 | * 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 | */ |
| 44 | export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { | 37 | export 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 | |||
| 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,16 +1,12 @@ |
| 1 | import { useAuth, useClerk, useUser } from "@clerk/react"; | 1 | import { useAuth, useClerk } from "@clerk/react"; |
| 2 | import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; | 2 | import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | buildConnectCliClerkAuthorizeUrl, | 6 | buildConnectCliClerkAuthorizeUrl, |
| 7 | connectCliSignInRedirectUrl, | 7 | connectCliSignInRedirectUrl, |
| 8 | readConnectCliAuthState, | ||
| 9 | readConnectCliCallbackResult, | ||
| 10 | rememberConnectCliAuthState, | ||
| 11 | } from "../../cloud/connectCliAuth"; | 8 | } from "../../cloud/connectCliAuth"; |
| 12 | import { isElectron } from "../../env"; | 9 | import { isElectron } from "../../env"; |
| 13 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ||
| 14 | import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; | 10 | import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; |
| 15 | import { resolveClerkSignInProps } from "../clerk/authRedirect"; | 11 | import { resolveClerkSignInProps } from "../clerk/authRedirect"; |
| 16 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| ... | @@ -45,10 +41,10 @@ const invalidLinkMessage = { | ... | @@ -45,10 +41,10 @@ const invalidLinkMessage = { |
| 45 | } as const; | 41 | } as const; |
| 46 | 42 | ||
| 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 a | 45 | * 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 returns | 46 | * 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 | */ |
| 53 | export function ConnectCliAuthorizeSurface() { | 49 | export 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]); |
| 94 | 86 | ||
| ... | @@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() { | ... | @@ -103,11 +95,7 @@ export function ConnectCliAuthorizeSurface() { |
| 103 | return ( | 95 | return ( |
| 104 | <AuthSurfaceShell> | 96 | <AuthSurfaceShell> |
| 105 | <ConnectCliAuthMessage | 97 | <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 | isSignedIn | 101 | 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 | */ | ||
| 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,7 +10,7 @@ export interface HostedPairingRequest { |
| 10 | 10 | ||
| 11 | export type HostedAppChannel = "latest" | "nightly"; | 11 | export type HostedAppChannel = "latest" | "nightly"; |
| 12 | 12 | ||
| 13 | export function configuredHostedAppUrl(): string { | 13 | function 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 | } |
| 16 | 16 |
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 |
| 29 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 29 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 30 | import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' | 30 | import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' |
| 31 | import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' | 31 | import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' |
| 32 | import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' | ||
| 33 | import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' | 32 | import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' |
| 34 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | 33 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 35 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | 34 | import { 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) |
| 137 | const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ | ||
| 138 | id: '/connect_/callback', | ||
| 139 | path: '/connect/callback', | ||
| 140 | getParentRoute: () => rootRouteImport, | ||
| 141 | } as any) | ||
| 142 | const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ | 136 | const 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 UsageRoute | 158 | '/usage': typeof UsageRoute |
| 165 | '/welcome': typeof WelcomeRoute | 159 | '/welcome': typeof WelcomeRoute |
| 166 | '/pull-requests': typeof ChatPullRequestsRoute | 160 | '/pull-requests': typeof ChatPullRequestsRoute |
| 167 | '/connect/callback': typeof ConnectCallbackRoute | ||
| 168 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute | 161 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| 169 | '/settings/appearance': typeof SettingsAppearanceRoute | 162 | '/settings/appearance': typeof SettingsAppearanceRoute |
| 170 | '/settings/archived': typeof SettingsArchivedRoute | 163 | '/settings/archived': typeof SettingsArchivedRoute |
| ... | @@ -188,7 +181,6 @@ export interface FileRoutesByTo { | ... | @@ -188,7 +181,6 @@ export interface FileRoutesByTo { |
| 188 | '/usage': typeof UsageRoute | 181 | '/usage': typeof UsageRoute |
| 189 | '/welcome': typeof WelcomeRoute | 182 | '/welcome': typeof WelcomeRoute |
| 190 | '/pull-requests': typeof ChatPullRequestsRoute | 183 | '/pull-requests': typeof ChatPullRequestsRoute |
| 191 | '/connect/callback': typeof ConnectCallbackRoute | ||
| 192 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute | 184 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| 193 | '/settings/appearance': typeof SettingsAppearanceRoute | 185 | '/settings/appearance': typeof SettingsAppearanceRoute |
| 194 | '/settings/archived': typeof SettingsArchivedRoute | 186 | '/settings/archived': typeof SettingsArchivedRoute |
| ... | @@ -215,7 +207,6 @@ export interface FileRoutesById { | ... | @@ -215,7 +207,6 @@ export interface FileRoutesById { |
| 215 | '/usage': typeof UsageRoute | 207 | '/usage': typeof UsageRoute |
| 216 | '/welcome': typeof WelcomeRoute | 208 | '/welcome': typeof WelcomeRoute |
| 217 | '/_chat/pull-requests': typeof ChatPullRequestsRoute | 209 | '/_chat/pull-requests': typeof ChatPullRequestsRoute |
| 218 | '/connect_/callback': typeof ConnectCallbackRoute | ||
| 219 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute | 210 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| 220 | '/settings/appearance': typeof SettingsAppearanceRoute | 211 | '/settings/appearance': typeof SettingsAppearanceRoute |
| 221 | '/settings/archived': typeof SettingsArchivedRoute | 212 | '/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 SettingsRouteWithChildren | 307 | SettingsRoute: typeof SettingsRouteWithChildren |
| 320 | UsageRoute: typeof UsageRoute | 308 | UsageRoute: typeof UsageRoute |
| 321 | WelcomeRoute: typeof WelcomeRoute | 309 | WelcomeRoute: typeof WelcomeRoute |
| 322 | ConnectCallbackRoute: typeof ConnectCallbackRoute | ||
| 323 | ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute | 310 | ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute |
| 324 | } | 311 | } |
| 325 | 312 | ||
| ... | @@ -465,13 +452,6 @@ declare module '@tanstack/react-router' { | ... | @@ -465,13 +452,6 @@ declare module '@tanstack/react-router' { |
| 465 | preLoaderRoute: typeof ProjectsProjectKeyRouteImport | 452 | preLoaderRoute: typeof ProjectsProjectKeyRouteImport |
| 466 | parentRoute: typeof rootRouteImport | 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 | '/_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 | } |
| 559 | export const routeTree = rootRouteImport | 538 | export 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]); |
| 153 | 153 | ||
| 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 @@ | ||
| 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,10 +72,15 @@ different credentials. The relay accepts both session-template JWTs and CLI |
| 72 | OAuth tokens; requiring a JWT template for the CLI would reject valid logins. | 72 | OAuth tokens; requiring a JWT template for the CLI would reject valid logins. |
| 73 | The CLI is a public OAuth client using PKCE and stores no client secret. | 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 | 75 | Loopback CLI authorization starts on the hosted `/connect` page so sign-in |
| 76 | before entering Clerk's authorize endpoint. Sending a signed-out browser | 76 | completes before entering Clerk's authorize endpoint. Sending a signed-out |
| 77 | straight to that endpoint loses the authorize parameters during the sign-in | 77 | browser straight to that endpoint loses the authorize parameters during the |
| 78 | redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves | 78 | sign-in redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) |
| 79 | PKCE and state for both loopback and pasted-code callbacks. SSH and headless | 79 | preserves PKCE and state for the loopback callback. |
| 80 | sessions use the pasted-code flow because the browser cannot ordinarily reach a | 80 | |
| 81 | listener on the remote machine. | 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,11 +39,12 @@ depend on. The deploy wrapper writes the resulting relay URL back to the root `. |
| 39 | In Clerk's OAuth applications settings: | 39 | In Clerk's OAuth applications settings: |
| 40 | 40 | ||
| 41 | 1. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE. | 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 | 42 | 2. 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 own | 43 | 3. Enable the `openid`, `profile`, `email`, and `offline_access` scopes. |
| 44 | `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect. | 44 | 4. Enable **Device authorization grant** on the application. Headless and SSH authorization use |
| 45 | 3. 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 |
| 46 | 4. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release | 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 | build environments. | 48 | build environments. |
| 48 | 49 | ||
| 49 | ## JWT template | 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,8 +21,9 @@ server with `npx t3 serve`. Saving your sign-in alone does not make the machine |
| 21 | reachable. | 21 | reachable. |
| 22 | 22 | ||
| 23 | On your other device, sign in to the same T3 Connect account and choose the | 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 | 24 | environment. Over SSH, the CLI prints a browser link and a short code. Open the |
| 25 | authorization code, so you do not need to forward an OAuth callback port. | 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 | T3 Connect renews access credentials when needed without disconnecting a healthy | 28 | T3 Connect renews access credentials when needed without disconnecting a healthy |
| 28 | connection. Pull request diffs and provider settings keep working after the | 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,19 +3,17 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 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"; |
| 12 | 9 | ||
| 13 | describe("connectAuth", () => { | 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 | 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); |
| 21 | 19 | ||
| ... | @@ -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 | }); |
| 30 | 30 | ||
| 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 | }); |
| 56 | 43 | ||
| 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"; |
| 3 | const CONNECT_AUTH_STATE_PARAM = "state"; | 3 | const CONNECT_AUTH_STATE_PARAM = "state"; |
| 4 | const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; | 4 | const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; |
| 5 | const CONNECT_AUTH_PORT_PARAM = "port"; | 5 | const CONNECT_AUTH_PORT_PARAM = "port"; |
| 6 | const CONNECT_AUTH_CODE_SEPARATOR = "."; | ||
| 7 | const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; | 6 | const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; |
| 8 | 7 | ||
| 9 | const CONNECT_AUTHORIZE_PATH = "/connect"; | 8 | const CONNECT_AUTHORIZE_PATH = "/connect"; |
| 10 | const CONNECT_CALLBACK_PATH = "/connect/callback"; | ||
| 11 | 9 | ||
| 12 | /** | 10 | /** |
| 13 | * The CLI prints URLs against this origin and the web bundle uses it to | 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,20 +15,20 @@ const CONNECT_CALLBACK_PATH = "/connect/callback"; |
| 17 | export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; | 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 | 18 | * 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 | */ |
| 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 | export interface ConnectAuthorizeRequest { | 24 | export 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 /connect | 28 | * The hosted /connect page asks Clerk to redirect the authorization code |
| 30 | * page then asks Clerk to redirect the authorization code straight to | 29 | * 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 | } |
| 35 | 33 | ||
| 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's | 36 | * `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 hitting | 39 | * The CLI routes through the hosted /connect page rather than hitting |
| 42 | * Clerk's /oauth/authorize directly: a signed-out browser sent straight to | 40 | * Clerk's /oauth/authorize directly: a signed-out browser sent straight to |
| 43 | * /oauth/authorize goes through Clerk's sign-in redirect, which does not | 41 | * /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, then | 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 | export function buildConnectAuthorizeRequestUrl(input: { | 47 | export 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 === undefined | 57 | [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 | } |
| 101 | 88 | ||
| 102 | export function connectCallbackUrl(hostedAppUrl: string): string { | ||
| 103 | return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString(); | ||
| 104 | } | ||
| 105 | |||
| 106 | export function buildConnectClerkAuthorizeUrl(input: { | 89 | export 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 | |||
| 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 | } |