This commit is contained in:
alex-sviridov 2025-11-20 17:00:15 -06:00 committed by GitHub
commit 608e8c2e9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 319 additions and 2 deletions

View File

@ -122,6 +122,41 @@
</div>
</transition>
</div>
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableProxyAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderProxyAuthentication }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/reverse_proxy_authentication" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</div>
<transition name="slide">
<div v-if="enableProxyAuth" class="flex flex-wrap pt-4">
<div class="w-full flex items-center mb-2">
<div class="grow">
<ui-text-input-with-label ref="proxyHeaderName" v-model="newAuthSettings.authProxyHeaderName" :disabled="savingSettings" :label="$strings.LabelProxyHeaderName" :placeholder="'X-Remote-User'" />
</div>
<div class="w-20 mx-1 mt-[1.375rem]">
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" :disabled="!newAuthSettings.authProxyHeaderName?.trim() || testingProxyHeader" :loading="testingProxyHeader" @click="testProxyHeader">
Test
</ui-btn>
</div>
</div>
<p class="text-sm text-gray-300 mb-4">
{{ $strings.LabelProxyHeaderNameDescription }}
</p>
<ui-text-input-with-label ref="proxyLogoutURL" v-model="newAuthSettings.authProxyLogoutURL" :disabled="savingSettings" :label="$strings.LabelProxyLogoutUrl" :placeholder="'https://proxy.example.com/logout'" class="mb-2" />
<p class="text-sm text-gray-300 mb-4">
{{ $strings.LabelProxyLogoutUrlDescription }}
</p>
</div>
</transition>
</div>
<div class="w-full flex items-center justify-between p-4">
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
@ -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
}
},

View File

@ -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() {

View File

@ -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}",

View File

@ -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'
}

View File

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

View File

@ -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<Object>} 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

View File

@ -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()

View File

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

View File

@ -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))
}