// Per-issuer oidc-provider instances. // // oidc-provider v9 is ESM-only, so it is loaded via dynamic import() from this // CommonJS module. One Provider is built lazily and cached per issuer URL (the // issuer is the tenant hostname + IDP_BASE_PATH), so multi-tenant / multi-host // deployments each get their own issuer, signing key, and clients. // // The Provider is fed our active signing key as a PRIVATE JWK (it both signs // id_tokens and serves the public half at the JWKS endpoint). Cookie-signing // keys are derived deterministically from SALTCORN_SESSION_SECRET so interaction // sessions survive a restart. const constants = require("../constants"); const crypto = require("../crypto"); const keys = require("../keys"); const claims = require("../claims"); const User = require("@saltcorn/data/models/user"); const SaltcornAdapter = require("./adapter"); const { issuerForReq } = require("./discovery"); const COOKIE_KEY_INFO = "saltcorn-idp:oidc-cookie-keys:v1"; const COOKIE_KEY_BYTES = 32; let providerClassPromise = null; const providersByIssuer = new Map(); const loadProviderClass = async () => { if (!providerClassPromise) { providerClassPromise = import("oidc-provider").then((m) => m.default); } return providerClassPromise; }; const buildProvider = async (issuer) => { const Provider = await loadProviderClass(); // Feed the FULL signing set (active first, then any RETIRING keys) so that // after a key rotation the rotated-out key remains in JWKS and id_tokens it // signed keep verifying through the grace window; oidc-provider signs new // tokens with the first key matching the alg (the active one). const signingJwks = await keys.getSigningPrivateJwks(); if (!signingJwks.length) { throw new Error("saltcorn-idp: no active signing key for issuer " + issuer); } const cookieKey = crypto.deriveSecretHex(COOKIE_KEY_INFO, COOKIE_KEY_BYTES); const secure = issuer.startsWith("https://"); const provider = new Provider(issuer, { adapter: SaltcornAdapter, jwks: { keys: signingJwks }, cookies: { keys: [cookieKey], long: { secure: secure, signed: true }, short: { secure: secure, signed: true } }, // No static clients: all relying parties are looked up dynamically from // the _idp_clients registry via the Client model in SaltcornAdapter. clients: [], // Put scope-granted claims in the id_token too (not only userinfo), so // relying parties can read groups/email straight from the id_token. conformIdTokenClaims: false, claims: { openid: ["sub"], email: ["email", "email_verified"], profile: ["name"], groups: ["groups"] }, scopes: ["openid", "email", "profile", "groups"], features: { devInteractions: { enabled: false } }, interactions: { url: async (ctx, interaction) => { return constants.IDP_BASE_PATH + "/interaction/" + interaction.uid; } }, // Authenticate EXISTING Saltcorn users only; never auto-provision. findAccount: async (ctx, sub) => { const uid = parseInt(sub, 10); const user = Number.isFinite(uid) ? await User.findOne({ id: uid }) : null; if (!user) { return undefined; } return { accountId: sub, claims: async (use, scope) => { return claims.oidcClaims(user, sub, scope); } }; } }); provider.proxy = true; return provider; }; const getProviderEntry = async (req) => { const issuer = issuerForReq(req); let entry = providersByIssuer.get(issuer); if (!entry) { const provider = await buildProvider(issuer); entry = { provider: provider, handler: provider.callback() }; providersByIssuer.set(issuer, entry); } return entry; }; // Drop the cached Provider(s) so the next request rebuilds with the current // active signing key. Each Provider is constructed with the active private JWK // baked in (jwks config), so after a key rotation the cached instances would // keep signing id_tokens with the OLD key and serving the OLD JWKS; clearing the // cache forces a rebuild. The key material itself lives in the DB, so a rebuilt // Provider picks up the freshly-rotated key. Called by keys.rotateActiveKey(). const clearProviderCache = () => { providersByIssuer.clear(); }; module.exports = { getProviderEntry, clearProviderCache };