sc-idp/lib/oidc/provider.js
2026-06-18 17:22:33 -05:00

133 lines
5.3 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);
// The per-worker cache is invalidated by the current signing-set fingerprint
// (read fresh from the DB), not just by issuer. In a clustered single-node
// deployment a rotation on ONE worker updates the shared DB but can only clear
// its OWN in-process cache; every other worker would keep serving the stale
// baked-in JWKS until restarted. Comparing the fingerprint here makes each
// worker rebuild on its next request, so all workers converge on the rotated
// signing key and JWKS. loadKeys() is a direct _sc_config read, so the
// fingerprint reflects writes from any worker immediately.
const fingerprint = await keys.signingSetFingerprint();
let entry = providersByIssuer.get(issuer);
if (!entry || entry.fingerprint !== fingerprint) {
const provider = await buildProvider(issuer);
entry = { provider: provider, handler: provider.callback(), fingerprint: fingerprint };
providersByIssuer.set(issuer, entry);
}
return entry;
};
// Drop this worker's cached Provider(s) so its next request rebuilds with the
// current active signing key. Called by keys.rotateActiveKey() as a local
// fast-path: the rotating worker rebuilds immediately rather than waiting for the
// fingerprint comparison in getProviderEntry to notice the change. OTHER workers
// are handled by that fingerprint check (this clear only affects the calling
// process), so cross-worker convergence does not depend on this call.
const clearProviderCache = () => {
providersByIssuer.clear();
};
module.exports = {
getProviderEntry,
clearProviderCache
};