Source: discovery.js

import { httpRequest, readJson } from './http.js';
import { ValidationError } from './errors.js';

const cache = new Map();

/**
 * Builds the OpenID Connect Discovery 1.0 §4 URL: the well-known suffix is appended to the issuer.
 * @param {string} issuer
 */
export function oidcDiscoveryUrl(issuer) {
  return `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
}

/**
 * Builds RFC 8414 §3-style well-known URLs: the suffix is inserted between host and path.
 * Some deployments instead append it (OIDC style), so both are returned in preference order.
 * @param {string} issuer
 * @param {string} suffix e.g. "ssf-configuration"
 */
export function wellKnownUrls(issuer, suffix) {
  const u = new URL(issuer);
  const path = u.pathname.replace(/\/+$/, '');
  const inserted = `${u.origin}/.well-known/${suffix}${path}`;
  const appended = `${u.origin}${path}/.well-known/${suffix}`;
  return inserted === appended ? [inserted] : [inserted, appended];
}

/**
 * Fetches metadata from the first URL that answers 2xx and checks that its `issuer` matches exactly.
 * @param {string[]} urls
 * @param {string} expectedIssuer
 * @param {{ timeoutMs?: number, fetch?: typeof fetch, cacheTtlMs?: number }} [opts]
 */
export async function fetchMetadata(urls, expectedIssuer, { cacheTtlMs = 60 * 60_000, ...httpOpts } = {}) {
  const cacheKey = urls.join('|');
  const hit = cache.get(cacheKey);
  if (hit && hit.expiresAt > Date.now()) return hit.metadata;

  let lastError;
  for (const url of urls) {
    const res = await httpRequest(url, { ...httpOpts, headers: { accept: 'application/json' } });
    if (res.status === 404 && url !== urls.at(-1)) {
      await res.body?.cancel();
      continue;
    }
    try {
      const metadata = await readJson(res);
      if (!metadata || typeof metadata !== 'object') throw new ValidationError(`Metadata at ${url} is not a JSON object`);
      // Discovery §4.3 / RFC 8414 §3.3: issuer MUST be identical to the one used to build the URL.
      if (metadata.issuer !== expectedIssuer) {
        throw new ValidationError(
          `Issuer mismatch in ${url}: expected "${expectedIssuer}", got "${metadata.issuer}"`,
          { code: 'issuer_mismatch' },
        );
      }
      cache.set(cacheKey, { metadata, expiresAt: Date.now() + cacheTtlMs });
      return metadata;
    } catch (err) {
      lastError = err;
      break;
    }
  }
  throw lastError ?? new ValidationError(`No metadata found for ${expectedIssuer}`);
}

const REQUIRED_OP_METADATA = [
  'issuer',
  'authorization_endpoint',
  'token_endpoint',
  'jwks_uri',
  'response_types_supported',
  'subject_types_supported',
  'id_token_signing_alg_values_supported',
];

/**
 * Validates an OpenID Provider metadata document (OIDC Discovery 1.0 §3) for use by a code-flow RP.
 * @param {Record<string, any>} metadata
 * @param {string} [expectedIssuer] When given, `issuer` must match exactly (§4.3).
 * @returns {Record<string, any>} the same metadata
 */
export function validateOpenIdProviderMetadata(metadata, expectedIssuer) {
  if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
    throw new ValidationError('OpenID Provider metadata must be a JSON object');
  }
  const missing = REQUIRED_OP_METADATA.filter((k) => metadata[k] === undefined);
  if (missing.length) {
    throw new ValidationError(`OpenID Provider metadata is missing required fields: ${missing.join(', ')}`);
  }
  if (expectedIssuer !== undefined && metadata.issuer !== expectedIssuer) {
    throw new ValidationError(`Issuer mismatch: expected "${expectedIssuer}", got "${metadata.issuer}"`, { code: 'issuer_mismatch' });
  }
  for (const key of ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'userinfo_endpoint', 'end_session_endpoint', 'revocation_endpoint']) {
    if (metadata[key] === undefined) continue;
    let url;
    try {
      url = new URL(metadata[key]);
    } catch {
      throw new ValidationError(`OpenID Provider metadata ${key} is not a valid URL`);
    }
    if (key === 'issuer' && (url.search || url.hash)) {
      throw new ValidationError('issuer must not contain a query or fragment');
    }
  }
  if (!Array.isArray(metadata.response_types_supported) || !metadata.response_types_supported.includes('code')) {
    throw new ValidationError('OpenID Provider does not support response_type=code');
  }
  if (!Array.isArray(metadata.id_token_signing_alg_values_supported)) {
    throw new ValidationError('id_token_signing_alg_values_supported must be an array');
  }
  return metadata;
}

/**
 * Retrieves and validates OpenID Provider metadata from `<issuer>/.well-known/openid-configuration`.
 * @param {string} issuer
 * @param {{ timeoutMs?: number, fetch?: typeof fetch, cacheTtlMs?: number }} [opts]
 */
export async function discoverOpenIdProvider(issuer, opts) {
  const metadata = await fetchMetadata([oidcDiscoveryUrl(issuer)], issuer, opts);
  return validateOpenIdProviderMetadata(metadata, issuer);
}

/**
 * Retrieves SSF 1.0 transmitter metadata from `/.well-known/ssf-configuration`
 * (RFC 8414-style path insertion first, then the appended form).
 * @param {string} issuer
 * @param {{ timeoutMs?: number, fetch?: typeof fetch, cacheTtlMs?: number }} [opts]
 */
export async function discoverSSFTransmitter(issuer, opts) {
  const metadata = await fetchMetadata(wellKnownUrls(issuer, 'ssf-configuration'), issuer, opts);
  if (!metadata.jwks_uri) throw new ValidationError('SSF transmitter metadata has no jwks_uri');
  return metadata;
}

/** Clears the in-process metadata cache (mostly useful in tests). */
export function clearDiscoveryCache() {
  cache.clear();
}