From 56f1bfef507228ddeb58795666d6264bfe5cd966 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 17:57:24 +0100 Subject: [PATCH 01/11] Auth/OpenID: Implement Permissions via OpenID * Ability to set group * Ability to set more advanced permissions * Modified TextInputWithLabel to provide an ability to specify a different placeholder then the name --- client/components/ui/TextInputWithLabel.vue | 3 +- client/pages/config/authentication.vue | 45 +++++++++++- server/Auth.js | 81 ++++++++++++++++++++- server/objects/settings/ServerSettings.js | 17 ++++- server/objects/user/User.js | 72 ++++++++++++++++++ 5 files changed, 210 insertions(+), 8 deletions(-) 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 * From f661e0835ce3653640dabcc19559348c0c70dff2 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 19:18:38 +0100 Subject: [PATCH 02/11] Auth: Simplify Code --- server/Auth.js | 277 ++++++++++++++++++++++++++----------------------- 1 file changed, 147 insertions(+), 130 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index a4cdd1fc..368f9a4d 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -98,139 +98,156 @@ class Auth { scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { - Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + try { + Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + + if (!userinfo.sub) { + throw new Error('Invalid userinfo, no sub') + } + + if (!this.validateGroupClaim(userinfo)) { + throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + } + + let user = await this.findOrCreateUser(userinfo) + + if (!user || !user.isActive) { + throw new Error('User not active or not found') + } + + 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 + user.openid_id_token = tokenset.id_token - let failureMessage = 'Unauthorized' - if (!userinfo.sub) { - Logger.error(`[Auth] openid callback invalid userinfo, no sub`) - return done(null, null, failureMessage) + return done(null, user) + } catch (error) { + Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`) + + return done(null, null, 'Unauthorized') } - - // 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) { - // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) - // Check that user is not already matched - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback - failureMessage = 'A matching user was found but is already matched with another user from your auth provider' - user = null - } - } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) - user = await Database.userModel.getUserByUsername(userinfo.preferred_username) - // Check that user is not already matched - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback - failureMessage = 'A matching user was found but is already matched with another user from your auth provider' - user = null - } - } - - // If existing user was matched and isActive then save sub to user - if (user?.isActive) { - Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) - user.authOpenIDSub = userinfo.sub - await Database.userModel.updateFromOld(user) - } else if (user && !user.isActive) { - Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) - } - - // Optionally auto register the user - if (!user && Database.serverSettings.authOpenIDAutoRegister) { - Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) - } - } - - if (!user?.isActive) { - if (user && !user.isActive) { - failureMessage = 'Unauthorized' - } - // deny login - done(null, null, failureMessage) - 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 - - // permit login - return done(null, user) })) } + /** + * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, + * or creates a new user if configured to do so. + */ + async findOrCreateUser(userinfo) { + let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) + + // Matched by sub + if (user) { + Logger.debug(`[Auth] openid: User found by sub`) + return user + } + + // Match existing user by email + if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { + Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await Database.userModel.getUserByEmail(userinfo.email) + + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + // Match existing user by username + else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { + Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) + user = await Database.userModel.getUserByUsername(userinfo.preferred_username) + + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + + // Found existing user via email or username + if (user) { + if (!user.isActive) { + Logger.warn(`[Auth] openid: User found but is not active`) + return null + } + + user.authOpenIDSub = userinfo.sub + await Database.userModel.updateFromOld(user) + + Logger.debug(`[Auth] openid: User found by email/username`) + return user + } + + // If no existing user was matched, auto-register if configured + if (Database.serverSettings.authOpenIDAutoRegister) { + Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) + return user + } + + Logger.warn(`[Auth] openid: User not found and auto-register is disabled`) + return null + } + + /** + * Validates the presence and content of the group claim in userinfo. + */ + validateGroupClaim(userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + if (!groupClaimName) // Allow no group claim when configured like this + return true + + // If configured it must exist in userinfo + if (!userinfo[groupClaimName]) { + return false + } + return true + } + +/** + * Sets the user group based on group claim in userinfo. + */ +async setUserGroup(user, userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + if (!groupClaimName) // No group claim configured, don't set anything + return + + if (!userinfo[groupClaimName]) + throw new Error(`Group claim ${groupClaimName} not found in userinfo`) + + const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = rolesInOrderOfPriority.find(role => groupsList.includes(role)) + if (userType) { + Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) + + if (user.type !== userType) { + user.type = userType; + await Database.userModel.updateFromOld(user) + } + } else { + throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + } +} + +/** + * Updates user permissions based on the advanced permissions claim. + */ +async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything + return + + const absPermissions = userinfo[absPermissionsClaim] + if (!absPermissions) + throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + + Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(absPermissions)}`) + user.updatePermissionsFromExternalJSON(absPermissions) + await Database.userModel.updateFromOld(user) +} + /** * Unuse strategy * @@ -421,7 +438,7 @@ class Auth { res.redirect(authorizationUrl) } catch (error) { - Logger.error(`[Auth] Error in /auth/openid route: ${error}`) + Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`) res.status(500).send('Internal Server Error') } @@ -477,7 +494,7 @@ class Auth { // Redirect to the overwrite URI saved in the map res.redirect(redirectUri) } catch (error) { - Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`) + Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) res.status(500).send('Internal Server Error') } }) From 50330b0a606901f320cdb8eda802575db2aa3ae6 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 19:18:47 +0100 Subject: [PATCH 03/11] Auth: Add translations --- client/pages/config/authentication.vue | 11 +++-------- client/strings/de.json | 3 +++ client/strings/en-us.json | 3 +++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 91c6cfe2..cecccee4 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -82,15 +82,12 @@

{{ $strings.LabelAutoRegisterDescription }}

-
Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.
+
{{ $strings.LabelOpenIDClaims }}
-

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

+

@@ -98,9 +95,7 @@
-

- 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 }}
                 
diff --git a/client/strings/de.json b/client/strings/de.json index ed99f095..611432f1 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -387,6 +387,9 @@ "LabelNotStarted": "Nicht begonnen", "LabelNumberOfBooks": "Anzahl der Hörbücher", "LabelNumberOfEpisodes": "Anzahl der Episoden", + "LabelOpenIDClaims": "Lass die folgenden Optionen leer, um die erweiterte Zuweisung von Gruppen und Berechtigungen zu deaktivieren und automatisch die 'User'-Gruppe zuzuweisen.", + "LabelOpenIDGroupClaimDescription": "Name des OpenID-Claims, der eine Liste der Benutzergruppen enthält. Wird häufig als groups bezeichnet. Wenn konfiguriert, wird die Anwendung automatisch Rollen basierend auf den Gruppenmitgliedschaften des Benutzers zuweisen, vorausgesetzt, dass diese Gruppen im Claim als 'admin', 'user' oder 'guest' benannt sind (Groß/Kleinschreibung ist irrelevant). Der Claim eine Liste sein, und wenn ein Benutzer mehreren Gruppen angehört, wird die Anwendung die Rolle zuordnen, die dem höchsten Zugriffslevel entspricht. Wenn keine Gruppe übereinstimmt, wird der Zugang verweigert.", + "LabelOpenIDAdvancedPermsClaimDescription": "Name des OpenID-Claims, der erweiterte Berechtigungen für Benutzeraktionen innerhalb der Anwendung enthält, die auf Nicht-Admin-Rollen angewendet werden (wenn konfiguriert). Wenn der Claim in der Antwort fehlt, wird der Zugang zu ABS verweigert. Fehlt eine einzelne Option, wird sie als false behandelt. Stelle sicher, dass der Claim des Identitätsanbieters der erwarteten Struktur entspricht:", "LabelOpenRSSFeed": "Öffne RSS-Feed", "LabelOverwrite": "Überschreiben", "LabelPassword": "Passwort", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 43a1ef44..b6fe3505 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -387,6 +387,9 @@ "LabelNotStarted": "Not Started", "LabelNumberOfBooks": "Number of Books", "LabelNumberOfEpisodes": "# of Episodes", + "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.", + "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:", "LabelOpenRSSFeed": "Open RSS Feed", "LabelOverwrite": "Overwrite", "LabelPassword": "Password", From 1646f0ebc21505a1ed00866cb7a033c5028ba5c4 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 19:35:34 +0100 Subject: [PATCH 04/11] OpenID: Ignore admin for advanced permissions Also removed some semicolons --- server/Auth.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 368f9a4d..e14348c7 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -193,7 +193,7 @@ class Auth { * Validates the presence and content of the group claim in userinfo. */ validateGroupClaim(userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim if (!groupClaimName) // Allow no group claim when configured like this return true @@ -208,7 +208,7 @@ class Auth { * Sets the user group based on group claim in userinfo. */ async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim if (!groupClaimName) // No group claim configured, don't set anything return @@ -223,7 +223,7 @@ async setUserGroup(user, userinfo) { Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) if (user.type !== userType) { - user.type = userType; + user.type = userType await Database.userModel.updateFromOld(user) } } else { @@ -239,6 +239,9 @@ async updateUserPermissions(user, userinfo) { if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything return + if (user.type === 'admin') + return + const absPermissions = userinfo[absPermissionsClaim] if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) From 617b8f4487d506da962658a8fd371584e8ba7734 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Thu, 28 Mar 2024 16:16:26 +0100 Subject: [PATCH 05/11] OpenID: Rename tags switch --- server/objects/user/User.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/objects/user/User.js b/server/objects/user/User.js index d09e921d..b473637b 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -277,7 +277,7 @@ class User { canAccessExplicitContent: 'accessExplicitContent', canAccessAllLibraries: 'accessAllLibraries', canAccessAllTags: 'accessAllTags', - tagsAreBlacklist: 'selectedTagsNotAccessible', + tagsAreDenylist: 'selectedTagsNotAccessible', // Direct mapping for array-based permissions allowedLibraries: 'librariesAccessible', allowedTags: 'itemTagsSelected', From 8cd50d56844bc525d026303896b7d77000568b74 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Fri, 29 Mar 2024 14:51:34 +0100 Subject: [PATCH 06/11] OpenID: Don't downgrade root --- server/Auth.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index e14348c7..59f32d7e 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -220,6 +220,16 @@ async setUserGroup(user, userinfo) { 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.`) + } else { + // If root user is logging in via OpenID, we will not change the type + return + } + } + Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) if (user.type !== userType) { @@ -239,7 +249,7 @@ async updateUserPermissions(user, userinfo) { if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything return - if (user.type === 'admin') + if (user.type === 'admin' || user.type === 'root') return const absPermissions = userinfo[absPermissionsClaim] From 90e1283058c6916b873fcab490dc881ad0a25155 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Fri, 29 Mar 2024 15:11:56 +0100 Subject: [PATCH 07/11] OpenID: Allow email_verified null and also check username Only disallow when email_verified explicitly false Also check username besides preferred_username, even when its not included in OIDC checks (synology uses username) --- server/Auth.js | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 59f32d7e..733acc36 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -144,22 +144,47 @@ class Auth { } // Match existing user by email - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) + if (Database.serverSettings.authOpenIDMatchExistingBy === 'email') { + if (userinfo.email) { + // Only disallow when email_verified explicitly set to false (allow both if not set or true) + if (userinfo.email_verified === false) { + Logger.warn(`[Auth] openid: User not found and email "${userinfo.email}" is not verified`) + return null + } else { + Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await Database.userModel.getUserByEmail(userinfo.email) - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + } else { + Logger.warn(`[Auth] openid: User not found and no email in userinfo`) + // We deny login, because if the admin whishes to match email, it makes sense to require it + return null } } // Match existing user by username - else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) - user = await Database.userModel.getUserByUsername(userinfo.preferred_username) + else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username') { + let username + + if (userinfo.preferred_username) { + Logger.info(`[Auth] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) + username = userinfo.preferred_username + } else if (userinfo.username) { + Logger.info(`[Auth] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) + username = userinfo.username + } else { + Logger.warn(`[Auth] openid: User not found and neither preferred_username nor username in userinfo`) + return null + } + + + user = await Database.userModel.getUserByUsername(username) if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) + Logger.warn(`[Auth] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) return null // User is linked to a different OpenID subject; do not proceed. } } From 7e8fd91fc5a3c7802573ae8903a1c25505f6c9c5 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Mar 2024 14:04:02 -0500 Subject: [PATCH 08/11] Update OIDC advanced permissions check to only perform an update on changes - Update permissions example to use UUIDv4 strings for allowedLibraries - More validation on advanced permission JSON to ensure arrays are array of strings - Only set allowedTags and allowedLibraries if the corresponding access all permission is false --- server/Auth.js | 112 +++++++++++++++++++----------------- server/objects/user/User.js | 75 +++++++++++++++++------- 2 files changed, 113 insertions(+), 74 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 733acc36..b52ee727 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -100,24 +100,24 @@ class Auth { }, async (tokenset, userinfo, done) => { try { Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) - + if (!userinfo.sub) { throw new Error('Invalid userinfo, no sub') } - + if (!this.validateGroupClaim(userinfo)) { throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) } - + let user = await this.findOrCreateUser(userinfo) - - if (!user || !user.isActive) { + + if (!user?.isActive) { throw new Error('User not active or not found') } - + 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 user.openid_id_token = tokenset.id_token @@ -229,62 +229,68 @@ class Auth { return true } -/** - * Sets the user group based on group claim in userinfo. - */ -async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) // No group claim configured, don't set anything - return + /** + * Sets the user group based on group claim in userinfo. + * + * @param {import('./objects/user/User')} user + * @param {Object} userinfo + */ + async setUserGroup(user, userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) // 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 Error(`Group claim ${groupClaimName} not found in userinfo`) - const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase()) - const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] - 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.`) - } else { - // If root user is logging in via OpenID, we will not change the type - return + 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.`) + } else { + // If root user is logging in via OpenID, we will not change the type + return + } } + + if (user.type !== userType) { + Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) + user.type = userType + await Database.userModel.updateFromOld(user) + } + } else { + throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) } + } - Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) + /** + * Updates user permissions based on the advanced permissions claim. + * + * @param {import('./objects/user/User')} user + * @param {Object} userinfo + */ + async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything + return - if (user.type !== userType) { - user.type = userType + 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 (user.updatePermissionsFromExternalJSON(absPermissions)) { + Logger.debug(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) await Database.userModel.updateFromOld(user) } - } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) } -} - -/** - * Updates user permissions based on the advanced permissions claim. - */ -async updateUserPermissions(user, userinfo) { - const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim - if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything - return - - 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`) - - Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(absPermissions)}`) - user.updatePermissionsFromExternalJSON(absPermissions) - await Database.userModel.updateFromOld(user) -} /** * Unuse strategy diff --git a/server/objects/user/User.js b/server/objects/user/User.js index b473637b..938c6d07 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -280,64 +280,97 @@ class User { tagsAreDenylist: 'selectedTagsNotAccessible', // Direct mapping for array-based permissions allowedLibraries: 'librariesAccessible', - allowedTags: 'itemTagsSelected', + allowedTags: 'itemTagsSelected' } /** - * Update user from external JSON + * Update user permissions from external JSON * - * @param {object} absPermissions JSON containg user permissions + * @param {Object} absPermissions JSON containing user permissions + * @returns {boolean} true if updates were made */ updatePermissionsFromExternalJSON(absPermissions) { + let hasUpdates = false + let updatedUserPermissions = {} + // Initialize all permissions to false first Object.keys(User.permissionMapping).forEach(mappingKey => { - const userPermKey = User.permissionMapping[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 + updatedUserPermissions[userPermKey] = false // Default to false for boolean permissions } - }); + }) + // Map the boolean permissions from absPermissions 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] - }); + if (updatedUserPermissions[userPermKey] !== undefined) { + updatedUserPermissions[userPermKey] = !!absPermissions[absKey] + } + }) - // Handle allowedLibraries and allowedTags separately if needed - if (absPermissions.allowedLibraries) { + // Update user permissions if changes were made + if (JSON.stringify(this.permissions) !== JSON.stringify(updatedUserPermissions)) { + this.permissions = updatedUserPermissions + hasUpdates = true + } + + // Handle allowedLibraries + if (this.permissions.accessAllLibraries) { + if (this.librariesAccessible.length) { + this.librariesAccessible = [] + hasUpdates = true + } + } else if (absPermissions.allowedLibraries?.length && absPermissions.allowedLibraries.join(',') !== this.librariesAccessible.join(',')) { + if (absPermissions.allowedLibraries.some(lid => typeof lid !== 'string')) { + throw new Error('Invalid permission property "allowedLibraries", expecting array of strings') + } this.librariesAccessible = absPermissions.allowedLibraries + hasUpdates = true } - if (absPermissions.allowedTags) { + + // Handle allowedTags + if (this.permissions.accessAllTags) { + if (this.itemTagsSelected.length) { + this.itemTagsSelected = [] + hasUpdates = true + } + } else if (absPermissions.allowedTags?.length && absPermissions.allowedTags.join(',') !== this.itemTagsSelected.join(',')) { + if (absPermissions.allowedTags.some(tag => typeof tag !== 'string')) { + throw new Error('Invalid permission property "allowedTags", expecting array of strings') + } this.itemTagsSelected = absPermissions.allowedTags + hasUpdates = true } + + return hasUpdates } + /** * Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like * - * @returns JSON string + * @returns {string} 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`]; + acc[key] = [`5406ba8a-16e1-451d-96d7-4931b0a0d966`, `918fd848-7c1d-4a02-818a-847435a879ca`] } else if (key === 'allowedTags') { - acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]; + acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`] } else { - acc[key] = false; + acc[key] = false } - return acc; - }, {}); + return acc + }, {}) - return JSON.stringify(samplePermissions, null, 2); // Pretty print the JSON + return JSON.stringify(samplePermissions, null, 2) // Pretty print the JSON } /** From a5d7a8151917a5073595d98e84f99d543c45f079 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Mar 2024 14:17:34 -0500 Subject: [PATCH 09/11] Clean up formatting of advanced group/permission claims on authentication page --- client/pages/config/authentication.vue | 15 ++++++++------- server/Auth.js | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index cecccee4..9f2e71ec 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -82,19 +82,20 @@

{{ $strings.LabelAutoRegisterDescription }}

-
{{ $strings.LabelOpenIDClaims }}
-
-
+

{{ $strings.LabelOpenIDClaims }}

+ +
+
-

+

-
-
+
+
-
+

{{ newAuthSettings.authOpenIDSamplePermissions }}
diff --git a/server/Auth.js b/server/Auth.js
index b52ee727..8ba87509 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -287,7 +287,7 @@ class Auth {
       throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
 
     if (user.updatePermissionsFromExternalJSON(absPermissions)) {
-      Logger.debug(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
+      Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
       await Database.userModel.updateFromOld(user)
     }
   }

From fc595bd799639cd7a6a8db6186acb95eb1c0b6da Mon Sep 17 00:00:00 2001
From: advplyr 
Date: Sat, 30 Mar 2024 14:25:38 -0500
Subject: [PATCH 10/11] Updates to authentication page for mobile screen sizes

---
 client/components/app/SettingsContent.vue |  2 +-
 client/pages/config/authentication.vue    | 14 +++++++-------
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/client/components/app/SettingsContent.vue b/client/components/app/SettingsContent.vue
index ec129ebc..40b0c02f 100644
--- a/client/components/app/SettingsContent.vue
+++ b/client/components/app/SettingsContent.vue
@@ -1,5 +1,5 @@