531 lines · 19.5 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary.import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";import * as Clock from "effect/Clock";import * as Console from "effect/Console";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import * as HttpClient from "effect/unstable/http/HttpClient";import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";import * as HttpRouter from "effect/unstable/http/HttpRouter";import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts";import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));const CLOUD_CLI_OAUTH_CALLBACK_HOST = "127.0.0.1";const CLOUD_CLI_OAUTH_CALLBACK_PORT = 34338; const PersistedToken = Schema.Struct({  accessToken: Schema.String,  refreshToken: Schema.String,  expiresAtEpochMs: Schema.Number,});type PersistedToken = typeof PersistedToken.Type; const PersistedTokenJson = Schema.fromJsonString(PersistedToken);const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); const OAuthTokenResponse = Schema.Struct({  access_token: Schema.String,  refresh_token: Schema.optional(Schema.String),  expires_in: Schema.Number,  token_type: Schema.String,}); type CredentialReadFailure = ServerSecretStore.SecretStoreReadError | Schema.SchemaError; type CredentialPersistFailure =  | Schema.SchemaError  | ServerSecretStore.SecretStoreTemporaryPathGenerationError  | ServerSecretStore.SecretStorePersistError; const CloudCliCredentialRefreshStage = Schema.Literals([  "read-credential",  "decode-credential",  "load-oauth-config",  "exchange-token",  "encode-credential",  "persist-credential",]);type CloudCliCredentialRefreshStage = typeof CloudCliCredentialRefreshStage.Type; const CloudCliAuthorizationStage = Schema.Literals([  "load-oauth-config",  "prepare-pkce",  "start-callback-server",  "exchange-token",  "encode-credential",  "persist-credential",]);type CloudCliAuthorizationStage = typeof CloudCliAuthorizationStage.Type; function tokenEndpointDiagnosticFields(tokenEndpoint: string | undefined) {  if (tokenEndpoint === undefined) return {};  const diagnostics = getUrlDiagnostics(tokenEndpoint);  return {    tokenEndpointInputLength: diagnostics.inputLength,    ...(diagnostics.protocol === undefined ? {} : { tokenEndpointProtocol: diagnostics.protocol }),    ...(diagnostics.hostname === undefined ? {} : { tokenEndpointHostname: diagnostics.hostname }),  };} function redirectUriDiagnosticFields(redirectUri: string | undefined) {  if (redirectUri === undefined) return {};  const diagnostics = getUrlDiagnostics(redirectUri);  return {    redirectUriInputLength: diagnostics.inputLength,    ...(diagnostics.protocol === undefined ? {} : { redirectUriProtocol: diagnostics.protocol }),    ...(diagnostics.hostname === undefined ? {} : { redirectUriHostname: diagnostics.hostname }),  };} export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()(  "CloudCliCredentialRemovalError",  {    secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Could not remove the stored T3 Connect CLI credential ${this.secretName}.`;  }} export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass<CloudCliCredentialRefreshError>()(  "CloudCliCredentialRefreshError",  {    stage: CloudCliCredentialRefreshStage,    secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),    tokenEndpointInputLength: Schema.optionalKey(Schema.Number),    tokenEndpointProtocol: Schema.optionalKey(Schema.String),    tokenEndpointHostname: Schema.optionalKey(Schema.String),    cause: Schema.Defect(),  },) {  static fromStage(input: {    readonly stage: CloudCliCredentialRefreshStage;    readonly cause: unknown;    readonly tokenEndpoint?: string;  }): CloudCliCredentialRefreshError {    return new CloudCliCredentialRefreshError({      stage: input.stage,      secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,      ...tokenEndpointDiagnosticFields(input.tokenEndpoint),      cause: input.cause,    });  }   static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialRefreshError {    return new CloudCliCredentialRefreshError({      stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential",      secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,      cause,    });  }   static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliCredentialRefreshError {    return new CloudCliCredentialRefreshError({      stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential",      secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,      cause,    });  }   override get message(): string {    const tokenEndpoint =      this.tokenEndpointInputLength === undefined        ? ""        : ` using the token endpoint${this.tokenEndpointHostname ? ` at ${this.tokenEndpointHostname}` : ""} (input length ${this.tokenEndpointInputLength})`;    return `Could not refresh the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}.`;  }} export class CloudCliCredentialReadError extends Schema.TaggedErrorClass<CloudCliCredentialReadError>()(  "CloudCliCredentialReadError",  {    stage: Schema.Literals(["read-credential", "decode-credential"]),    secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),    cause: Schema.Defect(),  },) {  static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialReadError {    return new CloudCliCredentialReadError({      stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential",      secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,      cause,    });  }   override get message(): string {    return `Could not inspect the stored T3 Connect CLI credential ${this.secretName} during ${this.stage}.`;  }} export class CloudCliAuthorizationError extends Schema.TaggedErrorClass<CloudCliAuthorizationError>()(  "CloudCliAuthorizationError",  {    stage: CloudCliAuthorizationStage,    secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),    tokenEndpointInputLength: Schema.optionalKey(Schema.Number),    tokenEndpointProtocol: Schema.optionalKey(Schema.String),    tokenEndpointHostname: Schema.optionalKey(Schema.String),    redirectUriInputLength: Schema.optionalKey(Schema.Number),    redirectUriProtocol: Schema.optionalKey(Schema.String),    redirectUriHostname: Schema.optionalKey(Schema.String),    callbackHost: Schema.optional(Schema.String),    callbackPort: Schema.optional(Schema.Number),    cause: Schema.Defect(),  },) {  static fromStage(input: {    readonly stage: CloudCliAuthorizationStage;    readonly cause: unknown;    readonly tokenEndpoint?: string;    readonly redirectUri?: string;    readonly callbackHost?: string;    readonly callbackPort?: number;  }): CloudCliAuthorizationError {    return new CloudCliAuthorizationError({      stage: input.stage,      secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,      ...tokenEndpointDiagnosticFields(input.tokenEndpoint),      ...redirectUriDiagnosticFields(input.redirectUri),      ...(input.callbackHost === undefined ? {} : { callbackHost: input.callbackHost }),      ...(input.callbackPort === undefined ? {} : { callbackPort: input.callbackPort }),      cause: input.cause,    });  }   static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliAuthorizationError {    return new CloudCliAuthorizationError({      stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential",      secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,      cause,    });  }   override get message(): string {    const tokenEndpoint =      this.tokenEndpointInputLength === undefined        ? ""        : ` using the token endpoint${this.tokenEndpointHostname ? ` at ${this.tokenEndpointHostname}` : ""} (input length ${this.tokenEndpointInputLength})`;    const redirectUri =      this.redirectUriInputLength === undefined        ? ""        : ` with a callback URI input of length ${this.redirectUriInputLength}`;    const callbackAddress =      this.callbackHost && this.callbackPort !== undefined        ? ` on ${this.callbackHost}:${this.callbackPort}`        : "";    return `Could not authorize the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}${redirectUri}${callbackAddress}.`;  }} export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass<CloudCliAuthorizationTimeoutError>()(  "CloudCliAuthorizationTimeoutError",  {    redirectUriInputLength: Schema.Number,    redirectUriProtocol: Schema.optionalKey(Schema.String),    redirectUriHostname: Schema.optionalKey(Schema.String),    timeoutMillis: Schema.Number,    cause: Schema.Defect(),  },) {  static fromRedirectUri(input: {    readonly redirectUri: string;    readonly timeoutMillis: number;    readonly cause: unknown;  }): CloudCliAuthorizationTimeoutError {    const diagnostics = getUrlDiagnostics(input.redirectUri);    return new CloudCliAuthorizationTimeoutError({      redirectUriInputLength: diagnostics.inputLength,      ...(diagnostics.protocol === undefined ? {} : { redirectUriProtocol: diagnostics.protocol }),      ...(diagnostics.hostname === undefined ? {} : { redirectUriHostname: diagnostics.hostname }),      timeoutMillis: input.timeoutMillis,      cause: input.cause,    });  }   override get message(): string {    const callback = this.redirectUriHostname ? ` for ${this.redirectUriHostname}` : "";    return `Timed out after ${this.timeoutMillis}ms waiting for T3 Connect authorization${callback} (callback URI input length ${this.redirectUriInputLength}).`;  }} export const CloudCliTokenManagerError = Schema.Union([  CloudCliCredentialRemovalError,  CloudCliCredentialRefreshError,  CloudCliCredentialReadError,  CloudCliAuthorizationError,  CloudCliAuthorizationTimeoutError,]);export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; export class CloudCliTokenManager extends Context.Service<  CloudCliTokenManager,  {    readonly get: Effect.Effect<      PersistedToken,      | CloudCliCredentialRefreshError      | CloudCliAuthorizationError      | CloudCliAuthorizationTimeoutError    >;    readonly getExisting: Effect.Effect<      Option.Option<PersistedToken>,      CloudCliCredentialRefreshError    >;    readonly hasCredential: Effect.Effect<boolean, CloudCliCredentialReadError>;    readonly clear: Effect.Effect<void, CloudCliCredentialRemovalError>;  }>()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} function stringToBytes(value: string): Uint8Array {  return new TextEncoder().encode(value);} function bytesToString(value: Uint8Array): string {  return new TextDecoder().decode(value);} export const make = Effect.gen(function* () {  const crypto = yield* Crypto.Crypto;  const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);  const secrets = yield* ServerSecretStore.ServerSecretStore;  const semaphore = yield* Semaphore.make(1);  const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) {    const encoded = yield* encodePersistedToken(token);    yield* secrets.set(CLOUD_CLI_OAUTH_TOKEN_SECRET, stringToBytes(encoded));    return token;  });   const clear = secrets.remove(CLOUD_CLI_OAUTH_TOKEN_SECRET).pipe(    Effect.mapError(      (cause) =>        new CloudCliCredentialRemovalError({          secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,          cause,        }),    ),  );   const read = Effect.fn("cloud.cli_token.read")(function* () {    const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);    if (Option.isNone(encoded)) return Option.none<PersistedToken>();    return Option.some(yield* decodePersistedToken(bytesToString(encoded.value)));  });   const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (    metadata: CloudCliOAuthConfig,    params: Record<string, string>,  ) {    const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(      HttpClientRequest.bodyUrlParams(params),      httpClient.execute,      Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),    );    const now = yield* Clock.currentTimeMillis;    return {      accessToken: response.access_token,      refreshToken: response.refresh_token ?? params.refresh_token ?? "",      expiresAtEpochMs: now + response.expires_in * 1_000,    } satisfies PersistedToken;  });   const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) {    const metadata = yield* cloudCliOAuthConfig.pipe(      Effect.mapError((cause) =>        CloudCliCredentialRefreshError.fromStage({          stage: "load-oauth-config",          cause,        }),      ),    );    return yield* exchangeToken(metadata, {      grant_type: "refresh_token",      refresh_token: token.refreshToken,      client_id: metadata.clientId,    }).pipe(      Effect.mapError((cause) =>        CloudCliCredentialRefreshError.fromStage({          stage: "exchange-token",          tokenEndpoint: metadata.tokenEndpoint,          cause,        }),      ),    );  });   const login = Effect.fn("cloud.cli_token.login")(function* () {    const metadata = yield* cloudCliOAuthConfig.pipe(      Effect.mapError((cause) =>        CloudCliAuthorizationError.fromStage({          stage: "load-oauth-config",          cause,        }),      ),    );    const { challenge, state, verifier } = yield* Effect.gen(function* () {      const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));      const challenge = Encoding.encodeBase64Url(        yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),      );      const state = yield* crypto.randomUUIDv4;      return { challenge, state, verifier };    }).pipe(      Effect.mapError((cause) =>        CloudCliAuthorizationError.fromStage({          stage: "prepare-pkce",          redirectUri: metadata.redirectUri,          cause,        }),      ),    );    const callback = yield* Deferred.make<string>();    const callbackRoute = HttpRouter.add(      "GET",      "/callback",      Effect.gen(function* () {        const request = yield* HttpServerRequest.HttpServerRequest;        const url = new URL(request.originalUrl, metadata.redirectUri);        const code = url.searchParams.get("code");        if (url.searchParams.get("state") !== state || !code) {          return HttpServerResponse.text("Invalid T3 Connect authorization callback.", {            status: 400,          });        }        yield* Deferred.succeed(callback, code);        return yield* HttpServerResponse.html`<html>  <body style="font-family: sans-serif; text-align: center; margin-top: 50px;">    <h1>T3 Connect authorization complete</h1>    <p>You can close this window and return to your terminal.</p>  </body></html>`;      }),    );    yield* HttpRouter.serve(callbackRoute, {      disableListenLog: true,      disableLogger: true,    }).pipe(      Layer.provide(        NodeHttpServer.layer(NodeHttp.createServer, {          host: CLOUD_CLI_OAUTH_CALLBACK_HOST,          port: CLOUD_CLI_OAUTH_CALLBACK_PORT,          disablePreemptiveShutdown: true,        }),      ),      Layer.build,      Effect.mapError((cause) =>        CloudCliAuthorizationError.fromStage({          stage: "start-callback-server",          redirectUri: metadata.redirectUri,          callbackHost: CLOUD_CLI_OAUTH_CALLBACK_HOST,          callbackPort: CLOUD_CLI_OAUTH_CALLBACK_PORT,          cause,        }),      ),    );    const authorizationUrl = new URL(metadata.authorizationEndpoint);    authorizationUrl.searchParams.set("client_id", metadata.clientId);    authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri);    authorizationUrl.searchParams.set("response_type", "code");    authorizationUrl.searchParams.set("scope", metadata.scopes.join(" "));    authorizationUrl.searchParams.set("state", state);    authorizationUrl.searchParams.set("code_challenge", challenge);    authorizationUrl.searchParams.set("code_challenge_method", "S256");    yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`);    const code = yield* Deferred.await(callback).pipe(      Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),      Effect.catchTags({        TimeoutError: (cause) =>          Effect.fail(            CloudCliAuthorizationTimeoutError.fromRedirectUri({              redirectUri: metadata.redirectUri,              timeoutMillis: Duration.toMillis(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),              cause,            }),          ),      }),    );    return yield* exchangeToken(metadata, {      grant_type: "authorization_code",      code,      redirect_uri: metadata.redirectUri,      client_id: metadata.clientId,      code_verifier: verifier,    }).pipe(      Effect.mapError((cause) =>        CloudCliAuthorizationError.fromStage({          stage: "exchange-token",          tokenEndpoint: metadata.tokenEndpoint,          redirectUri: metadata.redirectUri,          cause,        }),      ),    );  });   const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () {    const token = yield* read().pipe(      Effect.mapError(CloudCliCredentialRefreshError.fromCredentialRead),    );    if (Option.isNone(token)) return token;    const now = yield* Clock.currentTimeMillis;    if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) {      return token;    }    return Option.some(      yield* refresh(token.value).pipe(        Effect.flatMap((refreshed) =>          persist(refreshed).pipe(            Effect.mapError(CloudCliCredentialRefreshError.fromCredentialPersist),          ),

Lines 1–500 of 531.