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