UNPKG

agentfootprint

Version:

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

303 lines • 16.6 kB
/** * strategies/choose — `identityFromConfig`: config in, one strategy out, or a * boot refusal that names the key. * * Pattern: Strategy (GoF), chosen by CONFIG at install time — one per * deployment, behind the existing {@link IdentityVerifier} port. * Role: the one place a deployment's identity settings are judged. Every * door (`standingAgent`, an app's own routes) takes what this returns. * * ── The laws it enforces at boot (rule 9) ─────────────────────────────────── * • **Production names its strategy**, even `open`. Missing config must not * mean "no identity". `production` is an input the APP supplies; the * library never guesses it from `NODE_ENV`. * • **A typo is not `open`.** An unknown strategy, an unknown key, or a key a * later release reads is refused by name. * • **Somebody configured a sign-in and forgot to switch it on**: strategy * unset while `oidc-token` keys are set is refused, in every environment. * • **A half-configured verifier is not a smaller verifier**: every required * key is required; `IDENTITY_USER_ID_CLAIM` has no default. * • **Config errors refuse; outages answer 503.** Discovery runs at boot. A * 404, a body that is not JSON, a document without `jwks_uri` or naming * another issuer refuses to boot. An IdP that cannot be reached lets the * app start, says so in the banner, and answers 503 until it is reachable. * * Asynchronous — a refinement of the design's shape, on purpose: a discovery * config error must refuse the BOOT, and discovery is a network read. */ import { pathLabel } from '../verify/claims.js'; import { discoveryUrlFor, fetchableUrlProblem } from '../verify/discovery.js'; import { MAX_CLOCK_TOLERANCE_SECONDS, oidcIdentity, } from '../oidc.js'; import { IdentityConfigError, readStrategy } from './config.js'; import { browserDoor, browserSettings } from './browserChoice.js'; import { directoryPasswordChoice } from './directoryChoice.js'; import { proxyTokenChoice } from './proxyChoice.js'; import { localPasswordChoice } from './localChoice.js'; import { KEYS_IN_A_LATER_RELEASE, KEYS_IN_THIS_RELEASE, STRATEGIES_IN_THIS_RELEASE, keyLabel, } from './vocabulary.js'; /** * Judge a deployment's identity config and build its strategy — or refuse to * boot with an {@link IdentityConfigError} naming the key. * * @example * const choice = await identityFromConfig(identityConfigFromEnv(process.env), { * production: process.env.NODE_ENV === 'production', * }); * for (const line of choice.banner) console.log(line); * const host = await standingAgent({ agent, sessions, host, identity: choice.identity }); */ export async function identityFromConfig(config, boot) { const production = checkBoot(boot); const fields = checkFields(config); if (config.strategy === undefined) return unsetStrategy(fields, production); const strategy = readStrategy(config.strategy); if (!STRATEGIES_IN_THIS_RELEASE.includes(strategy)) { throw new IdentityConfigError(`IDENTITY_STRATEGY is '${strategy}', which is not in this release. This release starts ` + `${STRATEGIES_IN_THIS_RELEASE.join(' and ')}; the others are named so a deployment ` + `that asks for one is refused rather than run as something else.`, 'IDENTITY_STRATEGY'); } if (strategy === 'open') return openChoice(fields, true); refuseForeignFields(strategy, fields); if (strategy === 'local-password') return localPasswordChoice(config, boot, production); if (strategy === 'directory-password') return directoryPasswordChoice(config, boot, production); if (strategy === 'proxy-token') return proxyTokenChoice(config, boot, production); return oidcChoice(config, boot, production); } // ─── The three outcomes ────────────────────────────────────────────── function unsetStrategy(fields, production) { if (production) { throw new IdentityConfigError(`IDENTITY_STRATEGY is not set, and this is production. Production names its strategy — ` + `even 'open' — so an env file that failed to load cannot start a door with no identity. ` + `Set IDENTITY_STRATEGY=open to run with no identity on purpose.`, 'IDENTITY_STRATEGY'); } if (fields.length > 0) { throw new IdentityConfigError(`${fields.map(keyLabel).join(', ')} ${fields.length === 1 ? 'is' : 'are'} set but ` + `IDENTITY_STRATEGY is not. Somebody configured a sign-in and did not switch it on. ` + `Set IDENTITY_STRATEGY=oidc-token, or remove the keys.`, 'IDENTITY_STRATEGY'); } return openChoice(fields, false); } function openChoice(fields, stated) { return { strategy: 'open', mode: 'open', banner: [ stated ? 'identity: strategy open' : 'identity: strategy open (IDENTITY_STRATEGY is not set; production would refuse this)', 'identity: no identity — anyone who can reach this port can use it, and the record names nobody', ...fields.map((f) => `identity: WARNING ${keyLabel(f)} is set, but the strategy is open, so it does nothing`), ], }; } async function oidcChoice(config, boot, production) { const browser = browserSettings(config); const options = oidcOptions(config, boot, production); const verifier = buildVerifier(options); const state = await verifier.discover(); if (state.kind === 'misconfigured') { throw new IdentityConfigError(`oidc-token discovery refused: ${state.check.replace(/\.$/, '')}. Check IDENTITY_ISSUER.`, 'IDENTITY_ISSUER'); } const banner = oidcBanner(options, state, production); if (browser === undefined) { return { strategy: 'oidc-token', mode: 'token-only', identity: { verify: verifier.verify }, banner: banner.map(bannerSafe), }; } const door = await browserDoor(browser, config, boot, production, verifier); return { strategy: 'oidc-token', mode: 'redirect', identity: door.identity, signInDoor: door, hostSignIn: door.hostSignIn, banner: [...banner, ...door.banner].map(bannerSafe), }; } // ─── oidc-token: required keys, then the verifier's options ────────── /** The verifier's own construction refusals are boot refusals too. */ export function buildVerifier(options) { try { return oidcIdentity(options); } catch (err) { const text = err instanceof Error ? err.message.replace(/^\[identity\] oidcIdentity: /, '') : String(err); throw new IdentityConfigError(`oidc-token cannot start: ${text}`); } } export function oidcOptions(config, boot, production, shape = { strategy: 'oidc-token', literalIssuer: false, }) { const required = (value, field, what) => requiredFor(shape.strategy, value, field, what); const missing = (field, what) => missingFor(shape.strategy, field, what); const issuer = required(config.issuer, 'issuer', shape.literalIssuer ? `the literal \`iss\` the tokens carry (compared exactly, never fetched)` : 'the issuer URL your IdP names in its discovery document'); if (!shape.literalIssuer) checkUrl(issuer, 'issuer', production); const audience = config.audience; if (audience === undefined || (typeof audience === 'string' && audience.trim() === '')) { missing('audience', `this API's name at the IdP (the Web API identifier on AD FS, the API registration's client id on Entra ID) — never a client's id`); } const userIdClaim = required(config.userIdClaim, 'userIdClaim', `the claim that names the person ('oid' on Entra ID, your objectGUID claim on AD FS). ` + `There is no default: 'sub' is pairwise per application on both Microsoft IdPs, and ` + `this claim decides who owns every conversation`); const requiredScope = required(config.requiredScope, 'requiredScope', `the OAuth scope only this API's PERSON tokens carry (Entra 'access_as_user'). Without ` + `it an application's own token passes as a person`); if (/\s/.test(requiredScope)) { throw new IdentityConfigError(`${keyLabel('requiredScope')} is ONE scope, with no spaces.`, 'IDENTITY_REQUIRED_SCOPE'); } const allowedClients = config.allowedClients ?? (config.clientId !== undefined ? [config.clientId] : undefined); if (allowedClients === undefined || (Array.isArray(allowedClients) && allowedClients.length === 0)) { missing('allowedClients', `the client ids allowed to obtain a person token for this API, comma-separated, or 'any' ` + `to turn the client check off. Required when browser sign-in is off (with it on, it ` + `defaults to IDENTITY_CLIENT_ID)`); } if (config.allowedClients === 'any' && production) { throw new IdentityConfigError(`IDENTITY_ALLOWED_CLIENTS is 'any', and this is production. On Keycloak and Okta a ` + `service account's token carries your API's scope, so the client check is the only ` + `thing that refuses an application posing as a person. List the clients people sign ` + `in through — each with service accounts / client credentials turned OFF.`, 'IDENTITY_ALLOWED_CLIENTS'); } if (config.clockToleranceSeconds !== undefined && config.clockToleranceSeconds > MAX_CLOCK_TOLERANCE_SECONDS) { throw new IdentityConfigError(`IDENTITY_CLOCK_TOLERANCE_SECONDS is at most ${MAX_CLOCK_TOLERANCE_SECONDS}: it tolerates ` + `clock skew, and a larger value is a second token lifetime nobody chose.`, 'IDENTITY_CLOCK_TOLERANCE_SECONDS'); } if (config.jwksUrl !== undefined) checkUrl(config.jwksUrl, 'jwksUrl', production); return { issuer, audience, userIdClaim, requiredScope, allowedClients, ...(config.scopeClaim !== undefined && { scopeClaim: config.scopeClaim }), ...(config.rolesClaim !== undefined && { rolesClaim: config.rolesClaim }), ...(config.jwksUrl !== undefined && { jwksUrl: config.jwksUrl }), ...(config.clockToleranceSeconds !== undefined && { clockToleranceSeconds: config.clockToleranceSeconds, }), allowLoopbackHttp: !production, ...(boot.fetch !== undefined && { fetch: boot.fetch }), ...(boot.backend !== undefined && { backend: boot.backend }), }; } function oidcBanner(options, state, production) { const clients = options.allowedClients === 'any' ? `any — the client check is OFF (development only; production refuses it)` : `${options.allowedClients.join(', ')} (each must have service accounts / client credentials turned off)`; const lines = [ 'identity: strategy oidc-token (bearer access tokens for this API)', `identity: issuer ${options.issuer}`, `identity: audience ${[options.audience].flat().join(', ')}`, `identity: user id claim ${options.userIdClaim} (taken as bytes: never trimmed or case-folded)`, `identity: person test — required scope '${options.requiredScope}' in '${options.scopeClaim ?? 'scp'}'; app-only tokens refused; allowed clients: ${clients}`, `identity: roles claim ${pathLabel(options.rolesClaim ?? 'roles')} (a string is one role)`, `identity: clock tolerance ${options.clockToleranceSeconds ?? 60} s; every token must carry exp`, ]; if (state.kind === 'ready') { lines.push(`identity: discovery ok — ${discoveryUrlFor(options.issuer)}`); if (state.issuer.accessTokenIssuer !== undefined && state.issuer.accessTokenIssuer !== options.issuer) { lines.push(`identity: access tokens may also carry iss ${state.issuer.accessTokenIssuer} (the document's access_token_issuer)`); } lines.push(`identity: signing keys ${options.jwksUrl ?? state.issuer.jwksUri}`); } else { lines.push(`identity: WARNING discovery is unreachable (${state.reason}). Every request answers 503 until a later request finds the document.`); } if (!production && /^http:/i.test(options.issuer)) { lines.push('identity: WARNING plain-http issuer accepted because this is not production'); } return lines; } // ─── Checks ────────────────────────────────────────────────────────── function checkBoot(boot) { if (boot === null || typeof boot !== 'object' || typeof boot.production !== 'boolean') { throw new IdentityConfigError(`identityFromConfig needs { production: true | false } from the app. The library never ` + `guesses it: an app that treats more than NODE_ENV as production must say so.`); } return boot.production; } /** The set fields other than `strategy`; an unknown field refuses. */ function checkFields(config) { if (config === null || typeof config !== 'object') { throw new IdentityConfigError('identityFromConfig needs a config object (identityConfigFromEnv builds one).'); } const known = new Set(KEYS_IN_THIS_RELEASE.map((k) => k.field)); const set = []; for (const [field, value] of Object.entries(config)) { if (value === undefined) continue; if (!known.has(field)) refuseUnknownField(field); refuseControlCharacters(field, value); if (field !== 'strategy') set.push(field); } return set; } /** * No value may carry a control character: a CR/LF in a setting would forge a * banner line ("identity: person test — all checks on"), and no real issuer, * audience, claim or client id contains one. */ function refuseControlCharacters(field, value) { const values = Array.isArray(value) ? value : [value]; // eslint-disable-next-line no-control-regex if (values.some((v) => typeof v === 'string' && /[\u0000-\u001f\u007f]/.test(v))) { const key = KEYS_IN_THIS_RELEASE.find((k) => k.field === field); throw new IdentityConfigError(`${keyLabel(field)} contains a control character (a line break, a tab, …). Settings ` + `are single-line text.`, key?.env); } } /** A banner line with any control character escaped — belt and braces for values from a document. */ export function bannerSafe(line) { // eslint-disable-next-line no-control-regex const control = /[\u0000-\u001f\u007f]/g; return line.replace(control, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); } /** A key another strategy reads is refused: somebody believes it is doing something. */ function refuseForeignFields(strategy, fields) { for (const field of fields) { const key = KEYS_IN_THIS_RELEASE.find((k) => k.field === field); if (key?.owners === undefined || key.owners.includes(strategy)) continue; throw new IdentityConfigError(`${keyLabel(field)} belongs to ${key.owners.join(' / ')}, not ${strategy}. Remove it.`, key.env); } } function refuseUnknownField(field) { const env = `IDENTITY_${field.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`; const later = KEYS_IN_A_LATER_RELEASE[env]; throw new IdentityConfigError(later !== undefined ? `'${field}' belongs to ${later}, which is not in this release. Remove it.` : `'${field}' is not a setting this release reads. The settings are: ` + `${KEYS_IN_THIS_RELEASE.map((k) => k.field).join(', ')}.`, later !== undefined ? env : undefined); } function requiredFor(strategy, value, field, what) { if (typeof value !== 'string' || value.trim().length === 0) missingFor(strategy, field, what); return value; } function missingFor(strategy, field, what) { const key = KEYS_IN_THIS_RELEASE.find((k) => k.field === field); throw new IdentityConfigError(`${strategy} needs ${keyLabel(field)}: ${what}.`, key?.env); } export function checkUrl(url, field, production) { const problem = fetchableUrlProblem(url, !production); if (problem === undefined) return; const key = KEYS_IN_THIS_RELEASE.find((k) => k.field === field); throw new IdentityConfigError(`${keyLabel(field)} '${url}' ${problem}.`, key?.env); } //# sourceMappingURL=choose.js.map