Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved OIDC authentication #7445

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions server/helpers/jwt.js
Original file line number Diff line number Diff line change
@@ -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<string>} - 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<Object>} - 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
};
104 changes: 60 additions & 44 deletions server/modules/authentication/oidc/authentication.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const _ = require('lodash')
const { verifyJwt } = require('../../../helpers/jwt')

/* global WIKI */

Expand All @@ -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 || '/'
}
}
50 changes: 27 additions & 23 deletions server/modules/authentication/oidc/definition.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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