Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ export class AdminUsersManager {
this.checkUser(user, true)
return user
}
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 getGuest(guestId: number): Promise<GuestUser> {
const user: GuestUser = await this.adminQueries.usersQueries.listGuests(guestId, 0, true)
Expand Down
24 changes: 24 additions & 0 deletions backend/src/authentication/providers/oidc/auth-oidc.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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))
}

async getConfig(): Promise<Configuration> {
if (!this.config) {
this.config = await this.initializeOIDCClient()
Expand Down Expand Up @@ -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}` })
Expand Down Expand Up @@ -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<UserModel> {
private async processUserInfo(userInfo: UserInfoResponse & Record<string, unknown>, ip?: string): Promise<UserModel> {
// Extract user information
const { login, email } = this.extractLoginAndEmail(userInfo)

Expand All @@ -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}` }))

Expand All @@ -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<string, unknown>).groups, delimiter),
...this.normalizeOIDCStringListClaim((userInfo as unknown as Record<string, unknown>).roles, delimiter)
]
return claims.includes(this.oidcConfig.options.adminRoleOrGroup)
}

Expand Down
14 changes: 14 additions & 0 deletions environment/environment.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading