238 lines · 8.4 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Generic helpers for storing OAuth token secrets and transparently// refreshing access tokens before a request.//// Plugins (google-discovery, openapi) supply an adapter for their secrets// API and a `persistAuth` callback so this module stays decoupled from any// specific plugin SDK or store shape.// --------------------------------------------------------------------------- import { Effect } from "effect"; import { OAuth2Error, type OAuth2TokenResponse } from "./index";import {  OAUTH2_DEFAULT_TIMEOUT_MS,  refreshAccessToken,  shouldRefreshToken,  type ClientAuthMethod,} from "./index"; // ---------------------------------------------------------------------------// Secrets I/O adapter — plain string IDs, no plugin types.// --------------------------------------------------------------------------- export type OAuth2SecretsIO = {  readonly resolve: (secretId: string) => Effect.Effect<string, unknown>;  readonly setValue: (input: {    readonly secretId: string;    readonly value: string;    readonly name: string;    readonly purpose: string;  }) => Effect.Effect<void, unknown>;}; const wrapSecretError =  (operation: string) =>  (cause: unknown): OAuth2Error =>    new OAuth2Error({      message: `${operation}: ${cause instanceof Error ? cause.message : String(cause)}`,      cause,    }); // ---------------------------------------------------------------------------// storeOAuthTokens — freshly store access + refresh tokens as new secrets.// --------------------------------------------------------------------------- /** Shape of a newly persisted OAuth2 auth descriptor. */export type StoredOAuthTokens = {  readonly accessTokenSecretId: string;  readonly refreshTokenSecretId: string | null;  readonly tokenType: string;  readonly expiresAt: number | null;  readonly scope: string | null;}; export type StoreOAuthTokensInput = {  readonly tokens: OAuth2TokenResponse;  /** Slug used as prefix for new secret IDs, e.g. `acme_api`. */  readonly slug: string;  /** Human-readable name for the source, used in secret labels. */  readonly displayName: string;  /** `purpose` label stored on the access-token secret. */  readonly accessTokenPurpose: string;  /** `purpose` label stored on the refresh-token secret. */  readonly refreshTokenPurpose: string;  /** Adapter that creates a new secret and returns its freshly-minted ID. */  readonly createSecret: (input: {    readonly idPrefix: string;    readonly name: string;    readonly value: string;    readonly purpose: string;  }) => Effect.Effect<{ readonly id: string }, unknown>;}; /** * Persist access + refresh tokens from an OAuth2 token response as new * secrets and return a `StoredOAuthTokens` descriptor ready to write to a * source config. */export const storeOAuthTokens = (  input: StoreOAuthTokensInput,): Effect.Effect<StoredOAuthTokens, OAuth2Error> =>  Effect.gen(function* () {    const accessRef = yield* input      .createSecret({        idPrefix: `${input.slug}_access_token`,        name: `${input.displayName} Access Token`,        value: input.tokens.access_token,        purpose: input.accessTokenPurpose,      })      .pipe(Effect.mapError(wrapSecretError("Failed to store access token")));     const refreshRef = input.tokens.refresh_token      ? yield* input          .createSecret({            idPrefix: `${input.slug}_refresh_token`,            name: `${input.displayName} Refresh Token`,            value: input.tokens.refresh_token,            purpose: input.refreshTokenPurpose,          })          .pipe(Effect.mapError(wrapSecretError("Failed to store refresh token")))      : null;     return {      accessTokenSecretId: accessRef.id,      refreshTokenSecretId: refreshRef?.id ?? null,      tokenType: input.tokens.token_type ?? "Bearer",      expiresAt:        typeof input.tokens.expires_in === "number"          ? Date.now() + input.tokens.expires_in * 1000          : null,      scope: input.tokens.scope ?? null,    };  }); // ---------------------------------------------------------------------------// withRefreshedAccessToken — return the current access token, refreshing// first if it's within the skew window.// --------------------------------------------------------------------------- /** The subset of a stored OAuth2 auth descriptor this helper needs. */export type OAuth2AuthRefs = {  readonly clientIdSecretId: string;  readonly clientSecretSecretId: string | null;  readonly accessTokenSecretId: string;  readonly refreshTokenSecretId: string | null;  readonly tokenType: string;  readonly expiresAt: number | null;  readonly scopes: readonly string[];}; /** Snapshot of auth values written back after a successful refresh. */export type RefreshedAuthSnapshot = {  readonly tokenType: string;  readonly expiresAt: number | null;  readonly scope: string | null;}; export type WithRefreshedAccessTokenInput = {  readonly auth: OAuth2AuthRefs;  readonly tokenUrl: string;  readonly secrets: OAuth2SecretsIO;  /** Display name used when re-setting the access-token secret after refresh. */  readonly displayName: string;  readonly accessTokenPurpose: string;  readonly refreshTokenPurpose: string;  readonly clientAuth?: ClientAuthMethod;  readonly timeoutMs?: number;  /**   * Called after a successful refresh with the new expiry / tokenType /   * scope so the plugin can write the updated auth back to its source   * config. The accessTokenSecretId does NOT change — the value behind   * it is overwritten in place.   */  readonly persistAuth: (    snapshot: RefreshedAuthSnapshot,  ) => Effect.Effect<void, unknown>;}; /** * Resolve the current access token for a source — if it's within the * refresh-skew window, first exchange the refresh token for a new access * token, persist it, and write the updated expiry back via `persistAuth`. * * Returns the access-token string to be used for the upcoming request. */export const withRefreshedAccessToken = (  input: WithRefreshedAccessTokenInput,): Effect.Effect<string, OAuth2Error> =>  Effect.gen(function* () {    const { auth, secrets } = input;    const needsRefresh =      auth.refreshTokenSecretId !== null && shouldRefreshToken({ expiresAt: auth.expiresAt });     if (!needsRefresh) {      return yield* secrets        .resolve(auth.accessTokenSecretId)        .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth access token")));    }     // Proactive refresh path.    const refreshToken = yield* secrets      .resolve(auth.refreshTokenSecretId!)      .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth refresh token")));     const clientId = yield* secrets      .resolve(auth.clientIdSecretId)      .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client ID")));     const clientSecret =      auth.clientSecretSecretId === null        ? null        : yield* secrets            .resolve(auth.clientSecretSecretId)            .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client secret")));     const refreshed = yield* refreshAccessToken({      tokenUrl: input.tokenUrl,      clientId,      clientSecret,      refreshToken,      scopes: auth.scopes,      clientAuth: input.clientAuth,      timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS,    });     yield* secrets      .setValue({        secretId: auth.accessTokenSecretId,        value: refreshed.access_token,        name: `${input.displayName} Access Token`,        purpose: input.accessTokenPurpose,      })      .pipe(Effect.mapError(wrapSecretError("Failed to persist refreshed access token")));     if (refreshed.refresh_token && auth.refreshTokenSecretId) {      yield* secrets        .setValue({          secretId: auth.refreshTokenSecretId,          value: refreshed.refresh_token,          name: `${input.displayName} Refresh Token`,          purpose: input.refreshTokenPurpose,        })        .pipe(Effect.mapError(wrapSecretError("Failed to persist rotated refresh token")));    }     yield* input      .persistAuth({        tokenType: refreshed.token_type ?? auth.tokenType,        expiresAt:          typeof refreshed.expires_in === "number"            ? Date.now() + refreshed.expires_in * 1000            : auth.expiresAt,        scope: refreshed.scope ?? null,      })      .pipe(Effect.mapError(wrapSecretError("Failed to persist updated OAuth auth")));     return refreshed.access_token;  });