diff --git a/server/Auth.js b/server/Auth.js index 15e36576..54fa52a4 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -306,82 +306,108 @@ class Auth { // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { - // helper function from openid-client - function pick(object, ...paths) { - const obj = {} - for (const path of paths) { - if (object[path] !== undefined) { - obj[path] = object[path] + try { + // helper function from openid-client + function pick(object, ...paths) { + const obj = {} + for (const path of paths) { + if (object[path] !== undefined) { + obj[path] = object[path] + } } + return obj } - return obj - } - // Get the OIDC client from the strategy - // We need to call the client manually, because the strategy does not support forwarding the code challenge - // for API or mobile clients - const oidcStrategy = passport._strategy('openid-client') - oidcStrategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() - const client = oidcStrategy._client - const sessionKey = oidcStrategy._key + // Get the OIDC client from the strategy + // We need to call the client manually, because the strategy does not support forwarding the code challenge + // for API or mobile clients + const oidcStrategy = passport._strategy('openid-client') + oidcStrategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() + const client = oidcStrategy._client + const sessionKey = oidcStrategy._key - let code_challenge - let code_challenge_method + let code_challenge + let code_challenge_method - // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) - // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow - // and as such will not send a code challenge, we will generate then one - if (req.query.code_challenge) { - code_challenge = req.query.code_challenge - code_challenge_method = req.query.code_challenge_method || 'S256' + // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) + // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow + // and as such will not send a code challenge, we will generate then one + if (req.query.code_challenge) { + code_challenge = req.query.code_challenge + code_challenge_method = req.query.code_challenge_method || 'S256' - if (!['S256', 'plain'].includes(code_challenge_method)) { - return res.status(400).send('Invalid code_challenge_method') + if (!['S256', 'plain'].includes(code_challenge_method)) { + return res.status(400).send('Invalid code_challenge_method') + } + } else { + // If no code_challenge is provided, assume a web application flow and generate one + const code_verifier = OpenIDClient.generators.codeVerifier() + code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + code_challenge_method = 'S256' + + // Store the code_verifier in the session for later use in the token exchange + req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } } - } else { - // If no code_challenge is provided, assume a web application flow and generate one - const code_verifier = OpenIDClient.generators.codeVerifier() - code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) - code_challenge_method = 'S256' - // Store the code_verifier in the session for later use in the token exchange - req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } + const params = { + state: OpenIDClient.generators.random(), + // Other params by the passport strategy + ...oidcStrategy._params + } + + if (!params.nonce && params.response_type.includes('id_token')) { + params.nonce = OpenIDClient.generators.random() + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + ...pick(params, 'nonce', 'state', 'max_age', 'response_type') + } + + // Now get the URL to direct to + const authorizationUrl = client.authorizationUrl({ + ...params, + scope: 'openid profile email', + response_type: 'code', + code_challenge, + code_challenge_method, + }) + + // params (isRest, callback) to a cookie that will be send to the client + this.paramsToCookies(req, res) + + // Redirect the user agent (browser) to the authorization URL + res.redirect(authorizationUrl) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid route: ${error}`) + res.status(500).send('Internal Server Error') } - - const params = { - state: OpenIDClient.generators.random(), - // Other params by the passport strategy - ...oidcStrategy._params - } - - if (!params.nonce && params.response_type.includes('id_token')) { - params.nonce = OpenIDClient.generators.random() - } - - req.session[sessionKey] = { - ...req.session[sessionKey], - ...pick(params, 'nonce', 'state', 'max_age', 'response_type') - } - - // Now get the URL to direct to - const authorizationUrl = client.authorizationUrl({ - ...params, - scope: 'openid profile email', - response_type: 'code', - code_challenge, - code_challenge_method, - }) - - // params (isRest, callback) to a cookie that will be send to the client - this.paramsToCookies(req, res) - - // Redirect the user agent (browser) to the authorization URL - res.redirect(authorizationUrl) }) // openid strategy callback route (this receives the token from the configured openid login provider) - router.get('/auth/openid/callback', - passport.authenticate('openid-client'), + router.get('/auth/openid/callback', (req, res, next) => { + const oidcStrategy = passport._strategy('openid-client') + const sessionKey = oidcStrategy._key + + if (!req.session[sessionKey]) { + return res.status(400).send('No session') + } + + // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request + // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request + // Crucial for API/Mobile clients + if (req.query.code_verifier) { + req.session[sessionKey].code_verifier = req.query.code_verifier + } + + // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request + // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided + if (req.session[sessionKey].mobile) { + return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next) + } else { + return passport.authenticate('openid-client')(req, res, next) + } + }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this))