const axios = require('axios') const passport = require('passport') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') const LocalStrategy = require('./libs/passportLocal') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') /** * @class Class for handling all the authentication related functionality. */ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() } /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes("local")) { this.initAuthStrategyPassword() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { this.initAuthStrategyOpenID() } // Load the JwtStrategy (always) -> for bearer token auth passport.use(new JwtStrategy({ jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), secretOrKey: Database.serverSettings.tokenSecret }, this.jwtAuthCheck.bind(this))) // define how to seralize a user (to be put into the session) passport.serializeUser(function (user, cb) { process.nextTick(function () { // only store id to session return cb(null, JSON.stringify({ id: user.id, })) }) }) // define how to deseralize a user (use the ID to get it from the database) passport.deserializeUser((function (user, cb) { process.nextTick((async function () { const parsedUserInfo = JSON.parse(user) // load the user by ID that is stored in the session const dbUser = await Database.userModel.getUserById(parsedUserInfo.id) return cb(null, dbUser) }).bind(this)) }).bind(this)) } /** * Passport use LocalStrategy */ initAuthStrategyPassword() { passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) } /** * Passport use OpenIDClient.Strategy */ initAuthStrategyOpenID() { if (!Database.serverSettings.isOpenIDAuthSettingsValid) { Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) return } const openIdIssuerClient = new OpenIDClient.Issuer({ issuer: global.ServerSettings.authOpenIDIssuerURL, authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, token_endpoint: global.ServerSettings.authOpenIDTokenURL, userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, jwks_uri: global.ServerSettings.authOpenIDJwksURL, end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL }).Client const openIdClient = new openIdIssuerClient({ client_id: global.ServerSettings.authOpenIDClientID, client_secret: global.ServerSettings.authOpenIDClientSecret }) passport.use('openid-client', new OpenIDClient.Strategy({ client: openIdClient, params: { redirect_uri: '/auth/openid/callback', scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { Logger.debug(`[Auth] openid callback userinfo=`, userinfo) let failureMessage = 'Unauthorized' if (!userinfo.sub) { Logger.error(`[Auth] openid callback invalid userinfo, no sub`) return done(null, null, failureMessage) } // First check for matching user by sub let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) if (!user) { // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) user = await Database.userModel.getUserByEmail(userinfo.email) // Check that user is not already matched if (user?.authOpenIDSub) { Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback failureMessage = 'A matching user was found but is already matched with another user from your auth provider' user = null } } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) user = await Database.userModel.getUserByUsername(userinfo.preferred_username) // Check that user is not already matched if (user?.authOpenIDSub) { Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback failureMessage = 'A matching user was found but is already matched with another user from your auth provider' user = null } } // If existing user was matched and isActive then save sub to user if (user?.isActive) { Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) user.authOpenIDSub = userinfo.sub await Database.userModel.updateFromOld(user) } else if (user && !user.isActive) { Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) } // Optionally auto register the user if (!user && Database.serverSettings.authOpenIDAutoRegister) { Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) } } if (!user?.isActive) { if (user && !user.isActive) { failureMessage = 'Unauthorized' } // deny login done(null, null, failureMessage) return } // We also have to save the id_token for later (used for logout) because we cannot set cookies here user.openid_id_token = tokenset.id_token // permit login return done(null, user) })) } /** * Unuse strategy * * @param {string} name */ unuseAuthStrategy(name) { passport.unuse(name) } /** * Use strategy * * @param {string} name */ useAuthStrategy(name) { if (name === 'openid') { this.initAuthStrategyOpenID() } else if (name === 'local') { this.initAuthStrategyPassword() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } } /** * Stores the client's choice of login callback method in temporary cookies. * * The `authMethod` parameter specifies the authentication strategy and can have the following values: * - 'local': Standard authentication, * - 'api': Authentication for API use * - 'openid': OpenID authentication directly over web * - 'openid-mobile': OpenID authentication, but done via an mobile device * * @param {import('express').Request} req * @param {import('express').Response} res * @param {string} authMethod - The authentication method, default is 'local'. */ paramsToCookies(req, res, authMethod = 'local') { const TWO_MINUTES = 120000 // 2 minutes in milliseconds const isRest = ['api', 'openid-mobile'].includes(authMethod) const callback = req.query.redirect_uri || req.query.callback // Set the 'is_rest' cookie based on the authentication method res.cookie('is_rest', isRest.toString(), { maxAge: TWO_MINUTES, httpOnly: true }) // Additional handling for 'local' authMethod if (!isRest) { // Store 'auth_state' if present in the request if (req.query.state) { res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true }) } // Validate and store the callback URL if (!callback) { return res.status(400).send({ message: 'No callback parameter' }) } res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true }) } // Store the authentication method for long res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true }) } /** * Informs the client in the right mode about a successfull login and the token * (clients choise is restored from cookies). * * @param {import('express').Request} req * @param {import('express').Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { // get userLogin json (information about the user, server and the session) const data_json = await this.getUserLoginResponsePayload(req.user) if (req.cookies.is_rest === 'true') { // REST request - send data res.json(data_json) } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } } } /** * Creates all (express) routes required for authentication. * * @param {import('express').Router} router */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), async (req, res) => { // return the user login response json if the login was successfull res.json(await this.getUserLoginResponsePayload(req.user)) }) // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { // 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') const client = oidcStrategy._client const sessionKey = oidcStrategy._key try { const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' const hostUrl = new URL(`${protocol}://${req.get('host')}`) const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge // Only allow code flow (for mobile clients) if (req.query.response_type && req.query.response_type !== 'code') { Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`) return res.status(400).send('Invalid response_type, only code supported') } // Generate a state on web flow or if no state supplied const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state // Redirect URL for the SSO provider let redirectUri if (isMobileFlow) { // Mobile required redirect uri // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect // where we will handle the redirect to it if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) { Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`) return res.status(400).send('Invalid redirect_uri') } // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API // for the request to mobile-redirect and as such the session is not shared this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString() } else { redirectUri = new URL('/auth/openid/callback', hostUrl).toString() if (req.query.state) { Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) return res.status(400).send('Invalid state, not allowed on web flow') } } oidcStrategy._params.redirect_uri = redirectUri Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`) let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow) req.session[sessionKey] = { ...req.session[sessionKey], state: state, max_age: oidcStrategy._params.max_age, response_type: 'code', code_verifier: code_verifier, // not null if web flow mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback } const authorizationUrl = client.authorizationUrl({ ...oidcStrategy._params, state: state, response_type: 'code', code_challenge, code_challenge_method }) this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid') res.redirect(authorizationUrl) } catch (error) { Logger.error(`[Auth] Error in /auth/openid route: ${error}`) res.status(500).send('Internal Server Error') } function generatePkce(req, isMobileFlow) { if (isMobileFlow) { if (!req.query.code_challenge) { throw new Error('code_challenge required for mobile flow (PKCE)') } if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { throw new Error('Only S256 code_challenge_method method supported') } return { code_challenge: req.query.code_challenge, code_challenge_method: req.query.code_challenge_method || 'S256' } } else { const code_verifier = OpenIDClient.generators.codeVerifier() const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) return { code_challenge, code_challenge_method: 'S256', code_verifier } } } function isValidRedirectUri(uri) { // Check if the redirect_uri is in the whitelist return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') } }) // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth router.get('/auth/openid/mobile-redirect', (req, res) => { try { // Extract the state parameter from the request const { state, code } = req.query // Check if the state provided is in our list if (!state || !this.openIdAuthSession.has(state)) { Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') return res.status(400).send('State parameter mismatch') } let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri if (!mobile_redirect_uri) { Logger.error('[Auth] No redirect URI') return res.status(400).send('No redirect URI') } this.openIdAuthSession.delete(state) const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` // Redirect to the overwrite URI saved in the map res.redirect(redirectUri) } catch (error) { Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`) res.status(500).send('Internal Server Error') } }) // openid strategy callback route (this receives the token from the configured openid login provider) 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 } function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) { Logger.error(logMessage) if (response) { // Depending on the error, it can also have a body // We also log the request header the passport plugin sents for the URL const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED') Logger.debug(header + '\n' + response.body?.toString()) } if (isMobile) { return res.status(errorCode).send(errorMessage) } else { return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`) } } function passportCallback(req, res, next) { return (err, user, info) => { const isMobile = req.session[sessionKey]?.mobile === true if (err) { return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response) } if (!user) { // Info usually contains the error message from the SSO provider return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response) } req.logIn(user, (loginError) => { if (loginError) { return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) } // The id_token does not provide access to the user, but is used to identify the user to the SSO provider // instead it containts a JWT with userinfo like user email, username, etc. // the client will get to know it anyway in the logout url according to the oauth2 spec // so it is safe to send it to the client, but we use strict settings res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' }) next() }) } } // 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 // We set it here again because the passport param can change between requests return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) /** * Helper route used to auto-populate the openid URLs in config/authentication * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration" * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { if (!req.user.isAdminOrUp) { Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) return res.sendStatus(403) } if (!req.query.issuer) { return res.status(400).send('Invalid request. Query param \'issuer\' is required') } // Strip trailing slash let issuerUrl = req.query.issuer if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) // Append config pathname and validate URL let configUrl = null try { configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { throw new Error('Invalid pathname') } } catch (error) { Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) return res.status(400).send('Invalid request. Query param \'issuer\' is invalid') } axios.get(configUrl.toString()).then(({ data }) => { res.json({ issuer: data.issuer, authorization_endpoint: data.authorization_endpoint, token_endpoint: data.token_endpoint, userinfo_endpoint: data.userinfo_endpoint, end_session_endpoint: data.end_session_endpoint, jwks_uri: data.jwks_uri }) }).catch((error) => { Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) }) }) // Logout route router.post('/logout', (req, res) => { // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) } else { const authMethod = req.cookies.auth_method res.clearCookie('auth_method') if (authMethod === 'openid' || authMethod === 'openid-mobile') { // If we are using openid, we need to redirect to the logout endpoint // node-openid-client does not support doing it over passport const oidcStrategy = passport._strategy('openid-client') const client = oidcStrategy._client let postLogoutRedirectUri = null if (authMethod === 'openid') { const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' const host = req.get('host') // TODO: ABS does currently not support subfolders for installation // If we want to support it we need to include a config for the serverurl postLogoutRedirectUri = `${protocol}://${host}/login` } // else for openid-mobile we keep postLogoutRedirectUri on null // nice would be to redirect to the app here, but for example Authentik does not implement // the post_logout_redirect_uri parameter at all and for other providers // we would also need again to implement (and even before get to know somehow for 3rd party apps) // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution // (The URL needs to be whitelisted in the config of the SSO/ID provider) const logoutUrl = client.endSessionUrl({ id_token_hint: req.cookies.openid_id_token, post_logout_redirect_uri: postLogoutRedirectUri }) res.clearCookie('openid_id_token') // Tell the user agent (browser) to redirect to the authentification provider's logout URL res.send({ redirect_url: logoutUrl }) } else { res.sendStatus(200) } } }) }) } /** * middleware to use in express to only allow authenticated users. * @param {import('express').Request} req * @param {import('express').Response} res * @param {import('express').NextFunction} next */ isAuthenticated(req, res, next) { // check if session cookie says that we are authenticated if (req.isAuthenticated()) { next() } else { // try JWT to authenticate passport.authenticate("jwt")(req, res, next) } } /** * Function to generate a jwt token for a given user * * @param {{ id:string, username:string }} user * @returns {string} token */ generateAccessToken(user) { return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) } /** * Function to validate a jwt token for a given user * * @param {string} token * @returns {Object} tokens data */ static validateAccessToken(token) { try { return jwt.verify(token, global.ServerSettings.tokenSecret) } catch (err) { return null } } /** * Generate a token which is used to encrpt/protect the jwts. */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET } else { Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } await Database.updateServerSettings() // New token secret creation added in v2.1.0 so generate new API tokens for each user const users = await Database.userModel.getOldUsers() if (users.length) { for (const user of users) { user.token = await this.generateAccessToken(user) } await Database.updateBulkUsers(users) } } /** * Checks if the user in the validated jwt_payload really exists and is active. * @param {Object} jwt_payload * @param {function} done */ async jwtAuthCheck(jwt_payload, done) { // load user by id from the jwt token const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) if (!user?.isActive) { // deny login done(null, null) return } // approve login done(null, user) return } /** * Checks if a username and password tuple is valid and the user active. * @param {string} username * @param {string} password * @param {function} done */ async localAuthCheckUserPw(username, password, done) { // Load the user given it's username const user = await Database.userModel.getUserByUsername(username.toLowerCase()) if (!user?.isActive) { done(null, null) return } // Check passwordless root user if (user.type === 'root' && !user.pash) { if (password) { // deny login done(null, null) return } // approve login done(null, user) return } else if (!user.pash) { Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`) done(null, null) return } // Check password match const compare = await bcrypt.compare(password, user.pash) if (compare) { // approve login done(null, user) return } // deny login done(null, null) return } /** * Hashes a password with bcrypt. * @param {string} password * @returns {string} hash */ hashPass(password) { return new Promise((resolve) => { bcrypt.hash(password, 8, (err, hash) => { if (err) { resolve(null) } else { resolve(hash) } }) }) } /** * Return the login info payload for a user * * @param {Object} user * @returns {Promise} jsonPayload */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() return { user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), Source: global.Source } } /** * * @param {string} password * @param {*} user * @returns {boolean} */ comparePassword(password, user) { if (user.type === 'root' && !password && !user.pash) return true if (!password || !user.pash) return false return bcrypt.compare(password, user.pash) } /** * User changes their password from request * * @param {import('express').Request} req * @param {import('express').Response} res */ async userChangePassword(req, res) { let { password, newPassword } = req.body newPassword = newPassword || '' const matchingUser = req.user // Only root can have an empty password if (matchingUser.type !== 'root' && !newPassword) { return res.json({ error: 'Invalid new password - Only root can have an empty password' }) } // Check password match const compare = await this.comparePassword(password, matchingUser) if (!compare) { return res.json({ error: 'Invalid password' }) } let pw = '' if (newPassword) { pw = await this.hashPass(newPassword) if (!pw) { return res.json({ error: 'Hash failed' }) } } matchingUser.pash = pw const success = await Database.updateUser(matchingUser) if (success) { Logger.info(`[Auth] User "${matchingUser.username}" changed password`) res.json({ success: true }) } else { res.json({ error: 'Unknown error' }) } } } module.exports = Auth