diff --git a/backend/src/applications/users/services/admin-users-manager.service.ts b/backend/src/applications/users/services/admin-users-manager.service.ts index f94e08ec..0fa395c6 100644 --- a/backend/src/applications/users/services/admin-users-manager.service.ts +++ b/backend/src/applications/users/services/admin-users-manager.service.ts @@ -42,6 +42,29 @@ export class AdminUsersManager { this.checkUser(user, true) return user } + async getOrCreateUserGroupByName(name: string): Promise { + const existingGroup = await this.adminQueries.groupFromName(name) + + if (existingGroup) { + if (existingGroup.type !== GROUP_TYPE.USER) { + throw new HttpException(`Group name already exists but is not a user group: ${name}`, HttpStatus.CONFLICT) + } + + return this.getGroup(existingGroup.id) + } + + try { + return await this.createGroup({ name }) + } catch (e) { + // Handle a possible race when two OIDC logins create the same group at the same time. + const createdByAnotherLogin = await this.adminQueries.groupFromName(name) + if (createdByAnotherLogin?.type === GROUP_TYPE.USER) { + return this.getGroup(createdByAnotherLogin.id) + } + + throw e + } + } async getGuest(guestId: number): Promise { const user: GuestUser = await this.adminQueries.usersQueries.listGuests(guestId, 0, true) diff --git a/backend/src/authentication/providers/oidc/auth-oidc.config.ts b/backend/src/authentication/providers/oidc/auth-oidc.config.ts index 99ae979b..1aff7d9d 100644 --- a/backend/src/authentication/providers/oidc/auth-oidc.config.ts +++ b/backend/src/authentication/providers/oidc/auth-oidc.config.ts @@ -54,6 +54,24 @@ export class AuthProviderOIDCSecurityConfig { allowPrivateIpAvatarDownload? = false } +export class AuthProviderOIDCGroupsConfig { + @IsOptional() + @IsBoolean() + enabled? = false + + @IsOptional() + @IsString() + claim? = 'groups' + + @IsOptional() + @IsString() + delimiter? = ',' + + @IsOptional() + @IsString() + regex? = '.*' +} + export class AuthProviderOIDCOptionsConfig { @IsOptional() @IsBoolean() @@ -85,6 +103,12 @@ export class AuthProviderOIDCOptionsConfig { @Transform(({ value }) => value || DEFAULT_STORAGE_QUOTA_FIELD) storageQuotaClaim?: string = DEFAULT_STORAGE_QUOTA_FIELD + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => AuthProviderOIDCGroupsConfig) + groups?: AuthProviderOIDCGroupsConfig = new AuthProviderOIDCGroupsConfig() + @IsString() @IsNotEmpty() buttonText: string = 'Continue with OpenID Connect' diff --git a/backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts b/backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts index 35d8c630..756a30b2 100644 --- a/backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts +++ b/backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts @@ -73,6 +73,118 @@ export class AuthProviderOIDC implements AuthProvider { return this.usersManager.validateLocalPasswordByLogin(login, password, ip, scope, canUseLocalPassword) } + private mergeTokenClaimsWithUserInfo(claims: IDToken, userInfo: UserInfoResponse): UserInfoResponse & Record { + const tokenClaims = claims as unknown as Record + const userInfoClaims = userInfo as unknown as Record + const mergedUserInfo: Record = { ...tokenClaims, ...userInfoClaims } + const groupClaim = this.oidcConfig.options.groups?.claim || 'groups' + + for (const claimName of new Set([groupClaim, 'groups', 'roles'])) { + if (mergedUserInfo[claimName] === undefined && tokenClaims[claimName] !== undefined) { + mergedUserInfo[claimName] = tokenClaims[claimName] + } + } + + return mergedUserInfo as UserInfoResponse & Record + } + + private async syncOIDCGroups(user: UserModel, userInfo: UserInfoResponse & Record): Promise { + const groupsConfig = this.oidcConfig.options.groups + if (!groupsConfig?.enabled) { + return + } + + const groupFilter = this.getOIDCGroupFilter() + if (!groupFilter) { + return + } + + const groupNames = this.extractOIDCGroupNames(userInfo, groupFilter) + if (groupNames === null) { + return + } + + const targetGroupIds: number[] = [] + for (const groupName of groupNames) { + const group = await this.adminUsersManager.getOrCreateUserGroupByName(groupName) + targetGroupIds.push(group.id) + } + + const currentUser = await this.adminUsersManager.getUser(user.id) + const currentGroups = currentUser.groups ?? [] + const preservedGroupIds = currentGroups.filter((group) => !groupFilter.test(group.name)).map((group) => group.id) + const finalGroupIds = [...new Set([...preservedGroupIds, ...targetGroupIds])] + const currentGroupIds = currentGroups.map((group) => group.id) + + if (this.sameNumberSet(currentGroupIds, finalGroupIds)) { + return + } + + await this.adminUsersManager.updateUserOrGuest(user.id, { groups: finalGroupIds }) + } + + private extractOIDCGroupNames(userInfo: UserInfoResponse & Record, groupFilter: RegExp): string[] | null { + const groupsConfig = this.oidcConfig.options.groups + const claimName = groupsConfig?.claim || 'groups' + const rawGroups = userInfo[claimName] + + if (rawGroups === undefined || rawGroups === null) { + this.logger.warn({ tag: this.extractOIDCGroupNames.name, msg: `OIDC groups claim "${claimName}" is missing; skipping group synchronization` }) + return null + } + + if (!Array.isArray(rawGroups) && typeof rawGroups !== 'string') { + this.logger.warn({ + tag: this.extractOIDCGroupNames.name, + msg: `OIDC groups claim "${claimName}" is not an array or string; skipping group synchronization` + }) + return null + } + + return [ + ...new Set( + this.normalizeOIDCStringListClaim(rawGroups, groupsConfig?.delimiter ?? ',') + .map((groupName) => groupName.trim()) + .filter(Boolean) + .filter((groupName) => groupFilter.test(groupName)) + ) + ] + } + + private normalizeOIDCStringListClaim(value: unknown, delimiter = ','): string[] { + if (Array.isArray(value)) { + return value.flatMap((entry) => this.normalizeOIDCStringListClaim(entry, delimiter)) + } + + if (typeof value === 'string') { + return delimiter === '' ? [value] : value.split(delimiter) + } + + if (value === undefined || value === null) { + return [] + } + + return [String(value)] + } + + private getOIDCGroupFilter(): RegExp | null { + try { + return new RegExp(this.oidcConfig.options.groups?.regex || '.*') + } catch (e) { + this.logger.warn({ tag: this.getOIDCGroupFilter.name, msg: `invalid OIDC groups regex: ${e}` }) + return null + } + } + + private sameNumberSet(left: number[], right: number[]): boolean { + if (left.length !== right.length) { + return false + } + + const rightSet = new Set(right) + return left.every((value) => rightSet.has(value)) + } + async getConfig(): Promise { if (!this.config) { this.config = await this.initializeOIDCClient() @@ -176,7 +288,8 @@ export class AuthProviderOIDC implements AuthProvider { } // Process the user info and create/update the user - return await this.processUserInfo(userInfo, req.ip) + const mergedUserInfo = this.mergeTokenClaimsWithUserInfo(claims, userInfo) + return await this.processUserInfo(mergedUserInfo, req.ip) } catch (error: AuthorizationResponseError | HttpException | any) { if (error instanceof AuthorizationResponseError) { this.logger.error({ tag: this.handleCallback.name, msg: `OIDC callback error: ${error.code} - ${error.error_description}` }) @@ -298,7 +411,7 @@ export class AuthProviderOIDC implements AuthProvider { return (this.oidcConfig.security.supportPKCE ?? true) && config.serverMetadata().supportsPKCE() } - private async processUserInfo(userInfo: UserInfoResponse, ip?: string): Promise { + private async processUserInfo(userInfo: UserInfoResponse & Record, ip?: string): Promise { // Extract user information const { login, email } = this.extractLoginAndEmail(userInfo) @@ -322,6 +435,13 @@ export class AuthProviderOIDC implements AuthProvider { if (this.oidcConfig.options.autoSyncAvatar) { await this.updatePictureUrl(user, userInfo) } + + try { + await this.syncOIDCGroups(user, userInfo) + } catch (e) { + this.logger.warn({ tag: this.processUserInfo.name, msg: `unable to sync OIDC groups for *${user.login}* : ${e}` }) + } + // Update user access log this.usersManager.updateAccesses(user, ip, true).catch((e: Error) => this.logger.error({ tag: this.processUserInfo.name, msg: `${e}` })) @@ -334,8 +454,11 @@ export class AuthProviderOIDC implements AuthProvider { } // Check claims - const claims = [...(Array.isArray(userInfo.groups) ? userInfo.groups : []), ...(Array.isArray(userInfo.roles) ? userInfo.roles : [])] - + const delimiter = this.oidcConfig.options.groups?.delimiter ?? ',' + const claims = [ + ...this.normalizeOIDCStringListClaim((userInfo as unknown as Record).groups, delimiter), + ...this.normalizeOIDCStringListClaim((userInfo as unknown as Record).roles, delimiter) + ] return claims.includes(this.oidcConfig.options.adminRoleOrGroup) } diff --git a/environment/environment.dist.yaml b/environment/environment.dist.yaml index f1156719..fffd27b8 100755 --- a/environment/environment.dist.yaml +++ b/environment/environment.dist.yaml @@ -249,6 +249,20 @@ auth: # If the claim exists with null or 0, local storageQuota is set to null (unlimited). # default: `storageQuota` storageQuotaClaim: storageQuota + # groups: Synchronize OIDC groups as Sync-in user groups. + # When enabled, groups from the configured OIDC claim are created automatically + # and the current user's memberships are synchronized on every OIDC login. + # Supported claim formats: array, or delimiter-separated string. + # Environment variables: + # - SYNCIN_AUTH_OIDC_OPTIONS_GROUPS_ENABLED=true + # - SYNCIN_AUTH_OIDC_OPTIONS_GROUPS_CLAIM=groups + # - SYNCIN_AUTH_OIDC_OPTIONS_GROUPS_DELIMITER=, + # - SYNCIN_AUTH_OIDC_OPTIONS_GROUPS_REGEX=^(FG_|FA_) + groups: + enabled: false + claim: groups + delimiter: ',' + regex: '.*' # adminRoleOrGroup: Name of the role or group that grants Sync-in administrator access # Users with this value will be granted administrator privileges. # The value is matched against `roles` or `groups` claims provided by the IdP.