253 lines · 8.4 KB · TypeScript
RawDownload
/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: v1 migration resolves archived OAuth metadata before committing migrated rows */ import { Schema } from "effect"; import type { MigrationPlan } from "./migration-spec"; const OAuthAuthorizationServerMetadata = Schema.Struct({  authorization_endpoint: Schema.String,});const decodeOAuthAuthorizationServerMetadata = Schema.decodeUnknownSync(  OAuthAuthorizationServerMetadata,);const OAuthProtectedResourceMetadata = Schema.Struct({  authorization_servers: Schema.optional(Schema.Array(Schema.String)),});const decodeOAuthProtectedResourceMetadata = Schema.decodeUnknownSync(  OAuthProtectedResourceMetadata,);const DEFAULT_OAUTH_METADATA_TIMEOUT_MS = 20_000; export type MigrationOAuthMetadataFetch = (  input: string,  init: {    readonly headers: Readonly<Record<string, string>>;    readonly signal: AbortSignal;  },) => Promise<{  readonly ok: boolean;  readonly status: number;  readonly json: () => Promise<unknown>;}>; export interface ResolveMigrationOAuthAuthorizationUrlsOptions {  readonly fetch?: MigrationOAuthMetadataFetch;  readonly timeoutMs?: number;} export const migrationOAuthClientPlanKey = (  client: MigrationPlan["oauthClients"][number],): string =>  `${client.ownerKeys.tenant}\0${client.ownerKeys.owner}\0${client.ownerKeys.subject}\0${client.slug}`; const validateMigrationOAuthUrl = (value: string, label: string): string => {  const trimmed = value.trim();  const url = new URL(trimmed);  const loopbackHttp =    url.protocol === "http:" &&    (url.hostname === "localhost" ||      url.hostname === "127.0.0.1" ||      url.hostname === "::1" ||      url.hostname === "[::1]");  if (url.protocol !== "https:" && !loopbackHttp) {    throw new Error(`${label} must use https: or loopback http: ${trimmed}`);  }  return trimmed;}; const fetchOAuthJson = async (  url: string,  fetchImpl: MigrationOAuthMetadataFetch,  timeoutMs: number,): Promise<unknown> => {  const controller = new AbortController();  const timeout = setTimeout(() => controller.abort(), timeoutMs);  try {    const response = await fetchImpl(url, {      headers: { accept: "application/json" },      signal: controller.signal,    });    if (!response.ok) {      throw new Error(`OAuth metadata ${url} returned HTTP ${response.status}`);    }    return await response.json();  } finally {    clearTimeout(timeout);  }}; const fetchOAuthAuthorizationEndpoint = async (  metadataUrl: string,  fetchImpl: MigrationOAuthMetadataFetch,  timeoutMs: number,): Promise<string> => {  const metadata = decodeOAuthAuthorizationServerMetadata(    await fetchOAuthJson(metadataUrl, fetchImpl, timeoutMs),  );  return validateMigrationOAuthUrl(metadata.authorization_endpoint, "authorization_endpoint");}; const wellKnownAuthorizationServerUrlFor = (  issuer: string,  algorithm: "oauth2" | "oidc",): string => {  const url = new URL(validateMigrationOAuthUrl(issuer, "issuer"));  const origin = `${url.protocol}//${url.host}`;  const path = url.pathname.replace(/\/+$/, "");  const suffix = algorithm === "oauth2" ? "oauth-authorization-server" : "openid-configuration";  return path && path !== "/"    ? `${origin}/.well-known/${suffix}${path}`    : `${origin}/.well-known/${suffix}`;}; const resolveAuthorizationEndpointFromIssuer = async (  issuer: string,  fetchImpl: MigrationOAuthMetadataFetch,  timeoutMs: number,): Promise<string | null> => {  for (const algorithm of ["oauth2", "oidc"] as const) {    try {      return await fetchOAuthAuthorizationEndpoint(        wellKnownAuthorizationServerUrlFor(issuer, algorithm),        fetchImpl,        timeoutMs,      );    } catch {      // Best-effort fallback discovery: try the next standards-defined location.    }  }  return null;}; const protectedResourceMetadataUrlsFor = (resource: string): readonly string[] => {  const url = new URL(validateMigrationOAuthUrl(resource, "resource"));  const origin = `${url.protocol}//${url.host}`;  const path = url.pathname.replace(/\/+$/, "");  const urls: string[] = [];  if (path && path !== "/") {    urls.push(`${origin}/.well-known/oauth-protected-resource${path}`);  }  urls.push(`${origin}/.well-known/oauth-protected-resource`);  return urls;}; const discoverAuthorizationServersFromResource = async (  resource: string,  fetchImpl: MigrationOAuthMetadataFetch,  timeoutMs: number,): Promise<readonly string[]> => {  for (const metadataUrl of protectedResourceMetadataUrlsFor(resource)) {    try {      const metadata = decodeOAuthProtectedResourceMetadata(        await fetchOAuthJson(metadataUrl, fetchImpl, timeoutMs),      );      return metadata.authorization_servers ?? [];    } catch {      // Best-effort fallback discovery: try the next protected-resource location.    }  }  return [];}; const pushCandidate = (candidates: string[], value: string | null | undefined): void => {  const trimmed = value?.trim();  if (!trimmed || candidates.includes(trimmed)) return;  candidates.push(trimmed);}; const resolveMigrationOAuthAuthorizationUrl = async (  client: MigrationPlan["oauthClients"][number],  fetchImpl: MigrationOAuthMetadataFetch,  timeoutMs: number,): Promise<string | null> => {  const metadataUrl = client.authorizationServerMetadataUrl?.trim();  if (metadataUrl) {    // Best-effort like the resource/issuer discovery below: a dead, invalid,    // or unreachable metadata endpoint (offline machine, archived server)    // must not abort the whole migration — fall through to discovery.    try {      const endpoint = await fetchOAuthAuthorizationEndpoint(        validateMigrationOAuthUrl(metadataUrl, "authorizationServerMetadataUrl"),        fetchImpl,        timeoutMs,      );      if (endpoint) return endpoint;    } catch {      // fall through to issuer/resource discovery    }  }   if (client.grant !== "authorization_code") return null;   const candidates: string[] = [];  if (client.resource?.trim()) {    try {      for (const issuer of await discoverAuthorizationServersFromResource(        client.resource,        fetchImpl,        timeoutMs,      )) {        pushCandidate(candidates, issuer);      }    } catch {      // Best-effort: fall through to the stored authorization URL candidate.    }  }  pushCandidate(candidates, client.authorizationUrl);   for (const candidate of candidates) {    const endpoint = await resolveAuthorizationEndpointFromIssuer(candidate, fetchImpl, timeoutMs);    if (endpoint) return endpoint;  }  return null;}; export const migrationOAuthClientNeedsAuthorizationUrlResolution = (  client: MigrationPlan["oauthClients"][number],): boolean =>  !!client.authorizationServerMetadataUrl?.trim() ||  (client.grant === "authorization_code" &&    (!!client.resource?.trim() || !!client.authorizationUrl.trim())); export const migrationOAuthClientAuthorizationUrlResolutionSource = (  client: MigrationPlan["oauthClients"][number],): string =>  client.authorizationServerMetadataUrl?.trim() ||  client.resource?.trim() ||  client.authorizationUrl.trim(); export const resolveMigrationOAuthAuthorizationUrls = async (  plan: MigrationPlan,  options: ResolveMigrationOAuthAuthorizationUrlsOptions = {},): Promise<ReadonlyMap<string, string>> => {  const clientsToResolve = plan.oauthClients.filter(    migrationOAuthClientNeedsAuthorizationUrlResolution,  );  if (clientsToResolve.length === 0) return new Map();   const fetchImpl = options.fetch;  if (!fetchImpl) {    throw new Error("OAuth metadata resolution requires an injected fetch implementation.");  }  const timeoutMs = options.timeoutMs ?? DEFAULT_OAUTH_METADATA_TIMEOUT_MS;  const endpointByPlanKey = new Map<string, Promise<string | null>>();  const resolved = new Map<string, string>();   for (const client of clientsToResolve) {    const key = migrationOAuthClientPlanKey(client);    let endpoint = endpointByPlanKey.get(key);    if (!endpoint) {      endpoint = resolveMigrationOAuthAuthorizationUrl(client, fetchImpl, timeoutMs);      endpointByPlanKey.set(key, endpoint);    }    const authorizationUrl = await endpoint;    if (authorizationUrl) resolved.set(key, authorizationUrl);  }   return resolved;}; export const migrationOAuthAuthorizationUrlFor = (  client: MigrationPlan["oauthClients"][number],  resolvedUrls: ReadonlyMap<string, string>,): string => resolvedUrls.get(migrationOAuthClientPlanKey(client)) ?? client.authorizationUrl;