From 81804186a495c4a841bd4e43cea09baa67a7f980 Mon Sep 17 00:00:00 2001 From: Thomas DELORGE Date: Fri, 12 Jun 2026 10:09:45 +0200 Subject: [PATCH] feat(server): sync OAuth claims on login (#8073) Sync role and quota from the IdP on subsequent OAuth logins. Absent claims are left unchanged. Storage label sync requires a non-default claim. Quota `-1` means unlimited. Signed-off-by: Thomas DELORGE --- docs/docs/administration/oauth.md | 6 +- server/src/services/auth.service.spec.ts | 135 +++++++++++++++++++++- server/src/services/auth.service.ts | 136 ++++++++++++++++++++--- 3 files changed, 258 insertions(+), 19 deletions(-) diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 246b1be7fe..c361e0698f 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -81,7 +81,11 @@ Once you have a new OAuth client application configured, Immich can be configure :::note Claim Options [1] -Claim is only used on user creation and not synchronized after that. +Claims are applied when a user is first registered. On subsequent logins, claims present in the OAuth profile are synchronized to the Immich user profile. Claims that are absent from the profile are left unchanged. + +The storage label is an exception: with the default claim (`preferred_username`), it is only set at registration and is not updated on later logins. To keep the storage label in sync with the IdP, configure a different Storage Label Claim (for example `uid`). + +The storage quota claim is a number in GiB: `-1` for unlimited, `0` to block uploads, any positive value for a fixed quota. ::: diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 48dcf5a509..9881421334 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -931,7 +931,7 @@ describe(AuthService.name, () => { expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); }); - it('should ignore a negative quota', async () => { + it('should ignore an invalid negative quota', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: -5 }), @@ -950,6 +950,25 @@ describe(AuthService.name, () => { expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); }); + it('should set unlimited quota for -1', async () => { + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ immich_quota: -1 }), + }); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' })); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: null })); + }); + it('should set quota for 0 quota', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) }); @@ -1170,6 +1189,100 @@ describe(AuthService.name, () => { expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false })); }); + it('should sync the storage quota on subsequent logins when the claim is present', async () => { + const user = UserFactory.create({ oauthId: 'oauth-id', quotaSizeInBytes: 1_073_741_824 }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_quota: 5 }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 }); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.syncUsage).toHaveBeenCalledWith(user.id); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { + quotaSizeInBytes: 5_368_709_120, + updatedAt: expect.any(Date), + }); + }); + + it('should sync unlimited quota on subsequent logins when the claim is -1', async () => { + const user = UserFactory.create({ oauthId: 'oauth-id', quotaSizeInBytes: 1_073_741_824 }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_quota: -1 }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null }); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.syncUsage).not.toHaveBeenCalled(); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { + quotaSizeInBytes: null, + updatedAt: expect.any(Date), + }); + }); + + it('should not sync the storage label on subsequent logins with the default claim', async () => { + const user = UserFactory.create({ oauthId: 'oauth-id', storageLabel: 'custom-label' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId, preferred_username: 'idp-username' }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.user.getByStorageLabel).not.toHaveBeenCalled(); + }); + + it('should sync the storage label on subsequent logins when a custom claim is configured', async () => { + const user = UserFactory.create({ oauthId: 'oauth-id', storageLabel: 'custom-label' }); + + mocks.systemMetadata.get.mockResolvedValue({ + oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' }, + }); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_label: 'synced-label' }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.user.getByStorageLabel.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue({ ...user, storageLabel: 'synced-label' }); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { + storageLabel: 'synced-label', + updatedAt: expect.any(Date), + }); + }); + it('should promote an existing user to admin if the role claim contains admin on login', async () => { const user = UserFactory.create({ isAdmin: false, oauthId: 'oauth-id' }); @@ -1229,6 +1342,26 @@ describe(AuthService.name, () => { expect(mocks.user.update).not.toHaveBeenCalled(); }); + it('should not update claims on subsequent logins when they are absent from the profile', async () => { + const user = UserFactory.create({ oauthId: 'oauth-id', isAdmin: true, quotaSizeInBytes: 1_073_741_824 }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.user.syncUsage).not.toHaveBeenCalled(); + }); + it('should re-evaluate the role claim for a user linked by email', async () => { const user = UserFactory.create({ isAdmin: false }); const profile = OAuthProfileFactory.create({ immich_role: 'admin' }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 59e20276af..fa98a29f2d 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; +import sanitize from 'sanitize-filename'; +import { defaults, SystemConfig } from 'src/config'; import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { @@ -39,7 +41,18 @@ export interface LoginDetails { interface ClaimOptions { key: string; default: T; - isValid: (value: unknown) => boolean; + isValid: (value: unknown) => value is T; + parse?: (raw: unknown) => unknown; +} + +type OAuthClaimsConfig = Pick< + SystemConfig['oauth'], + 'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim' +>; + +interface ParsedOAuthClaims { + storageLabel?: string | null; + quotaSizeInBytes?: number | null; } export type ValidateRequest = { @@ -305,7 +318,7 @@ export class AuthService extends BaseService { idToken: oauthBearerToken, } = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier); const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined; - const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth; + const { autoRegister, roleClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub); @@ -343,16 +356,7 @@ export class AuthService extends BaseService { this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`); - const storageLabel = this.getClaim(profile, { - key: storageLabelClaim, - default: '', - isValid: (value: unknown): value is string => typeof value === 'string', - }); - const storageQuota = this.getClaim(profile, { - key: storageQuotaClaim, - default: defaultStorageQuota, - isValid: (value: unknown) => Number(value) >= 0, - }); + const claims = this.parseOAuthClaims(profile, oauth, true); user = await this.createUser({ name: @@ -362,10 +366,12 @@ export class AuthService extends BaseService { normalizedEmail, email: normalizedEmail, oauthId: profile.sub, - quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB, - storageLabel: storageLabel || null, + quotaSizeInBytes: claims.quotaSizeInBytes ?? null, + storageLabel: claims.storageLabel ?? null, isAdmin, }); + } else { + user = await this.syncOAuthClaims(user, profile, oauth); } if (!user.profileImagePath && profile.picture) { @@ -628,9 +634,105 @@ export class AuthService extends BaseService { return mapLoginResponse(user, token); } - private getClaim(profile: OAuthProfile, options: ClaimOptions): T { - const value = profile[options.key as keyof OAuthProfile]; - return options.isValid(value) ? (value as T) : options.default; + private getClaim(profile: OAuthProfile, options: ClaimOptions, useDefault: boolean): T | undefined { + const raw = profile[options.key as keyof OAuthProfile]; + const value = options.parse ? options.parse(raw) : raw; + if (options.isValid(value)) { + return value; + } + return useDefault ? options.default : undefined; + } + + private parseOAuthClaims(profile: OAuthProfile, oauth: OAuthClaimsConfig, useDefaults: boolean): ParsedOAuthClaims { + const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; + const claims: ParsedOAuthClaims = {}; + + // Sync label only when a non-default claim is configured. + const syncStorageLabel = storageLabelClaim !== defaults.oauth.storageLabelClaim; + + if (useDefaults || (syncStorageLabel && storageLabelClaim in profile)) { + const storageLabel = this.getClaim( + profile, + { + key: storageLabelClaim, + default: '', + isValid: (value: unknown): value is string => typeof value === 'string', + }, + useDefaults, + ); + + if (storageLabel !== undefined) { + claims.storageLabel = useDefaults ? storageLabel || null : this.formatStorageLabel(storageLabel); + } + } + + if (useDefaults || storageQuotaClaim in profile) { + const storageQuota = this.getClaim( + profile, + { + key: storageQuotaClaim, + default: defaultStorageQuota, + parse: Number, + isValid: (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && (value === -1 || value >= 0), + }, + useDefaults, + ); + + if (storageQuota !== undefined) { + claims.quotaSizeInBytes = + storageQuota === null || storageQuota === -1 ? null : storageQuota * HumanReadableSize.GiB; + } + } + + return claims; + } + + private formatStorageLabel(label: string): string | null { + if (!label) { + return null; + } + + return sanitize(label.replaceAll('.', '')); + } + + private async syncOAuthClaims( + user: UserAdmin, + profile: OAuthProfile, + oauth: OAuthClaimsConfig, + ): Promise { + const claims = this.parseOAuthClaims(profile, oauth, false); + const updates: { + storageLabel?: string | null; + quotaSizeInBytes?: number | null; + } = {}; + + if (claims.storageLabel !== undefined && claims.storageLabel !== user.storageLabel) { + if (claims.storageLabel) { + const duplicate = await this.userRepository.getByStorageLabel(claims.storageLabel); + if (duplicate && duplicate.id !== user.id) { + this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`); + } else { + updates.storageLabel = claims.storageLabel; + } + } else { + updates.storageLabel = null; + } + } + + if (claims.quotaSizeInBytes !== undefined && claims.quotaSizeInBytes !== user.quotaSizeInBytes) { + updates.quotaSizeInBytes = claims.quotaSizeInBytes; + } + + if (Object.keys(updates).length === 0) { + return user; + } + + if (updates.quotaSizeInBytes) { + await this.userRepository.syncUsage(user.id); + } + + return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() }); } private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined {