mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									8e5b7504ae
								
							
						
					
					
						commit
						56f1bfef50
					
				| @ -5,7 +5,7 @@ | ||||
|         >{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label | ||||
|       > | ||||
|     </slot> | ||||
|     <ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" /> | ||||
|     <ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| @ -14,6 +14,7 @@ export default { | ||||
|   props: { | ||||
|     value: [String, Number], | ||||
|     label: String, | ||||
|     placeholder: String, | ||||
|     note: String, | ||||
|     type: { | ||||
|       type: String, | ||||
|  | ||||
| @ -70,17 +70,42 @@ | ||||
|               <p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="flex items-center py-4 px-1"> | ||||
|             <div class="flex items-center py-4 px-1 w-full"> | ||||
|               <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> | ||||
|               <p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p> | ||||
|               <p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" /> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="flex items-center py-4 px-1"> | ||||
|             <div class="flex items-center py-4 px-1 w-full"> | ||||
|               <ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" /> | ||||
|               <p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p> | ||||
|               <p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="flex items-center pt-6 pb-1 px-1 w-full">Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.</div> | ||||
|             <div class="flex items-center mb-2"> | ||||
|               <div class="w-96"> | ||||
|                 <ui-text-input-with-label ref="openidGroupClaim" v-model="newAuthSettings.authOpenIDGroupClaim" :disabled="savingSettings" :placeholder="'groups'" :label="'Group Claim'" /> | ||||
|               </div> | ||||
|               <p class="pl-4 text-sm text-gray-300 mt-5"> | ||||
|                 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. | ||||
|               </p> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="flex mb-2"> | ||||
|               <div class="w-96 pt-6"> | ||||
|                 <ui-text-input-with-label ref="openidAdvancedPermsClaim" v-model="newAuthSettings.authOpenIDAdvancedPermsClaim" :disabled="savingSettings" :placeholder="'abspermissions'" :label="'Advanced Permission Claim'" /> | ||||
|               </div> | ||||
|               <div class="pl-4 text-sm text-gray-300 mt-5 flex-column"> | ||||
|                 <p class=""> | ||||
|                   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: | ||||
|                 </p> | ||||
|                 <pre class="text-pre-wrap mt-2" | ||||
|                   >{{ newAuthSettings.authOpenIDSamplePermissions }} | ||||
|                 </pre> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </transition> | ||||
|       </div> | ||||
| @ -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() { | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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() | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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 | ||||
|    *  | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user