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.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