diff --git a/lib/resources/oidc.js b/lib/resources/oidc.js index 86274d8bd..7b2785ac4 100644 --- a/lib/resources/oidc.js +++ b/lib/resources/oidc.js @@ -13,7 +13,14 @@ // OpenID Connect auth handling using Authorization Code Flow with PKCE. // TODO document _why_ auth-code-flow, and not e.g. implicit flow? -const { generators } = require('openid-client'); +const { // eslint-disable-line object-curly-newline + authorizationCodeGrant, + buildAuthorizationUrl, + calculatePKCECodeChallenge, + fetchUserInfo, + randomPKCECodeVerifier, + randomState, +} = require('openid-client'); // eslint-disable-line object-curly-newline const config = require('config'); const { parse, render } = require('mustache'); @@ -22,9 +29,8 @@ 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 @@ -95,7 +101,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(); }; @@ -105,19 +111,20 @@ module.exports = (service, endpoint) => { service.get('/oidc/login', endpoint.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, }); @@ -137,20 +144,21 @@ module.exports = (service, endpoint) => { service.get('/oidc/callback', endpoint.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) { @@ -164,7 +172,7 @@ module.exports = (service, endpoint) => { 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 ce2de861a..1ca468236 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 (err) { // 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: ${err}`); diff --git a/package-lock.json b/package-lock.json index bd02f8ac8..4967be21a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "mustache": "~2.3", "nodemailer": "~6", "odata-v4-parser": "~0.1", - "openid-client": "^5.4.3", + "openid-client": "^6.1.7", "path-to-regexp": "^6.2.2", "pg": "~8", "pg-query-stream": "~4", @@ -6222,9 +6222,10 @@ } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -7742,6 +7743,15 @@ "node": ">=6" } }, + "node_modules/oauth4webapi": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz", + "integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==", + "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", @@ -7828,14 +7838,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==", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -8003,15 +8005,6 @@ } } }, - "node_modules/oidc-provider/node_modules/jose": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz", - "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/oidc-provider/node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -8061,6 +8054,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "dev": true, "engines": { "node": "^10.13.0 || >=12.0.0" } @@ -8099,14 +8093,13 @@ "dev": true }, "node_modules/openid-client": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.3.tgz", - "integrity": "sha512-sVQOvjsT/sbSfYsQI/9liWQGVZH/Pp3rrtlGEwgk/bbHfrUDZ24DN57lAagIwFtuEu+FM9Ev7r85s8S/yPjimQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.1.7.tgz", + "integrity": "sha512-JfY/KvQgOutmG2P+oVNKInE7zIh+im1MQOaO7g5CtNnTWMociA563WweiEMKfR9ry9XG3K2HGvj9wEqhCQkPMg==", + "license": "MIT", "dependencies": { - "jose": "^4.14.4", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" + "jose": "^5.9.6", + "oauth4webapi": "^3.1.4" }, "funding": { "url": "https://github.com/sponsors/panva" @@ -16237,9 +16230,9 @@ } }, "jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==" + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==" }, "js-git": { "version": "0.7.8", @@ -17399,6 +17392,11 @@ } } }, + "oauth4webapi": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz", + "integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -17465,11 +17463,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.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -17590,12 +17583,6 @@ "ms": "2.1.2" } }, - "jose": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz", - "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==", - "dev": true - }, "jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -17625,7 +17612,8 @@ "oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", - "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==" + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "dev": true }, "on-finished": { "version": "2.3.0", @@ -17655,14 +17643,12 @@ "dev": true }, "openid-client": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.3.tgz", - "integrity": "sha512-sVQOvjsT/sbSfYsQI/9liWQGVZH/Pp3rrtlGEwgk/bbHfrUDZ24DN57lAagIwFtuEu+FM9Ev7r85s8S/yPjimQ==", - "requires": { - "jose": "^4.14.4", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.1.7.tgz", + "integrity": "sha512-JfY/KvQgOutmG2P+oVNKInE7zIh+im1MQOaO7g5CtNnTWMociA563WweiEMKfR9ry9XG3K2HGvj9wEqhCQkPMg==", + "requires": { + "jose": "^5.9.6", + "oauth4webapi": "^3.1.4" } }, "optionator": { diff --git a/package.json b/package.json index 5bb0feb5e..8ebe8d1c6 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "mustache": "~2.3", "nodemailer": "~6", "odata-v4-parser": "~0.1", - "openid-client": "^5.4.3", + "openid-client": "^6.1.7", "path-to-regexp": "^6.2.2", "pg": "~8", "pg-query-stream": "~4",