From d046f16860694924cdd99cd7e5059d0e3fc72528 Mon Sep 17 00:00:00 2001 From: Timothy Dobras <64758768+timdobras@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:41:42 +0300 Subject: [PATCH] fix(oauth): normalize email claim to lowercase and trim before account lookup and registration (#26841) * fix(oauth): normalize email claim to lowercase before account lookup and registration * test(auth): add test for OAuth email case normalization * chore: clean up --------- Co-authored-by: Jason Rasmussen --- e2e/src/specs/server/api/oauth.e2e-spec.ts | 2 +- server/src/services/auth.service.spec.ts | 20 ++++++++++++++++++++ server/src/services/auth.service.ts | 16 ++++++++-------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index e4127438f7..98cb28c821 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -259,7 +259,7 @@ describe(`/oauth`, () => { accessToken: expect.any(String), isAdmin: false, name: 'OAuth User', - userEmail: 'oauth-RS256-token@immich.app', + userEmail: 'oauth-rs256-token@immich.app', userId: expect.any(String), }); }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 94b8acd25e..a21790f5fe 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -634,6 +634,26 @@ describe(AuthService.name, () => { expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); }); + it('should normalize the email from the OAuth profile before linking', async () => { + const user = UserFactory.create(); + const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfile.mockResolvedValue(profile); + mocks.user.getByEmail.mockResolvedValue(user); + mocks.user.update.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, + {}, + loginDetails, + ); + + expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud'); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); + }); + it('should not link to a user with a different oauth sub', async () => { const user = UserFactory.create({ oauthId: 'existing-sub' }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 498c165888..ea3a896298 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -277,13 +277,14 @@ export class AuthService extends BaseService { const url = this.resolveRedirectUri(oauth, dto.url); const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier); + const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined; const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub); // link by email - if (!user && profile.email) { - const emailUser = await this.userRepository.getByEmail(profile.email); + if (!user && normalizedEmail) { + const emailUser = await this.userRepository.getByEmail(normalizedEmail); if (emailUser) { if (emailUser.oauthId) { throw new BadRequestException('User already exists, but is linked to another account.'); @@ -296,17 +297,16 @@ export class AuthService extends BaseService { if (!user) { if (!autoRegister) { this.logger.warn( - `Unable to register ${profile.sub}/${profile.email || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`, + `Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`, ); throw new BadRequestException(`User does not exist and auto registering is disabled.`); } - const email = profile.email; - if (!email) { + if (!normalizedEmail) { throw new BadRequestException('OAuth profile does not have an email address'); } - this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`); + this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`); const storageLabel = this.getClaim(profile, { key: storageLabelClaim, @@ -329,8 +329,8 @@ export class AuthService extends BaseService { profile.name || `${profile.given_name || ''} ${profile.family_name || ''}`.trim() || profile.preferred_username || - email, - email, + normalizedEmail, + email: normalizedEmail, oauthId: profile.sub, quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB, storageLabel: storageLabel || null,