diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 06fdda699..ae3bf55ce 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 0d3771b2e..913620313 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -947,7 +947,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 }), @@ -966,6 +966,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 }) }); @@ -1185,6 +1204,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 oauthId = 'oauth-id'; const user = UserFactory.create({ isAdmin: false, oauthId }); @@ -1247,6 +1360,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 dcfb60fc9..8d5da7b50 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; +import sanitize from 'sanitize-filename'; import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants.js'; import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js'; import { @@ -20,6 +21,7 @@ import { SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto.js'; +import { SystemConfig, defaults } from 'src/dtos/config.dto.js'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum.js'; import { OAuthProfile } from 'src/repositories/oauth.repository.js'; @@ -39,8 +41,15 @@ export interface LoginDetails { interface ClaimOptions { key: string; - default: T; - isValid: (value: unknown) => boolean; + isValid: (value: unknown) => value is T; + parse?: (raw: unknown) => T; +} + +type OAuthClaimsConfig = Pick; + +interface ParsedOAuthClaims { + storageLabel?: string | null; + quotaSizeInBytes?: number | null; } export type ValidateRequest = { @@ -309,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); @@ -333,7 +342,9 @@ export class AuthService extends BaseService { } // register new user - if (!user) { + if (user) { + user = await this.syncOAuthClaims(user, profile, oauth); + } else { if (!autoRegister) { this.logger.warn( `Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`, @@ -347,16 +358,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: @@ -366,8 +368,8 @@ 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, }); } @@ -632,9 +634,84 @@ 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): T | undefined { + const raw = profile[options.key as keyof OAuthProfile]; + const value = options.parse ? options.parse(raw) : raw; + return options.isValid(value) ? value : undefined; + } + + private parseOAuthClaims(profile: OAuthProfile, oauth: OAuthClaimsConfig, useDefaults: boolean): ParsedOAuthClaims { + const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; + const claims: ParsedOAuthClaims = {}; + + const storageLabel = this.getClaim(profile, { + key: storageLabelClaim, + isValid: (value): value is string => typeof value === 'string', + }); + + if (useDefaults) { + claims.storageLabel = storageLabel || null; + } + // Default claim (preferred_username) is registration-only; login sync is opt-in. + else if (storageLabelClaim !== defaults.oauth.storageLabelClaim && storageLabel !== undefined) { + claims.storageLabel = this.formatStorageLabel(storageLabel); + } + + const storageQuota = this.getClaim(profile, { + key: storageQuotaClaim, + parse: Number, + isValid: (value): value is number => + typeof value === 'number' && Number.isFinite(value) && (value === -1 || value >= 0), + }); + + if (storageQuota !== undefined) { + claims.quotaSizeInBytes = storageQuota === -1 ? null : storageQuota * HumanReadableSize.GiB; + } else if (useDefaults) { + claims.quotaSizeInBytes = + defaultStorageQuota === null || defaultStorageQuota === -1 ? null : defaultStorageQuota * 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 && claims.storageLabel !== user.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; + } + } + + 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 {