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

{{ $strings.HeaderProxyAuthentication }}

+ + + help_outline + + +
+ + +
+
+
+ +
+
+ + Test + +
+
+

+ {{ $strings.LabelProxyHeaderNameDescription }} +

+ +

+ {{ $strings.LabelProxyLogoutUrlDescription }} +

+
+
+
+

{{ $strings.MessageAuthenticationOIDCChangesRestart }}

{{ $strings.ButtonSave }} @@ -154,8 +189,10 @@ export default { return { enableLocalAuth: false, enableOpenIDAuth: false, + enableProxyAuth: false, showCustomLoginMessage: false, savingSettings: false, + testingProxyHeader: false, openIdSigningAlgorithmsSupportedByIssuer: [], newAuthSettings: {} } @@ -251,6 +288,34 @@ export default { this.$toast.error(errorMsg) }) }, + async testProxyHeader() { + if (!this.newAuthSettings.authProxyHeaderName?.trim()) { + this.$toast.error('Header name is required') + return + } + + this.testingProxyHeader = true + + try { + const response = await this.$axios.$get('/api/test-proxy-header', { + params: { + headerName: this.newAuthSettings.authProxyHeaderName + } + }) + + if (response.headerFound) { + this.$toast.success(`Header "${this.newAuthSettings.authProxyHeaderName}" found with value: "${response.headerValue}"`) + } else { + this.$toast.warning(`Header "${this.newAuthSettings.authProxyHeaderName}" not found in request`) + } + } catch (error) { + console.error('Failed to test proxy header', error) + const errorMsg = error.response?.data?.message || 'Failed to test proxy header' + this.$toast.error(errorMsg) + } finally { + this.testingProxyHeader = false + } + }, validateOpenID() { let isValid = true if (!this.newAuthSettings.authOpenIDIssuerURL) { @@ -323,7 +388,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 +397,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 +409,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 +437,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 8e5cde098..d4252288b 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -223,6 +223,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 @@ -308,6 +334,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 fb2bcb281..77c5ced71 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/index.js b/index.js index 7379322e8..f3c39199c 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,9 @@ if (isDev || options['prod-with-dev-env']) { if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath + if (devEnv.AuthProxyEnabled) process.env.AUTH_PROXY_ENABLED = '1' + if (devEnv.AuthProxyHeaderName) process.env.AUTH_PROXY_HEADER_NAME = devEnv.AuthProxyHeaderName + if (devEnv.AuthProxyLogoutURL) process.env.AUTH_PROXY_LOGOUT_URL = devEnv.AuthProxyLogoutURL process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf' } 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..0656b434b --- /dev/null +++ b/server/auth/ProxyAuthStrategy.js @@ -0,0 +1,95 @@ +const passport = require('passport') +const Database = require('../Database') +const Logger = require('../Logger') + +/** + * Proxy authentication strategy using configurable header + * Reads username from header set by proxy middleware + */ +class ProxyAuthStrategy { + constructor() { + this.name = 'proxy' + } + + /** + * Passport authenticate method + * @param {import('express').Request} req + * @param {Object} options + */ + authenticate(req, options) { + const headerName = global.ServerSettings.authProxyHeaderName + + if (!headerName) { + Logger.warn(`[ProxyAuthStrategy] Proxy header name not configured`) + return this.fail({ message: 'Proxy header name not configured' }, 500) + } + + const username = req.get(headerName) + + if (!username) { + Logger.warn(`[ProxyAuthStrategy] No ${headerName} header found`) + return this.fail({ message: `No ${headerName} header found` }, 401) + } + + let clientIp = req.ip || req.socket?.remoteAddress || 'Unknown' + // Clean up IPv6-mapped IPv4 addresses (::ffff:192.168.1.1 -> 192.168.1.1) + if (clientIp.startsWith('::ffff:')) { + clientIp = clientIp.substring(7) + } + + this.verifyUser(username) + .then(user => { + Logger.debug(`[ProxyAuthStrategy] Successful proxy login for "${user.username}" from IP ${clientIp}`) + return this.success(user) + }) + .catch(error => { + Logger.warn(`[ProxyAuthStrategy] Failed login attempt for "${username}" from IP ${clientIp}: ${error.message}`) + return this.fail({ message: error.message }, 401) + }) + } + + /** + * Initialize the strategy with passport + */ + init() { + passport.use(this.name, this) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + } + + /** + * Verify user from proxy header + * @param {string} username + * @returns {Promise} User object + */ + async verifyUser(username) { + const normalizedUsername = username.trim().toLowerCase() + + if (!normalizedUsername) { + throw new Error('Empty username') + } + + const user = await Database.userModel.getUserByUsername(normalizedUsername) + + if (!user) { + throw new Error('User not found') + } + + if (!user.isActive) { + throw new Error('User account is disabled') + } + + // Update user's last seen + user.lastSeen = new Date() + await user.save() + + return user + } +} + +module.exports = ProxyAuthStrategy \ No newline at end of file diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 490cb27d2..571b37992 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -771,5 +771,32 @@ class MiscController { currentDailyLogs: Logger.logManager.getMostRecentCurrentDailyLogs() }) } + + /** + * GET: /api/test-proxy-header + * Test proxy header endpoint + * + * @param {RequestWithUser} req + * @param {Response} res + */ + testProxyHeader(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to test proxy header`) + return res.sendStatus(403) + } + + const headerName = req.query.headerName + if (!headerName) { + return res.status(400).json({ message: 'Header name is required' }) + } + + const headerValue = req.headers[headerName.toLowerCase()] + + res.json({ + headerFound: !!headerValue, + headerValue: headerValue || null, + headerName: headerName + }) + } } module.exports = new MiscController() diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c75..34907c6ce 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,10 +151,21 @@ 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'] } + // Environment variable to enable proxy authentication (only during initialization) + if (process.env.AUTH_PROXY_ENABLED === 'true' || process.env.AUTH_PROXY_ENABLED === '1') { + if (!this.authActiveAuthMethods.includes('proxy')) { + Logger.info(`[ServerSettings] Enabling proxy authentication from environment variable AUTH_PROXY_ENABLED`) + this.authActiveAuthMethods.push('proxy') + } + } + // remove uninitialized methods // OpenID if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) { @@ -200,6 +215,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 +264,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 +298,7 @@ class ServerSettings { } get supportedAuthMethods() { - return ['local', 'openid'] + return ['local', 'openid', 'proxy'] } /** @@ -285,6 +312,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, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..4eac6bb8f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -352,6 +352,7 @@ class ApiRouter { this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) + this.router.get('/test-proxy-header', MiscController.testProxyHeader.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.get('/logger-data', MiscController.getLoggerData.bind(this)) }