UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

174 lines • 7.38 kB
/** * verify/discovery — read an issuer's OpenID discovery document, and say which * of three things happened. * * The classification is the point (rule 9: config errors refuse at boot, * outages answer 503). A mistyped issuer path that answers 404 is REACHABLE BUT * WRONG; calling it an outage would turn a typo into a permanent 503 that * nobody is ever told to fix. So: * * • `outage` — a network error, a timeout, or a 5xx. The IdP may come back. * • `misconfigured` — any other non-2xx (a redirect included: it is never * followed), a body that is not a JSON object, a * document with no `issuer` or no `jwks_uri`, an `issuer` that is not the * configured one (compared exactly — OpenID Connect Discovery §4.3), or a * key-set URL that is not `https`. The configuration names the wrong thing. * • `ready` — a document that passed every check above. * * `access_token_issuer` (AD FS) is read from a `ready` document only. AD FS * signs access tokens with `iss` = `http://<host>/adfs/services/trust` while * its discovery `issuer` is `https://<host>/adfs`; a verifier pinned to the * second refuses every AD FS access token. That value is COMPARED, never * fetched, so the https rule does not apply to it. * * Nothing here throws: the three outcomes are values, and the caller decides * what each means at boot and at request time. */ import { isLoopbackBind } from '../../../hosting/doorGuard.js'; import { isSlash, trimTrailing } from '../../../lib/linearText.js'; /** The document's endpoint fields: each must be a URL this library may call. */ const ENDPOINTS = [ ['authorization_endpoint', 'authorizationEndpoint'], ['token_endpoint', 'tokenEndpoint'], ['end_session_endpoint', 'endSessionEndpoint'], ]; /** `<issuer>/.well-known/openid-configuration`, a trailing `/` removed first. */ export function discoveryUrlFor(issuer) { return `${trimTrailing(issuer, isSlash)}/.well-known/openid-configuration`; } /** * Why `raw` is not a URL this library may fetch a credential-bearing answer * from — or `undefined` when it is. `https`, or `http` on a loopback host when * the caller allows it. */ export function fetchableUrlProblem(raw, allowLoopbackHttp) { let url; try { url = new URL(raw); } catch { return 'is not an absolute URL'; } if (url.protocol === 'https:') return undefined; if (url.protocol === 'http:') { if (allowLoopbackHttp && isLoopbackBind(url.hostname)) return undefined; return allowLoopbackHttp ? 'is plain http on a host that is not this machine — use https' : 'is plain http — use https (http is accepted only for localhost, outside production)'; } return `uses ${url.protocol} — use https`; } /** Fetch and classify. Never throws. */ export async function readDiscovery(configuredIssuer, options) { const url = discoveryUrlFor(configuredIssuer); let status; let text; try { const signal = AbortSignal.timeout(options.timeoutMs); const res = await options.fetch(url, { signal, headers: { accept: 'application/json' }, // Never followed: the document chooses the signing keys, so it is read // from the issuer's own URL or not at all (OIDC Discovery §4). redirect: 'manual', }); status = res.status; text = await res.text(); } catch (err) { const name = err?.name; const timedOut = name === 'TimeoutError' || name === 'AbortError'; return { kind: 'outage', reason: timedOut ? `${url} did not answer within ${options.timeoutMs} ms` : `${url} could not be reached`, }; } if (status >= 500) return { kind: 'outage', reason: `${url} answered HTTP ${status}` }; if (status >= 300 && status < 400) { return { kind: 'misconfigured', check: `${url} redirected (HTTP ${status}). The document is read only from the issuer's own ` + `URL — a redirect could hand the choice of signing keys to another origin, or to ` + `plain http — so the issuer must be the URL the document lives under.`, }; } if (status < 200 || status >= 300) { return { kind: 'misconfigured', check: `${url} answered HTTP ${status}. The issuer must be the URL the discovery document ` + `lives under (for AD FS, 'https://<host>/adfs'; for Entra ID, ` + `'https://login.microsoftonline.com/<tenant>/v2.0').`, }; } return checkDocument(configuredIssuer, url, text, options.allowLoopbackHttp); } function checkDocument(configuredIssuer, url, text, allowLoopbackHttp) { let doc; try { doc = JSON.parse(text); } catch { return { kind: 'misconfigured', check: `${url} did not answer JSON` }; } if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { return { kind: 'misconfigured', check: `${url} did not answer a JSON object` }; } const { issuer, jwks_uri: jwksUri, access_token_issuer: ati } = doc; if (typeof issuer !== 'string' || issuer.length === 0) { return { kind: 'misconfigured', check: `${url} names no 'issuer'` }; } if (issuer !== configuredIssuer) { return { kind: 'misconfigured', check: `${url} names the issuer '${issuer}', not the configured '${configuredIssuer}'. ` + `They are compared exactly, trailing '/' included — copy the document's value.`, }; } if (typeof jwksUri !== 'string' || jwksUri.length === 0) { return { kind: 'misconfigured', check: `${url} names no 'jwks_uri'` }; } const keysProblem = fetchableUrlProblem(jwksUri, allowLoopbackHttp); if (keysProblem !== undefined) { return { kind: 'misconfigured', check: `the document's jwks_uri '${jwksUri}' ${keysProblem}` }; } if (ati !== undefined && (typeof ati !== 'string' || ati.length === 0)) { return { kind: 'misconfigured', check: `${url} names an 'access_token_issuer' that is not a string`, }; } const endpoints = {}; for (const [field, name] of ENDPOINTS) { const value = doc[field]; if (value === undefined) continue; const problem = typeof value === 'string' ? fetchableUrlProblem(value, allowLoopbackHttp) : 'is not a string'; if (problem !== undefined) { return { kind: 'misconfigured', check: `the document's ${field} ${problem}` }; } endpoints[name] = value; } const record = doc; const methods = record.code_challenge_methods_supported; return { kind: 'ready', issuer: { issuer, jwksUri, ...(typeof ati === 'string' && { accessTokenIssuer: ati }), ...endpoints, ...(record.authorization_response_iss_parameter_supported === true && { issParameterSupported: true, }), ...(Array.isArray(methods) && { codeChallengeMethods: methods.filter((m) => typeof m === 'string'), }), }, }; } //# sourceMappingURL=discovery.js.map