| 1 | // @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary. |
| 2 | import * as NodeHttp from "node:http"; |
| 3 | |
| 4 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 5 | import * as Clock from "effect/Clock"; |
| 6 | import * as Cause from "effect/Cause"; |
| 7 | import * as Console from "effect/Console"; |
| 8 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Base64Url from "effect/encoding/Base64Url"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; |
| 16 | import * as Queue from "effect/Queue"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Semaphore from "effect/Semaphore"; |
| 19 | import * as Terminal from "effect/Terminal"; |
| 20 | import * as HttpClient from "effect/http/HttpClient"; |
| 21 | import * as HttpClientError from "effect/http/HttpClientError"; |
| 22 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 23 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 24 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 25 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 26 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 27 | |
| 28 | import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth"; |
| 29 | |
| 30 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 31 | import * as ExternalLauncher from "../process/externalLauncher.ts"; |
| 32 | import { |
| 33 | cloudCliOAuthConfig, |
| 34 | hostedAppUrlConfig, |
| 35 | type CloudCliOAuthConfig, |
| 36 | } from "./publicConfig.ts"; |
| 37 | import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; |
| 38 | |
| 39 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; |
| 40 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); |
| 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); |
| 47 | // Bounds one token or device-authorization request, including reading the |
| 48 | // body. A stalled refresh holds the manager's semaphore and blocks every |
| 49 | // other credential call. |
| 50 | const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10); |
| 51 | const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; |
| 52 | |
| 53 | function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { |
| 54 | return [ |
| 55 | "Open this URL to authorize T3 Connect:", |
| 56 | ` ${authorizationUrl}`, |
| 57 | "", |
| 58 | `Press ${boldTerminalText("Enter")} to open it in your browser.`, |
| 59 | `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`, |
| 60 | ].join("\n"); |
| 61 | } |
| 62 | |
| 63 | export type LoopbackAuthorizationResult = |
| 64 | | { readonly _tag: "AuthorizationCode"; readonly code: string } |
| 65 | | { readonly _tag: "HeadlessRequested" }; |
| 66 | |
| 67 | const readLoopbackAuthorizationAction = Effect.fn( |
| 68 | "cloud.cli_token.read_loopback_authorization_action", |
| 69 | )(function* (input: Queue.Dequeue<Terminal.UserInput, Cause.Done>) { |
| 70 | while (true) { |
| 71 | const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({}))); |
| 72 | const keyName = event.key.name.toLowerCase(); |
| 73 | if (!event.key.ctrl && !event.key.meta && keyName === "h") { |
| 74 | return "headless" as const; |
| 75 | } |
| 76 | if (keyName === "enter" || keyName === "return") { |
| 77 | return "open-browser" as const; |
| 78 | } |
| 79 | } |
| 80 | }); |
| 81 | |
| 82 | export const waitForLoopbackAuthorization = Effect.fn( |
| 83 | "cloud.cli_token.wait_for_loopback_authorization", |
| 84 | )(function* <E, R>(input: { |
| 85 | readonly authorizationUrl: string; |
| 86 | readonly callback: Effect.Effect<string, E, R>; |
| 87 | readonly terminal: Terminal.Terminal; |
| 88 | readonly launchBrowser: ( |
| 89 | url: string, |
| 90 | ) => Effect.Effect<void, ExternalLauncher.ExternalLauncherError>; |
| 91 | }) { |
| 92 | return yield* Effect.scoped( |
| 93 | Effect.gen(function* () { |
| 94 | const terminalInput = yield* input.terminal.readInput; |
| 95 | while (true) { |
| 96 | const result = yield* Effect.raceFirst( |
| 97 | input.callback.pipe( |
| 98 | Effect.map((code): LoopbackAuthorizationResult => ({ |
| 99 | _tag: "AuthorizationCode", |
| 100 | code, |
| 101 | })), |
| 102 | ), |
| 103 | readLoopbackAuthorizationAction(terminalInput), |
| 104 | ); |
| 105 | if (typeof result !== "string") { |
| 106 | return result; |
| 107 | } |
| 108 | if (result === "headless") { |
| 109 | return { _tag: "HeadlessRequested" } as const; |
| 110 | } |
| 111 | yield* input |
| 112 | .launchBrowser(input.authorizationUrl) |
| 113 | .pipe( |
| 114 | Effect.catch(() => |
| 115 | Console.warn( |
| 116 | `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`, |
| 117 | ), |
| 118 | ), |
| 119 | ); |
| 120 | } |
| 121 | }), |
| 122 | ); |
| 123 | }); |
| 124 | |
| 125 | const PersistedToken = Schema.Struct({ |
| 126 | accessToken: Schema.String, |
| 127 | refreshToken: Schema.String, |
| 128 | expiresAtEpochMs: Schema.Number, |
| 129 | identity: Schema.optional(Schema.String), |
| 130 | }); |
| 131 | export type PersistedToken = typeof PersistedToken.Type; |
| 132 | |
| 133 | const PersistedTokenJson = Schema.fromJsonString(PersistedToken); |
| 134 | const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); |
| 135 | const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); |
| 136 | |
| 137 | const OAuthTokenResponse = Schema.Struct({ |
| 138 | access_token: Schema.String, |
| 139 | refresh_token: Schema.optional(Schema.String), |
| 140 | id_token: Schema.optional(Schema.String), |
| 141 | expires_in: Schema.Number, |
| 142 | token_type: Schema.String, |
| 143 | }); |
| 144 | |
| 145 | const OAuthErrorResponse = Schema.Struct({ |
| 146 | error: Schema.String, |
| 147 | error_description: Schema.optional(Schema.String), |
| 148 | }); |
| 149 | |
| 150 | const DeviceAuthorizationResponse = Schema.Struct({ |
| 151 | device_code: Schema.String, |
| 152 | user_code: Schema.String, |
| 153 | verification_uri: Schema.String, |
| 154 | verification_uri_complete: Schema.optional(Schema.String), |
| 155 | expires_in: Schema.Number, |
| 156 | interval: Schema.optional(Schema.Number), |
| 157 | }); |
| 158 | |
| 159 | const OidcIdentityClaimsJson = Schema.fromJsonString( |
| 160 | Schema.Struct({ |
| 161 | email: Schema.optional(Schema.String), |
| 162 | preferred_username: Schema.optional(Schema.String), |
| 163 | sub: Schema.optional(Schema.String), |
| 164 | }), |
| 165 | ); |
| 166 | const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson); |
| 167 | |
| 168 | /** |
| 169 | * Best-effort read of the `email` (or fallback) claim from an OIDC id_token. |
| 170 | * Only used to show the operator which account they linked, so a malformed |
| 171 | * token degrades to "no identity" rather than an error. |
| 172 | */ |
| 173 | function idTokenIdentity(idToken: string | undefined): string | null { |
| 174 | if (!idToken) return null; |
| 175 | const payload = idToken.split(".")[1]; |
| 176 | if (!payload) return null; |
| 177 | const decoded = Base64Url.decodeString(payload); |
| 178 | if (decoded._tag !== "Success") return null; |
| 179 | const claims = decodeOidcIdentityClaimsJson(decoded.success); |
| 180 | if (Option.isNone(claims)) return null; |
| 181 | for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) { |
| 182 | if (typeof value === "string" && value.length > 0) return value; |
| 183 | } |
| 184 | return null; |
| 185 | } |
| 186 | |
| 187 | export class CloudCliCredentialRemovalError extends Schema.TaggedError<CloudCliCredentialRemovalError>()( |
| 188 | "CloudCliCredentialRemovalError", |
| 189 | { cause: Schema.Defect() }, |
| 190 | ) { |
| 191 | override get message(): string { |
| 192 | return "Could not remove the stored T3 Connect CLI credential."; |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | export class CloudCliCredentialRefreshError extends Schema.TaggedError<CloudCliCredentialRefreshError>()( |
| 197 | "CloudCliCredentialRefreshError", |
| 198 | { cause: Schema.Defect() }, |
| 199 | ) { |
| 200 | override get message(): string { |
| 201 | return "Could not refresh the T3 Connect CLI credential."; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | export class CloudCliCredentialReadError extends Schema.TaggedError<CloudCliCredentialReadError>()( |
| 206 | "CloudCliCredentialReadError", |
| 207 | { cause: Schema.Defect() }, |
| 208 | ) { |
| 209 | override get message(): string { |
| 210 | return "Could not read the stored T3 Connect CLI credential."; |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | export class CloudCliAuthorizationError extends Schema.TaggedError<CloudCliAuthorizationError>()( |
| 215 | "CloudCliAuthorizationError", |
| 216 | { cause: Schema.Defect() }, |
| 217 | ) { |
| 218 | override get message(): string { |
| 219 | return "Could not authorize the T3 Connect CLI."; |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudCliAuthorizationTimeoutError>()( |
| 224 | "CloudCliAuthorizationTimeoutError", |
| 225 | { cause: Schema.Defect() }, |
| 226 | ) { |
| 227 | override get message(): string { |
| 228 | return "Timed out waiting for T3 Connect authorization."; |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | export class CloudCliAuthorizationDeniedError extends Schema.TaggedError<CloudCliAuthorizationDeniedError>()( |
| 233 | "CloudCliAuthorizationDeniedError", |
| 234 | {}, |
| 235 | ) { |
| 236 | override get message(): string { |
| 237 | return "T3 Connect authorization was denied in the browser."; |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | export const CloudCliTokenManagerError = Schema.Union([ |
| 242 | CloudCliCredentialRemovalError, |
| 243 | CloudCliCredentialRefreshError, |
| 244 | CloudCliCredentialReadError, |
| 245 | CloudCliAuthorizationError, |
| 246 | CloudCliAuthorizationTimeoutError, |
| 247 | CloudCliAuthorizationDeniedError, |
| 248 | ]); |
| 249 | export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; |
| 250 | |
| 251 | export class CloudCliTokenManager extends Context.Service< |
| 252 | CloudCliTokenManager, |
| 253 | { |
| 254 | readonly get: Effect.Effect< |
| 255 | | { readonly _tag: "Authorized"; readonly token: PersistedToken } |
| 256 | | { readonly _tag: "HeadlessRequested" }, |
| 257 | CloudCliTokenManagerError | Terminal.QuitError |
| 258 | >; |
| 259 | readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>; |
| 260 | readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>; |
| 261 | readonly store: (token: PersistedToken) => Effect.Effect<void, CloudCliTokenManagerError>; |
| 262 | readonly clear: Effect.Effect<void, CloudCliTokenManagerError>; |
| 263 | } |
| 264 | >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} |
| 265 | |
| 266 | function stringToBytes(value: string): Uint8Array { |
| 267 | return new TextEncoder().encode(value); |
| 268 | } |
| 269 | |
| 270 | function bytesToString(value: Uint8Array): string { |
| 271 | return new TextDecoder().decode(value); |
| 272 | } |
| 273 | |
| 274 | const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* ( |
| 275 | response: HttpClientResponse.HttpClientResponse, |
| 276 | params: Record<string, string>, |
| 277 | ) { |
| 278 | const body = yield* HttpClientResponse.schemaBodyJson(OAuthTokenResponse)(response); |
| 279 | const now = yield* Clock.currentTimeMillis; |
| 280 | const identity = idTokenIdentity(body.id_token); |
| 281 | return { |
| 282 | token: { |
| 283 | accessToken: body.access_token, |
| 284 | refreshToken: body.refresh_token ?? params.refresh_token ?? "", |
| 285 | expiresAtEpochMs: now + body.expires_in * 1_000, |
| 286 | ...(identity === null ? {} : { identity }), |
| 287 | } satisfies PersistedToken, |
| 288 | identity, |
| 289 | }; |
| 290 | }); |
| 291 | |
| 292 | const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( |
| 293 | metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">, |
| 294 | params: Record<string, string>, |
| 295 | ) { |
| 296 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); |
| 297 | return yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( |
| 298 | HttpClientRequest.bodyUrlParams(params), |
| 299 | httpClient.execute, |
| 300 | Effect.flatMap((response) => readTokenResponse(response, params)), |
| 301 | Effect.timeout(OAUTH_REQUEST_TIMEOUT), |
| 302 | ); |
| 303 | }); |
| 304 | |
| 305 | const makePkceRequest = Effect.gen(function* () { |
| 306 | const crypto = yield* Crypto.Crypto; |
| 307 | const verifier = Base64Url.encode(yield* crypto.randomBytes(32)); |
| 308 | const challenge = Base64Url.encode( |
| 309 | yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), |
| 310 | ); |
| 311 | const state = Base64Url.encode(yield* crypto.randomBytes(16)); |
| 312 | return { verifier, challenge, state }; |
| 313 | }); |
| 314 | |
| 315 | export interface DeviceAuthorizationPrompt { |
| 316 | readonly verificationUri: string; |
| 317 | readonly verificationUriComplete: string | undefined; |
| 318 | readonly userCode: string; |
| 319 | readonly expiresIn: Duration.Duration; |
| 320 | } |
| 321 | |
| 322 | const isTransportError = (error: unknown) => |
| 323 | HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; |
| 324 | |
| 325 | /** |
| 326 | * Polls Clerk's token endpoint until the user approves or denies the device |
| 327 | * request in the browser (RFC 8628 §3.4/3.5). `authorization_pending` keeps |
| 328 | * waiting, while `slow_down` and transient failures widen the interval before |
| 329 | * the next tick; the caller bounds the whole loop with the device code's |
| 330 | * lifetime. |
| 331 | */ |
| 332 | const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* ( |
| 333 | metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint" | "clientId">, |
| 334 | deviceCode: string, |
| 335 | initialInterval: Duration.Duration, |
| 336 | ) { |
| 337 | const httpClient = yield* HttpClient.HttpClient; |
| 338 | const params = { |
| 339 | grant_type: DEVICE_CODE_GRANT_TYPE, |
| 340 | device_code: deviceCode, |
| 341 | client_id: metadata.clientId, |
| 342 | }; |
| 343 | let interval = initialInterval; |
| 344 | while (true) { |
| 345 | yield* Effect.sleep(interval); |
| 346 | const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( |
| 347 | HttpClientRequest.bodyUrlParams(params), |
| 348 | httpClient.execute, |
| 349 | Effect.asSome, |
| 350 | Effect.catchIf(isTransportError, () => Effect.succeedNone), |
| 351 | ); |
| 352 | // Transport failures and upstream 5xx are transient while the device code |
| 353 | // is still valid. RFC 8628 §3.5 asks clients to back off before retrying, |
| 354 | // so widen the interval like slow_down; drain the body so the connection |
| 355 | // returns to the pool for the next poll. |
| 356 | if (Option.isNone(response) || response.value.status >= 500) { |
| 357 | if (Option.isSome(response)) yield* Effect.ignore(response.value.text); |
| 358 | interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT); |
| 359 | continue; |
| 360 | } |
| 361 | if (response.value.status >= 200 && response.value.status < 300) { |
| 362 | return yield* readTokenResponse(response.value, params); |
| 363 | } |
| 364 | const failure = yield* HttpClientResponse.schemaBodyJson(OAuthErrorResponse)(response.value); |
| 365 | switch (failure.error) { |
| 366 | case "authorization_pending": |
| 367 | continue; |
| 368 | case "slow_down": |
| 369 | interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT); |
| 370 | continue; |
| 371 | case "expired_token": |
| 372 | return yield* new CloudCliAuthorizationTimeoutError({ cause: failure }); |
| 373 | case "access_denied": |
| 374 | return yield* new CloudCliAuthorizationDeniedError(); |
| 375 | default: |
| 376 | return yield* new CloudCliAuthorizationError({ |
| 377 | cause: failure.error_description ?? failure.error, |
| 378 | }); |
| 379 | } |
| 380 | } |
| 381 | }); |
| 382 | |
| 383 | /** |
| 384 | * OAuth device authorization grant for machines without a local browser |
| 385 | * (SSH). Clerk issues a short user code; the user approves it on Clerk's |
| 386 | * hosted device page from any browser while this process polls the token |
| 387 | * endpoint. Nothing is typed into the terminal and no redirect URI is |
| 388 | * involved, so the hosted app plays no part in this flow. |
| 389 | */ |
| 390 | export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_authorization_login")( |
| 391 | function* <E, R>(showPrompt: (prompt: DeviceAuthorizationPrompt) => Effect.Effect<void, E, R>) { |
| 392 | const metadata = yield* cloudCliOAuthConfig; |
| 393 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); |
| 394 | const authorization = yield* HttpClientRequest.post(metadata.deviceAuthorizationEndpoint).pipe( |
| 395 | HttpClientRequest.bodyUrlParams({ |
| 396 | client_id: metadata.clientId, |
| 397 | scope: metadata.scopes.join(" "), |
| 398 | }), |
| 399 | httpClient.execute, |
| 400 | Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), |
| 401 | Effect.timeout(OAUTH_REQUEST_TIMEOUT), |
| 402 | ); |
| 403 | // Clerk's advertised lifetime and interval are authoritative. |
| 404 | const expiresIn = Duration.seconds(authorization.expires_in); |
| 405 | const interval = |
| 406 | authorization.interval === undefined |
| 407 | ? DEVICE_AUTHORIZATION_DEFAULT_INTERVAL |
| 408 | : Duration.seconds(authorization.interval); |
| 409 | yield* showPrompt({ |
| 410 | verificationUri: authorization.verification_uri, |
| 411 | verificationUriComplete: authorization.verification_uri_complete, |
| 412 | userCode: authorization.user_code, |
| 413 | expiresIn, |
| 414 | }); |
| 415 | return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe( |
| 416 | Effect.timeout(expiresIn), |
| 417 | Effect.catchTags({ |
| 418 | TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), |
| 419 | }), |
| 420 | ); |
| 421 | }, |
| 422 | ); |
| 423 | |
| 424 | /** @public Service construction is part of the canonical Effect module API. */ |
| 425 | export const make = Effect.gen(function* () { |
| 426 | // Capture exactly the services the login/refresh flows need at build time, |
| 427 | // not the whole ambient context. |
| 428 | const crypto = yield* Crypto.Crypto; |
| 429 | const httpClient = yield* HttpClient.HttpClient; |
| 430 | const services = Context.make(Crypto.Crypto, crypto).pipe( |
| 431 | Context.add(HttpClient.HttpClient, httpClient), |
| 432 | ); |
| 433 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 434 | const terminal = yield* Terminal.Terminal; |
| 435 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 436 | const semaphore = yield* Semaphore.make(1); |
| 437 | const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { |
| 438 | const encoded = yield* encodePersistedToken(token); |
| 439 | yield* secrets.set(CLOUD_CLI_OAUTH_TOKEN_SECRET, stringToBytes(encoded)); |
| 440 | return token; |
| 441 | }); |
| 442 | |
| 443 | const clear = secrets |
| 444 | .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) |
| 445 | .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause }))); |
| 446 | |
| 447 | const read = Effect.fn("cloud.cli_token.read")(function* () { |
| 448 | const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); |
| 449 | if (Option.isNone(encoded)) return Option.none<PersistedToken>(); |
| 450 | return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); |
| 451 | }); |
| 452 | |
| 453 | const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { |
| 454 | const metadata = yield* cloudCliOAuthConfig; |
| 455 | const { token: refreshed } = yield* exchangeToken(metadata, { |
| 456 | grant_type: "refresh_token", |
| 457 | refresh_token: token.refreshToken, |
| 458 | client_id: metadata.clientId, |
| 459 | }); |
| 460 | return refreshed.identity === undefined && token.identity !== undefined |
| 461 | ? { ...refreshed, identity: token.identity } |
| 462 | : refreshed; |
| 463 | }); |
| 464 | |
| 465 | const login = Effect.fn("cloud.cli_token.login")(function* () { |
| 466 | const metadata = yield* cloudCliOAuthConfig; |
| 467 | const hostedAppUrl = yield* hostedAppUrlConfig; |
| 468 | const { verifier, challenge, state } = yield* makePkceRequest; |
| 469 | const callback = yield* Deferred.make<string>(); |
| 470 | const layerCallbackRoute = HttpRouter.add( |
| 471 | "GET", |
| 472 | "/callback", |
| 473 | Effect.gen(function* () { |
| 474 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 475 | const url = new URL(request.originalUrl, metadata.redirectUri); |
| 476 | const code = url.searchParams.get("code"); |
| 477 | if (url.searchParams.get("state") !== state || !code) { |
| 478 | return HttpServerResponse.text("Invalid T3 Connect authorization callback.", { |
| 479 | status: 400, |
| 480 | }); |
| 481 | } |
| 482 | yield* Deferred.succeed(callback, code); |
| 483 | return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml()); |
| 484 | }), |
| 485 | ); |
| 486 | yield* HttpRouter.serve(layerCallbackRoute, { |
| 487 | disableListenLog: true, |
| 488 | disableLogger: true, |
| 489 | }).pipe( |
| 490 | Layer.provide( |
| 491 | NodeHttpServer.layer(NodeHttp.createServer, { |
| 492 | host: "127.0.0.1", |
| 493 | port: metadata.loopbackPort, |
| 494 | disablePreemptiveShutdown: true, |
| 495 | }), |
| 496 | ), |
| 497 | Layer.build, |
| 498 | ); |
| 499 | // The hosted /connect page establishes a Clerk session before forwarding |
| 500 | // the request to /oauth/authorize with the loopback redirect URI. Sending |
| 501 | // a signed-out browser to /oauth/authorize directly loses the authorize |
| 502 | // parameters across Clerk's sign-in redirect (#5051). |
| 503 | const authorizationUrl = buildConnectAuthorizeRequestUrl({ |
| 504 | hostedAppUrl, |
| 505 | state, |
| 506 | challenge, |
| 507 | loopbackPort: metadata.loopbackPort, |
| 508 | }); |
| 509 | yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); |
| 510 | const authorization = yield* waitForLoopbackAuthorization({ |
| 511 | authorizationUrl, |
| 512 | callback: Deferred.await(callback).pipe( |
| 513 | Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), |
| 514 | Effect.catchTags({ |
| 515 | TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), |
| 516 | }), |
| 517 | ), |
| 518 | terminal, |
| 519 | launchBrowser: externalLauncher.launchBrowser, |
| 520 | }); |
| 521 | if (authorization._tag === "HeadlessRequested") { |
| 522 | return authorization; |
| 523 | } |
| 524 | const { token } = yield* exchangeToken(metadata, { |
| 525 | grant_type: "authorization_code", |
| 526 | code: authorization.code, |
| 527 | redirect_uri: metadata.redirectUri, |
| 528 | client_id: metadata.clientId, |
| 529 | code_verifier: verifier, |
| 530 | }); |
| 531 | return { _tag: "Authorized", token } as const; |
| 532 | }); |
| 533 | |
| 534 | const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { |
| 535 | const token = yield* read(); |
| 536 | if (Option.isNone(token)) return token; |
| 537 | const now = yield* Clock.currentTimeMillis; |
| 538 | if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) { |
| 539 | return token; |
| 540 | } |
| 541 | return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist))); |
| 542 | }); |
| 543 | |
| 544 | const getExisting = semaphore.withPermits(1)( |
| 545 | getExistingNoLock().pipe( |
| 546 | Effect.mapError((cause) => new CloudCliCredentialRefreshError({ cause })), |
| 547 | Effect.provide(services), |
| 548 | ), |
| 549 | ); |
| 550 | const hasCredential = semaphore.withPermits(1)( |
| 551 | read().pipe( |
| 552 | Effect.map(Option.isSome), |
| 553 | Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })), |
| 554 | ), |
| 555 | ); |
| 556 | const get = semaphore.withPermits(1)( |
| 557 | Effect.gen(function* () { |
| 558 | // A stored credential that can't be read or refreshed (corrupt, revoked, |
| 559 | // expired grant) must fall through to a fresh login rather than dead-end |
| 560 | // the command — authorizeCli applies the same fallback to device |
| 561 | // authorization. |
| 562 | const token = yield* getExistingNoLock().pipe( |
| 563 | Effect.orElseSucceed(() => Option.none<PersistedToken>()), |
| 564 | ); |
| 565 | if (Option.isSome(token)) { |
| 566 | return { _tag: "Authorized", token: token.value } as const; |
| 567 | } |
| 568 | const authorization = yield* Effect.scoped(login()); |
| 569 | return authorization._tag === "Authorized" |
| 570 | ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const) |
| 571 | : authorization; |
| 572 | }).pipe( |
| 573 | Effect.mapError((cause) => |
| 574 | Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }), |
| 575 | ), |
| 576 | Effect.provide(services), |
| 577 | ), |
| 578 | ); |
| 579 | const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) { |
| 580 | yield* semaphore.withPermits(1)( |
| 581 | persist(token).pipe( |
| 582 | Effect.asVoid, |
| 583 | Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })), |
| 584 | ), |
| 585 | ); |
| 586 | }); |
| 587 | |
| 588 | return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear }); |
| 589 | }); |
| 590 | |
| 591 | export const layer = Layer.effect(CloudCliTokenManager, make); |