oidc groups
#207
Replies: 2 comments 2 replies
-
|
absolut vibe coded, but its working :) --- a/backend/src/authentication/providers/oidc/auth-oidc.config.ts
+++ b/backend/src/authentication/providers/oidc/auth-oidc.config.ts
@@ -42,6 +42,25 @@
skipSubjectCheck? = false
}
+
+export class AuthProviderOIDCGroupsConfig {
+ @IsOptional()
+ @IsBoolean()
+ enabled? = false
+
+ @IsOptional()
+ @IsString()
+ claim? = 'groups'
+
+ @IsOptional()
+ @IsString()
+ delimiter? = ','
+
+ @IsOptional()
+ @IsString()
+ regex? = '.*'
+}
+
export class AuthProviderOIDCOptionsConfig {
@IsOptional()
@IsBoolean()
@@ -68,6 +87,13 @@
@IsString()
@Transform(({ value }) => value || DEFAULT_STORAGE_QUOTA_FIELD)
storageQuotaClaim?: string = DEFAULT_STORAGE_QUOTA_FIELD
+
+
+ @IsOptional()
+ @IsObject()
+ @ValidateNested()
+ @Type(() => AuthProviderOIDCGroupsConfig)
+ groups?: AuthProviderOIDCGroupsConfig = new AuthProviderOIDCGroupsConfig()
@IsString()
@IsNotEmpty()
--- a/backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts
+++ b/backend/src/authentication/providers/oidc/auth-provider-oidc.service.ts
@@ -184,7 +184,8 @@
}
// 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}` })
@@ -294,7 +295,7 @@
return (this.oidcConfig.security.supportPKCE ?? true) && config.serverMetadata().supportsPKCE()
}
- private async processUserInfo(userInfo: UserInfoResponse, ip?: string): Promise<UserModel> {
+ private async processUserInfo(userInfo: UserInfoResponse & Record<string, unknown>, ip?: string): Promise<UserModel> {
// Extract user information
const { login, email } = this.extractLoginAndEmail(userInfo)
@@ -316,11 +317,129 @@
user = await this.updateOrCreateUser(identity, user)
// Update picture url (if it exists)
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}` }))
return user
}
+ private mergeTokenClaimsWithUserInfo(claims: IDToken, userInfo: UserInfoResponse): UserInfoResponse & Record<string, unknown> {
+ const tokenClaims = claims as unknown as Record<string, unknown>
+ const userInfoClaims = userInfo as unknown as Record<string, unknown>
+ const mergedUserInfo: Record<string, unknown> = { ...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<string, unknown>
+ }
+
+ private async syncOIDCGroups(user: UserModel, userInfo: UserInfoResponse & Record<string, unknown>): Promise<void> {
+ 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<string, unknown>, 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))
+ }
+
+
private checkAdminRole(userInfo: UserInfoResponse): boolean {
if (!this.oidcConfig.options.adminRoleOrGroup) {
@@ -328,8 +447,11 @@
}
// 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<string, unknown>).groups, delimiter),
+ ...this.normalizeOIDCStringListClaim((userInfo as unknown as Record<string, unknown>).roles, delimiter)
+ ]
return claims.includes(this.oidcConfig.options.adminRoleOrGroup)
}
--- a/backend/src/applications/users/services/admin-users-manager.service.ts
+++ b/backend/src/applications/users/services/admin-users-manager.service.ts
@@ -241,6 +241,31 @@
}
return group
}
+ async getOrCreateUserGroupByName(name: string): Promise<AdminGroup> {
+ 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 createGroup(createGroupDto: CreateOrUpdateGroupDto): Promise<AdminGroup> {
if (!createGroupDto.name) {
--- a/environment/environment.dist.yaml
+++ b/environment/environment.dist.yaml
@@ -249,6 +249,21 @@
# 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.maybe its helpful :) |
Beta Was this translation helpful? Give feedback.
0 replies
-
|
@johaven should i try to create a PR for this? |
Beta Was this translation helpful? Give feedback.
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Hey,
it would be realy cool like autoCreateUser also autoCreateGroup from claim "groups" will generate and manage assigments :)
Thx!
Erik
Beta Was this translation helpful? Give feedback.
All reactions