diff --git a/client/components/ui/TextInputWithLabel.vue b/client/components/ui/TextInputWithLabel.vue index 032e24ca..f653a18b 100644 --- a/client/components/ui/TextInputWithLabel.vue +++ b/client/components/ui/TextInputWithLabel.vue @@ -5,7 +5,7 @@ >{{ label }}{{ note }} - + @@ -14,6 +14,7 @@ export default { props: { value: [String, Number], label: String, + placeholder: String, note: String, type: { type: String, diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 3373e287..91c6cfe2 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -70,17 +70,42 @@

{{ $strings.LabelMatchExistingUsersByDescription }}

-
+

{{ $strings.LabelAutoLaunch }}

-
+

{{ $strings.LabelAutoRegister }}

{{ $strings.LabelAutoRegisterDescription }}

+ +
Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.
+
+
+ +
+

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

+
+ +
+
+ +
+
+

+ 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: +

+
{{ newAuthSettings.authOpenIDSamplePermissions }}
+                
+
+
@@ -222,6 +247,22 @@ export default { } }) } + + function isValidClaim(claim) { + if (claim === '') return true + + const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i') + return pattern.test(claim) + } + if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) { + this.$toast.error('Group Claim: Invalid claim name') + isValid = false + } + if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) { + this.$toast.error('Advanced Permission Claim: Invalid claim name') + isValid = false + } + return isValid }, async saveSettings() { diff --git a/server/Auth.js b/server/Auth.js index 352faf66..a4cdd1fc 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -98,7 +98,7 @@ class Auth { scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { - Logger.debug(`[Auth] openid callback userinfo=`, userinfo) + Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) let failureMessage = 'Unauthorized' if (!userinfo.sub) { @@ -106,6 +106,35 @@ class Auth { return done(null, null, failureMessage) } + // Check if the claims itself are returned correctly + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + if (groupClaimName) { + if (!userinfo[groupClaimName]) { + Logger.error(`[Auth] openid callback invalid: Group claim ${groupClaimName} configured, but not found or empty in userinfo`) + return done(null, null, failureMessage) + } + + const groupsList = userinfo[groupClaimName] + const targetRoles = ['admin', 'user', 'guest'] + + // Convert the list to lowercase for case-insensitive comparison + const groupsListLowercase = groupsList.map(group => group.toLowerCase()) + + // Check if any of the target roles exist in the groups list + const containsTargetRole = targetRoles.some(role => groupsListLowercase.includes(role.toLowerCase())) + + if (!containsTargetRole) { + Logger.info(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList) + return done(null, null, failureMessage) + } + } + + const advancedPermsClaimName = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (advancedPermsClaimName && !userinfo[advancedPermsClaimName]) { + Logger.error(`[Auth] openid callback invalid: Advanced perms claim ${advancedPermsClaimName} configured, but not found or empty in userinfo`) + return done(null, null, failureMessage) + } + // First check for matching user by sub let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) if (!user) { @@ -157,6 +186,43 @@ class Auth { return } + // Set user group if name of groups claim is configured + if (groupClaimName) { + const groupsList = userinfo[groupClaimName] ? userinfo[groupClaimName].map(group => group.toLowerCase()) : [] + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = null + + for (let role of rolesInOrderOfPriority) { + if (groupsList.includes(role)) { + userType = role // This will override with the highest priority role found + break // Stop searching once the highest priority role is found + } + } + + // Actually already checked above, but just to be sure + if (!userType) { + Logger.error(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList) + return done(null, null, failureMessage) + } + + Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) + user.type = userType + await Database.userModel.updateFromOld(user) + } + + if (advancedPermsClaimName) { + try { + Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(userinfo[advancedPermsClaimName])}`) + + user.updatePermissionsFromExternalJSON(userinfo[advancedPermsClaimName]) + await Database.userModel.updateFromOld(user) + } catch (error) { + Logger.error(`[Auth] openid callback: Error updating advanced perms for user, error: `, error) + return done(null, null, failureMessage) + } + } + // We also have to save the id_token for later (used for logout) because we cannot set cookies here user.openid_id_token = tokenset.id_token @@ -334,10 +400,19 @@ class Auth { sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback } + var scope = 'openid profile email' + if (global.ServerSettings.authOpenIDGroupClaim) { + scope += ' ' + global.ServerSettings.authOpenIDGroupClaim + } + if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim + } + const authorizationUrl = client.authorizationUrl({ ...oidcStrategy._params, state: state, response_type: 'code', + scope: scope, code_challenge, code_challenge_method }) @@ -424,12 +499,12 @@ class Auth { } function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) { - Logger.error(logMessage) + Logger.error(JSON.stringify(logMessage, null, 2)) if (response) { // Depending on the error, it can also have a body // We also log the request header the passport plugin sents for the URL const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED') - Logger.debug(header + '\n' + response.body?.toString() + '\n' + JSON.stringify(response.body, null, 2)) + Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2)) } if (isMobile) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 5cc68a5c..5c2da381 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,6 +1,7 @@ const packageJson = require('../../../package.json') const { BookshelfView } = require('../../utils/constants') const Logger = require('../../Logger') +const User = require('../user/User') class ServerSettings { constructor(settings) { @@ -72,6 +73,8 @@ class ServerSettings { this.authOpenIDAutoRegister = false this.authOpenIDMatchExistingBy = null this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + this.authOpenIDGroupClaim = '' + this.authOpenIDAdvancedPermsClaim = '' if (settings) { this.construct(settings) @@ -129,6 +132,8 @@ class ServerSettings { this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] + this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' + this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -216,7 +221,9 @@ class ServerSettings { authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, - authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client + authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client } } @@ -226,6 +233,8 @@ class ServerSettings { delete json.authOpenIDClientID delete json.authOpenIDClientSecret delete json.authOpenIDMobileRedirectURIs + delete json.authOpenIDGroupClaim + delete json.authOpenIDAdvancedPermsClaim return json } @@ -262,7 +271,11 @@ class ServerSettings { authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, - authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client + authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + + authOpenIDSamplePermissions: User.getSampleAbsPermissions() } } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index d926e8be..d09e921d 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -268,6 +268,78 @@ class User { return hasUpdates } + // List of expected permission properties from the client + static permissionMapping = { + canDownload: 'download', + canUpload: 'upload', + canDelete: 'delete', + canUpdate: 'update', + canAccessExplicitContent: 'accessExplicitContent', + canAccessAllLibraries: 'accessAllLibraries', + canAccessAllTags: 'accessAllTags', + tagsAreBlacklist: 'selectedTagsNotAccessible', + // Direct mapping for array-based permissions + allowedLibraries: 'librariesAccessible', + allowedTags: 'itemTagsSelected', + } + + /** + * Update user from external JSON + * + * @param {object} absPermissions JSON containg user permissions + */ + updatePermissionsFromExternalJSON(absPermissions) { + // Initialize all permissions to false first + Object.keys(User.permissionMapping).forEach(mappingKey => { + const userPermKey = User.permissionMapping[mappingKey]; + if (typeof this.permissions[userPermKey] === 'boolean') { + this.permissions[userPermKey] = false; // Default to false for boolean permissions + } else { + this[userPermKey] = []; // Default to empty array for other properties + } + }); + + Object.keys(absPermissions).forEach(absKey => { + const userPermKey = User.permissionMapping[absKey] + if (!userPermKey) { + throw new Error(`Unexpected permission property: ${absKey}`) + } + + // Update the user's permissions based on absPermissions + this.permissions[userPermKey] = absPermissions[absKey] + }); + + // Handle allowedLibraries and allowedTags separately if needed + if (absPermissions.allowedLibraries) { + this.librariesAccessible = absPermissions.allowedLibraries + } + if (absPermissions.allowedTags) { + this.itemTagsSelected = absPermissions.allowedTags + } + } + + /** + * Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like + * + * @returns JSON string + */ + static getSampleAbsPermissions() { + // Start with a template object where all permissions are false for simplicity + const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => { + // For array-based permissions, provide a sample array + if (key === 'allowedLibraries') { + acc[key] = [`ExampleLibrary`, `AnotherLibrary`]; + } else if (key === 'allowedTags') { + acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]; + } else { + acc[key] = false; + } + return acc; + }, {}); + + return JSON.stringify(samplePermissions, null, 2); // Pretty print the JSON + } + /** * Get first available library id for user *