This commit is contained in:
Boris Rybalkin 2026-06-30 14:11:36 +03:00 committed by GitHub
commit 81cf70320b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 115 additions and 2 deletions

View file

@ -108,6 +108,20 @@
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupClaimDescription"></p>
</div>
<div class="flex flex-col sm:flex-row mb-4">
<div class="w-44 min-w-44">
<ui-text-input-with-label ref="openidAdminGroups" v-model="newAuthSettings.authOpenIDAdminGroups" :disabled="savingSettings" :placeholder="'admin'" :label="'Admin Groups'" />
</div>
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDAdminGroupsDescription"></p>
</div>
<div class="flex flex-col sm:flex-row mb-4">
<div class="w-44 min-w-44">
<ui-text-input-with-label ref="openidGroupDefaultRole" v-model="newAuthSettings.authOpenIDGroupDefaultRole" :disabled="savingSettings" :placeholder="'user'" :label="'Default Group Role'" />
</div>
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupDefaultRoleDescription"></p>
</div>
<div class="flex flex-col sm:flex-row mb-4">
<div class="w-44 min-w-44">
<ui-text-input-with-label ref="openidAdvancedPermsClaim" v-model="newAuthSettings.authOpenIDAdvancedPermsClaim" :disabled="savingSettings" :placeholder="'abspermissions'" :label="'Advanced Permission Claim'" />

View file

@ -497,9 +497,11 @@
"LabelNumberOfBooks": "Number of Books",
"LabelNumberOfChapters": "Number of chapters:",
"LabelNumberOfEpisodes": "# of Episodes",
"LabelOpenIDAdminGroupsDescription": "Comma-separated list of group names from the group claim that should be mapped to the <code>admin</code> role, in addition to the built-in case-insensitive 'admin', 'user', and 'guest' names. Use this when your identity provider's groups are not named to match the application's roles.",
"LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:",
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",
"LabelOpenIDGroupDefaultRoleDescription": "Role to assign when none of the user's groups match a known role (<code>admin</code>, <code>user</code>, <code>guest</code>, or an admin group above). Set to <code>user</code> or <code>guest</code> to admit such users with that role instead of denying access. Leave empty to deny access when no group matches.",
"LabelOpenRSSFeed": "Open RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelPaginationPageXOfY": "Page {0} of {1}",

View file

@ -192,10 +192,12 @@ class OidcAuthStrategy {
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
const adminGroups = (Database.serverSettings.authOpenIDAdminGroups || '').split(',').map((group) => group.trim().toLowerCase()).filter(Boolean)
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()).map((group) => (adminGroups.includes(group) ? 'admin' : group))
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
const defaultRole = (Database.serverSettings.authOpenIDGroupDefaultRole || '').toLowerCase()
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) || (rolesInOrderOfPriority.includes(defaultRole) ? defaultRole : undefined)
if (userType) {
if (user.type === 'root') {
// Check OpenID Group
@ -210,6 +212,7 @@ class OidcAuthStrategy {
if (user.type !== userType) {
Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
user.type = userType
user.permissions = Database.userModel.getDefaultPermissionsForUserType(userType)
await user.save()
}
} else {

View file

@ -82,6 +82,8 @@ class ServerSettings {
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = ''
this.authOpenIDAdminGroups = ''
this.authOpenIDGroupDefaultRole = null
this.authOpenIDSubfolderForRedirectURLs = undefined
if (settings) {
@ -146,6 +148,8 @@ class ServerSettings {
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
this.authOpenIDAdminGroups = settings.authOpenIDAdminGroups || ''
this.authOpenIDGroupDefaultRole = settings.authOpenIDGroupDefaultRole || null
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
if (!Array.isArray(this.authActiveAuthMethods)) {
@ -256,6 +260,8 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDAdminGroups: this.authOpenIDAdminGroups, // Do not return to client
authOpenIDGroupDefaultRole: this.authOpenIDGroupDefaultRole, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
}
}
@ -268,6 +274,8 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
delete json.authOpenIDAdminGroups
delete json.authOpenIDGroupDefaultRole
return json
}
@ -302,6 +310,8 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDAdminGroups: this.authOpenIDAdminGroups, // Do not return to client
authOpenIDGroupDefaultRole: this.authOpenIDGroupDefaultRole, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
authOpenIDSamplePermissions: User.getSampleAbsPermissions()

View file

@ -0,0 +1,84 @@
const { expect } = require('chai')
const sinon = require('sinon')
const Database = require('../../../server/Database')
const Logger = require('../../../server/Logger')
const User = require('../../../server/models/User')
const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
describe('OidcAuthStrategy', () => {
let strategy
let originalServerSettings
let originalUserModel
beforeEach(() => {
strategy = new OidcAuthStrategy()
originalServerSettings = Database.serverSettings
originalUserModel = Database.userModel
Database.userModel = User
sinon.stub(Logger, 'info')
})
afterEach(() => {
Database.serverSettings = originalServerSettings
Database.userModel = originalUserModel
sinon.restore()
})
const fakeUser = (type) => ({ type, username: 'tester', save: sinon.stub().resolves() })
describe('setUserGroup', () => {
it('maps a configured admin group to the admin role', async () => {
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: 'syncloud', authOpenIDGroupDefaultRole: null }
const user = fakeUser('user')
await strategy.setUserGroup(user, { groups: ['syncloud'] })
expect(user.type).to.equal('admin')
expect(user.permissions.upload).to.be.true
expect(user.save.calledOnce).to.be.true
})
it('still maps built-in role names when admin groups are configured', async () => {
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: 'syncloud', authOpenIDGroupDefaultRole: null }
const user = fakeUser('guest')
await strategy.setUserGroup(user, { groups: ['User'] })
expect(user.type).to.equal('user')
})
it('falls back to the default role when no group matches', async () => {
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: '', authOpenIDGroupDefaultRole: 'user' }
const user = fakeUser('guest')
await strategy.setUserGroup(user, { groups: ['some-unrelated-group'] })
expect(user.type).to.equal('user')
})
it('denies login when no group matches and no default role is set', async () => {
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: '', authOpenIDGroupDefaultRole: null }
const user = fakeUser('user')
let error
try {
await strategy.setUserGroup(user, { groups: ['some-unrelated-group'] })
} catch (e) {
error = e
}
expect(error).to.be.an('error')
})
it('does nothing when no group claim is configured', async () => {
Database.serverSettings = { authOpenIDGroupClaim: '' }
const user = fakeUser('user')
await strategy.setUserGroup(user, { groups: ['syncloud'] })
expect(user.type).to.equal('user')
expect(user.save.called).to.be.false
})
})
})