diff --git a/lib/resources/oidc.js b/lib/resources/oidc.js index 6556ea2ef..8dce6791d 100644 --- a/lib/resources/oidc.js +++ b/lib/resources/oidc.js @@ -15,18 +15,24 @@ const { createHash } = require('node:crypto'); -const { generators } = require('openid-client'); const config = require('config'); const { parse, render } = require('mustache'); +const { // eslint-disable-line object-curly-newline + authorizationCodeGrant, + buildAuthorizationUrl, + calculatePKCECodeChallenge, + fetchUserInfo, + randomPKCECodeVerifier, + randomState, +} = require('openid-client'); // eslint-disable-line object-curly-newline const { safeNextPathFrom } = require('../util/html'); const { redirect } = require('../util/http'); const { createUserSession } = require('../http/sessions'); const { // eslint-disable-line object-curly-newline CODE_CHALLENGE_METHOD, - RESPONSE_TYPE, SCOPES, - getClient, + getOidcConfig, getRedirectUri, isEnabled, } = require('../util/oidc'); // eslint-disable-line camelcase,object-curly-newline @@ -100,7 +106,7 @@ const loaderTemplate = ` `; parse(loaderTemplate); // caches template for future perf. -const stateFor = next => [ generators.state(), Buffer.from(next).toString('base64url') ].join(':'); +const stateFor = next => [ randomState(), Buffer.from(next).toString('base64url') ].join(':'); const nextFrom = state => { if (state) return Buffer.from(state.split(':')[1], 'base64url').toString(); }; @@ -110,19 +116,20 @@ module.exports = (service, __, anonymousEndpoint) => { service.get('/oidc/login', anonymousEndpoint.html(async ({ Sentry }, _, req, res) => { try { - const client = await getClient(); - const code_verifier = generators.codeVerifier(); // eslint-disable-line camelcase + const oidcConfig = await getOidcConfig(); + const code_verifier = randomPKCECodeVerifier(); // eslint-disable-line camelcase - const code_challenge = generators.codeChallenge(code_verifier); // eslint-disable-line camelcase + const code_challenge = await calculatePKCECodeChallenge(code_verifier); // eslint-disable-line camelcase const next = req.query.next ?? ''; const state = stateFor(next); - const authUrl = client.authorizationUrl({ + const authUrl = buildAuthorizationUrl(oidcConfig, { scope: SCOPES.join(' '), resource: `${envDomain}/v1`, code_challenge, code_challenge_method: CODE_CHALLENGE_METHOD, + redirect_uri: getRedirectUri(), state, }); @@ -142,20 +149,21 @@ module.exports = (service, __, anonymousEndpoint) => { service.get('/oidc/callback', anonymousEndpoint.html(async (container, _, req, res) => { try { - const code_verifier = req.cookies[CODE_VERIFIER_COOKIE]; // eslint-disable-line camelcase - const state = req.cookies[STATE_COOKIE]; // eslint-disable-line no-multi-spaces + const pkceCodeVerifier = req.cookies[CODE_VERIFIER_COOKIE]; + const expectedState = req.cookies[STATE_COOKIE]; // eslint-disable-line no-multi-spaces res.clearCookie(CODE_VERIFIER_COOKIE, callbackCookieProps); res.clearCookie(STATE_COOKIE, callbackCookieProps); // eslint-disable-line no-multi-spaces - const client = await getClient(); - - const params = client.callbackParams(req); + const oidcConfig = await getOidcConfig(); - const tokenSet = await client.callback(getRedirectUri(), params, { response_type: RESPONSE_TYPE, code_verifier, state }); + // N.B. use req.originalUrl in preference to req.url, as the latter is corrupted somewhere upstream. + const requestUrl = new URL(req.originalUrl, getRedirectUri()); - const { access_token } = tokenSet; + const tokens = await authorizationCodeGrant(oidcConfig, requestUrl, { pkceCodeVerifier, expectedState }); - const userinfo = await client.userinfo(access_token); + const { access_token } = tokens; + const expectedSubject = tokens.claims().sub; + const userinfo = await fetchUserInfo(oidcConfig, access_token, expectedSubject); const { email, email_verified } = userinfo; if (!email) { @@ -169,7 +177,7 @@ module.exports = (service, __, anonymousEndpoint) => { await initSession(container, req, res, user); - const nextPath = safeNextPathFrom(nextFrom(state)); + const nextPath = safeNextPathFrom(nextFrom(expectedState)); // This redirect would be ideal, but breaks `SameSite: Secure` cookies. // return redirect(303, nextPath); diff --git a/lib/util/oidc.js b/lib/util/oidc.js index c9413ecd5..45ee94cce 100644 --- a/lib/util/oidc.js +++ b/lib/util/oidc.js @@ -27,13 +27,13 @@ module.exports = { CODE_CHALLENGE_METHOD, RESPONSE_TYPE, SCOPES, - getClient, + getOidcConfig, getRedirectUri, isEnabled, }; const config = require('config'); -const { Issuer } = require('openid-client'); +const { allowInsecureRequests, discovery } = require('openid-client'); const oidcConfig = (config.has('default.oidc') && config.get('default.oidc')) || {}; @@ -48,19 +48,21 @@ function getRedirectUri() { return `${config.get('default.env.domain')}/v1/oidc/callback`; } -let clientLoader; // single instance, initialised lazily -function getClient() { - if (!clientLoader) clientLoader = initClient(); - return clientLoader; +let configLoader; // single instance, initialised lazily +function getOidcConfig() { + if (!configLoader) configLoader = initConfig(); + return configLoader; } -async function initClient() { +async function initConfig() { if (!isEnabled()) throw new Error('OIDC is not enabled.'); try { assertHasAll('config keys', Object.keys(oidcConfig), ['issuerUrl', 'clientId', 'clientSecret']); - const { issuerUrl } = oidcConfig; - const issuer = await Issuer.discover(issuerUrl); + const { issuerUrl, clientId, clientSecret } = oidcConfig; + const _issuerUrl = new URL(issuerUrl); + const execute = _issuerUrl.hostname === 'localhost' ? [ allowInsecureRequests ] : undefined; + const discoveredConfig = await discovery(_issuerUrl, clientId, clientSecret, undefined, { execute }); // eslint-disable-next-line object-curly-newline const { @@ -70,7 +72,7 @@ async function initClient() { response_types_supported, scopes_supported, token_endpoint_auth_methods_supported, - } = issuer.metadata; // eslint-disable-line object-curly-newline + } = discoveredConfig.serverMetadata(); // eslint-disable-line object-curly-newline // This code uses email to verify a user's identity. An unverified email // address is not suitable for verification. @@ -106,16 +108,7 @@ async function initClient() { assertHas('token signing alg', id_token_signing_alg_values_supported, TOKEN_SIGNING_ALG); assertHas('token endpoint auth method', token_endpoint_auth_methods_supported, TOKEN_ENDPOINT_AUTH_METHOD); - const client = new issuer.Client({ - client_id: oidcConfig.clientId, - client_secret: oidcConfig.clientSecret, - redirect_uris: [getRedirectUri()], - response_types: [RESPONSE_TYPE], - id_token_signed_response_alg: TOKEN_SIGNING_ALG, - token_endpoint_auth_method: TOKEN_ENDPOINT_AUTH_METHOD, - }); - - return client; + return discoveredConfig; } catch (cause) { // N.B. don't include the config here - it might include the client secret, perhaps in the wrong place. throw new Error('Failed to configure OpenID Connect client', { cause }); diff --git a/package-lock.json b/package-lock.json index ccdd347d8..7e15495b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "mustache": "^4.2.0", "nodemailer": "^8.0.4", "odata-v4-parser": "~0.1", - "openid-client": "^5.7.1", + "openid-client": "^6.8.3", "path-to-regexp": "^8.3.0", "pg": "~8.8.0", "pg-query-stream": "^4.14.0", @@ -11074,6 +11074,15 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11161,15 +11170,6 @@ "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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11429,6 +11429,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "dev": true, "license": "MIT", "engines": { "node": "^10.13.0 || >=12.0.0" @@ -11516,24 +11517,22 @@ "license": "MIT" }, "node_modules/openid-client": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", - "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.3.tgz", + "integrity": "sha512-AoY/NaN9esS3+xvHInFSK0g3skSfeE0uqQAKRj4rB6/GsBIvzwTUaYo9+HcqpKIaP0dP85p5W07hayKgS4GAeA==", "license": "MIT", "dependencies": { - "jose": "^4.15.9", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" }, "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/openid-client/node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -23955,6 +23954,11 @@ "yaml": "^1.10.0" } }, + "oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -24022,11 +24026,6 @@ } } }, - "object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" - }, "object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -24211,7 +24210,8 @@ "oidc-token-hash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", - "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==" + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "dev": true }, "on-finished": { "version": "2.3.0", @@ -24272,20 +24272,18 @@ } }, "openid-client": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", - "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.3.tgz", + "integrity": "sha512-AoY/NaN9esS3+xvHInFSK0g3skSfeE0uqQAKRj4rB6/GsBIvzwTUaYo9+HcqpKIaP0dP85p5W07hayKgS4GAeA==", "requires": { - "jose": "^4.15.9", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" }, "dependencies": { "jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==" } } }, diff --git a/package.json b/package.json index 7e4bf13b8..39722b6fc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "mustache": "^4.2.0", "nodemailer": "^8.0.4", "odata-v4-parser": "~0.1", - "openid-client": "^5.7.1", + "openid-client": "^6.8.3", "path-to-regexp": "^8.3.0", "pg": "~8.8.0", "pg-query-stream": "^4.14.0",