diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index f31f9ea22..47ccf5d59 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -108,6 +108,20 @@
+admin 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 (if configured). 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 false. 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 groups. If configured, 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 (admin, user, guest, or an admin group above). Set to user or guest 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}",
diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js
index 64ab82448..8014a436c 100644
--- a/server/auth/OidcAuthStrategy.js
+++ b/server/auth/OidcAuthStrategy.js
@@ -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
diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js
index 99f5b76aa..f51efce83 100644
--- a/server/objects/settings/ServerSettings.js
+++ b/server/objects/settings/ServerSettings.js
@@ -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()
diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js
new file mode 100644
index 000000000..e701ec426
--- /dev/null
+++ b/test/server/auth/OidcAuthStrategy.test.js
@@ -0,0 +1,78 @@
+const { expect } = require('chai')
+const sinon = require('sinon')
+
+const Database = require('../../../server/Database')
+const Logger = require('../../../server/Logger')
+const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
+
+describe('OidcAuthStrategy', () => {
+ let strategy
+ let originalServerSettings
+
+ beforeEach(() => {
+ strategy = new OidcAuthStrategy()
+ originalServerSettings = Database.serverSettings
+ sinon.stub(Logger, 'info')
+ })
+
+ afterEach(() => {
+ Database.serverSettings = originalServerSettings
+ 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.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
+ })
+ })
+})