mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 06:29:42 +00:00
Revamp OIDC auth: remove Passport wrapper, add schema-driven settings UI
- Remove Passport.js wrapper from OIDC auth, use openid-client directly - Add schema-driven OIDC settings UI (OidcSettingsSchema.js drives form rendering) - Add group mapping with KeyValueEditor (explicit mapping or legacy direct name match) - Add scopes configuration (authOpenIDScopes) - Add verified email enforcement option (authOpenIDRequireVerifiedEmail) - Fix group claim validation rejecting URN-style claims (#4744) - Add auto-discover endpoint for OIDC provider configuration - Store oidcIdToken in sessions table instead of cookie - Add AuthError class for structured error handling in auth flows - Migration v2.33.0 adds oidcIdToken column and new settings fields
This commit is contained in:
parent
fe13456a2b
commit
33bee70a12
16 changed files with 1554 additions and 571 deletions
9
server/auth/AuthError.js
Normal file
9
server/auth/AuthError.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class AuthError extends Error {
|
||||
constructor(message, statusCode = 500) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.name = 'AuthError'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthError
|
||||
|
|
@ -1,42 +1,20 @@
|
|||
const { Request, Response } = require('express')
|
||||
const passport = require('passport')
|
||||
const OpenIDClient = require('openid-client')
|
||||
const axios = require('axios')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
const AuthError = require('./AuthError')
|
||||
|
||||
/**
|
||||
* OpenID Connect authentication strategy
|
||||
* OpenID Connect authentication strategy (no Passport wrapper)
|
||||
*/
|
||||
class OidcAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'openid-client'
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {OpenIDClient.Strategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new OpenIDClient.Strategy(
|
||||
{
|
||||
client: this.getClient(),
|
||||
params: {
|
||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||
scope: this.getScope()
|
||||
}
|
||||
},
|
||||
this.verifyCallback.bind(this)
|
||||
)
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenID Connect client
|
||||
* @returns {OpenIDClient.Client}
|
||||
|
|
@ -44,7 +22,7 @@ class OidcAuthStrategy {
|
|||
getClient() {
|
||||
if (!this.client) {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
throw new Error('OpenID Connect settings are not valid')
|
||||
throw new AuthError('OpenID Connect settings are not valid', 500)
|
||||
}
|
||||
|
||||
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||
|
|
@ -73,60 +51,99 @@ class OidcAuthStrategy {
|
|||
* @returns {string}
|
||||
*/
|
||||
getScope() {
|
||||
let scope = 'openid profile email'
|
||||
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||
}
|
||||
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||
}
|
||||
return scope
|
||||
return global.ServerSettings.authOpenIDScopes || 'openid profile email'
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
* Reload the OIDC strategy after settings change (replaces init/unuse)
|
||||
*/
|
||||
init() {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
|
||||
return
|
||||
}
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
reload() {
|
||||
this.client = null
|
||||
this.openIdAuthSession.clear()
|
||||
Logger.info('[OidcAuth] Settings reloaded')
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify callback for OpenID Connect authentication
|
||||
* Clean up stale mobile auth sessions older than 10 minutes
|
||||
*/
|
||||
cleanupStaleAuthSessions() {
|
||||
const maxAge = 10 * 60 * 1000 // 10 minutes
|
||||
const now = Date.now()
|
||||
for (const [state, session] of this.openIdAuthSession) {
|
||||
if (now - (session.created_at || 0) > maxAge) {
|
||||
this.openIdAuthSession.delete(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the OIDC callback - exchange auth code for tokens and verify user.
|
||||
* Replaces the passport authenticate + verifyCallback flow.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns {Promise<import('../models/User')>} authenticated user
|
||||
* @throws {AuthError}
|
||||
*/
|
||||
async handleCallback(req) {
|
||||
const sessionData = req.session.oidc
|
||||
if (!sessionData) {
|
||||
throw new AuthError('No OIDC session found', 400)
|
||||
}
|
||||
|
||||
const client = this.getClient()
|
||||
|
||||
// If the client sends a code_verifier in query, use it (mobile flow)
|
||||
const codeVerifier = req.query.code_verifier || sessionData.code_verifier
|
||||
|
||||
// Exchange auth code for tokens
|
||||
const params = client.callbackParams(req)
|
||||
const tokenset = await client.callback(sessionData.sso_redirect_uri, params, {
|
||||
state: sessionData.state,
|
||||
code_verifier: codeVerifier,
|
||||
response_type: 'code'
|
||||
})
|
||||
|
||||
// Fetch userinfo
|
||||
const userinfo = await client.userinfo(tokenset.access_token)
|
||||
|
||||
// Verify and find/create user
|
||||
const user = await this.verifyUser(tokenset, userinfo)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user from OIDC token set and userinfo.
|
||||
* Returns user directly or throws AuthError.
|
||||
*
|
||||
* @param {Object} tokenset
|
||||
* @param {Object} userinfo
|
||||
* @param {Function} done - Passport callback
|
||||
* @returns {Promise<import('../models/User')>}
|
||||
* @throws {AuthError}
|
||||
*/
|
||||
async verifyCallback(tokenset, userinfo, done) {
|
||||
async verifyUser(tokenset, userinfo) {
|
||||
let isNewUser = false
|
||||
let user = null
|
||||
try {
|
||||
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||
|
||||
if (!userinfo.sub) {
|
||||
throw new Error('Invalid userinfo, no sub')
|
||||
throw new AuthError('Invalid userinfo, no sub', 401)
|
||||
}
|
||||
|
||||
if (!this.validateGroupClaim(userinfo)) {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
throw new AuthError(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`, 401)
|
||||
}
|
||||
|
||||
// Enforce email_verified check on every login if configured
|
||||
if (global.ServerSettings.authOpenIDRequireVerifiedEmail && userinfo.email_verified === false) {
|
||||
throw new AuthError('Email is not verified', 401)
|
||||
}
|
||||
|
||||
user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)
|
||||
|
||||
if (user?.error) {
|
||||
throw new Error('Invalid userinfo or already linked')
|
||||
throw new AuthError('Invalid userinfo or already linked', 401)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -137,27 +154,31 @@ class OidcAuthStrategy {
|
|||
isNewUser = true
|
||||
} else {
|
||||
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
|
||||
throw new AuthError('User not found and auto-register is disabled', 401)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
throw new AuthError('User not active or not found', 401)
|
||||
}
|
||||
|
||||
await this.setUserGroup(user, userinfo)
|
||||
await this.updateUserPermissions(user, userinfo)
|
||||
|
||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||
// Save the id_token for later (used for logout via DB session)
|
||||
user.openid_id_token = tokenset.id_token
|
||||
|
||||
return done(null, user)
|
||||
return user
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||
// Remove new user if an error occurs
|
||||
if (isNewUser && user) {
|
||||
await user.destroy()
|
||||
}
|
||||
return done(null, null, 'Unauthorized')
|
||||
if (error instanceof AuthError) {
|
||||
throw error
|
||||
}
|
||||
throw new AuthError(error.message || 'Unauthorized', 401)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +202,8 @@ class OidcAuthStrategy {
|
|||
|
||||
/**
|
||||
* Sets the user group based on group claim in userinfo.
|
||||
* Supports explicit group mapping via authOpenIDGroupMap or legacy direct name match.
|
||||
*
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
|
|
@ -190,17 +213,35 @@ class OidcAuthStrategy {
|
|||
// No group claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||
if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401)
|
||||
|
||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||
const groupMap = global.ServerSettings.authOpenIDGroupMap || {}
|
||||
|
||||
let userType = null
|
||||
|
||||
if (Object.keys(groupMap).length > 0) {
|
||||
// Explicit group mapping: iterate roles in priority order, check if any mapped group names match
|
||||
for (const role of rolesInOrderOfPriority) {
|
||||
const mappedGroups = Object.entries(groupMap)
|
||||
.filter(([, v]) => v === role)
|
||||
.map(([k]) => k.toLowerCase())
|
||||
if (mappedGroups.some((g) => groupsList.includes(g))) {
|
||||
userType = role
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy direct name match
|
||||
userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
}
|
||||
|
||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
if (userType) {
|
||||
if (user.type === 'root') {
|
||||
// Check OpenID Group
|
||||
if (userType !== 'admin') {
|
||||
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
||||
throw new AuthError(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`, 403)
|
||||
} else {
|
||||
// If root user is logging in via OpenID, we will not change the type
|
||||
return
|
||||
|
|
@ -213,7 +254,7 @@ class OidcAuthStrategy {
|
|||
await user.save()
|
||||
}
|
||||
} else {
|
||||
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
||||
throw new AuthError(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`, 401)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +272,7 @@ class OidcAuthStrategy {
|
|||
if (user.type === 'admin' || user.type === 'root') return
|
||||
|
||||
const absPermissions = userinfo[absPermissionsClaim]
|
||||
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||
if (!absPermissions) throw new AuthError(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`, 401)
|
||||
|
||||
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||
|
|
@ -274,24 +315,23 @@ class OidcAuthStrategy {
|
|||
*/
|
||||
isValidRedirectUri(uri) {
|
||||
// Check if the redirect_uri is in the whitelist
|
||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for OpenID Connect
|
||||
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||
* @param {Request} req
|
||||
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
|
||||
* @param {boolean} isMobileFlow - whether this is a mobile client flow (determined by caller)
|
||||
* @param {string|undefined} validatedCallback - pre-validated callback URL for web flow
|
||||
* @returns {{ authorizationUrl: string, isMobileFlow: boolean }|{status: number, error: string}}
|
||||
*/
|
||||
getAuthorizationUrl(req) {
|
||||
getAuthorizationUrl(req, isMobileFlow, validatedCallback) {
|
||||
const client = this.getClient()
|
||||
const strategy = this.getStrategy()
|
||||
const sessionKey = strategy._key
|
||||
|
||||
try {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||
|
||||
// Only allow code flow (for mobile clients)
|
||||
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||
|
|
@ -309,8 +349,6 @@ class OidcAuthStrategy {
|
|||
let redirectUri
|
||||
if (isMobileFlow) {
|
||||
// Mobile required redirect uri
|
||||
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||
// where we will handle the redirect to it
|
||||
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||
return {
|
||||
|
|
@ -318,9 +356,10 @@ class OidcAuthStrategy {
|
|||
error: 'Invalid redirect_uri'
|
||||
}
|
||||
}
|
||||
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||
// We cannot save the supplied redirect_uri in the session, because the mobile client uses browser instead of the API
|
||||
// for the request to mobile-redirect and as such the session is not shared
|
||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||
this.cleanupStaleAuthSessions()
|
||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri, created_at: Date.now() })
|
||||
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
|
|
@ -335,8 +374,6 @@ class OidcAuthStrategy {
|
|||
}
|
||||
}
|
||||
|
||||
// Update the strategy's redirect_uri for this request
|
||||
strategy._params.redirect_uri = redirectUri
|
||||
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||
|
||||
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||
|
|
@ -347,18 +384,17 @@ class OidcAuthStrategy {
|
|||
}
|
||||
}
|
||||
|
||||
req.session[sessionKey] = {
|
||||
...req.session[sessionKey],
|
||||
// Store OIDC session data using fixed key 'oidc'
|
||||
req.session.oidc = {
|
||||
state: state,
|
||||
max_age: strategy._params.max_age,
|
||||
response_type: 'code',
|
||||
code_verifier: pkceData.code_verifier, // not null if web flow
|
||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
isMobile: !!isMobileFlow,
|
||||
sso_redirect_uri: redirectUri, // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
callbackUrl: !isMobileFlow ? validatedCallback : undefined // web: pre-validated callback URL
|
||||
}
|
||||
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...strategy._params,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
response_type: 'code',
|
||||
|
|
@ -396,18 +432,11 @@ class OidcAuthStrategy {
|
|||
if (authMethod === 'openid') {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const host = req.get('host')
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// If we want to support it we need to include a config for the serverurl
|
||||
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||
}
|
||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||
// the post_logout_redirect_uri parameter at all and for other providers
|
||||
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
||||
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
||||
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
||||
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
||||
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
||||
// The client/app can simply append something like
|
||||
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url
|
||||
|
||||
return client.endSessionUrl({
|
||||
id_token_hint: idToken,
|
||||
|
|
|
|||
340
server/auth/OidcSettingsSchema.js
Normal file
340
server/auth/OidcSettingsSchema.js
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
const groups = [
|
||||
{ id: 'endpoints', label: 'Provider Endpoints', order: 1 },
|
||||
{ id: 'credentials', label: 'Client Credentials', order: 2 },
|
||||
{ id: 'behavior', label: 'Login Behavior', order: 3 },
|
||||
{ id: 'claims', label: 'Claims & Group Mapping', order: 4 },
|
||||
{ id: 'advanced', label: 'Advanced', order: 5 }
|
||||
]
|
||||
|
||||
const schema = [
|
||||
// Endpoints group
|
||||
{
|
||||
key: 'authOpenIDIssuerURL',
|
||||
type: 'text',
|
||||
label: 'Issuer URL',
|
||||
group: 'endpoints',
|
||||
order: 1,
|
||||
required: true,
|
||||
validate: 'url'
|
||||
},
|
||||
{
|
||||
key: 'discover',
|
||||
type: 'action',
|
||||
label: 'Auto-populate',
|
||||
group: 'endpoints',
|
||||
order: 2,
|
||||
description: 'Fetch endpoints from issuer discovery document',
|
||||
dependsOn: 'authOpenIDIssuerURL'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDAuthorizationURL',
|
||||
type: 'text',
|
||||
label: 'Authorize URL',
|
||||
group: 'endpoints',
|
||||
order: 3,
|
||||
required: true,
|
||||
validate: 'url'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDTokenURL',
|
||||
type: 'text',
|
||||
label: 'Token URL',
|
||||
group: 'endpoints',
|
||||
order: 4,
|
||||
required: true,
|
||||
validate: 'url'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDUserInfoURL',
|
||||
type: 'text',
|
||||
label: 'Userinfo URL',
|
||||
group: 'endpoints',
|
||||
order: 5,
|
||||
required: true,
|
||||
validate: 'url'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDJwksURL',
|
||||
type: 'text',
|
||||
label: 'JWKS URL',
|
||||
group: 'endpoints',
|
||||
order: 6,
|
||||
required: true,
|
||||
validate: 'url'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDLogoutURL',
|
||||
type: 'text',
|
||||
label: 'Logout URL',
|
||||
group: 'endpoints',
|
||||
order: 7,
|
||||
validate: 'url'
|
||||
},
|
||||
|
||||
// Credentials group
|
||||
{
|
||||
key: 'authOpenIDClientID',
|
||||
type: 'text',
|
||||
label: 'Client ID',
|
||||
group: 'credentials',
|
||||
order: 1,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDClientSecret',
|
||||
type: 'password',
|
||||
label: 'Client Secret',
|
||||
group: 'credentials',
|
||||
order: 2,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDTokenSigningAlgorithm',
|
||||
type: 'select',
|
||||
label: 'Signing Algorithm',
|
||||
group: 'credentials',
|
||||
order: 3,
|
||||
required: true,
|
||||
default: 'RS256',
|
||||
options: [
|
||||
{ value: 'RS256', label: 'RS256' },
|
||||
{ value: 'RS384', label: 'RS384' },
|
||||
{ value: 'RS512', label: 'RS512' },
|
||||
{ value: 'ES256', label: 'ES256' },
|
||||
{ value: 'ES384', label: 'ES384' },
|
||||
{ value: 'ES512', label: 'ES512' },
|
||||
{ value: 'PS256', label: 'PS256' },
|
||||
{ value: 'PS384', label: 'PS384' },
|
||||
{ value: 'PS512', label: 'PS512' },
|
||||
{ value: 'EdDSA', label: 'EdDSA' }
|
||||
]
|
||||
},
|
||||
|
||||
// Behavior group
|
||||
{
|
||||
key: 'authOpenIDButtonText',
|
||||
type: 'text',
|
||||
label: 'Button Text',
|
||||
group: 'behavior',
|
||||
order: 1,
|
||||
default: 'Login with OpenId'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDMatchExistingBy',
|
||||
type: 'select',
|
||||
label: 'Match Existing Users By',
|
||||
group: 'behavior',
|
||||
order: 2,
|
||||
options: [
|
||||
{ value: null, label: 'Do not match' },
|
||||
{ value: 'email', label: 'Match by email' },
|
||||
{ value: 'username', label: 'Match by username' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDAutoLaunch',
|
||||
type: 'boolean',
|
||||
label: 'Auto Launch',
|
||||
group: 'behavior',
|
||||
order: 3,
|
||||
description: 'Automatically redirect to the OpenID provider on login page'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDAutoRegister',
|
||||
type: 'boolean',
|
||||
label: 'Auto Register',
|
||||
group: 'behavior',
|
||||
order: 4,
|
||||
description: 'Automatically register new users from the OpenID provider'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDRequireVerifiedEmail',
|
||||
type: 'boolean',
|
||||
label: 'Require Verified Email',
|
||||
group: 'behavior',
|
||||
order: 5,
|
||||
description: 'Reject login if email_verified is false in the OIDC userinfo, even for existing users'
|
||||
},
|
||||
|
||||
// Claims group
|
||||
{
|
||||
key: 'authOpenIDScopes',
|
||||
type: 'text',
|
||||
label: 'Scopes',
|
||||
group: 'claims',
|
||||
order: 1,
|
||||
default: 'openid profile email',
|
||||
description: 'Space-separated list of OIDC scopes to request'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDGroupClaim',
|
||||
type: 'text',
|
||||
label: 'Group Claim',
|
||||
group: 'claims',
|
||||
order: 2,
|
||||
validate: 'claimName',
|
||||
description: 'Name of the claim containing group membership'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDGroupMap',
|
||||
type: 'keyvalue',
|
||||
label: 'Group Mapping',
|
||||
group: 'claims',
|
||||
order: 3,
|
||||
valueOptions: ['admin', 'user', 'guest'],
|
||||
description: 'Map OIDC group names to Audiobookshelf roles. If empty, groups are matched by name (admin/user/guest).',
|
||||
dependsOn: 'authOpenIDGroupClaim'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDAdvancedPermsClaim',
|
||||
type: 'text',
|
||||
label: 'Advanced Permission Claim',
|
||||
group: 'claims',
|
||||
order: 4,
|
||||
validate: 'claimName',
|
||||
description: 'Claim containing per-user permissions JSON'
|
||||
},
|
||||
|
||||
// Advanced group
|
||||
{
|
||||
key: 'authOpenIDMobileRedirectURIs',
|
||||
type: 'array',
|
||||
label: 'Mobile Redirect URIs',
|
||||
group: 'advanced',
|
||||
order: 1,
|
||||
default: ['audiobookshelf://oauth'],
|
||||
validate: 'uri',
|
||||
description: 'Allowed redirect URIs for mobile clients.'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDSubfolderForRedirectURLs',
|
||||
type: 'select',
|
||||
label: 'Web Redirect URLs Subfolder',
|
||||
group: 'advanced',
|
||||
order: 2,
|
||||
options: [
|
||||
{ value: '', label: 'None' }
|
||||
],
|
||||
description: 'Subfolder prefix for redirect URLs (e.g. /audiobookshelf)'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the OIDC settings schema
|
||||
* @returns {Array} schema field descriptors
|
||||
*/
|
||||
function getSchema() {
|
||||
// Lazily resolve sample permissions to avoid circular dependency at require time
|
||||
const User = require('../models/User')
|
||||
return schema.map((field) => {
|
||||
if (field.key === 'authOpenIDAdvancedPermsClaim') {
|
||||
return {
|
||||
...field,
|
||||
description: `Claim containing per-user permissions JSON. Sample: ${User.getSampleAbsPermissions()}`
|
||||
}
|
||||
}
|
||||
return field
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC settings groups
|
||||
* @returns {Array} group descriptors
|
||||
*/
|
||||
function getGroups() {
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OIDC settings values against the schema
|
||||
* @param {Object} values - key-value pairs of settings
|
||||
* @returns {{ valid: boolean, errors?: string[] }}
|
||||
*/
|
||||
function validateSettings(values) {
|
||||
const errors = []
|
||||
|
||||
// Reject unknown keys
|
||||
const knownKeys = new Set(schema.filter((f) => f.type !== 'action').map((f) => f.key))
|
||||
for (const key of Object.keys(values)) {
|
||||
if (!knownKeys.has(key)) {
|
||||
errors.push(`Unknown setting: "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of schema) {
|
||||
if (field.type === 'action') continue
|
||||
|
||||
const value = values[field.key]
|
||||
|
||||
// Check required fields
|
||||
if (field.required) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
errors.push(`${field.label} is required`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip validation for empty optional fields
|
||||
if (value === undefined || value === null || value === '') continue
|
||||
|
||||
// Type-specific validation
|
||||
if (field.validate === 'url') {
|
||||
try {
|
||||
new URL(value)
|
||||
} catch {
|
||||
errors.push(`${field.label}: Invalid URL`)
|
||||
}
|
||||
}
|
||||
|
||||
if (field.validate === 'uri') {
|
||||
if (Array.isArray(value)) {
|
||||
const uriPattern = /^\w+:\/\/[\w.-]+(\/[\w./-]*)*$/i
|
||||
for (const uri of value) {
|
||||
if (!uriPattern.test(uri)) {
|
||||
errors.push(`${field.label}: Invalid URI "${uri}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.validate === 'claimName') {
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
const claimPattern = /^[a-zA-Z][a-zA-Z0-9_:./-]*$/
|
||||
if (!claimPattern.test(value)) {
|
||||
errors.push(`${field.label}: Invalid claim name`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push(`${field.label}: Expected boolean`)
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'array') {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${field.label}: Expected array`)
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'keyvalue') {
|
||||
if (typeof value !== 'object' || Array.isArray(value) || value === null) {
|
||||
errors.push(`${field.label}: Expected object`)
|
||||
} else if (field.valueOptions) {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (!field.valueOptions.includes(v)) {
|
||||
errors.push(`${field.label}: Invalid value "${v}" for key "${k}". Must be one of: ${field.valueOptions.join(', ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
module.exports = { getSchema, getGroups, validateSettings }
|
||||
|
|
@ -156,9 +156,10 @@ class TokenManager {
|
|||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {string|null} [oidcIdToken=null] - OIDC id_token to store in session for logout
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||
*/
|
||||
async createTokensAndSession(user, req) {
|
||||
async createTokensAndSession(user, req, oidcIdToken = null) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
const userAgent = req.headers['user-agent']
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
|
|
@ -167,7 +168,7 @@ class TokenManager {
|
|||
// Calculate expiration time for the refresh token
|
||||
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
|
@ -392,6 +393,17 @@ class TokenManager {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by its refresh token
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<import('../models/Session')|null>}
|
||||
*/
|
||||
async getSessionByRefreshToken(refreshToken) {
|
||||
if (!refreshToken) return null
|
||||
return await Database.sessionModel.findOne({ where: { refreshToken } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a refresh token - used for logout
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue