diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index f31f9ea22..be0f26463 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -122,6 +122,32 @@ + +
+
+ +

{{ $strings.HeaderProxyAuthentication }}

+ + + help_outline + + +
+ + +
+ +

+ {{ $strings.LabelProxyHeaderNameDescription }} +

+ +

+ {{ $strings.LabelProxyLogoutUrlDescription }} +

+
+
+
+

{{ $strings.MessageAuthenticationOIDCChangesRestart }}

{{ $strings.ButtonSave }} @@ -154,6 +180,7 @@ export default { return { enableLocalAuth: false, enableOpenIDAuth: false, + enableProxyAuth: false, showCustomLoginMessage: false, savingSettings: false, openIdSigningAlgorithmsSupportedByIssuer: [], @@ -323,7 +350,7 @@ export default { return isValid }, async saveSettings() { - if (!this.enableLocalAuth && !this.enableOpenIDAuth) { + if (!this.enableLocalAuth && !this.enableOpenIDAuth && !this.enableProxyAuth) { this.$toast.error('Must have at least one authentication method enabled') return } @@ -332,6 +359,11 @@ export default { return } + if (this.enableProxyAuth && !this.newAuthSettings.authProxyHeaderName?.trim()) { + this.$toast.error('Authentication Header Name is required for proxy authentication') + return + } + if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) { this.newAuthSettings.authLoginCustomMessage = null } @@ -339,6 +371,7 @@ export default { this.newAuthSettings.authActiveAuthMethods = [] if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local') if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid') + if (this.enableProxyAuth) this.newAuthSettings.authActiveAuthMethods.push('proxy') this.savingSettings = true this.$axios @@ -366,6 +399,7 @@ export default { } this.enableLocalAuth = this.authMethods.includes('local') this.enableOpenIDAuth = this.authMethods.includes('openid') + this.enableProxyAuth = this.authMethods.includes('proxy') this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage } }, diff --git a/client/pages/login.vue b/client/pages/login.vue index ef3827afe..80e18ca15 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -222,6 +222,32 @@ export default { } this.processing = false }, + async attemptProxyAuth() { + this.error = null + this.processing = true + + try { + const authRes = await this.$axios.$post('/auth/proxy').catch((error) => { + console.error('Proxy auth failed', error.response) + if (error.response?.data?.message) { + this.error = error.response.data.message + } + return false + }) + + if (authRes?.error) { + this.error = authRes.error + } else if (authRes) { + this.setUser(authRes) + return + } + } catch (error) { + console.error('Proxy auth error', error) + this.error = 'Proxy authentication failed' + } + + this.processing = false + }, checkAuth() { const token = localStorage.getItem('token') if (!token) return false @@ -307,6 +333,11 @@ export default { } else { this.login_openid = false } + + if (authMethods.includes('proxy')) { + // Auto-attempt proxy authentication + this.attemptProxyAuth() + } } }, async mounted() { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 42832e37a..c087ea459 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -175,6 +175,7 @@ "HeaderOpenListeningSessions": "Open Listening Sessions", "HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOtherFiles": "Other Files", + "HeaderProxyAuthentication": "Proxy Authentication", "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Player Queue", @@ -531,6 +532,10 @@ "LabelProgress": "Progress", "LabelProvider": "Provider", "LabelProviderAuthorizationValue": "Authorization Header Value", + "LabelProxyHeaderName": "Authentication Header Name", + "LabelProxyHeaderNameDescription": "The name of the header that your proxy uses to pass the authenticated username to Audiobookshelf.", + "LabelProxyLogoutUrl": "Custom Logout URL", + "LabelProxyLogoutUrlDescription": "The URL users will be redirected to when they click the logout button. This should log the user out of your authenfication proxy.", "LabelPubDate": "Pub Date", "LabelPublishYear": "Publish Year", "LabelPublishedDate": "Published {0}", diff --git a/server/Auth.js b/server/Auth.js index f63e84460..97fd42620 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -8,6 +8,7 @@ const Logger = require('./Logger') const TokenManager = require('./auth/TokenManager') const LocalAuthStrategy = require('./auth/LocalAuthStrategy') const OidcAuthStrategy = require('./auth/OidcAuthStrategy') +const ProxyAuthStrategy = require('./auth/ProxyAuthStrategy') const RateLimiterFactory = require('./utils/rateLimiterFactory') const { escapeRegExp } = require('./utils') @@ -26,6 +27,7 @@ class Auth { this.tokenManager = new TokenManager() this.localAuthStrategy = new LocalAuthStrategy() this.oidcAuthStrategy = new OidcAuthStrategy() + this.proxyAuthStrategy = new ProxyAuthStrategy() } /** @@ -59,6 +61,31 @@ class Auth { * @param {NextFunction} next */ isAuthenticated(req, res, next) { + // If proxy auth is enabled and configured, try proxy auth first + if (global.ServerSettings.authActiveAuthMethods.includes('proxy') && global.ServerSettings.authProxyHeaderName) { + const headerName = global.ServerSettings.authProxyHeaderName + const username = req.get(headerName) + + if (username) { + // Try proxy authentication first + return passport.authenticate('proxy', { session: false }, (err, user, info) => { + if (err) { + Logger.error('[Auth] Proxy authentication error:', err) + return next(err) + } + if (user) { + // Proxy auth succeeded + req.user = user + return next() + } + // Proxy auth failed, fall back to JWT + Logger.debug('[Auth] Proxy auth failed, falling back to JWT:', info?.message) + return passport.authenticate('jwt', { session: false })(req, res, next) + })(req, res, next) + } + } + + // No proxy auth or no header present, use JWT authentication return passport.authenticate('jwt', { session: false })(req, res, next) } @@ -119,6 +146,11 @@ class Auth { this.oidcAuthStrategy.init() } + // Check if we should load the proxy strategy + if (global.ServerSettings.authActiveAuthMethods.includes('proxy')) { + this.proxyAuthStrategy.init() + } + // Load the JwtStrategy (always) -> for bearer token auth passport.use( new JwtStrategy( @@ -171,6 +203,8 @@ class Auth { this.oidcAuthStrategy.unuse() } else if (name === 'local') { this.localAuthStrategy.unuse() + } else if (name === 'proxy') { + this.proxyAuthStrategy.unuse() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } @@ -186,6 +220,8 @@ class Auth { this.oidcAuthStrategy.init() } else if (name === 'local') { this.localAuthStrategy.init() + } else if (name === 'proxy') { + this.proxyAuthStrategy.init() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } @@ -325,6 +361,18 @@ class Auth { res.json(userResponse) }) + // Proxy strategy login route (reads username from header) + router.post('/auth/proxy', this.authRateLimiter, passport.authenticate('proxy'), async (req, res) => { + // Check if mobile app wants refresh token in response + const returnTokens = req.headers['x-return-tokens'] === 'true' + + // Set auth method cookie for proxy authentication + res.cookie('auth_method', 'proxy', { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: 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 @@ -501,6 +549,12 @@ class Auth { if (authMethod === 'openid' || authMethod === 'openid-mobile') { logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) res.clearCookie('openid_id_token') + } else if (authMethod === 'proxy') { + // Use configured proxy logout URL if available + if (global.ServerSettings.authProxyLogoutURL) { + logoutUrl = global.ServerSettings.authProxyLogoutURL + Logger.info(`[Auth] Redirecting proxy user to configured logout URL: ${logoutUrl}`) + } } // Tell the user agent (browser) to redirect to the authentification provider's logout URL diff --git a/server/auth/ProxyAuthStrategy.js b/server/auth/ProxyAuthStrategy.js new file mode 100644 index 000000000..d99731df4 --- /dev/null +++ b/server/auth/ProxyAuthStrategy.js @@ -0,0 +1,168 @@ +const passport = require('passport') +const Database = require('../Database') +const Logger = require('../Logger') + +/** + * Custom strategy for proxy authentication + * Reads username from configurable header set by proxy middleware + */ +class ProxyStrategy { + constructor(verify) { + this.name = 'proxy' + this.verify = verify + } + + authenticate(req, options) { + const headerName = global.ServerSettings.authProxyHeaderName + const ip = req.ip || req.connection?.remoteAddress || 'Unknown' + const method = req.method + const url = req.originalUrl || req.url + + // Log all proxy auth attempts for debugging + Logger.debug(`[ProxyAuthStrategy] ${method} ${url} from IP ${ip}`) + Logger.debug(`[ProxyAuthStrategy] Configured header name: ${headerName}`) + + // Log all headers for debugging (but mask sensitive ones) + const headers = {} + for (const [key, value] of Object.entries(req.headers)) { + if (key.toLowerCase().includes('authorization') || key.toLowerCase().includes('cookie')) { + headers[key] = '[MASKED]' + } else { + headers[key] = value + } + } + Logger.debug(`[ProxyAuthStrategy] Request headers:`, headers) + + if (!headerName) { + Logger.warn(`[ProxyAuthStrategy] Proxy header name not configured for ${method} ${url} from IP ${ip}`) + return this.fail({ message: 'Proxy header name not configured' }, 500) + } + + const username = req.get(headerName) + Logger.debug(`[ProxyAuthStrategy] Header ${headerName} value: "${username}"`) + + if (!username) { + Logger.warn(`[ProxyAuthStrategy] No ${headerName} header found for ${method} ${url} from IP ${ip}`) + return this.fail({ message: `No ${headerName} header found` }, 401) + } + + const verified = (err, user, info) => { + if (err) { + return this.error(err) + } + if (!user) { + return this.fail(info, 401) + } + return this.success(user, info) + } + + try { + this.verify(req, username, verified) + } catch (ex) { + return this.error(ex) + } + } +} + +/** + * Proxy authentication strategy using configurable header + */ +class ProxyAuthStrategy { + constructor() { + this.name = 'proxy' + this.strategy = null + } + + /** + * Get the passport strategy instance + * @returns {ProxyStrategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new ProxyStrategy(this.verifyUser.bind(this)) + } + return this.strategy + } + + /** + * Initialize the strategy with passport + */ + init() { + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + } + + /** + * Verify user from proxy header + * @param {import('express').Request} req + * @param {string} username + * @param {Function} done - Passport callback + */ + async verifyUser(req, username, done) { + try { + // Normalize username (trim and lowercase, following existing pattern) + const normalizedUsername = username.trim().toLowerCase() + + if (!normalizedUsername) { + const headerName = global.ServerSettings.authProxyHeaderName + this.logFailedLoginAttempt(req, username, `Empty username in ${headerName} header`) + return done(null, false, { message: `Invalid username in ${headerName} header` }) + } + + // Look up user in database + let user = await Database.userModel.getUserByUsername(normalizedUsername) + + if (user && !user.isActive) { + this.logFailedLoginAttempt(req, normalizedUsername, 'User is not active') + return done(null, false, { message: 'User account is disabled' }) + } + + if (!user) { + this.logFailedLoginAttempt(req, normalizedUsername, 'User not found') + return done(null, false, { message: 'User not found' }) + } + + // Update user's last seen + user.lastSeen = new Date() + await user.save() + + this.logSuccessfulLoginAttempt(req, user.username) + return done(null, user) + + } catch (error) { + Logger.error(`[ProxyAuthStrategy] Authentication error:`, error) + return done(error) + } + } + + + /** + * Log failed login attempt + * @param {import('express').Request} req + * @param {string} username + * @param {string} reason + */ + logFailedLoginAttempt(req, username, reason) { + const ip = req.ip || req.connection?.remoteAddress || 'Unknown' + Logger.warn(`[ProxyAuthStrategy] Failed login attempt for "${username}" from IP ${ip}: ${reason}`) + } + + /** + * Log successful login attempt + * @param {import('express').Request} req + * @param {string} username + */ + logSuccessfulLoginAttempt(req, username) { + const ip = req.ip || req.connection?.remoteAddress || 'Unknown' + Logger.info(`[ProxyAuthStrategy] Successful proxy login for "${username}" from IP ${ip}`) + } +} + +module.exports = ProxyAuthStrategy \ No newline at end of file diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c75..178e6a913 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -64,6 +64,10 @@ class ServerSettings { this.authLoginCustomMessage = null this.authActiveAuthMethods = ['local'] + // Proxy authentication settings + this.authProxyHeaderName = null + this.authProxyLogoutURL = null + // openid settings this.authOpenIDIssuerURL = null this.authOpenIDAuthorizationURL = null @@ -147,6 +151,9 @@ class ServerSettings { this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs + this.authProxyHeaderName = settings.authProxyHeaderName || null + this.authProxyLogoutURL = settings.authProxyLogoutURL || null + if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] } @@ -200,6 +207,16 @@ class ServerSettings { Logger.info(`[ServerSettings] Using allowIframe from environment variable`) this.allowIframe = true } + + // Proxy authentication environment override + if (process.env.AUTH_PROXY_HEADER_NAME) { + Logger.info(`[ServerSettings] Using proxy header name from environment variable: ${process.env.AUTH_PROXY_HEADER_NAME}`) + this.authProxyHeaderName = process.env.AUTH_PROXY_HEADER_NAME + } + if (process.env.AUTH_PROXY_LOGOUT_URL) { + Logger.info(`[ServerSettings] Using proxy logout URL from environment variable: ${process.env.AUTH_PROXY_LOGOUT_URL}`) + this.authProxyLogoutURL = process.env.AUTH_PROXY_LOGOUT_URL + } } toJSON() { @@ -239,6 +256,8 @@ class ServerSettings { buildNumber: this.buildNumber, authLoginCustomMessage: this.authLoginCustomMessage, authActiveAuthMethods: this.authActiveAuthMethods, + authProxyHeaderName: this.authProxyHeaderName, + authProxyLogoutURL: this.authProxyLogoutURL, authOpenIDIssuerURL: this.authOpenIDIssuerURL, authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL, @@ -271,7 +290,7 @@ class ServerSettings { } get supportedAuthMethods() { - return ['local', 'openid'] + return ['local', 'openid', 'proxy'] } /** @@ -285,6 +304,8 @@ class ServerSettings { return { authLoginCustomMessage: this.authLoginCustomMessage, authActiveAuthMethods: this.authActiveAuthMethods, + authProxyHeaderName: this.authProxyHeaderName, + authProxyLogoutURL: this.authProxyLogoutURL, authOpenIDIssuerURL: this.authOpenIDIssuerURL, authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL,