/* 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;
/* 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;