Bläddra i källkod

refactor(server): auth/oauth (#3242)

* refactor(server): auth/oauth

* fix: show server error message on login failure
Jason Rasmussen 2 år sedan
förälder
incheckning
08c7054845
30 ändrade filer med 453 tillägg och 730 borttagningar
  1. 2 0
      server/src/domain/auth/auth.constant.ts
  2. 0 62
      server/src/domain/auth/auth.core.ts
  3. 133 9
      server/src/domain/auth/auth.service.spec.ts
  4. 256 48
      server/src/domain/auth/auth.service.ts
  5. 2 0
      server/src/domain/auth/dto/index.ts
  6. 0 0
      server/src/domain/auth/dto/oauth-auth-code.dto.ts
  7. 0 0
      server/src/domain/auth/dto/oauth-config.dto.ts
  8. 1 1
      server/src/domain/auth/index.ts
  9. 1 0
      server/src/domain/auth/response-dto/index.ts
  10. 0 0
      server/src/domain/auth/response-dto/oauth-config-response.dto.ts
  11. 0 0
      server/src/domain/auth/user-token.repository.ts
  12. 0 2
      server/src/domain/domain.module.ts
  13. 0 2
      server/src/domain/index.ts
  14. 0 2
      server/src/domain/oauth/dto/index.ts
  15. 0 4
      server/src/domain/oauth/index.ts
  16. 0 1
      server/src/domain/oauth/oauth.constants.ts
  17. 0 107
      server/src/domain/oauth/oauth.core.ts
  18. 0 217
      server/src/domain/oauth/oauth.service.spec.ts
  19. 0 92
      server/src/domain/oauth/oauth.service.ts
  20. 0 1
      server/src/domain/oauth/response-dto/index.ts
  21. 2 2
      server/src/domain/storage-template/storage-template.service.spec.ts
  22. 1 1
      server/src/domain/system-config/system-config.core.ts
  23. 7 10
      server/src/domain/system-config/system-config.service.spec.ts
  24. 0 2
      server/src/domain/user-token/index.ts
  25. 0 57
      server/src/domain/user-token/user-token.core.ts
  26. 3 3
      server/src/immich/controllers/oauth.controller.ts
  27. 1 1
      server/src/infra/repositories/user-token.repository.ts
  28. 24 90
      server/test/fixtures.ts
  29. 8 8
      web/src/lib/components/forms/login-form.svelte
  30. 12 8
      web/src/lib/utils/handle-error.ts

+ 2 - 0
server/src/domain/auth/auth.constant.ts

@@ -1,3 +1,5 @@
+export const MOBILE_REDIRECT = 'app.immich:/';
+export const LOGIN_URL = '/auth/login?autoLaunch=0';
 export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
 export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
 export const IMMICH_API_KEY_NAME = 'api_key';

+ 0 - 62
server/src/domain/auth/auth.core.ts

@@ -1,62 +0,0 @@
-import { SystemConfig, UserEntity } from '@app/infra/entities';
-import { ICryptoRepository } from '../crypto/crypto.repository';
-import { ISystemConfigRepository } from '../system-config';
-import { SystemConfigCore } from '../system-config/system-config.core';
-import { IUserTokenRepository, UserTokenCore } from '../user-token';
-import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
-import { LoginResponseDto, mapLoginResponse } from './response-dto';
-
-export interface LoginDetails {
-  isSecure: boolean;
-  clientIp: string;
-  deviceType: string;
-  deviceOS: string;
-}
-
-export class AuthCore {
-  private userTokenCore: UserTokenCore;
-  constructor(
-    private cryptoRepository: ICryptoRepository,
-    configRepository: ISystemConfigRepository,
-    userTokenRepository: IUserTokenRepository,
-    private config: SystemConfig,
-  ) {
-    this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
-    const configCore = new SystemConfigCore(configRepository);
-    configCore.config$.subscribe((config) => (this.config = config));
-  }
-
-  isPasswordLoginEnabled() {
-    return this.config.passwordLogin.enabled;
-  }
-
-  getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
-    const maxAge = 400 * 24 * 3600; // 400 days
-
-    let authTypeCookie = '';
-    let accessTokenCookie = '';
-
-    if (isSecure) {
-      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
-      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
-    } else {
-      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
-      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
-    }
-    return [accessTokenCookie, authTypeCookie];
-  }
-
-  async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
-    const accessToken = await this.userTokenCore.create(user, loginDetails);
-    const response = mapLoginResponse(user, accessToken);
-    const cookie = this.getCookies(response, authType, loginDetails);
-    return { response, cookie };
-  }
-
-  validatePassword(inputPassword: string, user: UserEntity): boolean {
-    if (!user || !user.password) {
-      return false;
-    }
-    return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
-  }
-}

+ 133 - 9
server/src/domain/auth/auth.service.spec.ts

@@ -1,4 +1,4 @@
-import { SystemConfig, UserEntity } from '@app/infra/entities';
+import { UserEntity } from '@app/infra/entities';
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import {
   authStub,
@@ -23,10 +23,10 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
 import { ISharedLinkRepository } from '../shared-link';
 import { ISystemConfigRepository } from '../system-config';
 import { IUserRepository } from '../user';
-import { IUserTokenRepository } from '../user-token';
 import { AuthType } from './auth.constant';
 import { AuthService } from './auth.service';
 import { AuthUserDto, SignUpDto } from './dto';
+import { IUserTokenRepository } from './user-token.repository';
 
 // const token = Buffer.from('my-api-key', 'utf8').toString('base64');
 
@@ -55,7 +55,6 @@ describe('AuthService', () => {
   let shareMock: jest.Mocked<ISharedLinkRepository>;
   let keyMock: jest.Mocked<IKeyRepository>;
   let callbackMock: jest.Mock;
-  let create: (config: SystemConfig) => AuthService;
 
   afterEach(() => {
     jest.resetModules();
@@ -87,9 +86,7 @@ describe('AuthService', () => {
     shareMock = newSharedLinkRepositoryMock();
     keyMock = newKeyRepositoryMock();
 
-    create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
-
-    sut = create(systemConfigStub.enabled);
+    sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock);
   });
 
   it('should be defined', () => {
@@ -98,8 +95,7 @@ describe('AuthService', () => {
 
   describe('login', () => {
     it('should throw an error if password login is disabled', async () => {
-      sut = create(systemConfigStub.disabled);
-
+      configMock.load.mockResolvedValue(systemConfigStub.disabled);
       await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
     });
 
@@ -191,8 +187,8 @@ describe('AuthService', () => {
 
   describe('logout', () => {
     it('should return the end session endpoint', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.enabled);
       const authUser = { id: '123' } as AuthUserDto;
-
       await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
         successful: true,
         redirectUri: 'http://end-session-endpoint',
@@ -385,4 +381,132 @@ describe('AuthService', () => {
       expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
     });
   });
+
+  describe('getMobileRedirect', () => {
+    it('should pass along the query params', () => {
+      expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
+    });
+
+    it('should work if called without query params', () => {
+      expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
+    });
+  });
+
+  describe('generateConfig', () => {
+    it('should work when oauth is not configured', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.disabled);
+      await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
+        enabled: false,
+        passwordLoginEnabled: false,
+      });
+    });
+
+    it('should generate the config', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.enabled);
+      await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
+        enabled: true,
+        buttonText: 'OAuth',
+        url: 'http://authorization-url',
+        autoLaunch: false,
+        passwordLoginEnabled: true,
+      });
+    });
+  });
+
+  describe('callback', () => {
+    it('should throw an error if OAuth is not enabled', async () => {
+      await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should not allow auto registering', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
+      userMock.getByEmail.mockResolvedValue(null);
+      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should link an existing user', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
+      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
+      userMock.update.mockResolvedValue(userEntityStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+
+      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
+        loginResponseStub.user1oauth,
+      );
+
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+      expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
+    });
+
+    it('should allow auto registering by default', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.enabled);
+      userMock.getByEmail.mockResolvedValue(null);
+      userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
+      userMock.create.mockResolvedValue(userEntityStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+
+      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
+        loginResponseStub.user1oauth,
+      );
+
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
+      expect(userMock.create).toHaveBeenCalledTimes(1);
+    });
+
+    it('should use the mobile redirect override', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.override);
+      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+
+      await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
+
+      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
+    });
+
+    it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.override);
+      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+
+      await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);
+
+      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
+    });
+  });
+
+  describe('link', () => {
+    it('should link an account', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.enabled);
+      userMock.update.mockResolvedValue(userEntityStub.user1);
+
+      await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
+
+      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
+    });
+
+    it('should not link an already linked oauth.sub', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.enabled);
+      userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
+
+      await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+
+      expect(userMock.update).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('unlink', () => {
+    it('should unlink an account', async () => {
+      configMock.load.mockResolvedValue(systemConfigStub.enabled);
+      userMock.update.mockResolvedValue(userEntityStub.user1);
+
+      await sut.unlink(authStub.user1);
+
+      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
+    });
+  });
 });

+ 256 - 48
server/src/domain/auth/auth.service.ts

@@ -1,4 +1,4 @@
-import { SystemConfig } from '@app/infra/entities';
+import { SystemConfig, UserEntity } from '@app/infra/entities';
 import {
   BadRequestException,
   Inject,
@@ -9,99 +9,112 @@ import {
 } from '@nestjs/common';
 import cookieParser from 'cookie';
 import { IncomingHttpHeaders } from 'http';
+import { DateTime } from 'luxon';
+import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
 import { IKeyRepository } from '../api-key';
 import { ICryptoRepository } from '../crypto/crypto.repository';
-import { OAuthCore } from '../oauth/oauth.core';
 import { ISharedLinkRepository } from '../shared-link';
-import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
-import { IUserRepository, UserCore } from '../user';
-import { IUserTokenRepository, UserTokenCore } from '../user-token';
-import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
-import { AuthCore, LoginDetails } from './auth.core';
-import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
+import { ISystemConfigRepository } from '../system-config';
+import { SystemConfigCore } from '../system-config/system-config.core';
+import { IUserRepository, UserCore, UserResponseDto } from '../user';
+import {
+  AuthType,
+  IMMICH_ACCESS_COOKIE,
+  IMMICH_API_KEY_HEADER,
+  IMMICH_AUTH_TYPE_COOKIE,
+  LOGIN_URL,
+  MOBILE_REDIRECT,
+} from './auth.constant';
+import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto } from './dto';
 import {
   AdminSignupResponseDto,
   AuthDeviceResponseDto,
   LoginResponseDto,
   LogoutResponseDto,
   mapAdminSignupResponse,
+  mapLoginResponse,
   mapUserToken,
+  OAuthConfigResponseDto,
 } from './response-dto';
+import { IUserTokenRepository } from './user-token.repository';
+
+export interface LoginDetails {
+  isSecure: boolean;
+  clientIp: string;
+  deviceType: string;
+  deviceOS: string;
+}
+
+interface LoginResponse {
+  response: LoginResponseDto;
+  cookie: string[];
+}
+
+interface OAuthProfile extends UserinfoResponse {
+  email: string;
+}
 
 @Injectable()
 export class AuthService {
-  private userTokenCore: UserTokenCore;
-  private authCore: AuthCore;
-  private oauthCore: OAuthCore;
   private userCore: UserCore;
-
+  private configCore: SystemConfigCore;
   private logger = new Logger(AuthService.name);
 
   constructor(
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(IUserRepository) userRepository: IUserRepository,
-    @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
+    @Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
     @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
     @Inject(IKeyRepository) private keyRepository: IKeyRepository,
-    @Inject(INITIAL_SYSTEM_CONFIG)
-    initialConfig: SystemConfig,
   ) {
-    this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
-    this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
-    this.oauthCore = new OAuthCore(configRepository, initialConfig);
+    this.configCore = new SystemConfigCore(configRepository);
     this.userCore = new UserCore(userRepository, cryptoRepository);
+
+    custom.setHttpOptionsDefaults({ timeout: 30000 });
   }
 
-  public async login(
-    loginCredential: LoginCredentialDto,
-    loginDetails: LoginDetails,
-  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
-    if (!this.authCore.isPasswordLoginEnabled()) {
+  async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
+    const config = await this.configCore.getConfig();
+    if (!config.passwordLogin.enabled) {
       throw new UnauthorizedException('Password login has been disabled');
     }
 
-    let user = await this.userCore.getByEmail(loginCredential.email, true);
+    let user = await this.userCore.getByEmail(dto.email, true);
     if (user) {
-      const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
+      const isAuthenticated = this.validatePassword(dto.password, user);
       if (!isAuthenticated) {
         user = null;
       }
     }
 
     if (!user) {
-      this.logger.warn(
-        `Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
-      );
+      this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
       throw new BadRequestException('Incorrect email or password');
     }
 
-    return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
+    return this.createLoginResponse(user, AuthType.PASSWORD, details);
   }
 
-  public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
+  async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
     if (authUser.accessTokenId) {
-      await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
+      await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId);
     }
 
-    if (authType === AuthType.OAUTH) {
-      const url = await this.oauthCore.getLogoutEndpoint();
-      if (url) {
-        return { successful: true, redirectUri: url };
-      }
-    }
-
-    return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
+    return {
+      successful: true,
+      redirectUri: await this.getLogoutEndpoint(authType),
+    };
   }
 
-  public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
+  async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
     const { password, newPassword } = dto;
     const user = await this.userCore.getByEmail(authUser.email, true);
     if (!user) {
       throw new UnauthorizedException();
     }
 
-    const valid = this.authCore.validatePassword(password, user);
+    const valid = this.validatePassword(password, user);
     if (!valid) {
       throw new BadRequestException('Wrong password');
     }
@@ -109,7 +122,7 @@ export class AuthService {
     return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
   }
 
-  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
+  async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
     const adminUser = await this.userCore.getAdmin();
 
     if (adminUser) {
@@ -133,7 +146,7 @@ export class AuthService {
     }
   }
 
-  public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
+  async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
     const shareKey = (headers['x-immich-share-key'] || params.key) as string;
     const userToken = (headers['x-immich-user-token'] ||
       params.userToken ||
@@ -146,7 +159,7 @@ export class AuthService {
     }
 
     if (userToken) {
-      return this.userTokenCore.validate(userToken);
+      return this.validateUserToken(userToken);
     }
 
     if (apiKey) {
@@ -157,22 +170,153 @@ export class AuthService {
   }
 
   async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
-    const userTokens = await this.userTokenCore.getAll(authUser.id);
+    const userTokens = await this.userTokenRepository.getAll(authUser.id);
     return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
   }
 
   async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
-    await this.userTokenCore.delete(authUser.id, deviceId);
+    await this.userTokenRepository.delete(authUser.id, deviceId);
   }
 
   async logoutDevices(authUser: AuthUserDto): Promise<void> {
-    const devices = await this.userTokenCore.getAll(authUser.id);
+    const devices = await this.userTokenRepository.getAll(authUser.id);
     for (const device of devices) {
       if (device.id === authUser.accessTokenId) {
         continue;
       }
-      await this.userTokenCore.delete(authUser.id, device.id);
+      await this.userTokenRepository.delete(authUser.id, device.id);
+    }
+  }
+
+  getMobileRedirect(url: string) {
+    return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
+  }
+
+  async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
+    const config = await this.configCore.getConfig();
+    const response = {
+      enabled: config.oauth.enabled,
+      passwordLoginEnabled: config.passwordLogin.enabled,
+    };
+
+    if (!response.enabled) {
+      return response;
+    }
+
+    const { scope, buttonText, autoLaunch } = config.oauth;
+    const url = (await this.getOAuthClient(config)).authorizationUrl({
+      redirect_uri: this.normalize(config, dto.redirectUri),
+      scope,
+      state: generators.state(),
+    });
+
+    return { ...response, buttonText, url, autoLaunch };
+  }
+
+  async callback(
+    dto: OAuthCallbackDto,
+    loginDetails: LoginDetails,
+  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
+    const config = await this.configCore.getConfig();
+    const profile = await this.getOAuthProfile(config, dto.url);
+    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
+    let user = await this.userCore.getByOAuthId(profile.sub);
+
+    // link existing user
+    if (!user) {
+      const emailUser = await this.userCore.getByEmail(profile.email);
+      if (emailUser) {
+        user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
+      }
+    }
+
+    // register new user
+    if (!user) {
+      if (!config.oauth.autoRegister) {
+        this.logger.warn(
+          `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
+        );
+        throw new BadRequestException(`User does not exist and auto registering is disabled.`);
+      }
+
+      this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
+      user = await this.userCore.createUser({
+        firstName: profile.given_name || '',
+        lastName: profile.family_name || '',
+        email: profile.email,
+        oauthId: profile.sub,
+      });
+    }
+
+    return this.createLoginResponse(user, AuthType.OAUTH, loginDetails);
+  }
+
+  async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
+    const config = await this.configCore.getConfig();
+    const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
+    const duplicate = await this.userCore.getByOAuthId(oauthId);
+    if (duplicate && duplicate.id !== user.id) {
+      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
+      throw new BadRequestException('This OAuth account has already been linked to another user.');
+    }
+    return this.userCore.updateUser(user, user.id, { oauthId });
+  }
+
+  async unlink(user: AuthUserDto): Promise<UserResponseDto> {
+    return this.userCore.updateUser(user, user.id, { oauthId: '' });
+  }
+
+  private async getLogoutEndpoint(authType: AuthType): Promise<string> {
+    if (authType !== AuthType.OAUTH) {
+      return LOGIN_URL;
     }
+
+    const config = await this.configCore.getConfig();
+    if (!config.oauth.enabled) {
+      return LOGIN_URL;
+    }
+
+    const client = await this.getOAuthClient(config);
+    return client.issuer.metadata.end_session_endpoint || LOGIN_URL;
+  }
+
+  private async getOAuthProfile(config: SystemConfig, url: string): Promise<OAuthProfile> {
+    const redirectUri = this.normalize(config, url.split('?')[0]);
+    const client = await this.getOAuthClient(config);
+    const params = client.callbackParams(url);
+    const tokens = await client.callback(redirectUri, params, { state: params.state });
+    return client.userinfo<OAuthProfile>(tokens.access_token || '');
+  }
+
+  private async getOAuthClient(config: SystemConfig) {
+    const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
+
+    if (!enabled) {
+      throw new BadRequestException('OAuth2 is not enabled');
+    }
+
+    const metadata: ClientMetadata = {
+      client_id: clientId,
+      client_secret: clientSecret,
+      response_types: ['code'],
+    };
+
+    const issuer = await Issuer.discover(issuerUrl);
+    const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
+    if (algorithms[0] === 'HS256') {
+      metadata.id_token_signed_response_alg = algorithms[0];
+    }
+
+    return new issuer.Client(metadata);
+  }
+
+  private normalize(config: SystemConfig, redirectUri: string) {
+    const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
+    const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth;
+    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
+      return mobileRedirectUri;
+    }
+    return redirectUri;
   }
 
   private getBearerToken(headers: IncomingHttpHeaders): string | null {
@@ -232,4 +376,68 @@ export class AuthService {
 
     throw new UnauthorizedException('Invalid API key');
   }
+
+  private validatePassword(inputPassword: string, user: UserEntity): boolean {
+    if (!user || !user.password) {
+      return false;
+    }
+    return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
+  }
+
+  private async validateUserToken(tokenValue: string): Promise<AuthUserDto> {
+    const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
+    let token = await this.userTokenRepository.getByToken(hashedToken);
+
+    if (token?.user) {
+      const now = DateTime.now();
+      const updatedAt = DateTime.fromJSDate(token.updatedAt);
+      const diff = now.diff(updatedAt, ['hours']);
+      if (diff.hours > 1) {
+        token = await this.userTokenRepository.save({ ...token, updatedAt: new Date() });
+      }
+
+      return {
+        ...token.user,
+        isPublicUser: false,
+        isAllowUpload: true,
+        isAllowDownload: true,
+        isShowExif: true,
+        accessTokenId: token.id,
+      };
+    }
+
+    throw new UnauthorizedException('Invalid user token');
+  }
+
+  private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
+    const key = this.cryptoRepository.randomBytes(32).toString('base64').replace(/\W/g, '');
+    const token = this.cryptoRepository.hashSha256(key);
+
+    await this.userTokenRepository.create({
+      token,
+      user,
+      deviceOS: loginDetails.deviceOS,
+      deviceType: loginDetails.deviceType,
+    });
+
+    const response = mapLoginResponse(user, key);
+    const cookie = this.getCookies(response, authType, loginDetails);
+    return { response, cookie };
+  }
+
+  private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
+    const maxAge = 400 * 24 * 3600; // 400 days
+
+    let authTypeCookie = '';
+    let accessTokenCookie = '';
+
+    if (isSecure) {
+      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
+      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
+    } else {
+      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
+      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
+    }
+    return [accessTokenCookie, authTypeCookie];
+  }
 }

+ 2 - 0
server/src/domain/auth/dto/index.ts

@@ -1,4 +1,6 @@
 export * from './auth-user.dto';
 export * from './change-password.dto';
 export * from './login-credential.dto';
+export * from './oauth-auth-code.dto';
+export * from './oauth-config.dto';
 export * from './sign-up.dto';

+ 0 - 0
server/src/domain/oauth/dto/oauth-auth-code.dto.ts → server/src/domain/auth/dto/oauth-auth-code.dto.ts


+ 0 - 0
server/src/domain/oauth/dto/oauth-config.dto.ts → server/src/domain/auth/dto/oauth-config.dto.ts


+ 1 - 1
server/src/domain/auth/index.ts

@@ -1,5 +1,5 @@
 export * from './auth.constant';
-export * from './auth.core';
 export * from './auth.service';
 export * from './dto';
 export * from './response-dto';
+export * from './user-token.repository';

+ 1 - 0
server/src/domain/auth/response-dto/index.ts

@@ -2,4 +2,5 @@ export * from './admin-signup-response.dto';
 export * from './auth-device-response.dto';
 export * from './login-response.dto';
 export * from './logout-response.dto';
+export * from './oauth-config-response.dto';
 export * from './validate-asset-token-response.dto';

+ 0 - 0
server/src/domain/oauth/response-dto/oauth-config-response.dto.ts → server/src/domain/auth/response-dto/oauth-config-response.dto.ts


+ 0 - 0
server/src/domain/user-token/user-token.repository.ts → server/src/domain/auth/user-token.repository.ts


+ 0 - 2
server/src/domain/domain.module.ts

@@ -7,7 +7,6 @@ import { FacialRecognitionService } from './facial-recognition';
 import { JobService } from './job';
 import { MediaService } from './media';
 import { MetadataService } from './metadata';
-import { OAuthService } from './oauth';
 import { PartnerService } from './partner';
 import { PersonService } from './person';
 import { SearchService } from './search';
@@ -29,7 +28,6 @@ const providers: Provider[] = [
   JobService,
   MediaService,
   MetadataService,
-  OAuthService,
   PersonService,
   PartnerService,
   SearchService,

+ 0 - 2
server/src/domain/index.ts

@@ -13,7 +13,6 @@ export * from './facial-recognition';
 export * from './job';
 export * from './media';
 export * from './metadata';
-export * from './oauth';
 export * from './partner';
 export * from './person';
 export * from './search';
@@ -25,4 +24,3 @@ export * from './storage-template';
 export * from './system-config';
 export * from './tag';
 export * from './user';
-export * from './user-token';

+ 0 - 2
server/src/domain/oauth/dto/index.ts

@@ -1,2 +0,0 @@
-export * from './oauth-auth-code.dto';
-export * from './oauth-config.dto';

+ 0 - 4
server/src/domain/oauth/index.ts

@@ -1,4 +0,0 @@
-export * from './dto';
-export * from './oauth.constants';
-export * from './oauth.service';
-export * from './response-dto';

+ 0 - 1
server/src/domain/oauth/oauth.constants.ts

@@ -1 +0,0 @@
-export const MOBILE_REDIRECT = 'app.immich:/';

+ 0 - 107
server/src/domain/oauth/oauth.core.ts

@@ -1,107 +0,0 @@
-import { SystemConfig } from '@app/infra/entities';
-import { BadRequestException, Injectable, Logger } from '@nestjs/common';
-import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
-import { ISystemConfigRepository } from '../system-config';
-import { SystemConfigCore } from '../system-config/system-config.core';
-import { OAuthConfigDto } from './dto';
-import { MOBILE_REDIRECT } from './oauth.constants';
-import { OAuthConfigResponseDto } from './response-dto';
-
-type OAuthProfile = UserinfoResponse & {
-  email: string;
-};
-
-@Injectable()
-export class OAuthCore {
-  private readonly logger = new Logger(OAuthCore.name);
-  private configCore: SystemConfigCore;
-
-  constructor(configRepository: ISystemConfigRepository, private config: SystemConfig) {
-    this.configCore = new SystemConfigCore(configRepository);
-
-    custom.setHttpOptionsDefaults({
-      timeout: 30000,
-    });
-
-    this.configCore.config$.subscribe((config) => (this.config = config));
-  }
-
-  async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
-    const response = {
-      enabled: this.config.oauth.enabled,
-      passwordLoginEnabled: this.config.passwordLogin.enabled,
-    };
-
-    if (!response.enabled) {
-      return response;
-    }
-
-    const { scope, buttonText, autoLaunch } = this.config.oauth;
-    const url = (await this.getClient()).authorizationUrl({
-      redirect_uri: this.normalize(dto.redirectUri),
-      scope,
-      state: generators.state(),
-    });
-
-    return { ...response, buttonText, url, autoLaunch };
-  }
-
-  async callback(url: string): Promise<OAuthProfile> {
-    const redirectUri = this.normalize(url.split('?')[0]);
-    const client = await this.getClient();
-    const params = client.callbackParams(url);
-    const tokens = await client.callback(redirectUri, params, { state: params.state });
-    return await client.userinfo<OAuthProfile>(tokens.access_token || '');
-  }
-
-  isAutoRegisterEnabled() {
-    return this.config.oauth.autoRegister;
-  }
-
-  asUser(profile: OAuthProfile) {
-    return {
-      firstName: profile.given_name || '',
-      lastName: profile.family_name || '',
-      email: profile.email,
-      oauthId: profile.sub,
-    };
-  }
-
-  async getLogoutEndpoint(): Promise<string | null> {
-    if (!this.config.oauth.enabled) {
-      return null;
-    }
-    return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
-  }
-
-  private async getClient() {
-    const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
-
-    if (!enabled) {
-      throw new BadRequestException('OAuth2 is not enabled');
-    }
-
-    const metadata: ClientMetadata = {
-      client_id: clientId,
-      client_secret: clientSecret,
-      response_types: ['code'],
-    };
-
-    const issuer = await Issuer.discover(issuerUrl);
-    const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
-    if (algorithms[0] === 'HS256') {
-      metadata.id_token_signed_response_alg = algorithms[0];
-    }
-
-    return new issuer.Client(metadata);
-  }
-
-  private normalize(redirectUri: string) {
-    const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
-    const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
-    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
-      return mobileRedirectUri;
-    }
-    return redirectUri;
-  }
-}

+ 0 - 217
server/src/domain/oauth/oauth.service.spec.ts

@@ -1,217 +0,0 @@
-import { SystemConfig, UserEntity } from '@app/infra/entities';
-import { BadRequestException } from '@nestjs/common';
-import {
-  authStub,
-  loginResponseStub,
-  newCryptoRepositoryMock,
-  newSystemConfigRepositoryMock,
-  newUserRepositoryMock,
-  newUserTokenRepositoryMock,
-  systemConfigStub,
-  userEntityStub,
-  userTokenEntityStub,
-} from '@test';
-import { generators, Issuer } from 'openid-client';
-import { OAuthService } from '.';
-import { LoginDetails } from '../auth';
-import { ICryptoRepository } from '../crypto';
-import { ISystemConfigRepository } from '../system-config';
-import { IUserRepository } from '../user';
-import { IUserTokenRepository } from '../user-token';
-
-const email = 'user@immich.com';
-const sub = 'my-auth-user-sub';
-const loginDetails: LoginDetails = {
-  isSecure: true,
-  clientIp: '127.0.0.1',
-  deviceOS: '',
-  deviceType: '',
-};
-
-describe('OAuthService', () => {
-  let sut: OAuthService;
-  let userMock: jest.Mocked<IUserRepository>;
-  let cryptoMock: jest.Mocked<ICryptoRepository>;
-  let configMock: jest.Mocked<ISystemConfigRepository>;
-  let userTokenMock: jest.Mocked<IUserTokenRepository>;
-  let callbackMock: jest.Mock;
-  let create: (config: SystemConfig) => OAuthService;
-
-  beforeEach(async () => {
-    callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
-
-    jest.spyOn(generators, 'state').mockReturnValue('state');
-    jest.spyOn(Issuer, 'discover').mockResolvedValue({
-      id_token_signing_alg_values_supported: ['HS256'],
-      Client: jest.fn().mockResolvedValue({
-        issuer: {
-          metadata: {
-            end_session_endpoint: 'http://end-session-endpoint',
-          },
-        },
-        authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
-        callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
-        callback: callbackMock,
-        userinfo: jest.fn().mockResolvedValue({ sub, email }),
-      }),
-    } as any);
-
-    cryptoMock = newCryptoRepositoryMock();
-    configMock = newSystemConfigRepositoryMock();
-    userMock = newUserRepositoryMock();
-    userTokenMock = newUserTokenRepositoryMock();
-
-    create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
-
-    sut = create(systemConfigStub.disabled);
-  });
-
-  it('should be defined', () => {
-    expect(sut).toBeDefined();
-  });
-
-  describe('getMobileRedirect', () => {
-    it('should pass along the query params', () => {
-      expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
-    });
-
-    it('should work if called without query params', () => {
-      expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
-    });
-  });
-
-  describe('generateConfig', () => {
-    it('should work when oauth is not configured', async () => {
-      await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
-        enabled: false,
-        passwordLoginEnabled: false,
-      });
-    });
-
-    it('should generate the config', async () => {
-      sut = create(systemConfigStub.enabled);
-      await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
-        enabled: true,
-        buttonText: 'OAuth',
-        url: 'http://authorization-url',
-        autoLaunch: false,
-        passwordLoginEnabled: true,
-      });
-    });
-  });
-
-  describe('login', () => {
-    it('should throw an error if OAuth is not enabled', async () => {
-      await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
-    });
-
-    it('should not allow auto registering', async () => {
-      sut = create(systemConfigStub.noAutoRegister);
-      userMock.getByEmail.mockResolvedValue(null);
-      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
-    });
-
-    it('should link an existing user', async () => {
-      sut = create(systemConfigStub.noAutoRegister);
-      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
-      userMock.update.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
-
-      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
-        loginResponseStub.user1oauth,
-      );
-
-      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
-      expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
-    });
-
-    it('should allow auto registering by default', async () => {
-      sut = create(systemConfigStub.enabled);
-
-      userMock.getByEmail.mockResolvedValue(null);
-      userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
-      userMock.create.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
-
-      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
-        loginResponseStub.user1oauth,
-      );
-
-      expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
-      expect(userMock.create).toHaveBeenCalledTimes(1);
-    });
-
-    it('should use the mobile redirect override', async () => {
-      sut = create(systemConfigStub.override);
-
-      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
-
-      await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
-
-      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
-    });
-
-    it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
-      sut = create(systemConfigStub.override);
-
-      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
-
-      await sut.login({ url: `app.immich:///?code=abc123` }, loginDetails);
-
-      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
-    });
-  });
-
-  describe('link', () => {
-    it('should link an account', async () => {
-      sut = create(systemConfigStub.enabled);
-
-      userMock.update.mockResolvedValue(userEntityStub.user1);
-
-      await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
-
-      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
-    });
-
-    it('should not link an already linked oauth.sub', async () => {
-      sut = create(systemConfigStub.enabled);
-
-      userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
-
-      await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-
-      expect(userMock.update).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('unlink', () => {
-    it('should unlink an account', async () => {
-      sut = create(systemConfigStub.enabled);
-
-      userMock.update.mockResolvedValue(userEntityStub.user1);
-
-      await sut.unlink(authStub.user1);
-
-      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
-    });
-  });
-
-  describe('getLogoutEndpoint', () => {
-    it('should return null if OAuth is not configured', async () => {
-      await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
-    });
-
-    it('should get the session endpoint from the discovery document', async () => {
-      sut = create(systemConfigStub.enabled);
-
-      await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
-    });
-  });
-});

+ 0 - 92
server/src/domain/oauth/oauth.service.ts

@@ -1,92 +0,0 @@
-import { SystemConfig } from '@app/infra/entities';
-import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
-import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
-import { AuthCore, LoginDetails } from '../auth/auth.core';
-import { ICryptoRepository } from '../crypto';
-import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
-import { IUserRepository, UserCore, UserResponseDto } from '../user';
-import { IUserTokenRepository } from '../user-token';
-import { OAuthCallbackDto, OAuthConfigDto } from './dto';
-import { MOBILE_REDIRECT } from './oauth.constants';
-import { OAuthCore } from './oauth.core';
-import { OAuthConfigResponseDto } from './response-dto';
-
-@Injectable()
-export class OAuthService {
-  private authCore: AuthCore;
-  private oauthCore: OAuthCore;
-  private userCore: UserCore;
-
-  private readonly logger = new Logger(OAuthService.name);
-
-  constructor(
-    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
-    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
-    @Inject(IUserRepository) userRepository: IUserRepository,
-    @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
-    @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
-  ) {
-    this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
-    this.userCore = new UserCore(userRepository, cryptoRepository);
-    this.oauthCore = new OAuthCore(configRepository, initialConfig);
-  }
-
-  getMobileRedirect(url: string) {
-    return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
-  }
-
-  generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
-    return this.oauthCore.generateConfig(dto);
-  }
-
-  async login(
-    dto: OAuthCallbackDto,
-    loginDetails: LoginDetails,
-  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
-    const profile = await this.oauthCore.callback(dto.url);
-
-    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
-    let user = await this.userCore.getByOAuthId(profile.sub);
-
-    // link existing user
-    if (!user) {
-      const emailUser = await this.userCore.getByEmail(profile.email);
-      if (emailUser) {
-        user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
-      }
-    }
-
-    // register new user
-    if (!user) {
-      if (!this.oauthCore.isAutoRegisterEnabled()) {
-        this.logger.warn(
-          `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
-        );
-        throw new BadRequestException(`User does not exist and auto registering is disabled.`);
-      }
-
-      this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
-      user = await this.userCore.createUser(this.oauthCore.asUser(profile));
-    }
-
-    return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
-  }
-
-  public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
-    const { sub: oauthId } = await this.oauthCore.callback(dto.url);
-    const duplicate = await this.userCore.getByOAuthId(oauthId);
-    if (duplicate && duplicate.id !== user.id) {
-      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
-      throw new BadRequestException('This OAuth account has already been linked to another user.');
-    }
-    return this.userCore.updateUser(user, user.id, { oauthId });
-  }
-
-  public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
-    return this.userCore.updateUser(user, user.id, { oauthId: '' });
-  }
-
-  public async getLogoutEndpoint(): Promise<string | null> {
-    return this.oauthCore.getLogoutEndpoint();
-  }
-}

+ 0 - 1
server/src/domain/oauth/response-dto/index.ts

@@ -1 +0,0 @@
-export * from './oauth-config-response.dto';

+ 2 - 2
server/src/domain/storage-template/storage-template.service.spec.ts

@@ -4,7 +4,6 @@ import {
   newStorageRepositoryMock,
   newSystemConfigRepositoryMock,
   newUserRepositoryMock,
-  systemConfigStub,
   userEntityStub,
 } from '@test';
 import { when } from 'jest-when';
@@ -12,6 +11,7 @@ import { StorageTemplateService } from '.';
 import { IAssetRepository } from '../asset';
 import { IStorageRepository } from '../storage/storage.repository';
 import { ISystemConfigRepository } from '../system-config';
+import { defaults } from '../system-config/system-config.core';
 import { IUserRepository } from '../user';
 
 describe(StorageTemplateService.name, () => {
@@ -31,7 +31,7 @@ describe(StorageTemplateService.name, () => {
     storageMock = newStorageRepositoryMock();
     userMock = newUserRepositoryMock();
 
-    sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
+    sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
   });
 
   describe('handle template migration', () => {

+ 1 - 1
server/src/domain/system-config/system-config.core.ts

@@ -16,7 +16,7 @@ import { ISystemConfigRepository } from './system-config.repository';
 
 export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
 
-const defaults = Object.freeze<SystemConfig>({
+export const defaults = Object.freeze<SystemConfig>({
   ffmpeg: {
     crf: 23,
     threads: 0,

+ 7 - 10
server/src/domain/system-config/system-config.service.spec.ts

@@ -7,9 +7,9 @@ import {
   VideoCodec,
 } from '@app/infra/entities';
 import { BadRequestException } from '@nestjs/common';
-import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test';
+import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
 import { IJobRepository, JobName, QueueName } from '../job';
-import { SystemConfigValidator } from './system-config.core';
+import { defaults, SystemConfigValidator } from './system-config.core';
 import { ISystemConfigRepository } from './system-config.repository';
 import { SystemConfigService } from './system-config.service';
 
@@ -81,7 +81,7 @@ describe(SystemConfigService.name, () => {
     it('should return the default config', () => {
       configMock.load.mockResolvedValue(updates);
 
-      expect(sut.getDefaults()).toEqual(systemConfigStub.defaults);
+      expect(sut.getDefaults()).toEqual(defaults);
       expect(configMock.load).not.toHaveBeenCalled();
     });
   });
@@ -89,12 +89,9 @@ describe(SystemConfigService.name, () => {
   describe('addValidator', () => {
     it('should call the validator on config changes', async () => {
       const validator: SystemConfigValidator = jest.fn();
-
       sut.addValidator(validator);
-
-      await sut.updateConfig(systemConfigStub.defaults);
-
-      expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults);
+      await sut.updateConfig(defaults);
+      expect(validator).toHaveBeenCalledWith(defaults);
     });
   });
 
@@ -102,7 +99,7 @@ describe(SystemConfigService.name, () => {
     it('should return the default config', async () => {
       configMock.load.mockResolvedValue([]);
 
-      await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults);
+      await expect(sut.getConfig()).resolves.toEqual(defaults);
     });
 
     it('should merge the overrides', async () => {
@@ -172,7 +169,7 @@ describe(SystemConfigService.name, () => {
 
       await sut.refreshConfig();
 
-      expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults);
+      expect(changeMock).toHaveBeenCalledWith(defaults);
 
       subscription.unsubscribe();
     });

+ 0 - 2
server/src/domain/user-token/index.ts

@@ -1,2 +0,0 @@
-export * from './user-token.core';
-export * from './user-token.repository';

+ 0 - 57
server/src/domain/user-token/user-token.core.ts

@@ -1,57 +0,0 @@
-import { UserEntity, UserTokenEntity } from '@app/infra/entities';
-import { Injectable, UnauthorizedException } from '@nestjs/common';
-import { DateTime } from 'luxon';
-import { LoginDetails } from '../auth';
-import { ICryptoRepository } from '../crypto';
-import { IUserTokenRepository } from './user-token.repository';
-
-@Injectable()
-export class UserTokenCore {
-  constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {}
-
-  async validate(tokenValue: string) {
-    const hashedToken = this.crypto.hashSha256(tokenValue);
-    let token = await this.repository.getByToken(hashedToken);
-
-    if (token?.user) {
-      const now = DateTime.now();
-      const updatedAt = DateTime.fromJSDate(token.updatedAt);
-      const diff = now.diff(updatedAt, ['hours']);
-      if (diff.hours > 1) {
-        token = await this.repository.save({ ...token, updatedAt: new Date() });
-      }
-
-      return {
-        ...token.user,
-        isPublicUser: false,
-        isAllowUpload: true,
-        isAllowDownload: true,
-        isShowExif: true,
-        accessTokenId: token.id,
-      };
-    }
-
-    throw new UnauthorizedException('Invalid user token');
-  }
-
-  async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
-    const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
-    const token = this.crypto.hashSha256(key);
-    await this.repository.create({
-      token,
-      user,
-      deviceOS: loginDetails.deviceOS,
-      deviceType: loginDetails.deviceType,
-    });
-
-    return key;
-  }
-
-  async delete(userId: string, id: string): Promise<void> {
-    await this.repository.delete(userId, id);
-  }
-
-  getAll(userId: string): Promise<UserTokenEntity[]> {
-    return this.repository.getAll(userId);
-  }
-}

+ 3 - 3
server/src/immich/controllers/oauth.controller.ts

@@ -1,11 +1,11 @@
 import {
+  AuthService,
   AuthUserDto,
   LoginDetails,
   LoginResponseDto,
   OAuthCallbackDto,
   OAuthConfigDto,
   OAuthConfigResponseDto,
-  OAuthService,
   UserResponseDto,
 } from '@app/domain';
 import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
@@ -19,7 +19,7 @@ import { UseValidation } from '../app.utils';
 @Authenticated()
 @UseValidation()
 export class OAuthController {
-  constructor(private service: OAuthService) {}
+  constructor(private service: AuthService) {}
 
   @PublicRoute()
   @Get('mobile-redirect')
@@ -44,7 +44,7 @@ export class OAuthController {
     @Body() dto: OAuthCallbackDto,
     @GetLoginDetails() loginDetails: LoginDetails,
   ): Promise<LoginResponseDto> {
-    const { response, cookie } = await this.service.login(dto, loginDetails);
+    const { response, cookie } = await this.service.callback(dto, loginDetails);
     res.header('Set-Cookie', cookie);
     return response;
   }

+ 1 - 1
server/src/infra/repositories/user-token.repository.ts

@@ -1,4 +1,4 @@
-import { IUserTokenRepository } from '@app/domain/user-token';
+import { IUserTokenRepository } from '@app/domain';
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';

+ 24 - 90
server/test/fixtures.ts

@@ -5,7 +5,6 @@ import {
   AuthUserDto,
   ExifResponseDto,
   mapUser,
-  QueueName,
   SearchResult,
   SharedLinkResponseDto,
   TagResponseDto,
@@ -19,19 +18,17 @@ import {
   AssetEntity,
   AssetFaceEntity,
   AssetType,
-  AudioCodec,
   ExifEntity,
   PartnerEntity,
   PersonEntity,
   SharedLinkEntity,
   SharedLinkType,
-  SystemConfig,
+  SystemConfigEntity,
+  SystemConfigKey,
   TagEntity,
   TagType,
-  TranscodePolicy,
   UserEntity,
   UserTokenEntity,
-  VideoCodec,
 } from '@app/infra/entities';
 
 const today = new Date();
@@ -704,91 +701,28 @@ export const keyStub = {
   } as APIKeyEntity),
 };
 
-export const systemConfigStub = {
-  defaults: Object.freeze({
-    ffmpeg: {
-      crf: 23,
-      threads: 0,
-      preset: 'ultrafast',
-      targetAudioCodec: AudioCodec.AAC,
-      targetResolution: '720',
-      targetVideoCodec: VideoCodec.H264,
-      maxBitrate: '0',
-      twoPass: false,
-      transcode: TranscodePolicy.REQUIRED,
-    },
-    job: {
-      [QueueName.BACKGROUND_TASK]: { concurrency: 5 },
-      [QueueName.CLIP_ENCODING]: { concurrency: 2 },
-      [QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
-      [QueueName.OBJECT_TAGGING]: { concurrency: 2 },
-      [QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
-      [QueueName.SEARCH]: { concurrency: 5 },
-      [QueueName.SIDECAR]: { concurrency: 5 },
-      [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
-      [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
-      [QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
-    },
-    oauth: {
-      autoLaunch: false,
-      autoRegister: true,
-      buttonText: 'Login with OAuth',
-      clientId: '',
-      clientSecret: '',
-      enabled: false,
-      issuerUrl: '',
-      mobileOverrideEnabled: false,
-      mobileRedirectUri: '',
-      scope: 'openid email profile',
-    },
-    passwordLogin: {
-      enabled: true,
-    },
-    storageTemplate: {
-      template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
-    },
-  } as SystemConfig),
-  enabled: Object.freeze({
-    passwordLogin: {
-      enabled: true,
-    },
-    oauth: {
-      enabled: true,
-      autoRegister: true,
-      buttonText: 'OAuth',
-      autoLaunch: false,
-    },
-  } as SystemConfig),
-  disabled: Object.freeze({
-    passwordLogin: {
-      enabled: false,
-    },
-    oauth: {
-      enabled: false,
-      buttonText: 'OAuth',
-      issuerUrl: 'http://issuer,',
-      autoLaunch: false,
-    },
-  } as SystemConfig),
-  noAutoRegister: {
-    oauth: {
-      enabled: true,
-      autoRegister: false,
-      autoLaunch: false,
-    },
-    passwordLogin: { enabled: true },
-  } as SystemConfig,
-  override: {
-    oauth: {
-      enabled: true,
-      autoRegister: true,
-      autoLaunch: false,
-      buttonText: 'OAuth',
-      mobileOverrideEnabled: true,
-      mobileRedirectUri: 'http://mobile-redirect',
-    },
-    passwordLogin: { enabled: true },
-  } as SystemConfig,
+export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
+  defaults: [],
+  enabled: [
+    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
+    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
+  ],
+  disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }],
+  noAutoRegister: [
+    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
+    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false },
+    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
+  ],
+  override: [
+    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
+    { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
+    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
+  ],
 };
 
 export const loginResponseStub = {

+ 8 - 8
web/src/lib/components/forms/login-form.svelte

@@ -2,13 +2,13 @@
   import { goto } from '$app/navigation';
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
   import { AppRoute } from '$lib/constants';
-  import { handleError } from '$lib/utils/handle-error';
-  import { api, oauth, OAuthConfigResponseDto } from '@api';
+  import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
+  import { OAuthConfigResponseDto, api, oauth } from '@api';
   import { createEventDispatcher, onMount } from 'svelte';
   import { fade } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
 
-  let error: string;
+  let errorMessage: string;
   let email = '';
   let password = '';
   let oauthError: string;
@@ -53,7 +53,7 @@
 
   const login = async () => {
     try {
-      error = '';
+      errorMessage = '';
       loading = true;
 
       const { data } = await api.authenticationApi.login({
@@ -70,8 +70,8 @@
 
       dispatch('success');
       return;
-    } catch (e) {
-      error = 'Incorrect email or password';
+    } catch (error) {
+      errorMessage = (await getServerErrorMessage(error)) || 'Incorrect email or password';
       loading = false;
       return;
     }
@@ -80,9 +80,9 @@
 
 {#if authConfig.passwordLoginEnabled}
   <form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
-    {#if error}
+    {#if errorMessage}
       <p class="text-red-400" transition:fade>
-        {error}
+        {errorMessage}
       </p>
     {/if}
 

+ 12 - 8
web/src/lib/utils/handle-error.ts

@@ -2,13 +2,7 @@ import type { ApiError } from '@api';
 import { CanceledError } from 'axios';
 import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
 
-export async function handleError(error: unknown, message: string) {
-  if (error instanceof CanceledError) {
-    return;
-  }
-
-  console.error(`[handleError]: ${message}`, error);
-
+export async function getServerErrorMessage(error: unknown) {
   let data = (error as ApiError)?.response?.data;
   if (data instanceof Blob) {
     const response = await data.text();
@@ -19,7 +13,17 @@ export async function handleError(error: unknown, message: string) {
     }
   }
 
-  let serverMessage = data?.message;
+  return data?.message || null;
+}
+
+export async function handleError(error: unknown, message: string) {
+  if (error instanceof CanceledError) {
+    return;
+  }
+
+  console.error(`[handleError]: ${message}`, error);
+
+  let serverMessage = await getServerErrorMessage(error);
   if (serverMessage) {
     serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
   }