Skip to content
Source
Sign inSign up

CliTokenManager.ts

645 lines24.0 KB

1// @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary.
2import * as NodeHttp from "node:http";
3​
4import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as Clock from "effect/Clock";
6import * as Cause from "effect/Cause";
7import * as Console from "effect/Console";
8import * as Context from "effect/Context";
9import * as Digest from "effect/crypto/Digest";
10import * as SecureRandom from "effect/crypto/SecureRandom";
11import * as Deferred from "effect/Deferred";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Base64Url from "effect/encoding/Base64Url";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Queue from "effect/Queue";
18import * as Schema from "effect/Schema";
19import * as Semaphore from "effect/Semaphore";
20import * as Terminal from "effect/Terminal";
21import * as HttpClient from "effect/http/HttpClient";
22import * as HttpClientError from "effect/http/HttpClientError";
23import * as HttpClientRequest from "effect/http/HttpClientRequest";
24import * as HttpClientResponse from "effect/http/HttpClientResponse";
25import * as HttpRouter from "effect/http/HttpRouter";
26import * as HttpServerRequest from "effect/http/HttpServerRequest";
27import * as HttpServerResponse from "effect/http/HttpServerResponse";
28​
29import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth";
30​
31import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
32import * as ExternalLauncher from "../process/externalLauncher.ts";
33import {
34 cloudCliOAuthConfig,
35 hostedAppUrlConfig,
36 type CloudCliOAuthConfig,
37} from "./publicConfig.ts";
38import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
39​
40const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
41const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
42const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
43const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
44// RFC 8628 defaults, used only when Clerk omits the field.
45const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5);
46// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval".
47const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5);
48// A stalled OAuth request holds the semaphore and blocks all credential calls.
49const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10);
50const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
51​
52function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
53 return [
54 "Open this URL to authorize T3 Connect:",
55 ` ${authorizationUrl}`,
56 "",
57 `Press ${boldTerminalText("Enter")} to open it in your browser.`,
58 `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`,
59 ].join("\n");
61​
62export type LoopbackAuthorizationResult =
63 | { readonly _tag: "AuthorizationCode"; readonly code: string }
64 | { readonly _tag: "HeadlessRequested" };
65​
66const readLoopbackAuthorizationAction = Effect.fn(
67 "cloud.cli_token.read_loopback_authorization_action",
68)(function* (input: Queue.Dequeue<Terminal.UserInput, Cause.Done>) {
69 while (true) {
70 const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({})));
71 const keyName = event.key.name.toLowerCase();
72 if (!event.key.ctrl && !event.key.meta && keyName === "h") {
73 return "headless" as const;
74 }
75 if (keyName === "enter" || keyName === "return") {
76 return "open-browser" as const;
77 }
78 }
79});
80​
81const waitForLoopbackAuthorization = Effect.fn("cloud.cli_token.wait_for_loopback_authorization")(
82 function* <E, R>(input: {
83 readonly authorizationUrl: string;
84 readonly callback: Effect.Effect<string, E, R>;
85 readonly terminal: Terminal.Terminal;
86 readonly launchBrowser: (
87 url: string,
88 ) => Effect.Effect<void, ExternalLauncher.ExternalLauncherError>;
89 }) {
90 return yield* Effect.scoped(
91 Effect.gen(function* () {
92 const terminalInput = yield* input.terminal.readInput;
93 while (true) {
94 const result = yield* Effect.raceFirst(
95 input.callback.pipe(
96 Effect.map((code): LoopbackAuthorizationResult => ({
97 _tag: "AuthorizationCode",
98 code,
99 })),
100 ),
101 readLoopbackAuthorizationAction(terminalInput),
102 );
103 if (typeof result !== "string") {
104 return result;
105 }
106 if (result === "headless") {
107 return { _tag: "HeadlessRequested" } as const;
108 }
109 yield* input
110 .launchBrowser(input.authorizationUrl)
111 .pipe(
112 Effect.catch(() =>
113 Console.warn(
114 `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`,
115 ),
116 ),
117 );
118 }
119 }),
120 );
121 },
122);
123​
124const PersistedToken = Schema.Struct({
125 accessToken: Schema.String,
126 refreshToken: Schema.String,
127 expiresAtEpochMs: Schema.Number,
128 identity: Schema.optional(Schema.String),
129});
130export type PersistedToken = typeof PersistedToken.Type;
131​
132const PersistedTokenJson = Schema.fromJsonString(PersistedToken);
133const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);
134const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson);
135​
136const OAuthTokenResponse = Schema.Struct({
137 access_token: Schema.String,
138 refresh_token: Schema.optional(Schema.String),
139 id_token: Schema.optional(Schema.String),
140 expires_in: Schema.Number,
141 token_type: Schema.String,
142});
143​
144const OAuthErrorResponse = Schema.Struct({
145 error: Schema.String,
146 error_description: Schema.optional(Schema.String),
147});
148const OAuthRefreshErrorCode = Schema.Literals([
149 "invalid_grant",
150 "invalid_client",
151 "unauthorized_client",
152 "unsupported_grant_type",
153 "invalid_request",
154 "invalid_scope",
155 "access_denied",
156]);
157const decodeOAuthRefreshErrorResponse = Schema.decodeUnknownOption(
158 Schema.Struct({ error: OAuthRefreshErrorCode }),
159);
160​
161const isPermanentRefreshStatus = (status: number): boolean =>
162 status >= 400 && status < 500 && status !== 408 && status !== 429;
163​
164const DeviceAuthorizationResponse = Schema.Struct({
165 device_code: Schema.String,
166 user_code: Schema.String,
167 verification_uri: Schema.String,
168 verification_uri_complete: Schema.optional(Schema.String),
169 expires_in: Schema.Number,
170 interval: Schema.optional(Schema.Number),
171});
172​
173const OidcIdentityClaimsJson = Schema.fromJsonString(
174 Schema.Struct({
175 email: Schema.optional(Schema.String),
176 preferred_username: Schema.optional(Schema.String),
177 sub: Schema.optional(Schema.String),
178 }),
179);
180const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson);
181​
182function idTokenIdentity(idToken: string | undefined): string | null {
183 if (!idToken) return null;
184 const payload = idToken.split(".")[1];
185 if (!payload) return null;
186 const decoded = Base64Url.decodeString(payload);
187 if (decoded._tag !== "Success") return null;
188 const claims = decodeOidcIdentityClaimsJson(decoded.success);
189 if (Option.isNone(claims)) return null;
190 for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) {
191 if (typeof value === "string" && value.length > 0) return value;
192 }
193 return null;
195​
196export class CloudCliCredentialRemovalError extends Schema.TaggedError<CloudCliCredentialRemovalError>()(
197 "CloudCliCredentialRemovalError",
198 { cause: Schema.Defect() },
199) {
200 override get message(): string {
201 return "Could not remove the stored T3 Connect CLI credential.";
202 }
204​
205export class CloudCliCredentialRefreshError extends Schema.TaggedError<CloudCliCredentialRefreshError>()(
206 "CloudCliCredentialRefreshError",
207 {
208 cause: Schema.Defect(),
209 status: Schema.optional(Schema.Number),
210 oauthErrorCode: Schema.optional(OAuthRefreshErrorCode),
211 },
212) {
213 get requiresLogin(): boolean {
214 return this.status !== undefined && isPermanentRefreshStatus(this.status);
215 }
216​
217 override get message(): string {
218 return this.requiresLogin
219 ? `Could not refresh the T3 Connect CLI credential${
220 this.oauthErrorCode === undefined ? "" : ` (OAuth error: ${this.oauthErrorCode})`
221 }. Run \`t3 connect login\` to authorize again.`
222 : "Could not refresh the T3 Connect CLI credential.";
223 }
225​
226export const isCloudCliCredentialRefreshError = Schema.is(CloudCliCredentialRefreshError);
227​
228export class CloudCliCredentialReadError extends Schema.TaggedError<CloudCliCredentialReadError>()(
229 "CloudCliCredentialReadError",
230 { cause: Schema.Defect() },
231) {
232 override get message(): string {
233 return "Could not read the stored T3 Connect CLI credential.";
234 }
236​
237export class CloudCliAuthorizationError extends Schema.TaggedError<CloudCliAuthorizationError>()(
238 "CloudCliAuthorizationError",
239 { cause: Schema.Defect() },
240) {
241 override get message(): string {
242 return "Could not authorize the T3 Connect CLI.";
243 }
245​
246export class CloudCliAuthorizationTimeoutError extends Schema.TaggedError<CloudCliAuthorizationTimeoutError>()(
247 "CloudCliAuthorizationTimeoutError",
248 { cause: Schema.Defect() },
249) {
250 override get message(): string {
251 return "Timed out waiting for T3 Connect authorization.";
252 }
254​
255export class CloudCliAuthorizationDeniedError extends Schema.TaggedError<CloudCliAuthorizationDeniedError>()(
256 "CloudCliAuthorizationDeniedError",
257 {},
258) {
259 override get message(): string {
260 return "T3 Connect authorization was denied in the browser.";
261 }
263​
264export const CloudCliTokenManagerError = Schema.Union([
265 CloudCliCredentialRemovalError,
266 CloudCliCredentialRefreshError,
267 CloudCliCredentialReadError,
268 CloudCliAuthorizationError,
269 CloudCliAuthorizationTimeoutError,
270 CloudCliAuthorizationDeniedError,
271]);
272export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type;
273​
274export class CloudCliTokenManager extends Context.Service<
275 CloudCliTokenManager,
276 {
277 readonly get: Effect.Effect<
278 | { readonly _tag: "Authorized"; readonly token: PersistedToken }
279 | { readonly _tag: "HeadlessRequested" },
280 CloudCliTokenManagerError | Terminal.QuitError
281 >;
282 readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>;
283 readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>;
284 readonly store: (token: PersistedToken) => Effect.Effect<void, CloudCliTokenManagerError>;
285 readonly clear: Effect.Effect<void, CloudCliTokenManagerError>;
286 }
287>()("t3/cloud/CliTokenManager/CloudCliTokenManager") {}
288​
289function stringToBytes(value: string): Uint8Array {
290 return new TextEncoder().encode(value);
292​
293function bytesToString(value: Uint8Array): string {
294 return new TextDecoder().decode(value);
296​
297const readTokenResponse = Effect.fn("cloud.cli_token.read_token_response")(function* (
298 response: HttpClientResponse.HttpClientResponse,
299 params: Record<string, string>,
300) {
301 const body = yield* HttpClientResponse.schemaBodyJson(OAuthTokenResponse)(response);
302 const now = yield* Clock.currentTimeMillis;
303 const identity = idTokenIdentity(body.id_token);
304 return {
305 token: {
306 accessToken: body.access_token,
307 refreshToken: body.refresh_token ?? params.refresh_token ?? "",
308 expiresAtEpochMs: now + body.expires_in * 1_000,
309 ...(identity === null ? {} : { identity }),
310 } satisfies PersistedToken,
311 identity,
312 };
313});
314​
315const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
316 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,
317 params: Record<string, string>,
318) {
319 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
320 const startedAt = yield* Clock.monotonicTimeNanos;
321 return yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
322 HttpClientRequest.bodyUrlParams(params),
323 httpClient.execute,
324 Effect.flatMap((response) => readTokenResponse(response, params)),
325 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
326 Effect.catchTags({
327 // The enclosing operation owns the span for this inline handler.
328 HttpClientError: Effect.fnUntraced(function* (cause) {
329 const reason = cause.reason;
330 if (
331 params.grant_type !== "refresh_token" ||
332 reason._tag !== "StatusCodeError" ||
333 !isPermanentRefreshStatus(reason.response.status)
334 ) {
335 return yield* cause;
336 }
337 // Reading a rejection body shares the exchange budget without losing
338 // the known status when the body stalls or cannot be decoded.
339 const elapsed = Duration.toMillis(
340 Duration.nanos((yield* Clock.monotonicTimeNanos) - startedAt),
341 );
342 const body = yield* reason.response.json.pipe(
343 Effect.timeout(Math.max(0, Duration.toMillis(OAUTH_REQUEST_TIMEOUT) - elapsed)),
344 Effect.mapError(
345 (cause) =>
346 new CloudCliCredentialRefreshError({
347 status: reason.response.status,
348 cause: new HttpClientError.HttpClientError({
349 reason: new HttpClientError.StatusCodeError({
350 request: reason.request,
351 response: reason.response,
352 cause,
353 }),
354 }),
355 }),
356 ),
357 );
358 const diagnostic = decodeOAuthRefreshErrorResponse(body);
359 return yield* new CloudCliCredentialRefreshError({
360 cause,
361 status: reason.response.status,
362 ...(Option.isSome(diagnostic) ? { oauthErrorCode: diagnostic.value.error } : {}),
363 });
364 }),
365 }),
366 );
367});
368​
369const makePkceRequest = Effect.gen(function* () {
370 const random = yield* SecureRandom.SecureRandom;
371 const digest = yield* Digest.Digest;
372 const verifier = Base64Url.encode(yield* random.randomBytes(32));
373 const challenge = Base64Url.encode(
374 yield* digest.digest(Digest.sha256, new TextEncoder().encode(verifier)),
375 );
376 const state = Base64Url.encode(yield* random.randomBytes(16));
377 return { verifier, challenge, state };
378});
379​
380export interface DeviceAuthorizationPrompt {
381 readonly verificationUri: string;
382 readonly verificationUriComplete: string | undefined;
383 readonly userCode: string;
384 readonly expiresIn: Duration.Duration;
386​
387const isTransportError = (error: unknown) =>
388 HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError";
389​
390const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* (
391 metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint" | "clientId">,
392 deviceCode: string,
393 initialInterval: Duration.Duration,
394) {
395 const httpClient = yield* HttpClient.HttpClient;
396 const params = {
397 grant_type: DEVICE_CODE_GRANT_TYPE,
398 device_code: deviceCode,
399 client_id: metadata.clientId,
400 };
401 let interval = initialInterval;
402 while (true) {
403 yield* Effect.sleep(interval);
404 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
405 HttpClientRequest.bodyUrlParams(params),
406 httpClient.execute,
407 Effect.asSome,
408 Effect.catchIf(isTransportError, () => Effect.succeedNone),
409 );
410 // Retry transport errors and 5xx while the device code is valid, backing off per RFC 8628 §3.5. Drain bodies to reuse connections.
411 if (Option.isNone(response) || response.value.status >= 500) {
412 if (Option.isSome(response)) yield* Effect.ignore(response.value.text);
413 interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT);
414 continue;
415 }
416 if (response.value.status >= 200 && response.value.status < 300) {
417 return yield* readTokenResponse(response.value, params);
418 }
419 const failure = yield* HttpClientResponse.schemaBodyJson(OAuthErrorResponse)(response.value);
420 switch (failure.error) {
421 case "authorization_pending":
422 continue;
423 case "slow_down":
424 interval = Duration.sum(interval, DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT);
425 continue;
426 case "expired_token":
427 return yield* new CloudCliAuthorizationTimeoutError({ cause: failure });
428 case "access_denied":
429 return yield* new CloudCliAuthorizationDeniedError();
430 default:
431 return yield* new CloudCliAuthorizationError({
432 cause: failure.error_description ?? failure.error,
433 });
434 }
435 }
436});
437​
438export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_authorization_login")(
439 function* <E, R>(showPrompt: (prompt: DeviceAuthorizationPrompt) => Effect.Effect<void, E, R>) {
440 const metadata = yield* cloudCliOAuthConfig;
441 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
442 const authorization = yield* HttpClientRequest.post(metadata.deviceAuthorizationEndpoint).pipe(
443 HttpClientRequest.bodyUrlParams({
444 client_id: metadata.clientId,
445 scope: metadata.scopes.join(" "),
446 }),
447 httpClient.execute,
448 Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)),
449 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
450 );
451 const expiresIn = Duration.seconds(authorization.expires_in);
452 const interval =
453 authorization.interval === undefined
454 ? DEVICE_AUTHORIZATION_DEFAULT_INTERVAL
455 : Duration.seconds(authorization.interval);
456 yield* showPrompt({
457 verificationUri: authorization.verification_uri,
458 verificationUriComplete: authorization.verification_uri_complete,
459 userCode: authorization.user_code,
460 expiresIn,
461 });
462 return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe(
463 Effect.timeout(expiresIn),
464 Effect.catchTags({
465 TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
466 }),
467 );
468 },
469);
470​
471/** @public */
472export const make = Effect.gen(function* () {
473 const random = yield* SecureRandom.SecureRandom;
474 const digest = yield* Digest.Digest;
475 const httpClient = yield* HttpClient.HttpClient;
476 const services = Context.make(SecureRandom.SecureRandom, random).pipe(
477 Context.add(Digest.Digest, digest),
478 Context.add(HttpClient.HttpClient, httpClient),
479 );
480 const secrets = yield* ServerSecretStore.ServerSecretStore;
481 const terminal = yield* Terminal.Terminal;
482 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
483 const semaphore = yield* Semaphore.make(1);
484 const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) {
485 const encoded = yield* encodePersistedToken(token);
486 yield* secrets.set(CLOUD_CLI_OAUTH_TOKEN_SECRET, stringToBytes(encoded));
487 return token;
488 });
489​
490 const clear = secrets
491 .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)
492 .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause })));
493​
494 const read = Effect.fn("cloud.cli_token.read")(function* () {
495 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
496 if (Option.isNone(encoded)) return Option.none<PersistedToken>();
497 return Option.some(yield* decodePersistedToken(bytesToString(encoded.value)));
498 });
499​
500 const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) {
501 const metadata = yield* cloudCliOAuthConfig;
502 const { token: refreshed } = yield* exchangeToken(metadata, {
503 grant_type: "refresh_token",
504 refresh_token: token.refreshToken,
505 client_id: metadata.clientId,
506 });
507 return refreshed.identity === undefined && token.identity !== undefined
508 ? { ...refreshed, identity: token.identity }
509 : refreshed;
510 });
511​
512 const login = Effect.fn("cloud.cli_token.login")(function* () {
513 const metadata = yield* cloudCliOAuthConfig;
514 const hostedAppUrl = yield* hostedAppUrlConfig;
515 const { verifier, challenge, state } = yield* makePkceRequest;
516 const callback = yield* Deferred.make<string>();
517 const layerCallbackRoute = HttpRouter.add(
518 "GET",
519 "/callback",
520 Effect.gen(function* () {
521 const request = yield* HttpServerRequest.HttpServerRequest;
522 const url = new URL(request.originalUrl, metadata.redirectUri);
523 const code = url.searchParams.get("code");
524 if (url.searchParams.get("state") !== state || !code) {
525 return HttpServerResponse.text("Invalid T3 Connect authorization callback.", {
526 status: 400,
527 });
528 }
529 yield* Deferred.succeed(callback, code);
530 return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml());
531 }),
532 );
533 yield* HttpRouter.serve(layerCallbackRoute, {
534 disableListenLog: true,
535 disableLogger: true,
536 }).pipe(
537 Layer.provide(
538 NodeHttpServer.layer(NodeHttp.createServer, {
539 host: "127.0.0.1",
540 port: metadata.loopbackPort,
541 disablePreemptiveShutdown: true,
542 }),
543 ),
544 Layer.build,
545 );
546 // /connect establishes a Clerk session first; /oauth/authorize loses parameters across sign-in.
547 const authorizationUrl = buildConnectAuthorizeRequestUrl({
548 hostedAppUrl,
549 state,
550 challenge,
551 loopbackPort: metadata.loopbackPort,
552 });
553 yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl));
554 const authorization = yield* waitForLoopbackAuthorization({
555 authorizationUrl,
556 callback: Deferred.await(callback).pipe(
557 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
558 Effect.catchTags({
559 TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
560 }),
561 ),
562 terminal,
563 launchBrowser: externalLauncher.launchBrowser,
564 });
565 if (authorization._tag === "HeadlessRequested") {
566 return authorization;
567 }
568 const { token } = yield* exchangeToken(metadata, {
569 grant_type: "authorization_code",
570 code: authorization.code,
571 redirect_uri: metadata.redirectUri,
572 client_id: metadata.clientId,
573 code_verifier: verifier,
574 });
575 return { _tag: "Authorized", token } as const;
576 });
577​
578 const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () {
579 const token = yield* read();
580 if (Option.isNone(token)) return token;
581 const now = yield* Clock.currentTimeMillis;
582 if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) {
583 return token;
584 }
585 return Option.some(
586 // The endpoint can rotate the refresh token before shutdown interrupts us.
587 // Keep the lock through persistence; the exchange still times out internally.
588 yield* refresh(token.value).pipe(Effect.flatMap(persist), Effect.uninterruptible),
589 );
590 });
591​
592 const getExisting = semaphore.withPermits(1)(
593 getExistingNoLock().pipe(
594 Effect.mapError((cause) =>
595 isCloudCliCredentialRefreshError(cause)
596 ? cause
597 : new CloudCliCredentialRefreshError({
598 cause,
599 ...(HttpClientError.isHttpClientError(cause) &&
600 cause.reason._tag === "StatusCodeError"
601 ? { status: cause.reason.response.status }
602 : {}),
603 }),
604 ),
605 Effect.provide(services),
606 ),
607 );
608 const hasCredential = semaphore.withPermits(1)(
609 read().pipe(
610 Effect.map(Option.isSome),
611 Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })),
612 ),
613 );
614 const get = semaphore.withPermits(1)(
615 Effect.gen(function* () {
616 const token = yield* getExistingNoLock().pipe(
617 Effect.orElseSucceed(() => Option.none<PersistedToken>()),
618 );
619 if (Option.isSome(token)) {
620 return { _tag: "Authorized", token: token.value } as const;
621 }
622 const authorization = yield* Effect.scoped(login());
623 return authorization._tag === "Authorized"
624 ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const)
625 : authorization;
626 }).pipe(
627 Effect.mapError((cause) =>
628 Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }),
629 ),
630 Effect.provide(services),
631 ),
632 );
633 const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) {
634 yield* semaphore.withPermits(1)(
635 persist(token).pipe(
636 Effect.asVoid,
637 Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })),
638 ),
639 );
640 });
641​
642 return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear });
643});
644​
645export const layer = Layer.effect(CloudCliTokenManager, make);