Browse Source

refactor(server): auth service (#1383)

* refactor: auth

* chore: tests

* Remove await on non-async method

* refactor: constants

* chore: remove extra async

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jason Rasmussen 2 năm trước cách đây
mục cha
commit
eade36ee82
64 tập tin đã thay đổi với 1450 bổ sung1550 xóa
  1. 0 72
      server/apps/immich/src/api-v1/auth/auth.controller.ts
  2. 0 12
      server/apps/immich/src/api-v1/auth/auth.module.ts
  3. 0 244
      server/apps/immich/src/api-v1/auth/auth.service.spec.ts
  4. 0 119
      server/apps/immich/src/api-v1/auth/auth.service.ts
  5. 0 9
      server/apps/immich/src/api-v1/auth/dto/jwt-payload.dto.ts
  6. 3 3
      server/apps/immich/src/api-v1/communication/communication.gateway.ts
  7. 0 2
      server/apps/immich/src/api-v1/communication/communication.module.ts
  8. 1 2
      server/apps/immich/src/api-v1/job/job.module.ts
  9. 0 12
      server/apps/immich/src/api-v1/oauth/oauth.module.ts
  10. 0 263
      server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts
  11. 0 153
      server/apps/immich/src/api-v1/oauth/oauth.service.ts
  12. 2 3
      server/apps/immich/src/api-v1/server-info/server-info.module.ts
  13. 9 6
      server/apps/immich/src/app.module.ts
  14. 71 0
      server/apps/immich/src/controllers/auth.controller.ts
  15. 2 0
      server/apps/immich/src/controllers/index.ts
  16. 18 16
      server/apps/immich/src/controllers/oauth.controller.ts
  17. 4 8
      server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts
  18. 0 160
      server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts
  19. 0 104
      server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts
  20. 6 23
      server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts
  21. 2 4
      server/apps/immich/test/album.e2e-spec.ts
  22. 2 3
      server/apps/immich/test/user.e2e-spec.ts
  23. 281 310
      server/immich-openapi-specs.json
  24. 1 1
      server/libs/domain/src/auth/auth.config.ts
  25. 0 0
      server/libs/domain/src/auth/auth.constant.ts
  26. 80 0
      server/libs/domain/src/auth/auth.core.ts
  27. 263 0
      server/libs/domain/src/auth/auth.service.spec.ts
  28. 160 0
      server/libs/domain/src/auth/auth.service.ts
  29. 4 0
      server/libs/domain/src/auth/crypto.repository.ts
  30. 0 0
      server/libs/domain/src/auth/dto/change-password.dto.ts
  31. 4 0
      server/libs/domain/src/auth/dto/index.ts
  32. 4 0
      server/libs/domain/src/auth/dto/jwt-payload.dto.ts
  33. 0 0
      server/libs/domain/src/auth/dto/login-credential.dto.spec.ts
  34. 0 0
      server/libs/domain/src/auth/dto/login-credential.dto.ts
  35. 0 0
      server/libs/domain/src/auth/dto/sign-up.dto.spec.ts
  36. 0 0
      server/libs/domain/src/auth/dto/sign-up.dto.ts
  37. 4 0
      server/libs/domain/src/auth/index.ts
  38. 1 1
      server/libs/domain/src/auth/response-dto/admin-signup-response.dto.ts
  39. 4 0
      server/libs/domain/src/auth/response-dto/index.ts
  40. 1 1
      server/libs/domain/src/auth/response-dto/login-response.dto.ts
  41. 0 0
      server/libs/domain/src/auth/response-dto/logout-response.dto.ts
  42. 0 0
      server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts
  43. 5 4
      server/libs/domain/src/domain.module.ts
  44. 1 0
      server/libs/domain/src/index.ts
  45. 2 0
      server/libs/domain/src/oauth/dto/index.ts
  46. 0 0
      server/libs/domain/src/oauth/dto/oauth-auth-code.dto.ts
  47. 0 0
      server/libs/domain/src/oauth/dto/oauth-config.dto.ts
  48. 4 0
      server/libs/domain/src/oauth/index.ts
  49. 1 0
      server/libs/domain/src/oauth/oauth.constants.ts
  50. 107 0
      server/libs/domain/src/oauth/oauth.core.ts
  51. 193 0
      server/libs/domain/src/oauth/oauth.service.spec.ts
  52. 81 0
      server/libs/domain/src/oauth/oauth.service.ts
  53. 1 0
      server/libs/domain/src/oauth/response-dto/index.ts
  54. 0 0
      server/libs/domain/src/oauth/response-dto/oauth-config-response.dto.ts
  55. 1 1
      server/libs/domain/src/system-config/index.ts
  56. 2 0
      server/libs/domain/src/system-config/system-config.constants.ts
  57. 3 1
      server/libs/domain/src/system-config/system-config.core.ts
  58. 1 1
      server/libs/domain/src/system-config/system-config.service.spec.ts
  59. 1 1
      server/libs/domain/src/system-config/system-config.service.ts
  60. 2 0
      server/libs/domain/test/crypto.repository.mock.ts
  61. 92 0
      server/libs/domain/test/fixtures.ts
  62. 18 5
      server/libs/infra/src/auth/crypto.repository.ts
  63. 7 5
      server/libs/infra/src/infra.module.ts
  64. 1 1
      server/package.json

+ 0 - 72
server/apps/immich/src/api-v1/auth/auth.controller.ts

@@ -1,72 +0,0 @@
-import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
-import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
-import { Request, Response } from 'express';
-import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant';
-import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
-import { Authenticated } from '../../decorators/authenticated.decorator';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { UserResponseDto } from '@app/domain';
-import { AuthService } from './auth.service';
-import { ChangePasswordDto } from './dto/change-password.dto';
-import { LoginCredentialDto } from './dto/login-credential.dto';
-import { SignUpDto } from './dto/sign-up.dto';
-import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
-import { LoginResponseDto } from './response-dto/login-response.dto';
-import { LogoutResponseDto } from './response-dto/logout-response.dto';
-import { ValidateAccessTokenResponseDto } from './response-dto/validate-asset-token-response.dto,';
-
-@ApiTags('Authentication')
-@Controller('auth')
-export class AuthController {
-  constructor(private readonly authService: AuthService, private readonly immichJwtService: ImmichJwtService) {}
-
-  @Post('/login')
-  async login(
-    @Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
-    @Ip() clientIp: string,
-    @Res({ passthrough: true }) response: Response,
-    @Req() request: Request,
-  ): Promise<LoginResponseDto> {
-    const loginResponse = await this.authService.login(loginCredential, clientIp);
-    response.setHeader(
-      'Set-Cookie',
-      this.immichJwtService.getCookies(loginResponse, AuthType.PASSWORD, request.secure),
-    );
-    return loginResponse;
-  }
-
-  @Post('/admin-sign-up')
-  @ApiBadRequestResponse({ description: 'The server already has an admin' })
-  async adminSignUp(
-    @Body(new ValidationPipe({ transform: true })) signUpCredential: SignUpDto,
-  ): Promise<AdminSignupResponseDto> {
-    return await this.authService.adminSignUp(signUpCredential);
-  }
-
-  @Authenticated()
-  @ApiBearerAuth()
-  @Post('/validateToken')
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  async validateAccessToken(@GetAuthUser() authUser: AuthUserDto): Promise<ValidateAccessTokenResponseDto> {
-    return new ValidateAccessTokenResponseDto(true);
-  }
-
-  @Authenticated()
-  @ApiBearerAuth()
-  @Post('change-password')
-  async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
-    return this.authService.changePassword(authUser, dto);
-  }
-
-  @Post('/logout')
-  async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
-    const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
-
-    const cookies = this.immichJwtService.getCookieNames();
-    for (const cookie of cookies) {
-      response.clearCookie(cookie);
-    }
-
-    return this.authService.logout(authType);
-  }
-}

+ 0 - 12
server/apps/immich/src/api-v1/auth/auth.module.ts

@@ -1,12 +0,0 @@
-import { Module } from '@nestjs/common';
-import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
-import { OAuthModule } from '../oauth/oauth.module';
-import { AuthController } from './auth.controller';
-import { AuthService } from './auth.service';
-
-@Module({
-  imports: [ImmichJwtModule, OAuthModule],
-  controllers: [AuthController],
-  providers: [AuthService],
-})
-export class AuthModule {}

+ 0 - 244
server/apps/immich/src/api-v1/auth/auth.service.spec.ts

@@ -1,244 +0,0 @@
-import { UserEntity } from '@app/infra';
-import { BadRequestException, UnauthorizedException } from '@nestjs/common';
-import * as bcrypt from 'bcrypt';
-import { SystemConfig } from '@app/infra';
-import { SystemConfigService } from '@app/domain';
-import { AuthType } from '../../constants/jwt.constant';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { OAuthService } from '../oauth/oauth.service';
-import { IUserRepository } from '@app/domain';
-import { AuthService } from './auth.service';
-import { SignUpDto } from './dto/sign-up.dto';
-import { LoginResponseDto } from './response-dto/login-response.dto';
-
-const fixtures = {
-  login: {
-    email: 'test@immich.com',
-    password: 'password',
-  },
-};
-
-const config = {
-  enabled: {
-    passwordLogin: {
-      enabled: true,
-    },
-  } as SystemConfig,
-  disabled: {
-    passwordLogin: {
-      enabled: false,
-    },
-  } as SystemConfig,
-};
-
-const CLIENT_IP = '127.0.0.1';
-
-jest.mock('bcrypt');
-jest.mock('@nestjs/common', () => ({
-  ...jest.requireActual('@nestjs/common'),
-  Logger: jest.fn().mockReturnValue({
-    verbose: jest.fn(),
-    debug: jest.fn(),
-    log: jest.fn(),
-    info: jest.fn(),
-    warn: jest.fn(),
-    error: jest.fn(),
-  }),
-}));
-
-describe('AuthService', () => {
-  let sut: AuthService;
-  let userRepositoryMock: jest.Mocked<IUserRepository>;
-  let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
-  let immichConfigServiceMock: jest.Mocked<SystemConfigService>;
-  let oauthServiceMock: jest.Mocked<OAuthService>;
-  let compare: jest.Mock;
-
-  afterEach(() => {
-    jest.resetModules();
-  });
-
-  beforeEach(async () => {
-    jest.mock('bcrypt');
-    compare = bcrypt.compare as jest.Mock;
-
-    userRepositoryMock = {
-      get: jest.fn(),
-      getAdmin: jest.fn(),
-      getByOAuthId: jest.fn(),
-      getByEmail: jest.fn(),
-      getList: jest.fn(),
-      create: jest.fn(),
-      update: jest.fn(),
-      delete: jest.fn(),
-      restore: jest.fn(),
-    };
-
-    immichJwtServiceMock = {
-      getCookieNames: jest.fn(),
-      getCookies: jest.fn(),
-      createLoginResponse: jest.fn(),
-      validateToken: jest.fn(),
-      extractJwtFromHeader: jest.fn(),
-      extractJwtFromCookie: jest.fn(),
-    } as unknown as jest.Mocked<ImmichJwtService>;
-
-    oauthServiceMock = {
-      getLogoutEndpoint: jest.fn(),
-    } as unknown as jest.Mocked<OAuthService>;
-
-    immichConfigServiceMock = {
-      config$: { subscribe: jest.fn() },
-    } as unknown as jest.Mocked<SystemConfigService>;
-
-    sut = new AuthService(
-      oauthServiceMock,
-      immichJwtServiceMock,
-      userRepositoryMock,
-      immichConfigServiceMock,
-      config.enabled,
-    );
-  });
-
-  it('should be defined', () => {
-    expect(sut).toBeDefined();
-  });
-
-  it('should subscribe to config changes', async () => {
-    expect(immichConfigServiceMock.config$.subscribe).toHaveBeenCalled();
-  });
-
-  describe('login', () => {
-    it('should throw an error if password login is disabled', async () => {
-      sut = new AuthService(
-        oauthServiceMock,
-        immichJwtServiceMock,
-        userRepositoryMock,
-        immichConfigServiceMock,
-        config.disabled,
-      );
-
-      await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(UnauthorizedException);
-    });
-
-    it('should check the user exists', async () => {
-      userRepositoryMock.getByEmail.mockResolvedValue(null);
-      await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
-    });
-
-    it('should check the user has a password', async () => {
-      userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity);
-      await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
-    });
-
-    it('should successfully log the user in', async () => {
-      userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity);
-      compare.mockResolvedValue(true);
-      const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto;
-      immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto);
-      await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto);
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
-      expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
-    });
-  });
-
-  describe('changePassword', () => {
-    it('should change the password', async () => {
-      const authUser = { email: 'test@imimch.com' } as UserEntity;
-      const dto = { password: 'old-password', newPassword: 'new-password' };
-
-      compare.mockResolvedValue(true);
-
-      userRepositoryMock.getByEmail.mockResolvedValue({
-        email: 'test@immich.com',
-        password: 'hash-password',
-      } as UserEntity);
-
-      await sut.changePassword(authUser, dto);
-
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
-      expect(compare).toHaveBeenCalledWith('old-password', 'hash-password');
-    });
-
-    it('should throw when auth user email is not found', async () => {
-      const authUser = { email: 'test@imimch.com' } as UserEntity;
-      const dto = { password: 'old-password', newPassword: 'new-password' };
-
-      userRepositoryMock.getByEmail.mockResolvedValue(null);
-
-      await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(UnauthorizedException);
-    });
-
-    it('should throw when password does not match existing password', async () => {
-      const authUser = { email: 'test@imimch.com' } as UserEntity;
-      const dto = { password: 'old-password', newPassword: 'new-password' };
-
-      compare.mockResolvedValue(false);
-
-      userRepositoryMock.getByEmail.mockResolvedValue({
-        email: 'test@immich.com',
-        password: 'hash-password',
-      } as UserEntity);
-
-      await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
-    });
-
-    it('should throw when user does not have a password', async () => {
-      const authUser = { email: 'test@imimch.com' } as UserEntity;
-      const dto = { password: 'old-password', newPassword: 'new-password' };
-
-      compare.mockResolvedValue(false);
-
-      userRepositoryMock.getByEmail.mockResolvedValue({
-        email: 'test@immich.com',
-        password: '',
-      } as UserEntity);
-
-      await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
-    });
-  });
-
-  describe('logout', () => {
-    it('should return the end session endpoint', async () => {
-      oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint');
-      await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
-        successful: true,
-        redirectUri: 'end-session-endpoint',
-      });
-    });
-
-    it('should return the default redirect', async () => {
-      await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
-        successful: true,
-        redirectUri: '/auth/login?autoLaunch=0',
-      });
-      expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('adminSignUp', () => {
-    const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
-
-    it('should only allow one admin', async () => {
-      userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity);
-      await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
-      expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
-    });
-
-    it('should sign up the admin', async () => {
-      userRepositoryMock.getAdmin.mockResolvedValue(null);
-      userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
-      await expect(sut.adminSignUp(dto)).resolves.toEqual({
-        id: 'admin',
-        createdAt: 'today',
-        email: 'test@immich.com',
-        firstName: 'immich',
-        lastName: 'admin',
-      });
-      expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
-      expect(userRepositoryMock.create).toHaveBeenCalled();
-    });
-  });
-});

+ 0 - 119
server/apps/immich/src/api-v1/auth/auth.service.ts

@@ -1,119 +0,0 @@
-import {
-  BadRequestException,
-  Inject,
-  Injectable,
-  InternalServerErrorException,
-  Logger,
-  UnauthorizedException,
-} from '@nestjs/common';
-import * as bcrypt from 'bcrypt';
-import { UserEntity } from '@app/infra';
-import { AuthType } from '../../constants/jwt.constant';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { IUserRepository } from '@app/domain';
-import { ChangePasswordDto } from './dto/change-password.dto';
-import { LoginCredentialDto } from './dto/login-credential.dto';
-import { SignUpDto } from './dto/sign-up.dto';
-import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
-import { LoginResponseDto } from './response-dto/login-response.dto';
-import { LogoutResponseDto } from './response-dto/logout-response.dto';
-import { OAuthService } from '../oauth/oauth.service';
-import { UserCore } from '@app/domain';
-import { SystemConfigService, INITIAL_SYSTEM_CONFIG } from '@app/domain';
-import { SystemConfig } from '@app/infra';
-
-@Injectable()
-export class AuthService {
-  private userCore: UserCore;
-  private logger = new Logger(AuthService.name);
-
-  constructor(
-    private oauthService: OAuthService,
-    private immichJwtService: ImmichJwtService,
-    @Inject(IUserRepository) userRepository: IUserRepository,
-    private configService: SystemConfigService,
-    @Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
-  ) {
-    this.userCore = new UserCore(userRepository);
-    this.configService.config$.subscribe((config) => (this.config = config));
-  }
-
-  public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
-    if (!this.config.passwordLogin.enabled) {
-      throw new UnauthorizedException('Password login has been disabled');
-    }
-
-    let user = await this.userCore.getByEmail(loginCredential.email, true);
-
-    if (user) {
-      const isAuthenticated = await this.validatePassword(loginCredential.password, user);
-      if (!isAuthenticated) {
-        user = null;
-      }
-    }
-
-    if (!user) {
-      this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
-      throw new BadRequestException('Incorrect email or password');
-    }
-
-    return this.immichJwtService.createLoginResponse(user);
-  }
-
-  public async logout(authType: AuthType): Promise<LogoutResponseDto> {
-    if (authType === AuthType.OAUTH) {
-      const url = await this.oauthService.getLogoutEndpoint();
-      if (url) {
-        return { successful: true, redirectUri: url };
-      }
-    }
-
-    return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
-  }
-
-  public 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 = await this.validatePassword(password, user);
-    if (!valid) {
-      throw new BadRequestException('Wrong password');
-    }
-
-    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
-  }
-
-  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
-    const adminUser = await this.userCore.getAdmin();
-
-    if (adminUser) {
-      throw new BadRequestException('The server already has an admin');
-    }
-
-    try {
-      const admin = await this.userCore.createUser({
-        isAdmin: true,
-        email: dto.email,
-        firstName: dto.firstName,
-        lastName: dto.lastName,
-        password: dto.password,
-      });
-
-      return mapAdminSignupResponse(admin);
-    } catch (error) {
-      this.logger.error(`Unable to register admin user: ${error}`, (error as Error).stack);
-      throw new InternalServerErrorException('Failed to register new admin user');
-    }
-  }
-
-  private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
-    if (!user || !user.password) {
-      return false;
-    }
-    return await bcrypt.compare(inputPassword, user.password);
-  }
-}

+ 0 - 9
server/apps/immich/src/api-v1/auth/dto/jwt-payload.dto.ts

@@ -1,9 +0,0 @@
-export class JwtPayloadDto {
-  constructor(userId: string, email: string) {
-    this.userId = userId;
-    this.email = email;
-  }
-
-  userId: string;
-  email: string;
-}

+ 3 - 3
server/apps/immich/src/api-v1/communication/communication.gateway.ts

@@ -1,13 +1,13 @@
 import { Logger } from '@nestjs/common';
 import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
 import { Server, Socket } from 'socket.io';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { AuthService } from '@app/domain';
 
 @WebSocketGateway({ cors: true })
 export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
   private logger = new Logger(CommunicationGateway.name);
 
-  constructor(private immichJwtService: ImmichJwtService) {}
+  constructor(private authService: AuthService) {}
 
   @WebSocketServer() server!: Server;
 
@@ -20,7 +20,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
     try {
       this.logger.log(`New websocket connection: ${client.id}`);
 
-      const user = await this.immichJwtService.validateSocket(client);
+      const user = await this.authService.validateSocket(client);
       if (user) {
         client.join(user.id);
       } else {

+ 0 - 2
server/apps/immich/src/api-v1/communication/communication.module.ts

@@ -1,9 +1,7 @@
 import { Module } from '@nestjs/common';
 import { CommunicationGateway } from './communication.gateway';
-import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
 
 @Module({
-  imports: [ImmichJwtModule],
   providers: [CommunicationGateway],
   exports: [CommunicationGateway],
 })

+ 1 - 2
server/apps/immich/src/api-v1/job/job.module.ts

@@ -1,7 +1,6 @@
 import { Module } from '@nestjs/common';
 import { JobService } from './job.service';
 import { JobController } from './job.controller';
-import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { ExifEntity } from '@app/infra';
 import { TagModule } from '../tag/tag.module';
@@ -9,7 +8,7 @@ import { AssetModule } from '../asset/asset.module';
 import { StorageModule } from '@app/storage';
 
 @Module({
-  imports: [TypeOrmModule.forFeature([ExifEntity]), ImmichJwtModule, TagModule, AssetModule, StorageModule],
+  imports: [TypeOrmModule.forFeature([ExifEntity]), TagModule, AssetModule, StorageModule],
   controllers: [JobController],
   providers: [JobService],
 })

+ 0 - 12
server/apps/immich/src/api-v1/oauth/oauth.module.ts

@@ -1,12 +0,0 @@
-import { Module } from '@nestjs/common';
-import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
-import { OAuthController } from './oauth.controller';
-import { OAuthService } from './oauth.service';
-
-@Module({
-  imports: [ImmichJwtModule],
-  controllers: [OAuthController],
-  providers: [OAuthService],
-  exports: [OAuthService],
-})
-export class OAuthModule {}

+ 0 - 263
server/apps/immich/src/api-v1/oauth/oauth.service.spec.ts

@@ -1,263 +0,0 @@
-import { SystemConfig, UserEntity } from '@app/infra';
-import { SystemConfigService } from '@app/domain';
-import { BadRequestException } from '@nestjs/common';
-import { generators, Issuer } from 'openid-client';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
-import { OAuthService } from '../oauth/oauth.service';
-import { IUserRepository } from '@app/domain';
-
-const email = 'user@immich.com';
-const sub = 'my-auth-user-sub';
-
-const config = {
-  disabled: {
-    oauth: {
-      enabled: false,
-      buttonText: 'OAuth',
-      issuerUrl: 'http://issuer,',
-      autoLaunch: false,
-    },
-    passwordLogin: { enabled: true },
-  } as SystemConfig,
-  enabled: {
-    oauth: {
-      enabled: true,
-      autoRegister: true,
-      buttonText: 'OAuth',
-      autoLaunch: false,
-    },
-    passwordLogin: { enabled: true },
-  } 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,
-};
-
-const user = {
-  id: 'user_id',
-  email,
-  firstName: 'user',
-  lastName: 'imimch',
-  oauthId: '',
-} as UserEntity;
-
-const authUser: AuthUserDto = {
-  id: 'user_id',
-  email,
-  isAdmin: true,
-};
-
-const loginResponse = {
-  accessToken: 'access-token',
-  userId: 'user',
-  userEmail: 'user@immich.com,',
-} as LoginResponseDto;
-
-jest.mock('@nestjs/common', () => ({
-  ...jest.requireActual('@nestjs/common'),
-  Logger: jest.fn().mockReturnValue({
-    verbose: jest.fn(),
-    debug: jest.fn(),
-    log: jest.fn(),
-    info: jest.fn(),
-    warn: jest.fn(),
-    error: jest.fn(),
-  }),
-}));
-
-describe('OAuthService', () => {
-  let sut: OAuthService;
-  let userRepositoryMock: jest.Mocked<IUserRepository>;
-  let immichConfigServiceMock: jest.Mocked<SystemConfigService>;
-  let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
-  let callbackMock: jest.Mock;
-
-  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);
-
-    userRepositoryMock = {
-      get: jest.fn(),
-      getAdmin: jest.fn(),
-      getByOAuthId: jest.fn(),
-      getByEmail: jest.fn(),
-      getList: jest.fn(),
-      create: jest.fn(),
-      update: jest.fn(),
-      delete: jest.fn(),
-      restore: jest.fn(),
-    };
-
-    immichJwtServiceMock = {
-      getCookieNames: jest.fn(),
-      getCookies: jest.fn(),
-      createLoginResponse: jest.fn(),
-      validateToken: jest.fn(),
-      extractJwtFromHeader: jest.fn(),
-      extractJwtFromCookie: jest.fn(),
-    } as unknown as jest.Mocked<ImmichJwtService>;
-
-    immichConfigServiceMock = {
-      config$: { subscribe: jest.fn() },
-    } as unknown as jest.Mocked<SystemConfigService>;
-
-    sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.disabled);
-  });
-
-  it('should be defined', () => {
-    expect(sut).toBeDefined();
-  });
-
-  describe('generateConfig', () => {
-    it('should work when oauth is not configured', async () => {
-      await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
-        enabled: false,
-        passwordLoginEnabled: true,
-      });
-    });
-
-    it('should generate the config', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.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: '' })).rejects.toBeInstanceOf(BadRequestException);
-    });
-
-    it('should not allow auto registering', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.noAutoRegister);
-      userRepositoryMock.getByEmail.mockResolvedValue(null);
-      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
-    });
-
-    it('should link an existing user', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.noAutoRegister);
-      userRepositoryMock.getByEmail.mockResolvedValue(user);
-      userRepositoryMock.update.mockResolvedValue(user);
-      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
-
-      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
-
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
-      expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
-    });
-
-    it('should allow auto registering by default', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
-
-      userRepositoryMock.getByEmail.mockResolvedValue(null);
-      userRepositoryMock.getAdmin.mockResolvedValue(user);
-      userRepositoryMock.create.mockResolvedValue(user);
-      immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
-
-      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
-
-      expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
-      expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
-      expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
-    });
-
-    it('should use the mobile redirect override', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.override);
-
-      userRepositoryMock.getByOAuthId.mockResolvedValue(user);
-
-      await sut.login({ url: `app.immich:/?code=abc123` });
-
-      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
-    });
-  });
-
-  describe('link', () => {
-    it('should link an account', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
-
-      userRepositoryMock.update.mockResolvedValue(user);
-
-      await sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' });
-
-      expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: sub });
-    });
-
-    it('should not link an already linked oauth.sub', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
-
-      userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
-
-      await expect(sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-
-      expect(userRepositoryMock.update).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('unlink', () => {
-    it('should unlink an account', async () => {
-      sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
-
-      userRepositoryMock.update.mockResolvedValue(user);
-
-      await sut.unlink(authUser);
-
-      expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.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 = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock, config.enabled);
-
-      await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
-    });
-  });
-});

+ 0 - 153
server/apps/immich/src/api-v1/oauth/oauth.service.ts

@@ -1,153 +0,0 @@
-import { SystemConfig } from '@app/infra';
-import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
-import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
-import { IUserRepository, UserResponseDto, UserCore, SystemConfigService, INITIAL_SYSTEM_CONFIG } from '@app/domain';
-import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
-import { OAuthConfigDto } from './dto/oauth-config.dto';
-import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
-
-type OAuthProfile = UserinfoResponse & {
-  email: string;
-};
-
-export const MOBILE_REDIRECT = 'app.immich:/';
-
-@Injectable()
-export class OAuthService {
-  private readonly userCore: UserCore;
-  private readonly logger = new Logger(OAuthService.name);
-
-  constructor(
-    private immichJwtService: ImmichJwtService,
-    configService: SystemConfigService,
-    @Inject(IUserRepository) userRepository: IUserRepository,
-    @Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
-  ) {
-    this.userCore = new UserCore(userRepository);
-
-    custom.setHttpOptionsDefaults({
-      timeout: 30000,
-    });
-
-    configService.config$.subscribe((config) => (this.config = config));
-  }
-
-  public 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 redirectUri = this.normalize(dto.redirectUri);
-    const url = (await this.getClient()).authorizationUrl({
-      redirect_uri: redirectUri,
-      scope,
-      state: generators.state(),
-    });
-
-    return { ...response, buttonText, url, autoLaunch };
-  }
-
-  public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
-    const profile = await this.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.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.immichJwtService.createLoginResponse(user);
-  }
-
-  public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
-    const { sub: oauthId } = await this.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> {
-    if (!this.config.oauth.enabled) {
-      return null;
-    }
-    return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
-  }
-
-  private async callback(url: string): Promise<any> {
-    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 || '');
-  }
-
-  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 === MOBILE_REDIRECT;
-    const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
-    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
-      return mobileRedirectUri;
-    }
-    return redirectUri;
-  }
-}

+ 2 - 3
server/apps/immich/src/api-v1/server-info/server-info.module.ts

@@ -1,12 +1,11 @@
 import { Module } from '@nestjs/common';
 import { ServerInfoService } from './server-info.service';
 import { ServerInfoController } from './server-info.controller';
-import { AssetEntity, UserEntity } from '@app/infra';
+import { AssetEntity } from '@app/infra';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
 
 @Module({
-  imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity]), ImmichJwtModule],
+  imports: [TypeOrmModule.forFeature([AssetEntity])],
   controllers: [ServerInfoController],
   providers: [ServerInfoService],
 })

+ 9 - 6
server/apps/immich/src/app.module.ts

@@ -1,7 +1,6 @@
 import { immichAppConfig } from '@app/common/config';
 import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
 import { AssetModule } from './api-v1/asset/asset.module';
-import { AuthModule } from './api-v1/auth/auth.module';
 import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
 import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
 import { ConfigModule } from '@nestjs/config';
@@ -13,12 +12,17 @@ import { AppController } from './app.controller';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { JobModule } from './api-v1/job/job.module';
-import { OAuthModule } from './api-v1/oauth/oauth.module';
 import { TagModule } from './api-v1/tag/tag.module';
 import { ShareModule } from './api-v1/share/share.module';
 import { DomainModule } from '@app/domain';
 import { InfraModule } from '@app/infra';
-import { APIKeyController, SystemConfigController, UserController } from './controllers';
+import {
+  APIKeyController,
+  AuthController,
+  OAuthController,
+  SystemConfigController,
+  UserController,
+} from './controllers';
 
 @Module({
   imports: [
@@ -30,9 +34,6 @@ import { APIKeyController, SystemConfigController, UserController } from './cont
 
     AssetModule,
 
-    AuthModule,
-    OAuthModule,
-
     ImmichJwtModule,
 
     DeviceInfoModule,
@@ -59,6 +60,8 @@ import { APIKeyController, SystemConfigController, UserController } from './cont
     //
     AppController,
     APIKeyController,
+    AuthController,
+    OAuthController,
     SystemConfigController,
     UserController,
   ],

+ 71 - 0
server/apps/immich/src/controllers/auth.controller.ts

@@ -0,0 +1,71 @@
+import {
+  AdminSignupResponseDto,
+  AuthService,
+  AuthType,
+  AuthUserDto,
+  ChangePasswordDto,
+  IMMICH_ACCESS_COOKIE,
+  IMMICH_AUTH_TYPE_COOKIE,
+  LoginCredentialDto,
+  LoginResponseDto,
+  LogoutResponseDto,
+  SignUpDto,
+  UserResponseDto,
+  ValidateAccessTokenResponseDto,
+} from '@app/domain';
+import { Body, Controller, Ip, Post, Req, Res, ValidationPipe } from '@nestjs/common';
+import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { Request, Response } from 'express';
+import { GetAuthUser } from '../decorators/auth-user.decorator';
+import { Authenticated } from '../decorators/authenticated.decorator';
+
+@ApiTags('Authentication')
+@Controller('auth')
+export class AuthController {
+  constructor(private readonly authService: AuthService) {}
+
+  @Post('login')
+  async login(
+    @Body(new ValidationPipe({ transform: true })) loginCredential: LoginCredentialDto,
+    @Ip() clientIp: string,
+    @Req() req: Request,
+    @Res({ passthrough: true }) res: Response,
+  ): Promise<LoginResponseDto> {
+    const { response, cookie } = await this.authService.login(loginCredential, clientIp, req.secure);
+    res.setHeader('Set-Cookie', cookie);
+    return response;
+  }
+
+  @Post('admin-sign-up')
+  @ApiBadRequestResponse({ description: 'The server already has an admin' })
+  adminSignUp(
+    @Body(new ValidationPipe({ transform: true })) signUpCredential: SignUpDto,
+  ): Promise<AdminSignupResponseDto> {
+    return this.authService.adminSignUp(signUpCredential);
+  }
+
+  @Authenticated()
+  @ApiBearerAuth()
+  @Post('validateToken')
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  validateAccessToken(@GetAuthUser() authUser: AuthUserDto): ValidateAccessTokenResponseDto {
+    return { authStatus: true };
+  }
+
+  @Authenticated()
+  @ApiBearerAuth()
+  @Post('change-password')
+  async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
+    return this.authService.changePassword(authUser, dto);
+  }
+
+  @Post('logout')
+  async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<LogoutResponseDto> {
+    const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];
+
+    res.clearCookie(IMMICH_ACCESS_COOKIE);
+    res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
+
+    return this.authService.logout(authType);
+  }
+}

+ 2 - 0
server/apps/immich/src/controllers/index.ts

@@ -1,3 +1,5 @@
 export * from './api-key.controller';
+export * from './auth.controller';
+export * from './oauth.controller';
 export * from './system-config.controller';
 export * from './user.controller';

+ 18 - 16
server/apps/immich/src/api-v1/oauth/oauth.controller.ts → server/apps/immich/src/controllers/oauth.controller.ts

@@ -1,21 +1,23 @@
+import {
+  AuthUserDto,
+  LoginResponseDto,
+  MOBILE_REDIRECT,
+  OAuthCallbackDto,
+  OAuthConfigDto,
+  OAuthConfigResponseDto,
+  OAuthService,
+  UserResponseDto,
+} from '@app/domain';
 import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res, ValidationPipe } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { Request, Response } from 'express';
-import { AuthType } from '../../constants/jwt.constant';
-import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
-import { Authenticated } from '../../decorators/authenticated.decorator';
-import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
-import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
-import { UserResponseDto } from '@app/domain';
-import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
-import { OAuthConfigDto } from './dto/oauth-config.dto';
-import { MOBILE_REDIRECT, OAuthService } from './oauth.service';
-import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
+import { GetAuthUser } from '../decorators/auth-user.decorator';
+import { Authenticated } from '../decorators/authenticated.decorator';
 
 @ApiTags('OAuth')
 @Controller('oauth')
 export class OAuthController {
-  constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
+  constructor(private readonly oauthService: OAuthService) {}
 
   @Get('mobile-redirect')
   @Redirect()
@@ -31,13 +33,13 @@ export class OAuthController {
 
   @Post('callback')
   public async callback(
-    @Res({ passthrough: true }) response: Response,
+    @Res({ passthrough: true }) res: Response,
     @Body(ValidationPipe) dto: OAuthCallbackDto,
-    @Req() request: Request,
+    @Req() req: Request,
   ): Promise<LoginResponseDto> {
-    const loginResponse = await this.oauthService.login(dto);
-    response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH, request.secure));
-    return loginResponse;
+    const { response, cookie } = await this.oauthService.login(dto, req.secure);
+    res.setHeader('Set-Cookie', cookie);
+    return response;
   }
 
   @Authenticated()

+ 4 - 8
server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts

@@ -1,15 +1,11 @@
 import { Module } from '@nestjs/common';
-import { ImmichJwtService } from './immich-jwt.service';
-import { JwtModule } from '@nestjs/jwt';
-import { jwtConfig } from '../../config/jwt.config';
-import { JwtStrategy } from './strategies/jwt.strategy';
-import { APIKeyStrategy } from './strategies/api-key.strategy';
 import { ShareModule } from '../../api-v1/share/share.module';
+import { APIKeyStrategy } from './strategies/api-key.strategy';
+import { JwtStrategy } from './strategies/jwt.strategy';
 import { PublicShareStrategy } from './strategies/public-share.strategy';
 
 @Module({
-  imports: [JwtModule.register(jwtConfig), ShareModule],
-  providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy],
-  exports: [ImmichJwtService],
+  imports: [ShareModule],
+  providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy],
 })
 export class ImmichJwtModule {}

+ 0 - 160
server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts

@@ -1,160 +0,0 @@
-import { Logger } from '@nestjs/common';
-import { JwtService } from '@nestjs/jwt';
-import { Request } from 'express';
-import { UserEntity } from '@app/infra';
-import { LoginResponseDto } from '../../api-v1/auth/response-dto/login-response.dto';
-import { AuthType } from '../../constants/jwt.constant';
-import { ImmichJwtService } from './immich-jwt.service';
-import { UserService } from '@app/domain';
-
-describe('ImmichJwtService', () => {
-  let jwtServiceMock: jest.Mocked<JwtService>;
-  let userServiceMock: jest.Mocked<UserService>;
-  let sut: ImmichJwtService;
-
-  beforeEach(() => {
-    jwtServiceMock = {
-      sign: jest.fn(),
-      verifyAsync: jest.fn(),
-    } as unknown as jest.Mocked<JwtService>;
-
-    userServiceMock = {
-      getUserById: jest.fn(),
-    } as unknown as jest.Mocked<UserService>;
-
-    sut = new ImmichJwtService(jwtServiceMock, userServiceMock);
-  });
-
-  afterEach(() => {
-    jest.resetModules();
-  });
-
-  describe('getCookieNames', () => {
-    it('should return the cookie names', async () => {
-      expect(sut.getCookieNames()).toEqual(['immich_access_token', 'immich_auth_type']);
-    });
-  });
-
-  describe('getCookies', () => {
-    it('should generate the cookie headers (secure)', async () => {
-      jwtServiceMock.sign.mockImplementation((value) => value as string);
-      const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
-      const cookies = sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD, true);
-      expect(cookies).toEqual([
-        'immich_access_token=test-user@immich.com; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
-        'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
-      ]);
-    });
-
-    it('should generate the cookie headers (insecure)', () => {
-      jwtServiceMock.sign.mockImplementation((value) => value as string);
-      const dto = { accessToken: 'test-user@immich.com', userId: 'test-user' };
-      const cookies = sut.getCookies(dto as LoginResponseDto, AuthType.PASSWORD, false);
-      expect(cookies).toEqual([
-        'immich_access_token=test-user@immich.com; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
-        'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
-      ]);
-    });
-  });
-
-  describe('createLoginResponse', () => {
-    it('should create the login response', async () => {
-      jwtServiceMock.sign.mockReturnValue('fancy-token');
-      const user: UserEntity = {
-        id: 'user',
-        firstName: 'immich',
-        lastName: 'user',
-        isAdmin: false,
-        email: 'test@immich.com',
-        password: 'changeme',
-        oauthId: '',
-        profileImagePath: '',
-        shouldChangePassword: false,
-        createdAt: 'today',
-        tags: [],
-      };
-
-      const dto: LoginResponseDto = {
-        accessToken: 'fancy-token',
-        firstName: 'immich',
-        isAdmin: false,
-        lastName: 'user',
-        profileImagePath: '',
-        shouldChangePassword: false,
-        userEmail: 'test@immich.com',
-        userId: 'user',
-      };
-      await expect(sut.createLoginResponse(user)).resolves.toEqual(dto);
-    });
-  });
-
-  describe('validateToken', () => {
-    it('should validate the token', async () => {
-      const dto = { userId: 'test-user', email: 'test-user@immich.com' };
-      jwtServiceMock.verifyAsync.mockImplementation(() => dto as any);
-      const response = await sut.validateToken('access-token');
-
-      expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
-      expect(response).toEqual({ userId: 'test-user', status: true });
-    });
-
-    it('should handle an invalid token', async () => {
-      jwtServiceMock.verifyAsync.mockImplementation(() => {
-        throw new Error('Invalid token!');
-      });
-
-      const error = jest.spyOn(Logger, 'error');
-      error.mockImplementation(() => null);
-      const response = await sut.validateToken('access-token');
-
-      expect(jwtServiceMock.verifyAsync).toHaveBeenCalledTimes(1);
-      expect(error).toHaveBeenCalledTimes(1);
-      expect(response).toEqual({ userId: null, status: false });
-    });
-  });
-
-  describe('extractJwtFromHeader', () => {
-    it('should handle no authorization header', () => {
-      const request = {
-        headers: {},
-      } as Request;
-      const token = sut.extractJwtFromHeader(request.headers);
-      expect(token).toBe(null);
-    });
-
-    it('should get the token from the authorization header', () => {
-      const upper = {
-        headers: {
-          authorization: 'Bearer token',
-        },
-      } as Request;
-
-      const lower = {
-        headers: {
-          authorization: 'bearer token',
-        },
-      } as Request;
-
-      expect(sut.extractJwtFromHeader(upper.headers)).toBe('token');
-      expect(sut.extractJwtFromHeader(lower.headers)).toBe('token');
-    });
-  });
-
-  describe('extracJwtFromCookie', () => {
-    it('should handle no cookie', () => {
-      const request = {} as Request;
-      const token = sut.extractJwtFromCookie(request.cookies);
-      expect(token).toBe(null);
-    });
-
-    it('should get the token from the immich cookie', () => {
-      const request = {
-        cookies: {
-          immich_access_token: 'cookie',
-        },
-      } as Request;
-      const token = sut.extractJwtFromCookie(request.cookies);
-      expect(token).toBe('cookie');
-    });
-  });
-});

+ 0 - 104
server/apps/immich/src/modules/immich-jwt/immich-jwt.service.ts

@@ -1,104 +0,0 @@
-import { UserEntity } from '@app/infra';
-import { Injectable, Logger } from '@nestjs/common';
-import { JwtService } from '@nestjs/jwt';
-import { IncomingHttpHeaders } from 'http';
-import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
-import { LoginResponseDto, mapLoginResponse } from '../../api-v1/auth/response-dto/login-response.dto';
-import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, jwtSecret } from '../../constants/jwt.constant';
-import { Socket } from 'socket.io';
-import cookieParser from 'cookie';
-import { UserResponseDto, UserService } from '@app/domain';
-
-export type JwtValidationResult = {
-  status: boolean;
-  userId: string | null;
-};
-
-@Injectable()
-export class ImmichJwtService {
-  constructor(private jwtService: JwtService, private userService: UserService) {}
-
-  public getCookieNames() {
-    return [IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE];
-  }
-
-  public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
-    const maxAge = 7 * 24 * 3600; // 7 days
-
-    let accessTokenCookie = '';
-    let authTypeCookie = '';
-
-    if (isSecure) {
-      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
-      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
-    } else {
-      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
-      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
-    }
-
-    return [accessTokenCookie, authTypeCookie];
-  }
-
-  public async createLoginResponse(user: UserEntity): Promise<LoginResponseDto> {
-    const payload = new JwtPayloadDto(user.id, user.email);
-    const accessToken = await this.generateToken(payload);
-
-    return mapLoginResponse(user, accessToken);
-  }
-
-  public async validateToken(accessToken: string): Promise<JwtValidationResult> {
-    try {
-      const payload = await this.jwtService.verifyAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret });
-      return {
-        userId: payload.userId,
-        status: true,
-      };
-    } catch (e) {
-      Logger.error('Error validating token from websocket request', 'ValidateWebsocketToken');
-      return {
-        userId: null,
-        status: false,
-      };
-    }
-  }
-
-  public extractJwtFromHeader(headers: IncomingHttpHeaders) {
-    if (!headers.authorization) {
-      return null;
-    }
-    const [type, accessToken] = headers.authorization.split(' ');
-    if (type.toLowerCase() !== 'bearer') {
-      return null;
-    }
-
-    return accessToken;
-  }
-
-  public extractJwtFromCookie(cookies: Record<string, string>) {
-    return cookies?.[IMMICH_ACCESS_COOKIE] || null;
-  }
-
-  public async validateSocket(client: Socket): Promise<UserResponseDto | null> {
-    const headers = client.handshake.headers;
-    const accessToken =
-      this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers);
-
-    if (accessToken) {
-      const { userId, status } = await this.validateToken(accessToken);
-      if (userId && status) {
-        const user = await this.userService.getUserById(userId).catch(() => null);
-        if (user) {
-          return user;
-        }
-      }
-    }
-
-    return null;
-  }
-
-  private async generateToken(payload: JwtPayloadDto) {
-    return this.jwtService.sign({
-      ...payload,
-    });
-  }
-}

+ 6 - 23
server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts

@@ -1,21 +1,17 @@
-import { Injectable, UnauthorizedException } from '@nestjs/common';
+import { AuthService, AuthUserDto, JwtPayloadDto, jwtSecret } from '@app/domain';
+import { Injectable } from '@nestjs/common';
 import { PassportStrategy } from '@nestjs/passport';
 import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
-import { UserService } from '@app/domain';
-import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
-import { jwtSecret } from '../../../constants/jwt.constant';
-import { AuthUserDto } from '../../../decorators/auth-user.decorator';
-import { ImmichJwtService } from '../immich-jwt.service';
 
 export const JWT_STRATEGY = 'jwt';
 
 @Injectable()
 export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
-  constructor(private userService: UserService, immichJwtService: ImmichJwtService) {
+  constructor(private authService: AuthService) {
     super({
       jwtFromRequest: ExtractJwt.fromExtractors([
-        (req) => immichJwtService.extractJwtFromCookie(req.cookies),
-        (req) => immichJwtService.extractJwtFromHeader(req.headers),
+        (req) => authService.extractJwtFromCookie(req.cookies),
+        (req) => authService.extractJwtFromHeader(req.headers),
       ]),
       ignoreExpiration: false,
       secretOrKey: jwtSecret,
@@ -23,19 +19,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
   }
 
   async validate(payload: JwtPayloadDto): Promise<AuthUserDto> {
-    const { userId } = payload;
-    const user = await this.userService.getUserById(userId).catch(() => null);
-    if (!user) {
-      throw new UnauthorizedException('Failure to validate JWT payload');
-    }
-
-    const authUser = new AuthUserDto();
-    authUser.id = user.id;
-    authUser.email = user.email;
-    authUser.isAdmin = user.isAdmin;
-    authUser.isPublicUser = false;
-    authUser.isAllowUpload = true;
-
-    return authUser;
+    return this.authService.validatePayload(payload);
   }
 }

+ 2 - 4
server/apps/immich/test/album.e2e-spec.ts

@@ -7,10 +7,8 @@ import { AlbumModule } from '../src/api-v1/album/album.module';
 import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
 import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
 import { AuthUserDto } from '../src/decorators/auth-user.decorator';
-import { DomainModule, UserService } from '@app/domain';
+import { AuthService, DomainModule, UserService } from '@app/domain';
 import { DataSource } from 'typeorm';
-import { AuthService } from '../src/api-v1/auth/auth.service';
-import { AuthModule } from '../src/api-v1/auth/auth.module';
 
 function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
   return request(app.getHttpServer()).post('/album').send(data);
@@ -49,7 +47,7 @@ describe('Album', () => {
 
     beforeAll(async () => {
       const builder = Test.createTestingModule({
-        imports: [DomainModule.register({ imports: [InfraModule] }), AuthModule, AlbumModule],
+        imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule],
       });
       authUser = getAuthUser(); // set default auth user
       const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();

+ 2 - 3
server/apps/immich/test/user.e2e-spec.ts

@@ -7,8 +7,7 @@ import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
 import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
 import { DataSource } from 'typeorm';
 import { UserController } from '../src/controllers';
-import { AuthModule } from '../src/api-v1/auth/auth.module';
-import { AuthService } from '../src/api-v1/auth/auth.service';
+import { AuthService } from '@app/domain';
 
 function _createUser(userService: UserService, data: CreateUserDto) {
   return userService.createUser(data);
@@ -52,7 +51,7 @@ describe('User', () => {
 
     beforeAll(async () => {
       const builder = Test.createTestingModule({
-        imports: [DomainModule.register({ imports: [InfraModule] }), AuthModule],
+        imports: [DomainModule.register({ imports: [InfraModule] })],
         controllers: [UserController],
       });
       const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 281 - 310
server/immich-openapi-specs.json


+ 1 - 1
server/apps/immich/src/config/jwt.config.ts → server/libs/domain/src/auth/auth.config.ts

@@ -1,5 +1,5 @@
 import { JwtModuleOptions } from '@nestjs/jwt';
-import { jwtSecret } from '../constants/jwt.constant';
+import { jwtSecret } from './auth.constant';
 
 export const jwtConfig: JwtModuleOptions = {
   secret: jwtSecret,

+ 0 - 0
server/apps/immich/src/constants/jwt.constant.ts → server/libs/domain/src/auth/auth.constant.ts


+ 80 - 0
server/libs/domain/src/auth/auth.core.ts

@@ -0,0 +1,80 @@
+import { SystemConfig, UserEntity } from '@app/infra/db/entities';
+import { IncomingHttpHeaders } from 'http';
+import { ISystemConfigRepository } from '../system-config';
+import { SystemConfigCore } from '../system-config/system-config.core';
+import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
+import { ICryptoRepository } from './crypto.repository';
+import { JwtPayloadDto } from './dto/jwt-payload.dto';
+import { LoginResponseDto, mapLoginResponse } from './response-dto';
+
+export type JwtValidationResult = {
+  status: boolean;
+  userId: string | null;
+};
+
+export class AuthCore {
+  constructor(
+    private cryptoRepository: ICryptoRepository,
+    configRepository: ISystemConfigRepository,
+    private config: SystemConfig,
+  ) {
+    const configCore = new SystemConfigCore(configRepository);
+    configCore.config$.subscribe((config) => (this.config = config));
+  }
+
+  isPasswordLoginEnabled() {
+    return this.config.passwordLogin.enabled;
+  }
+
+  public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
+    const maxAge = 7 * 24 * 3600; // 7 days
+
+    let authTypeCookie = '';
+    let accessTokenCookie = '';
+
+    if (isSecure) {
+      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
+      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
+    } else {
+      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
+      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
+    }
+    return [accessTokenCookie, authTypeCookie];
+  }
+
+  public createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
+    const payload: JwtPayloadDto = { userId: user.id, email: user.email };
+    const accessToken = this.generateToken(payload);
+    const response = mapLoginResponse(user, accessToken);
+    const cookie = this.getCookies(response, authType, isSecure);
+    return { response, cookie };
+  }
+
+  validatePassword(inputPassword: string, user: UserEntity): boolean {
+    if (!user || !user.password) {
+      return false;
+    }
+    return this.cryptoRepository.compareSync(inputPassword, user.password);
+  }
+
+  extractJwtFromHeader(headers: IncomingHttpHeaders) {
+    if (!headers.authorization) {
+      return null;
+    }
+
+    const [type, accessToken] = headers.authorization.split(' ');
+    if (type.toLowerCase() !== 'bearer') {
+      return null;
+    }
+
+    return accessToken;
+  }
+
+  extractJwtFromCookie(cookies: Record<string, string>) {
+    return cookies?.[IMMICH_ACCESS_COOKIE] || null;
+  }
+
+  private generateToken(payload: JwtPayloadDto) {
+    return this.cryptoRepository.signJwt({ ...payload });
+  }
+}

+ 263 - 0
server/libs/domain/src/auth/auth.service.spec.ts

@@ -0,0 +1,263 @@
+import { SystemConfig, UserEntity } from '@app/infra/db/entities';
+import { BadRequestException, UnauthorizedException } from '@nestjs/common';
+import { generators, Issuer } from 'openid-client';
+import { Socket } from 'socket.io';
+import {
+  authStub,
+  entityStub,
+  loginResponseStub,
+  newCryptoRepositoryMock,
+  newSystemConfigRepositoryMock,
+  newUserRepositoryMock,
+  systemConfigStub,
+} from '../../test';
+import { ISystemConfigRepository } from '../system-config';
+import { IUserRepository } from '../user';
+import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
+import { AuthService } from './auth.service';
+import { ICryptoRepository } from './crypto.repository';
+import { SignUpDto } from './dto';
+
+const email = 'test@immich.com';
+const sub = 'my-auth-user-sub';
+
+const fixtures = {
+  login: {
+    email,
+    password: 'password',
+  },
+};
+
+const CLIENT_IP = '127.0.0.1';
+
+jest.mock('@nestjs/common', () => ({
+  ...jest.requireActual('@nestjs/common'),
+  Logger: jest.fn().mockReturnValue({
+    verbose: jest.fn(),
+    debug: jest.fn(),
+    log: jest.fn(),
+    info: jest.fn(),
+    warn: jest.fn(),
+    error: jest.fn(),
+  }),
+}));
+
+describe('AuthService', () => {
+  let sut: AuthService;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
+  let userMock: jest.Mocked<IUserRepository>;
+  let configMock: jest.Mocked<ISystemConfigRepository>;
+  let callbackMock: jest.Mock;
+  let create: (config: SystemConfig) => AuthService;
+
+  afterEach(() => {
+    jest.resetModules();
+  });
+
+  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();
+    userMock = newUserRepositoryMock();
+    configMock = newSystemConfigRepositoryMock();
+
+    create = (config) => new AuthService(cryptoMock, configMock, userMock, config);
+
+    sut = create(systemConfigStub.enabled);
+  });
+
+  it('should be defined', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('login', () => {
+    it('should throw an error if password login is disabled', async () => {
+      sut = create(systemConfigStub.disabled);
+
+      await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
+    });
+
+    it('should check the user exists', async () => {
+      userMock.getByEmail.mockResolvedValue(null);
+      await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should check the user has a password', async () => {
+      userMock.getByEmail.mockResolvedValue({} as UserEntity);
+      await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should successfully log the user in', async () => {
+      userMock.getByEmail.mockResolvedValue(entityStub.user1);
+      await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should generate the cookie headers (insecure)', async () => {
+      userMock.getByEmail.mockResolvedValue(entityStub.user1);
+      await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('changePassword', () => {
+    it('should change the password', async () => {
+      const authUser = { email: 'test@imimch.com' } as UserEntity;
+      const dto = { password: 'old-password', newPassword: 'new-password' };
+
+      userMock.getByEmail.mockResolvedValue({
+        email: 'test@immich.com',
+        password: 'hash-password',
+      } as UserEntity);
+
+      await sut.changePassword(authUser, dto);
+
+      expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
+      expect(cryptoMock.compareSync).toHaveBeenCalledWith('old-password', 'hash-password');
+    });
+
+    it('should throw when auth user email is not found', async () => {
+      const authUser = { email: 'test@imimch.com' } as UserEntity;
+      const dto = { password: 'old-password', newPassword: 'new-password' };
+
+      userMock.getByEmail.mockResolvedValue(null);
+
+      await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(UnauthorizedException);
+    });
+
+    it('should throw when password does not match existing password', async () => {
+      const authUser = { email: 'test@imimch.com' } as UserEntity;
+      const dto = { password: 'old-password', newPassword: 'new-password' };
+
+      cryptoMock.compareSync.mockReturnValue(false);
+
+      userMock.getByEmail.mockResolvedValue({
+        email: 'test@immich.com',
+        password: 'hash-password',
+      } as UserEntity);
+
+      await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should throw when user does not have a password', async () => {
+      const authUser = { email: 'test@imimch.com' } as UserEntity;
+      const dto = { password: 'old-password', newPassword: 'new-password' };
+
+      cryptoMock.compareSync.mockReturnValue(false);
+
+      userMock.getByEmail.mockResolvedValue({
+        email: 'test@immich.com',
+        password: '',
+      } as UserEntity);
+
+      await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
+    });
+  });
+
+  describe('logout', () => {
+    it('should return the end session endpoint', async () => {
+      await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
+        successful: true,
+        redirectUri: 'http://end-session-endpoint',
+      });
+    });
+
+    it('should return the default redirect', async () => {
+      await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
+        successful: true,
+        redirectUri: '/auth/login?autoLaunch=0',
+      });
+    });
+  });
+
+  describe('adminSignUp', () => {
+    const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
+
+    it('should only allow one admin', async () => {
+      userMock.getAdmin.mockResolvedValue({} as UserEntity);
+      await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
+      expect(userMock.getAdmin).toHaveBeenCalled();
+    });
+
+    it('should sign up the admin', async () => {
+      userMock.getAdmin.mockResolvedValue(null);
+      userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
+      await expect(sut.adminSignUp(dto)).resolves.toEqual({
+        id: 'admin',
+        createdAt: 'today',
+        email: 'test@immich.com',
+        firstName: 'immich',
+        lastName: 'admin',
+      });
+      expect(userMock.getAdmin).toHaveBeenCalled();
+      expect(userMock.create).toHaveBeenCalled();
+    });
+  });
+
+  describe('validateSocket', () => {
+    it('should validate using authorization header', async () => {
+      userMock.get.mockResolvedValue(entityStub.user1);
+      const client = { handshake: { headers: { authorization: 'Bearer jwt-token' } } };
+      await expect(sut.validateSocket(client as Socket)).resolves.toEqual(entityStub.user1);
+    });
+  });
+
+  describe('validatePayload', () => {
+    it('should throw if no user is found', async () => {
+      userMock.get.mockResolvedValue(null);
+      await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
+    });
+
+    it('should return an auth dto', async () => {
+      userMock.get.mockResolvedValue(entityStub.admin);
+      await expect(sut.validatePayload({ email: 'a', userId: 'test' })).resolves.toEqual(authStub.admin);
+    });
+  });
+
+  describe('extractJwtFromCookie', () => {
+    it('should extract the access token', () => {
+      const cookie = { [IMMICH_ACCESS_COOKIE]: 'signed-jwt', [IMMICH_AUTH_TYPE_COOKIE]: 'password' };
+      expect(sut.extractJwtFromCookie(cookie)).toEqual('signed-jwt');
+    });
+
+    it('should work with no cookies', () => {
+      expect(sut.extractJwtFromCookie(undefined as any)).toBeNull();
+    });
+
+    it('should work on empty cookies', () => {
+      expect(sut.extractJwtFromCookie({})).toBeNull();
+    });
+  });
+
+  describe('extractJwtFromHeader', () => {
+    it('should extract the access token', () => {
+      expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
+    });
+
+    it('should work without the auth header', () => {
+      expect(sut.extractJwtFromHeader({})).toBeNull();
+    });
+
+    it('should ignore basic auth', () => {
+      expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull();
+    });
+  });
+});

+ 160 - 0
server/libs/domain/src/auth/auth.service.ts

@@ -0,0 +1,160 @@
+import { SystemConfig } from '@app/infra/db/entities';
+import {
+  BadRequestException,
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+  Logger,
+  UnauthorizedException,
+} from '@nestjs/common';
+import * as cookieParser from 'cookie';
+import { IncomingHttpHeaders } from 'http';
+import { Socket } from 'socket.io';
+import { OAuthCore } from '../oauth/oauth.core';
+import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
+import { IUserRepository, UserCore, UserResponseDto } from '../user';
+import { AuthType, jwtSecret } from './auth.constant';
+import { AuthCore } from './auth.core';
+import { ICryptoRepository } from './crypto.repository';
+import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto';
+import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
+
+@Injectable()
+export class AuthService {
+  private authCore: AuthCore;
+  private oauthCore: OAuthCore;
+  private userCore: UserCore;
+
+  private logger = new Logger(AuthService.name);
+
+  constructor(
+    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
+    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
+    @Inject(IUserRepository) userRepository: IUserRepository,
+    @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
+  ) {
+    this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
+    this.oauthCore = new OAuthCore(configRepository, initialConfig);
+    this.userCore = new UserCore(userRepository);
+  }
+
+  public async login(
+    loginCredential: LoginCredentialDto,
+    clientIp: string,
+    isSecure: boolean,
+  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
+    if (!this.authCore.isPasswordLoginEnabled()) {
+      throw new UnauthorizedException('Password login has been disabled');
+    }
+
+    let user = await this.userCore.getByEmail(loginCredential.email, true);
+    if (user) {
+      const isAuthenticated = await this.authCore.validatePassword(loginCredential.password, user);
+      if (!isAuthenticated) {
+        user = null;
+      }
+    }
+
+    if (!user) {
+      this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
+      throw new BadRequestException('Incorrect email or password');
+    }
+
+    return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
+  }
+
+  public async logout(authType: AuthType): Promise<LogoutResponseDto> {
+    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' };
+  }
+
+  public 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 = await this.authCore.validatePassword(password, user);
+    if (!valid) {
+      throw new BadRequestException('Wrong password');
+    }
+
+    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
+  }
+
+  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
+    const adminUser = await this.userCore.getAdmin();
+
+    if (adminUser) {
+      throw new BadRequestException('The server already has an admin');
+    }
+
+    try {
+      const admin = await this.userCore.createUser({
+        isAdmin: true,
+        email: dto.email,
+        firstName: dto.firstName,
+        lastName: dto.lastName,
+        password: dto.password,
+      });
+
+      return mapAdminSignupResponse(admin);
+    } catch (error) {
+      this.logger.error(`Unable to register admin user: ${error}`, (error as Error).stack);
+      throw new InternalServerErrorException('Failed to register new admin user');
+    }
+  }
+
+  async validateSocket(client: Socket): Promise<UserResponseDto | null> {
+    try {
+      const headers = client.handshake.headers;
+      const accessToken =
+        this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers);
+
+      if (accessToken) {
+        const payload = await this.cryptoRepository.verifyJwtAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret });
+        if (payload?.userId && payload?.email) {
+          const user = await this.userCore.get(payload.userId);
+          if (user) {
+            return user;
+          }
+        }
+      }
+    } catch (e) {
+      return null;
+    }
+    return null;
+  }
+
+  async validatePayload(payload: JwtPayloadDto) {
+    const { userId } = payload;
+    const user = await this.userCore.get(userId);
+    if (!user) {
+      throw new UnauthorizedException('Failure to validate JWT payload');
+    }
+
+    const authUser = new AuthUserDto();
+    authUser.id = user.id;
+    authUser.email = user.email;
+    authUser.isAdmin = user.isAdmin;
+    authUser.isPublicUser = false;
+    authUser.isAllowUpload = true;
+
+    return authUser;
+  }
+
+  extractJwtFromCookie(cookies: Record<string, string>) {
+    return this.authCore.extractJwtFromCookie(cookies);
+  }
+
+  extractJwtFromHeader(headers: IncomingHttpHeaders) {
+    return this.authCore.extractJwtFromHeader(headers);
+  }
+}

+ 4 - 0
server/libs/domain/src/auth/crypto.repository.ts

@@ -1,7 +1,11 @@
+import { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
+
 export const ICryptoRepository = 'ICryptoRepository';
 
 export interface ICryptoRepository {
   randomBytes(size: number): Buffer;
   hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
   compareSync(data: Buffer | string, encrypted: string): boolean;
+  signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string;
+  verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T>;
 }

+ 0 - 0
server/apps/immich/src/api-v1/auth/dto/change-password.dto.ts → server/libs/domain/src/auth/dto/change-password.dto.ts


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

@@ -1 +1,5 @@
 export * from './auth-user.dto';
+export * from './change-password.dto';
+export * from './jwt-payload.dto';
+export * from './login-credential.dto';
+export * from './sign-up.dto';

+ 4 - 0
server/libs/domain/src/auth/dto/jwt-payload.dto.ts

@@ -0,0 +1,4 @@
+export class JwtPayloadDto {
+  userId!: string;
+  email!: string;
+}

+ 0 - 0
server/apps/immich/src/api-v1/auth/dto/login-credential.dto.spec.ts → server/libs/domain/src/auth/dto/login-credential.dto.spec.ts


+ 0 - 0
server/apps/immich/src/api-v1/auth/dto/login-credential.dto.ts → server/libs/domain/src/auth/dto/login-credential.dto.ts


+ 0 - 0
server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts → server/libs/domain/src/auth/dto/sign-up.dto.spec.ts


+ 0 - 0
server/apps/immich/src/api-v1/auth/dto/sign-up.dto.ts → server/libs/domain/src/auth/dto/sign-up.dto.ts


+ 4 - 0
server/libs/domain/src/auth/index.ts

@@ -1,2 +1,6 @@
+export * from './auth.config';
+export * from './auth.constant';
+export * from './auth.service';
 export * from './crypto.repository';
 export * from './dto';
+export * from './response-dto';

+ 1 - 1
server/apps/immich/src/api-v1/auth/response-dto/admin-signup-response.dto.ts → server/libs/domain/src/auth/response-dto/admin-signup-response.dto.ts

@@ -1,4 +1,4 @@
-import { UserEntity } from '@app/infra';
+import { UserEntity } from '@app/infra/db/entities';
 
 export class AdminSignupResponseDto {
   id!: string;

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

@@ -0,0 +1,4 @@
+export * from './admin-signup-response.dto';
+export * from './login-response.dto';
+export * from './logout-response.dto';
+export * from './validate-asset-token-response.dto';

+ 1 - 1
server/apps/immich/src/api-v1/auth/response-dto/login-response.dto.ts → server/libs/domain/src/auth/response-dto/login-response.dto.ts

@@ -1,4 +1,4 @@
-import { UserEntity } from '@app/infra';
+import { UserEntity } from '@app/infra/db/entities';
 import { ApiResponseProperty } from '@nestjs/swagger';
 
 export class LoginResponseDto {

+ 0 - 0
server/apps/immich/src/api-v1/auth/response-dto/logout-response.dto.ts → server/libs/domain/src/auth/response-dto/logout-response.dto.ts


+ 0 - 0
server/apps/immich/src/api-v1/auth/response-dto/validate-asset-token-response.dto,.ts → server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts


+ 5 - 4
server/libs/domain/src/domain.module.ts

@@ -1,13 +1,14 @@
 import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
 import { APIKeyService } from './api-key';
-import { SystemConfigService } from './system-config';
+import { AuthService } from './auth';
+import { OAuthService } from './oauth';
+import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
 import { UserService } from './user';
 
-export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
-
 const providers: Provider[] = [
-  //
   APIKeyService,
+  AuthService,
+  OAuthService,
   SystemConfigService,
   UserService,
 

+ 1 - 0
server/libs/domain/src/index.ts

@@ -2,5 +2,6 @@ export * from './api-key';
 export * from './auth';
 export * from './domain.module';
 export * from './job';
+export * from './oauth';
 export * from './system-config';
 export * from './user';

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

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

+ 0 - 0
server/apps/immich/src/api-v1/oauth/dto/oauth-auth-code.dto.ts → server/libs/domain/src/oauth/dto/oauth-auth-code.dto.ts


+ 0 - 0
server/apps/immich/src/api-v1/oauth/dto/oauth-config.dto.ts → server/libs/domain/src/oauth/dto/oauth-config.dto.ts


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

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

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

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

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

@@ -0,0 +1,107 @@
+import { SystemConfig } from '@app/infra/db/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 === MOBILE_REDIRECT;
+    const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
+    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
+      return mobileRedirectUri;
+    }
+    return redirectUri;
+  }
+}

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

@@ -0,0 +1,193 @@
+import { SystemConfig, UserEntity } from '@app/infra/db/entities';
+import { BadRequestException } from '@nestjs/common';
+import { generators, Issuer } from 'openid-client';
+import {
+  authStub,
+  entityStub,
+  loginResponseStub,
+  newCryptoRepositoryMock,
+  newSystemConfigRepositoryMock,
+  newUserRepositoryMock,
+  systemConfigStub,
+} from '../../test';
+import { ICryptoRepository } from '../auth';
+import { OAuthService } from '../oauth';
+import { ISystemConfigRepository } from '../system-config';
+import { IUserRepository } from '../user';
+
+const email = 'user@immich.com';
+const sub = 'my-auth-user-sub';
+
+jest.mock('@nestjs/common', () => ({
+  ...jest.requireActual('@nestjs/common'),
+  Logger: jest.fn().mockReturnValue({
+    verbose: jest.fn(),
+    debug: jest.fn(),
+    log: jest.fn(),
+    info: jest.fn(),
+    warn: jest.fn(),
+    error: jest.fn(),
+  }),
+}));
+
+describe('OAuthService', () => {
+  let sut: OAuthService;
+  let userMock: jest.Mocked<IUserRepository>;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
+  let configMock: jest.Mocked<ISystemConfigRepository>;
+  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();
+
+    create = (config) => new OAuthService(cryptoMock, configMock, userMock, config);
+
+    sut = create(systemConfigStub.disabled);
+  });
+
+  it('should be defined', () => {
+    expect(sut).toBeDefined();
+  });
+
+  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: '' }, true)).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' }, true)).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+    });
+
+    it('should link an existing user', async () => {
+      sut = create(systemConfigStub.noAutoRegister);
+      userMock.getByEmail.mockResolvedValue(entityStub.user1);
+      userMock.update.mockResolvedValue(entityStub.user1);
+
+      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
+        loginResponseStub.user1oauth,
+      );
+
+      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
+      expect(userMock.update).toHaveBeenCalledWith(entityStub.user1.id, { oauthId: sub });
+    });
+
+    it('should allow auto registering by default', async () => {
+      sut = create(systemConfigStub.enabled);
+
+      userMock.getByEmail.mockResolvedValue(null);
+      userMock.getAdmin.mockResolvedValue(entityStub.user1);
+      userMock.create.mockResolvedValue(entityStub.user1);
+
+      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).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(entityStub.user1);
+
+      await sut.login({ url: `app.immich:/?code=abc123` }, true);
+
+      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(entityStub.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(entityStub.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');
+    });
+  });
+});

+ 81 - 0
server/libs/domain/src/oauth/oauth.service.ts

@@ -0,0 +1,81 @@
+import { SystemConfig } from '@app/infra/db/entities';
+import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
+import { AuthType, AuthUserDto, ICryptoRepository, LoginResponseDto } from '../auth';
+import { AuthCore } from '../auth/auth.core';
+import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
+import { IUserRepository, UserCore, UserResponseDto } from '../user';
+import { OAuthCallbackDto, OAuthConfigDto } from './dto';
+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(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
+  ) {
+    this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
+    this.userCore = new UserCore(userRepository);
+    this.oauthCore = new OAuthCore(configRepository, initialConfig);
+  }
+
+  generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
+    return this.oauthCore.generateConfig(dto);
+  }
+
+  async login(dto: OAuthCallbackDto, isSecure: boolean): 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, isSecure);
+  }
+
+  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();
+  }
+}

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

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

+ 0 - 0
server/apps/immich/src/api-v1/oauth/response-dto/oauth-config-response.dto.ts → server/libs/domain/src/oauth/response-dto/oauth-config-response.dto.ts


+ 1 - 1
server/libs/domain/src/system-config/index.ts

@@ -1,5 +1,5 @@
 export * from './dto';
 export * from './response-dto';
+export * from './system-config.constants';
 export * from './system-config.repository';
 export * from './system-config.service';
-export * from './system-config.datetime-variables';

+ 2 - 0
server/libs/domain/src/system-config/system-config.datetime-variables.ts → server/libs/domain/src/system-config/system-config.constants.ts

@@ -18,3 +18,5 @@ export const supportedPresetTokens = [
   '{{y}}-{{MMM}}-{{dd}}/{{filename}}',
   '{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
 ];
+
+export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';

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

@@ -37,12 +37,14 @@ const defaults: SystemConfig = Object.freeze({
   },
 });
 
+const singleton = new Subject<SystemConfig>();
+
 @Injectable()
 export class SystemConfigCore {
   private logger = new Logger(SystemConfigCore.name);
   private validators: SystemConfigValidator[] = [];
 
-  public config$ = new Subject<SystemConfig>();
+  public config$ = singleton;
 
   constructor(private repository: ISystemConfigRepository) {}
 

+ 1 - 1
server/libs/domain/src/system-config/system-config.service.spec.ts

@@ -1,4 +1,4 @@
-import { SystemConfigEntity, SystemConfigKey } from '@app/infra';
+import { SystemConfigEntity, SystemConfigKey } from '@app/infra/db/entities';
 import { BadRequestException } from '@nestjs/common';
 import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
 import { IJobRepository, JobName } from '../job';

+ 1 - 1
server/libs/domain/src/system-config/system-config.service.ts

@@ -7,7 +7,7 @@ import {
   supportedPresetTokens,
   supportedSecondTokens,
   supportedYearTokens,
-} from './system-config.datetime-variables';
+} from './system-config.constants';
 import { Inject, Injectable } from '@nestjs/common';
 import { IJobRepository, JobName } from '../job';
 import { mapConfig, SystemConfigDto } from './dto/system-config.dto';

+ 2 - 0
server/libs/domain/test/crypto.repository.mock.ts

@@ -5,5 +5,7 @@ export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => {
     randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')),
     compareSync: jest.fn().mockReturnValue(true),
     hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
+    signJwt: jest.fn().mockReturnValue('signed-jwt'),
+    verifyJwtAsync: jest.fn().mockResolvedValue({ userId: 'test', email: 'test' }),
   };
 };

+ 92 - 0
server/libs/domain/test/fixtures.ts

@@ -72,4 +72,96 @@ export const systemConfigStub = {
       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 loginResponseStub = {
+  user1oauth: {
+    response: {
+      accessToken: 'signed-jwt',
+      userId: 'immich_id',
+      userEmail: 'immich@test.com',
+      firstName: 'immich_first_name',
+      lastName: 'immich_last_name',
+      profileImagePath: '',
+      isAdmin: false,
+      shouldChangePassword: false,
+    },
+    cookie: [
+      'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
+      'immich_auth_type=oauth; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
+    ],
+  },
+  user1password: {
+    response: {
+      accessToken: 'signed-jwt',
+      userId: 'immich_id',
+      userEmail: 'immich@test.com',
+      firstName: 'immich_first_name',
+      lastName: 'immich_last_name',
+      profileImagePath: '',
+      isAdmin: false,
+      shouldChangePassword: false,
+    },
+    cookie: [
+      'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
+      'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
+    ],
+  },
+  user1insecure: {
+    response: {
+      accessToken: 'signed-jwt',
+      userId: 'immich_id',
+      userEmail: 'immich@test.com',
+      firstName: 'immich_first_name',
+      lastName: 'immich_last_name',
+      profileImagePath: '',
+      isAdmin: false,
+      shouldChangePassword: false,
+    },
+    cookie: [
+      'immich_access_token=signed-jwt; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
+      'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
+    ],
+  },
 };

+ 18 - 5
server/libs/infra/src/auth/crypto.repository.ts

@@ -1,9 +1,22 @@
 import { ICryptoRepository } from '@app/domain';
+import { Injectable } from '@nestjs/common';
+import { JwtService, JwtVerifyOptions } from '@nestjs/jwt';
 import { compareSync, hash } from 'bcrypt';
 import { randomBytes } from 'crypto';
 
-export const cryptoRepository: ICryptoRepository = {
-  randomBytes,
-  hash,
-  compareSync,
-};
+@Injectable()
+export class CryptoRepository implements ICryptoRepository {
+  constructor(private jwtService: JwtService) {}
+
+  randomBytes = randomBytes;
+  hash = hash;
+  compareSync = compareSync;
+
+  signJwt(payload: string | Buffer | object) {
+    return this.jwtService.sign(payload);
+  }
+
+  verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T> {
+    return this.jwtService.verifyAsync(token, options);
+  }
+}

+ 7 - 5
server/libs/infra/src/infra.module.ts

@@ -6,19 +6,20 @@ import {
   IUserRepository,
   QueueName,
 } from '@app/domain';
-import { databaseConfig, UserEntity } from '@app/infra';
+import { databaseConfig, UserEntity } from './db';
 import { BullModule } from '@nestjs/bull';
 import { Global, Module, Provider } from '@nestjs/common';
+import { JwtModule } from '@nestjs/jwt';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { cryptoRepository } from './auth/crypto.repository';
+import { jwtConfig } from '@app/domain';
+import { CryptoRepository } from './auth/crypto.repository';
 import { APIKeyEntity, SystemConfigEntity, UserRepository } from './db';
 import { APIKeyRepository } from './db/repository';
 import { SystemConfigRepository } from './db/repository/system-config.repository';
 import { JobRepository } from './job';
 
 const providers: Provider[] = [
-  //
-  { provide: ICryptoRepository, useValue: cryptoRepository },
+  { provide: ICryptoRepository, useClass: CryptoRepository },
   { provide: IKeyRepository, useClass: APIKeyRepository },
   { provide: IJobRepository, useClass: JobRepository },
   { provide: ISystemConfigRepository, useClass: SystemConfigRepository },
@@ -28,6 +29,7 @@ const providers: Provider[] = [
 @Global()
 @Module({
   imports: [
+    JwtModule.register(jwtConfig),
     TypeOrmModule.forRoot(databaseConfig),
     TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SystemConfigEntity]),
     BullModule.forRootAsync({
@@ -60,6 +62,6 @@ const providers: Provider[] = [
     ),
   ],
   providers: [...providers],
-  exports: [...providers, BullModule],
+  exports: [...providers, BullModule, JwtModule],
 })
 export class InfraModule {}

+ 1 - 1
server/package.json

@@ -27,7 +27,7 @@
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
-    "test:e2e": "jest --config ./apps/immich/test/jest-e2e.json",
+    "test:e2e": "jest --config ./apps/immich/test/jest-e2e.json --runInBand",
     "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
     "api:typescript": "bash ./bin/generate-open-api.sh web",
     "api:dart": "bash ./bin/generate-open-api.sh mobile",

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác