124 lines
4.6 KiB
JavaScript
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
|
|
};
|