diff --git a/.vscode/settings.json b/.vscode/settings.json index dc1a054c6..75e3a4478 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "vue" ], "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "i18n-ally.localesPaths": [ "server/locales" diff --git a/package.json b/package.json index 458a6d789..5c157ba75 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "js-yaml": "3.14.0", "jsdom": "16.4.0", "jsonwebtoken": "9.0.0", + "jwks-rsa": "3.1.0", "katex": "0.12.0", "klaw": "3.0.0", "knex": "0.21.7", diff --git a/server/helpers/jwt.js b/server/helpers/jwt.js new file mode 100644 index 000000000..5a19aa11a --- /dev/null +++ b/server/helpers/jwt.js @@ -0,0 +1,47 @@ +const jwt = require('jsonwebtoken') +const jwksClient = require('jwks-rsa') + +/** + * Function to get the signing key for a specific token. + * @param {Object} header - JWT header containing the `kid`. + * @returns {Promise} - Resolves with the signing key. + */ +function getSigningKey(header, jwksUri) { + return new Promise((resolve, reject) => { + const client = jwksClient({ jwksUri }) + client.getSigningKey(header.kid, (err, key) => { + if (err) { + return reject(new Error('Error getting signing key: ' + err)) + } + resolve(key.getPublicKey()) + }) + }) +} + +/** + * Verifies a JWT token using a public key from JWKS. + * @param {string} token - The JWT token to verify. + * @param {Object} conf - Configuration object containing `issuer` and `clientId`. + * @returns {Promise} - Resolves with the decoded token if verification is successful. + */ +async function verifyJwt(token, conf) { + try { + const decodedHeader = jwt.decode(token, { complete: true }) + if (!decodedHeader || !decodedHeader.header) { + throw new Error('JWT verification failed: Invalid token header') + } + const signingKey = await getSigningKey(decodedHeader.header, conf.jwksUri) + const decoded = jwt.verify(token, signingKey, { + algorithms: conf.algorithms || ['RS256'], + issuer: conf.issuer, + audience: conf.clientId + }) + return decoded + } catch (err) { + throw new Error('JWT verification failed: ' + err.message) + } +} + +module.exports = { + verifyJwt +} diff --git a/server/modules/authentication/oidc/authentication.js b/server/modules/authentication/oidc/authentication.js index 4c7383e4b..857af9c8a 100644 --- a/server/modules/authentication/oidc/authentication.js +++ b/server/modules/authentication/oidc/authentication.js @@ -1,4 +1,5 @@ const _ = require('lodash') +const { verifyJwt } = require('../../../helpers/jwt') /* global WIKI */ @@ -9,33 +10,60 @@ const _ = require('lodash') const OpenIDConnectStrategy = require('passport-openidconnect').Strategy module.exports = { - init (passport, conf) { - passport.use(conf.key, - new OpenIDConnectStrategy({ + async init(passport, conf) { + try { + let oidcConfig = { + issuer: conf.issuer, authorizationURL: conf.authorizationURL, tokenURL: conf.tokenURL, + userInfoURL: conf.userInfoURL, clientID: conf.clientId, clientSecret: conf.clientSecret, - issuer: conf.issuer, - userInfoURL: conf.userInfoURL, callbackURL: conf.callbackURL, + scope: 'profile email ' + conf.scope, passReqToCallback: true, skipUserProfile: conf.skipUserProfile, acrValues: conf.acrValues - }, async (req, iss, uiProfile, idProfile, context, idToken, accessToken, refreshToken, params, cb) => { - const profile = Object.assign({}, idProfile, uiProfile) - + } + if (conf.wellKnownURL) { + try { + const response = await fetch(conf.wellKnownURL) + if (!response.ok) throw new Error(response.statusText) + const wellKnown = await response.json() + if (!oidcConfig.issuer) oidcConfig.issuer = wellKnown.issuer + if (!oidcConfig.authorizationURL) oidcConfig.authorizationURL = wellKnown.authorization_endpoint + if (!oidcConfig.tokenURL) oidcConfig.tokenURL = wellKnown.token_endpoint + if (!oidcConfig.userInfoURL) oidcConfig.userInfoURL = wellKnown.userinfo_endpoint + oidcConfig.jwksUri = wellKnown.jwks_uri + oidcConfig.idTokenSigningAlgValuesSupported = wellKnown.id_token_signing_alg_values_supported + } catch (error) { + WIKI.logger.error('Error fetching OIDC well-known configuration:', error) + } + } + passport.use(conf.key, new OpenIDConnectStrategy(oidcConfig, async (req, iss, uiProfile, idProfile, context, idToken, accessToken, refreshToken, params, cb) => { + let idTokenClaims = {} + if (conf.mergeIdTokenClaims && idToken) { + idTokenClaims = await verifyJwt(idToken, { + issuer: oidcConfig.issuer, + clientId: oidcConfig.clientID, + jwksUri: oidcConfig.jwksUri, + algorithms: oidcConfig.idTokenSigningAlgValuesSupported + }) + } + // Merge claims from ID token and profile, with idProfile taking precedence + const profile = { ...idTokenClaims, ...idProfile } try { const user = await WIKI.models.users.processProfile({ providerKey: req.params.strategy, profile: { ...profile, - email: _.get(profile, '_json.' + conf.emailClaim), - displayName: _.get(profile, '_json.' + conf.displayNameClaim, '') + id: _.get(profile, conf.userIdClaim), + displayName: _.get(profile, conf.displayNameClaim, 'Unknown User'), + email: _.get(profile, conf.emailClaim) } }) if (conf.mapGroups) { - const groups = _.get(profile, '_json.' + conf.groupsClaim) + const groups = _.get(profile, conf.groupsClaim) if (groups && _.isArray(groups)) { const currentGroups = (await user.$relatedQuery('groups').select('groups.id')).map(g => g.id) const expectedGroups = Object.values(WIKI.auth.groups).filter(g => groups.includes(g.name)).map(g => g.id) @@ -51,14 +79,12 @@ module.exports = { } catch (err) { cb(err, null) } - }) - ) - }, - logout (conf) { - if (!conf.logoutURL) { - return '/' - } else { - return conf.logoutURL + })) + } catch (err) { + WIKI.logger.error(`Error initializing OpenID Connect strategy: ${err}`) } + }, + logout(conf) { + return conf.logoutURL || '/' } } diff --git a/server/modules/authentication/oidc/definition.yml b/server/modules/authentication/oidc/definition.yml index 774575c1b..468015660 100644 --- a/server/modules/authentication/oidc/definition.yml +++ b/server/modules/authentication/oidc/definition.yml @@ -7,66 +7,62 @@ color: blue-grey darken-2 website: http://openid.net/connect/ isAvailable: true useForm: false -scopes: - - openid - - profile - - email props: - clientId: + wellKnownURL: type: String - title: Client ID - hint: Application Client ID + title: Well-Known Configuration URL + hint: The Well-Known configuration Endpoint URL (e.g. https://provider/.well-known/openid-configuration) order: 1 - clientSecret: - type: String - title: Client Secret - hint: Application Client Secret - order: 2 authorizationURL: type: String title: Authorization Endpoint URL - hint: Application Authorization Endpoint URL - order: 3 + hint: Application Authorization Endpoint URL (overrides value from well-known URL if set) + order: 2 tokenURL: type: String title: Token Endpoint URL - hint: Application Token Endpoint URL - order: 4 + hint: Application Token Endpoint URL (overrides value from well-known URL if set) + order: 3 userInfoURL: type: String title: User Info Endpoint URL - hint: User Info Endpoint URL + hint: User Info Endpoint URL (overrides value from well-known URL if set) + order: 4 + issuer: + type: String + title: Issuer URL + hint: Issuer URL (overrides value from well-known URL if set) order: 5 - skipUserProfile: - type: Boolean - default: false - title: Skip User Profile - hint: Skips call to the OIDC UserInfo endpoint + clientId: + type: String + title: Client ID + hint: Application Client ID order: 6 - issuer: + clientSecret: type: String - title: Issuer - hint: Issuer URL + title: Client Secret + hint: Application Client Secret order: 7 + userIdClaim: + type: String + title: User Id Claim + hint: Field containing the unique user identifier + default: sub + maxWidth: 500 + order: 8 emailClaim: type: String title: Email Claim hint: Field containing the email address default: email maxWidth: 500 - order: 8 + order: 9 displayNameClaim: type: String title: Display Name Claim hint: Field containing the user display name - default: displayName + default: name maxWidth: 500 - order: 9 - mapGroups: - type: Boolean - title: Map Groups - hint: Map groups matching names from the groups claim value - default: false order: 10 groupsClaim: type: String @@ -75,13 +71,37 @@ props: default: groups maxWidth: 500 order: 11 + mergeIdTokenClaims: + type: Boolean + title: Merge ID Token Claims + hint: If enabled, verifies the ID token and merges its claims into the user profile + default: false + order: 12 + mapGroups: + type: Boolean + title: Map Groups + hint: Map groups matching names from the groups claim value + default: false + order: 13 + skipUserProfile: + type: Boolean + default: false + title: Skip User Profile + hint: Skips call to the OIDC UserInfo endpoint + order: 14 logoutURL: type: String title: Logout URL hint: (optional) Logout URL on the OAuth2 provider where the user will be redirected to complete the logout process. - order: 12 + order: 15 + scope: + type: String + title: Additional Scopes + hint: (optional) Additional space-separated OIDC scopes (e.g. 'offline_access groups') - openid, profile and email are always included + maxWidth: 500 + order: 16 acrValues: type: String title: ACR Values hint: (optional) Authentication Context Class Reference - order: 13 + order: 17