diff --git a/package.json b/package.json index 458a6d789e..5c157ba75d 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 0000000000..7b59a0e62f --- /dev/null +++ b/server/helpers/jwt.js @@ -0,0 +1,48 @@ +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('Error getting signing key:' + err); + } + const signingKey = key.getPublicKey(); + resolve(signingKey); + }); + }); +} + +/** + * 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 +}; \ No newline at end of file diff --git a/server/modules/authentication/oidc/authentication.js b/server/modules/authentication/oidc/authentication.js index 4c7383e4b0..7da5c13dc1 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,56 +10,71 @@ const _ = require('lodash') const OpenIDConnectStrategy = require('passport-openidconnect').Strategy module.exports = { - init (passport, conf) { - passport.use(conf.key, - new OpenIDConnectStrategy({ - authorizationURL: conf.authorizationURL, - tokenURL: conf.tokenURL, - clientID: conf.clientId, - clientSecret: conf.clientSecret, - issuer: conf.issuer, - userInfoURL: conf.userInfoURL, - callbackURL: conf.callbackURL, - 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) + async init (passport, conf) { + try { + const response = await fetch(conf.wellKnownURL) + if (!response.ok) throw new Error(`Failed to fetch well-known config: ${response.statusText}`) + const wellKnown = await response.json() - 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, '') - } - }) - if (conf.mapGroups) { - const groups = _.get(profile, '_json.' + 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) - for (const groupId of _.difference(expectedGroups, currentGroups)) { - await user.$relatedQuery('groups').relate(groupId) + passport.use(conf.key, + new OpenIDConnectStrategy({ + issuer: wellKnown.issuer, + authorizationURL: wellKnown.authorization_endpoint, + tokenURL: wellKnown.token_endpoint, + userInfoURL: wellKnown.userinfo_endpoint, + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL, + scope: conf.scope, + passReqToCallback: true, + skipUserProfile: conf.skipUserProfile, + acrValues: conf.acrValues + }, async (req, iss, uiProfile, idProfile, context, idToken, accessToken, refreshToken, params, cb) => { + let idTokenClaims = {} + if (conf.mergeIdTokenClaims && idToken) { + idTokenClaims = await verifyJwt(idToken, { + issuer: wellKnown.issuer, + clientId: conf.clientId, + jwksUri: wellKnown.jwks_uri, + algorithms: wellKnown.id_token_signing_alg_values_supported + }) + } + // 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, + id: _.get(profile, conf.userIdClaim), + displayName: _.get(profile, conf.displayNameClaim, '???'), + email: _.get(profile, conf.emailClaim), } - for (const groupId of _.difference(currentGroups, expectedGroups)) { - await user.$relatedQuery('groups').unrelate().where('groupId', groupId) + }) + if (conf.mapGroups) { + 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) + for (const groupId of _.difference(expectedGroups, currentGroups)) { + await user.$relatedQuery('groups').relate(groupId) + } + for (const groupId of _.difference(currentGroups, expectedGroups)) { + await user.$relatedQuery('groups').unrelate().where('groupId', groupId) + } } } + cb(null, user) + } catch (err) { + cb(err, null) } - cb(null, user) - } catch (err) { - cb(err, null) - } - }) - ) + }) + ) + } catch (error) { + console.error('Error initializing OpenID Connect strategy:', error) + } }, logout (conf) { - if (!conf.logoutURL) { - return '/' - } else { - return conf.logoutURL - } + return conf.logoutURL || '/' } } diff --git a/server/modules/authentication/oidc/definition.yml b/server/modules/authentication/oidc/definition.yml index 774575c1b7..33e4f757ff 100644 --- a/server/modules/authentication/oidc/definition.yml +++ b/server/modules/authentication/oidc/definition.yml @@ -22,66 +22,70 @@ props: title: Client Secret hint: Application Client Secret order: 2 - authorizationURL: + wellKnownURL: type: String - title: Authorization Endpoint URL - hint: Application Authorization Endpoint URL + title: Well-Known Configuration URL + hint: The Well-Known configuration Endpoint URL (e.g. https://provider/.well-known/openid-configuration) order: 3 - tokenURL: - type: String - title: Token Endpoint URL - hint: Application Token Endpoint URL - order: 4 - userInfoURL: - type: String - title: User Info Endpoint URL - hint: User Info Endpoint URL - order: 5 skipUserProfile: type: Boolean default: false title: Skip User Profile hint: Skips call to the OIDC UserInfo endpoint - order: 6 - issuer: + order: 4 + userIdClaim: + userIdClaim: type: String - title: Issuer - hint: Issuer URL - order: 7 + title: ID Claim + hint: Field containing the user ID + default: id + maxWidth: 500 + order: 5 emailClaim: type: String title: Email Claim hint: Field containing the email address default: email maxWidth: 500 - order: 8 + order: 6 displayNameClaim: type: String title: Display Name Claim hint: Field containing the user display name default: displayName maxWidth: 500 - order: 9 + order: 7 + 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: 8 mapGroups: type: Boolean title: Map Groups hint: Map groups matching names from the groups claim value default: false - order: 10 + order: 9 groupsClaim: type: String title: Groups Claim hint: Field containing the group names default: groups maxWidth: 500 - order: 11 + order: 10 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: 11 + scope: + type: String + title: Scope + hint: (optional) Application Client permission scopes. order: 12 acrValues: type: String title: ACR Values hint: (optional) Authentication Context Class Reference - order: 13 + order: 13 \ No newline at end of file