chore(deps)!: openid-client 5 -> 6 (4j)

v6 replaced the Issuer/Client classes with a functional API. The four
touchpoints rewritten: discovery() + ClientSecretBasic/Post for the cached
configuration, serverMetadata() for the admin config test,
buildAuthorizationUrl() (returns URL -> .href), and authorizationCodeGrant()
with pkceCodeVerifier/expectedNonce/expectedState for the callback exchange —
same validation surface (JWKS signature, iss/aud/exp/nbf, PKCE, state, nonce),
now enforced by the library's single grant call.

scripts/test-oidc-smoke.js: 44/44. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 18:05:57 -05:00
parent 522eaaf101
commit 1627e75b29
3 changed files with 53 additions and 78 deletions

65
package-lock.json generated
View File

@ -38,7 +38,7 @@
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"openid-client": "^5.7.1", "openid-client": "^6.8.4",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",
@ -8843,9 +8843,9 @@
} }
}, },
"node_modules/jose": { "node_modules/jose": {
"version": "4.15.9", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
@ -9808,6 +9808,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -9817,15 +9826,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -9883,15 +9883,6 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -9914,38 +9905,18 @@
} }
}, },
"node_modules/openid-client": { "node_modules/openid-client": {
"version": "5.7.1", "version": "6.8.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jose": "^4.15.9", "jose": "^6.2.2",
"lru-cache": "^6.0.0", "oauth4webapi": "^3.8.5"
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",

View File

@ -61,7 +61,7 @@
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"openid-client": "^5.7.1", "openid-client": "^6.8.4",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",

View File

@ -51,7 +51,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const { Issuer } = require('openid-client'); const oidc = require('openid-client'); // v6 functional API (discovery/buildAuthorizationUrl/authorizationCodeGrant)
const { getDb, getSetting, setSetting } = require('../db/database.cts'); const { getDb, getSetting, setSetting } = require('../db/database.cts');
const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); const { encryptSecret, decryptSecret } = require('./encryptionService.cts');
@ -462,20 +462,20 @@ function getPublicOidcInfo() {
} }
} }
// ── openid-client Issuer / Client cache ─────────────────────────────────────── // ── openid-client Configuration cache ─────────────────────────────────────────
// Cache keyed by issuer/client/redirect so Admin config changes pick up a // Cache keyed by issuer/client/redirect so Admin config changes pick up a
// fresh client. TTL prevents stale JWKS when the provider rotates keys. // fresh configuration. TTL prevents stale JWKS when the provider rotates keys.
let _cachedClient = null; let _cachedClient = null;
let _cachedClientKey = null; let _cachedClientKey = null;
let _cacheTs = 0; let _cacheTs = 0;
const CLIENT_CACHE_TTL = 60 * 60 * 1000; // 1 hour — JWKS key rotation window const CLIENT_CACHE_TTL = 60 * 60 * 1000; // 1 hour — JWKS key rotation window
/** /**
* Returns a cached openid-client Client for the given config. * Returns a cached openid-client v6 Configuration for the given config.
* Performs OIDC discovery (fetching jwks_uri and endpoints) on first call * Performs OIDC discovery (fetching jwks_uri and endpoints) on first call
* or when the cache has expired. Signature verification uses the discovered * or when the cache has expired. Signature verification uses the discovered
* JWKS URI automatically via openid-client@5. * JWKS automatically via openid-client.
*/ */
async function getOidcClient(config) { async function getOidcClient(config) {
const now = Date.now(); const now = Date.now();
@ -489,14 +489,16 @@ async function getOidcClient(config) {
return _cachedClient; return _cachedClient;
} }
const issuer = await Issuer.discover(config.issuerUrl); const clientAuth =
_cachedClient = new issuer.Client({ config.tokenEndpointAuthMethod === 'client_secret_post'
client_id: config.clientId, ? oidc.ClientSecretPost(config.clientSecret)
client_secret: config.clientSecret, : oidc.ClientSecretBasic(config.clientSecret);
token_endpoint_auth_method: config.tokenEndpointAuthMethod, _cachedClient = await oidc.discovery(
redirect_uris: [config.redirectUri], new URL(config.issuerUrl),
response_types: ['code'], config.clientId,
}); { redirect_uris: [config.redirectUri], response_types: ['code'] },
clientAuth,
);
_cachedClientKey = clientKey; _cachedClientKey = clientKey;
_cacheTs = now; _cacheTs = now;
return _cachedClient; return _cachedClient;
@ -513,7 +515,7 @@ async function testOidcConfiguration(config = getOidcConfig()) {
try { try {
const client = await getOidcClient(config); const client = await getOidcClient(config);
const metadata = client.issuer?.metadata || {}; const metadata = client.serverMetadata() || {};
const missingMetadata = []; const missingMetadata = [];
if (!metadata.authorization_endpoint) missingMetadata.push('authorization_endpoint'); if (!metadata.authorization_endpoint) missingMetadata.push('authorization_endpoint');
if (!metadata.token_endpoint) missingMetadata.push('token_endpoint'); if (!metadata.token_endpoint) missingMetadata.push('token_endpoint');
@ -618,15 +620,15 @@ async function buildAuthorizationUrl(config, state) {
const client = await getOidcClient(config); const client = await getOidcClient(config);
const codeChallenge = generateCodeChallenge(state.codeVerifier); const codeChallenge = generateCodeChallenge(state.codeVerifier);
// client.authorizationUrl() uses the discovered authorization_endpoint // oidc.buildAuthorizationUrl() uses the discovered authorization_endpoint
return client.authorizationUrl({ return oidc.buildAuthorizationUrl(client, {
scope: config.scopes.join(' '), scope: config.scopes.join(' '),
redirect_uri: config.redirectUri, redirect_uri: config.redirectUri,
state: state.id, state: state.id,
nonce: state.nonce, nonce: state.nonce,
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); }).href;
} }
// ── Token exchange + full ID token verification ─────────────────────────────── // ── Token exchange + full ID token verification ───────────────────────────────
@ -650,21 +652,23 @@ async function buildAuthorizationUrl(config, state) {
async function exchangeAndVerifyTokens(config, code, stateId, savedState) { async function exchangeAndVerifyTokens(config, code, stateId, savedState) {
const client = await getOidcClient(config); const client = await getOidcClient(config);
// client.callback() handles token exchange + full validation in one step. // authorizationCodeGrant() handles token exchange + full validation in one
// It throws OPError (provider error) or RPError (validation failure) on any problem. // step (signature via discovered JWKS, iss/aud/exp/nbf, PKCE, state, nonce).
const tokenSet = await client.callback( // It throws on any problem. The "current URL" is the callback URL as the
config.redirectUri, // provider redirected to it.
{ code, state: stateId }, // parameters from the callback URL const currentUrl = new URL(config.redirectUri);
{ currentUrl.searchParams.set('code', code);
code_verifier: savedState.code_verifier, currentUrl.searchParams.set('state', stateId);
nonce: savedState.nonce,
state: stateId, // ensures state param matches expected value
},
);
const claims = tokenSet.claims(); const tokens = await oidc.authorizationCodeGrant(client, currentUrl, {
pkceCodeVerifier: savedState.code_verifier,
expectedNonce: savedState.nonce,
expectedState: stateId,
});
if (!claims.sub) throw new Error('ID token missing required sub claim'); const claims = tokens.claims();
if (!claims?.sub) throw new Error('ID token missing required sub claim');
return claims; return claims;
} }