audiobookshelf/server/Auth.js
Alex Cortelyou b37cc9e4ce Fix OIDC callback dead-ending on "No session"
When the express-session holding the OIDC transaction is missing at
/auth/openid/callback (e.g. the in-memory session store was cleared by a
server restart, or the callback page was reloaded), the server returned a
bare 400 "No session" white screen and the flow could not recover until the
user manually cleared their cookies.

Instead, destroy the stale session and clear the connect.sid cookie so the
next attempt starts clean, then redirect web clients back to /login to
restart the flow. Mobile/API clients (identified by the client-supplied
code_verifier) still receive the 400 status they expect.
2026-07-02 20:57:30 -07:00

522 lines
19 KiB
JavaScript

const { Request, Response, NextFunction } = require('express')
const passport = require('passport')
const JwtStrategy = require('passport-jwt').Strategy
const ExtractJwt = require('passport-jwt').ExtractJwt
const Database = require('./Database')
const Logger = require('./Logger')
const TokenManager = require('./auth/TokenManager')
const LocalAuthStrategy = require('./auth/LocalAuthStrategy')
const OidcAuthStrategy = require('./auth/OidcAuthStrategy')
const RateLimiterFactory = require('./utils/rateLimiterFactory')
const { escapeRegExp } = require('./utils')
/**
* @class Class for handling all the authentication related functionality.
*/
class Auth {
constructor() {
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
/** @type {import('express-rate-limit').RateLimitRequestHandler} */
this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter()
this.tokenManager = new TokenManager()
this.localAuthStrategy = new LocalAuthStrategy()
this.oidcAuthStrategy = new OidcAuthStrategy()
}
/**
* Checks if the request should not be authenticated.
* @param {Request} req
* @returns {boolean}
*/
authNotNeeded(req) {
return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path))
}
/**
* Middleware to register passport in express-session
*
* @param {function} middleware
*/
ifAuthNeeded(middleware) {
return (req, res, next) => {
if (this.authNotNeeded(req)) {
return next()
}
middleware(req, res, next)
}
}
/**
* middleware to use in express to only allow authenticated users.
*
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
isAuthenticated(req, res, next) {
return passport.authenticate('jwt', { session: false })(req, res, next)
}
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateAccessToken(user) {
return this.tokenManager.generateAccessToken(user)
}
/**
* Invalidate all JWT sessions for a given user
* If user is current user and refresh token is valid, rotate tokens for the current session
*
* @param {import('./models/User')} user
* @param {Request} req
* @param {Response} res
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
*/
async invalidateJwtSessionsForUser(user, req, res) {
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
}
/**
* Return the login info payload for a user
*
* @param {import('./models/User')} user
* @returns {Promise<Object>} jsonPayload
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds()
return {
user: user.toOldJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
Source: global.Source
}
}
// #region Passport strategies
/**
* 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.localAuthStrategy.init()
}
// Check if we should load the openid strategy
if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
this.oidcAuthStrategy.init()
}
// Load the JwtStrategy (always) -> for bearer token auth
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
secretOrKey: TokenManager.TokenSecret,
// Handle expiration manaully in order to disable api keys that are expired
ignoreExpiration: true
},
this.tokenManager.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)
)
}
// #endregion
/**
* Unuse strategy
*
* @param {string} name
*/
unuseAuthStrategy(name) {
if (name === 'openid') {
this.oidcAuthStrategy.unuse()
} else if (name === 'local') {
this.localAuthStrategy.unuse()
} else {
Logger.error('[Auth] Invalid auth strategy ' + name)
}
}
/**
* Use strategy
*
* @param {string} name
*/
useAuthStrategy(name) {
if (name === 'openid') {
this.oidcAuthStrategy.init()
} else if (name === 'local') {
this.localAuthStrategy.init()
} else {
Logger.error('[Auth] Invalid auth strategy ' + name)
}
}
/**
* Returns if the given auth method is API based.
*
* @param {string} authMethod
* @returns {boolean}
*/
isAuthMethodAPIBased(authMethod) {
return ['api', 'openid-mobile'].includes(authMethod)
}
/**
* 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 {Request} req
* @param {Response} res
* @param {string} authMethod - The authentication method, default is 'local'.
* @returns {Object|null} - Returns error object if validation fails, null if successful
*/
paramsToCookies(req, res, authMethod = 'local') {
const TWO_MINUTES = 120000 // 2 minutes in milliseconds
const callback = req.query.redirect_uri || req.query.callback
// Additional handling for non-API based authMethod
if (!this.isAuthMethodAPIBased(authMethod)) {
// 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) {
res.status(400).send({ message: 'No callback parameter' })
return { error: 'No callback parameter' }
}
// Security: Validate callback URL is same-origin only
if (!this.oidcAuthStrategy.isValidWebCallbackUrl(callback, req)) {
Logger.warn(`[Auth] Rejected invalid callback URL: ${callback}`)
res.status(400).send({ message: 'Invalid callback URL - must be same-origin' })
return { error: 'Invalid callback URL - must be same-origin' }
}
res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
}
// Store the authentication method for long
Logger.debug(`[Auth] paramsToCookies: setting auth_method cookie to ${authMethod}`)
res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
return null
}
/**
* Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies).
*
* @param {Request} req
* @param {Response} res
*/
async handleLoginSuccessBasedOnCookie(req, res) {
// Handle token generation and get userResponse object
// For API based auth (e.g. mobile), we will return the refresh token in the response
const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method)
Logger.debug(`[Auth] handleLoginSuccessBasedOnCookie: isApiBased: ${isApiBased}, auth_method: ${req.cookies.auth_method}`)
const userResponse = await this.handleLoginSuccess(req, res, isApiBased)
if (isApiBased) {
// REST request - send data
res.json(userResponse)
} else {
// UI request -> check if we have a callback url
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
// TODO: Temporarily continue sending the old token as setToken
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
} else {
res.status(400).send('No callback or already expired')
}
}
}
/**
* After login success from local or oidc
* req.user is set by passport.authenticate
*
* attaches the access token to the user in the response
* if returnTokens is true, also attaches the refresh token to the user in the response
*
* if returnTokens is false, sets the refresh token cookie
*
* @param {Request} req
* @param {Response} res
* @param {boolean} returnTokens
*/
async handleLoginSuccess(req, res, returnTokens = false) {
// Create tokens and session
const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req)
const userResponse = await this.getUserLoginResponsePayload(req.user)
userResponse.user.refreshToken = returnTokens ? refreshToken : null
userResponse.user.accessToken = accessToken
Logger.debug(`[Auth] handleLoginSuccess: returnTokens: ${returnTokens}, isRefreshTokenInResponse: ${!!userResponse.user.refreshToken}`)
if (!returnTokens) {
this.tokenManager.setRefreshTokenCookie(req, res, refreshToken)
}
return userResponse
}
// #region Auth routes
/**
* 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', this.authRateLimiter, passport.authenticate('local'), async (req, res) => {
// Check if mobile app wants refresh token in response
const returnTokens = req.headers['x-return-tokens'] === 'true'
const userResponse = await this.handleLoginSuccess(req, res, returnTokens)
res.json(userResponse)
})
// Refresh token route
router.post('/auth/refresh', this.authRateLimiter, async (req, res) => {
let refreshToken = req.cookies.refresh_token
// If x-refresh-token header is present, use it instead of the cookie
// and return the refresh token in the response
let shouldReturnRefreshToken = false
if (req.headers['x-refresh-token']) {
refreshToken = req.headers['x-refresh-token']
shouldReturnRefreshToken = true
}
if (!refreshToken) {
Logger.error(`[Auth] Failed to refresh token. No refresh token provided`)
return res.status(401).json({ error: 'No refresh token provided' })
}
Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`)
const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res)
if (refreshResponse.error) {
return res.status(401).json({ error: refreshResponse.error })
}
const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user)
userResponse.user.accessToken = refreshResponse.accessToken
userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null
res.json(userResponse)
})
// openid strategy login route (this redirects to the configured openid login provider)
router.get('/auth/openid', this.authRateLimiter, (req, res) => {
const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req)
if (authorizationUrlResponse.error) {
return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)
}
// Check if paramsToCookies sent a response (e.g., due to invalid callback URL)
const cookieResult = this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid')
if (cookieResult && cookieResult.error) {
return // Response already sent by paramsToCookies
}
res.redirect(authorizationUrlResponse.authorizationUrl)
})
// 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', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res))
// openid strategy callback route (this receives the token from the configured openid login provider)
router.get(
'/auth/openid/callback',
this.authRateLimiter,
(req, res, next) => {
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
if (!req.session[sessionKey]) {
Logger.warn(`[Auth] No openid session found in callback - restarting login`)
const isMobileFlow = !!req.query.code_verifier
return req.session.destroy(() => {
res.clearCookie('connect.sid')
if (isMobileFlow) return res.status(400).send('No session')
res.redirect(`/login?error=${encodeURIComponent('Login session expired, please try again')}&autoLaunch=0`)
})
}
// 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(JSON.stringify(logMessage, null, 2))
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' + JSON.stringify(response.body, null, 2))
}
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.authRateLimiter, 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 || typeof req.query.issuer !== 'string') {
return res.status(400).send("Invalid request. Query param 'issuer' is required")
}
const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer)
if (openIdIssuerConfig.error) {
return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error)
}
res.json(openIdIssuerConfig)
})
// Logout route
router.post('/logout', async (req, res) => {
// Refresh token be alternatively be sent in the header
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
// Clear refresh token cookie
res.clearCookie('refresh_token', {
path: '/'
})
// Invalidate the session in database using refresh token
if (refreshToken) {
await this.tokenManager.invalidateRefreshToken(refreshToken)
} else {
Logger.info(`[Auth] logout: No refresh token on request`)
}
req.logout((err) => {
if (err) {
res.sendStatus(500)
} else {
const authMethod = req.cookies.auth_method
res.clearCookie('auth_method')
let logoutUrl = null
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod)
res.clearCookie('openid_id_token')
}
// Tell the user agent (browser) to redirect to the authentification provider's logout URL
// (or redirect_url: null if we don't have one)
res.send({ redirect_url: logoutUrl })
}
})
})
}
// #endregion
}
module.exports = Auth