sc-idp/lib/oidc/provider.js
2026-06-01 16:40:54 -05:00

124 lines
4.6 KiB
JavaScript

// 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
};