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