agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
324 lines • 15.5 kB
JavaScript
;
/**
* oidcSignIn — browser sign-in for the `oidc-token` strategy: the OpenID
* Connect authorization-code flow, through `openid-client`.
*
* **Pending independent review.** The sign-in door is where the classic
* sign-in bugs live; the owner gates the release of this piece on an outside
* human security review (design Q5). It is built and tested; it is not yet
* cleared for a company install.
*
* Pattern: Adapter over `openid-client` (an OpenID-certified relying party by
* the author of `jose`), behind the door's {@link RedirectSignIn}
* port. Optional peer, loaded lazily — the `jose` precedent.
* Role: the protocol half. The sign-in door owns the cookies, `returnTo`
* and the callback order; this file builds the authorization URL and
* turns a callback into a person.
*
* ── One verification path (design §5.3, Q1) ────────────────────────────────
* The callback does NOT read the person out of the ID token. The same code
* exchange returns an ACCESS token for this API, and the strategy's own
* `verify` — the person test included — reads the person out of that. So a
* person signing in through the browser and the same person calling with a
* bearer token get the same `userId` by construction. The ID token only binds
* the login to this browser (`nonce`) and is then dropped; nothing is stored.
*
* ── What `openid-client` checks, and what this file adds ───────────────────
* `openid-client` checks `state`, the code exchange (client authentication +
* PKCE verifier), and the ID token (signature, `iss`, `aud` = the client id,
* `nonce`, `exp`, the clock tolerance). This file requires an ID token in every
* token response (with PKCE off the nonce is the only defence against an
* injected code), then runs `verify` on the access token.
*
* ── Client authentication ───────────────────────────────────────────────────
* A private key (`private_key_jwt`, RFC 7523) is preferred: nothing shared
* leaves the server. A client secret (`client_secret_basic`) is accepted —
* design §5.2: "a secret file is accepted". Exactly one of the two.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.oidcSignIn = exports.OidcSignInSetupError = exports.MissingOpenIdClientError = void 0;
const node_crypto_1 = require("node:crypto");
const errors_js_1 = require("../../hosting/errors.js");
const types_js_1 = require("../../hosting/signin/types.js");
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
/** Raised when browser sign-in is configured and `openid-client` is not installed. */
class MissingOpenIdClientError extends Error {
code = 'ERR_MISSING_OPENID_CLIENT';
constructor() {
super('oidc-token browser sign-in requires the `openid-client` peer dependency.\n' +
' Install: npm install openid-client');
this.name = 'MissingOpenIdClientError';
}
}
exports.MissingOpenIdClientError = MissingOpenIdClientError;
/**
* A browser sign-in setting that can never work, found by `ready()` at boot —
* `setting` names which: the client key (not RSA / EC P-256, or unreadable)
* or the IdP's discovery document (no authorization or token endpoint, or
* PKCE required and `S256` not offered).
*/
class OidcSignInSetupError extends Error {
code = 'ERR_OIDC_SIGN_IN_SETUP';
setting;
constructor(setting, sentence) {
super(`[identity] oidcSignIn: ${sentence}.`);
this.name = 'OidcSignInSetupError';
this.setting = setting;
}
}
exports.OidcSignInSetupError = OidcSignInSetupError;
function oidcSignIn(options) {
checkOptions(options);
const pkce = (options.pkce ?? 'required') === 'required';
// The library and the client authentication: loaded ONCE, and at boot by
// `ready()` — a missing library or a key of the wrong type is a refusal to
// start, never a login that answers `unavailable` (review idI57 S-1).
let loaded;
const pieces = () => (loaded ??= (async () => {
const lib = options.backend ?? (await loadOpenIdClient());
const auth = options.credential.kind === 'private-key'
? lib.PrivateKeyJwt(await importSigningKey(options.credential.pem))
: lib.ClientSecretBasic(options.credential.secret);
return { lib, auth };
})().catch((err) => {
loaded = undefined;
throw err;
}));
let configuration;
const configured = () => (configuration ??= (async () => {
const state = await options.verifier.discover();
if (state.kind !== 'ready')
throw new types_js_1.RedirectSignInError('unavailable');
const issuer = state.issuer;
const problem = issuerProblem(issuer, pkce);
if (problem !== undefined)
throw new OidcSignInSetupError('discovery', problem);
const { lib, auth } = await pieces();
const config = new lib.Configuration({
issuer: issuer.issuer,
authorization_endpoint: issuer.authorizationEndpoint,
token_endpoint: issuer.tokenEndpoint,
jwks_uri: issuer.jwksUri,
...(issuer.endSessionEndpoint !== undefined && {
end_session_endpoint: issuer.endSessionEndpoint,
}),
// RFC 9207: advertised ⇒ required on the callback (review idI57 N-1).
...(issuer.issParameterSupported === true && {
authorization_response_iss_parameter_supported: true,
}),
}, options.clientId, {}, auth);
config[lib.clockTolerance] = options.clockToleranceSeconds ?? 60;
if (options.allowLoopbackHttp === true)
lib.allowInsecureRequests(config);
return { lib, config, issuer };
})().catch((err) => {
configuration = undefined;
throw err;
}));
return {
strategy: 'oidc-token',
pkce,
async ready() {
await pieces();
const state = await options.verifier.discover();
// An outage is not a refusal: the first login tries again.
if (state.kind !== 'ready')
return;
const problem = issuerProblem(state.issuer, pkce);
if (problem !== undefined)
throw new OidcSignInSetupError('discovery', problem);
},
async authorizationUrl(attempt, redirectUri) {
const { lib, config } = await configured();
const parameters = {
response_type: 'code',
redirect_uri: redirectUri,
scope: options.scope,
state: attempt.state,
nonce: attempt.nonce,
...(options.resource !== undefined && { resource: options.resource }),
};
if (pkce) {
if (attempt.codeVerifier === undefined)
throw new types_js_1.RedirectSignInError('unavailable');
parameters.code_challenge = await lib.calculatePKCECodeChallenge(attempt.codeVerifier);
parameters.code_challenge_method = 'S256';
}
return lib.buildAuthorizationUrl(config, parameters).href;
},
async complete(callback, attempt) {
if (callback.searchParams.has('error'))
throw new types_js_1.RedirectSignInError('idp-error');
const { lib, config } = await configured();
let tokens;
try {
tokens = await lib.authorizationCodeGrant(config, callback, {
expectedState: attempt.state,
expectedNonce: attempt.nonce,
idTokenExpected: true,
...(pkce && { pkceCodeVerifier: attempt.codeVerifier }),
});
}
catch (err) {
throw new types_js_1.RedirectSignInError(classifyGrantFailure(err));
}
// Every response must carry an ID token (the nonce binds the login to
// this browser) and an access token for this API (the person).
if (typeof tokens.id_token !== 'string' || tokens.id_token.length === 0) {
throw new types_js_1.RedirectSignInError('id-token-refused');
}
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
throw new types_js_1.RedirectSignInError('not-a-person');
}
try {
const identity = await options.verifier.verify(tokens.access_token);
const shown = options.displayNameClaim === undefined
? undefined
: identity.claims?.[options.displayNameClaim];
return {
identity: {
userId: identity.userId,
...(identity.roles !== undefined && { roles: identity.roles }),
},
...(typeof shown === 'string' && shown.length > 0 && { displayName: shown }),
};
}
catch (err) {
if (err instanceof errors_js_1.VerifierUnavailableError)
throw new types_js_1.RedirectSignInError('unavailable');
if (err instanceof errors_js_1.IdentityNotVerifiedError)
throw new types_js_1.RedirectSignInError('not-a-person');
throw new types_js_1.RedirectSignInError('not-a-person');
}
},
async endSessionUrl(postLogoutRedirectUri) {
const { lib, config, issuer } = await configured();
if (issuer.endSessionEndpoint === undefined)
return undefined;
return lib.buildEndSessionUrl(config, {
client_id: options.clientId,
post_logout_redirect_uri: postLogoutRedirectUri,
}).href;
},
};
}
exports.oidcSignIn = oidcSignIn;
/**
* The grant failed: a network fault is `unavailable` (503-shaped); anything the
* IdP or the ID-token check refused is a refusal. Only the CLASS travels — the
* library's message can quote the IdP.
*/
function classifyGrantFailure(err) {
const e = err;
const code = typeof e?.code === 'string' ? e.code : '';
if (e?.name === 'TypeError' || /TIMEOUT/i.test(code) || code === 'OAUTH_RESPONSE_IS_NOT_JSON') {
return 'unavailable';
}
if (code === 'OAUTH_AUTHORIZATION_RESPONSE_ERROR')
return 'idp-error';
// The token endpoint said no (a spent code, a bad client credential, a
// PKCE verifier that does not match).
if (code === 'OAUTH_RESPONSE_BODY_ERROR' || code === 'OAUTH_WWW_AUTHENTICATE_CHALLENGE') {
return 'exchange-failed';
}
// Everything else is the response failing a check: no ID token, a nonce,
// an audience, an issuer or a signature that does not match.
return 'id-token-refused';
}
/** Why a discovered issuer cannot run browser sign-in, or `undefined`. */
function issuerProblem(issuer, pkce) {
if (issuer.authorizationEndpoint === undefined || issuer.tokenEndpoint === undefined) {
return `the IdP's discovery document names no ${issuer.authorizationEndpoint === undefined ? 'authorization_endpoint' : 'token_endpoint'}, so a browser cannot sign in through it`;
}
const methods = issuer.codeChallengeMethods;
if (pkce && methods !== undefined && !methods.includes('S256')) {
return `PKCE is required, and the IdP's discovery document offers ${methods.length === 0 ? 'no PKCE method' : methods.join(', ')} but not S256`;
}
return undefined;
}
/** A PEM PKCS#8 private key → a Web Crypto signing key (RS256 or ES256). */
async function importSigningKey(pem) {
let key;
try {
key = (0, node_crypto_1.createPrivateKey)(pem);
}
catch {
throw new OidcSignInSetupError('client-key', 'the client key does not parse as a private key (an encrypted PEM is not accepted)');
}
const der = key.export({ type: 'pkcs8', format: 'der' });
const type = key.asymmetricKeyType;
if (type === 'rsa') {
const imported = await node_crypto_1.webcrypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
return { key: imported };
}
if (type === 'ec' && key.asymmetricKeyDetails?.namedCurve === 'prime256v1') {
const imported = await node_crypto_1.webcrypto.subtle.importKey('pkcs8', der, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
return { key: imported };
}
throw new OidcSignInSetupError('client-key', 'the client key must be an RSA or an EC P-256 private key (PKCS#8 PEM)');
}
function checkOptions(options) {
const refuse = (sentence) => {
throw new TypeError(`[identity] oidcSignIn: ${sentence}.`);
};
if (typeof options.clientId !== 'string' || options.clientId.length === 0)
refuse('clientId is required');
if (!/(^|\s)openid(\s|$)/.test(options.scope ?? ''))
refuse(`scope must include 'openid'`);
const credential = options.credential;
if (credential?.kind === 'private-key') {
if (typeof credential.pem !== 'string' || !credential.pem.includes('PRIVATE KEY')) {
refuse('the client key is a PEM private key');
}
}
else if (credential?.kind === 'secret') {
if (typeof credential.secret !== 'string' || credential.secret.length === 0) {
refuse('the client secret is empty');
}
}
else {
refuse('a client credential (a private key, or a secret) is required');
}
if (options.pkce !== undefined && options.pkce !== 'required' && options.pkce !== 'off') {
refuse(`pkce is 'required' or 'off'`);
}
}
/** Load `openid-client` (ESM-only) lazily, or refuse by name. */
async function loadOpenIdClient() {
try {
const spec = 'openid-client';
return (await Promise.resolve(`${spec}`).then(s => __importStar(require(s))));
}
catch {
try {
return (0, lazyRequire_js_1.lazyRequire)('openid-client');
}
catch {
throw new MissingOpenIdClientError();
}
}
}
//# sourceMappingURL=oidcSignIn.js.map