mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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>
This commit is contained in:
		
							
								
								
									
										7
									
								
								server/libs/domain/src/auth/auth.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/libs/domain/src/auth/auth.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import { JwtModuleOptions } from '@nestjs/jwt';
 | 
			
		||||
import { jwtSecret } from './auth.constant';
 | 
			
		||||
 | 
			
		||||
export const jwtConfig: JwtModuleOptions = {
 | 
			
		||||
  secret: jwtSecret,
 | 
			
		||||
  signOptions: { expiresIn: '30d' },
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										7
									
								
								server/libs/domain/src/auth/auth.constant.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/libs/domain/src/auth/auth.constant.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
export const jwtSecret = process.env.JWT_SECRET;
 | 
			
		||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
 | 
			
		||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
 | 
			
		||||
export enum AuthType {
 | 
			
		||||
  PASSWORD = 'password',
 | 
			
		||||
  OAUTH = 'oauth',
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										80
									
								
								server/libs/domain/src/auth/auth.core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								server/libs/domain/src/auth/auth.core.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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
									
								
								server/libs/domain/src/auth/auth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								server/libs/domain/src/auth/auth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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
									
								
								server/libs/domain/src/auth/auth.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								server/libs/domain/src/auth/auth.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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>;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								server/libs/domain/src/auth/dto/change-password.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/libs/domain/src/auth/dto/change-password.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class ChangePasswordDto {
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @ApiProperty({ example: 'password' })
 | 
			
		||||
  password!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @MinLength(8)
 | 
			
		||||
  @ApiProperty({ example: 'password' })
 | 
			
		||||
  newPassword!: string;
 | 
			
		||||
}
 | 
			
		||||
@@ -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
									
								
								server/libs/domain/src/auth/dto/jwt-payload.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/libs/domain/src/auth/dto/jwt-payload.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
export class JwtPayloadDto {
 | 
			
		||||
  userId!: string;
 | 
			
		||||
  email!: string;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								server/libs/domain/src/auth/dto/login-credential.dto.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								server/libs/domain/src/auth/dto/login-credential.dto.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
import { plainToInstance } from 'class-transformer';
 | 
			
		||||
import { validateSync } from 'class-validator';
 | 
			
		||||
import { LoginCredentialDto } from './login-credential.dto';
 | 
			
		||||
 | 
			
		||||
describe('LoginCredentialDto', () => {
 | 
			
		||||
  it('should fail without an email', () => {
 | 
			
		||||
    const dto = plainToInstance(LoginCredentialDto, { password: 'password' });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(1);
 | 
			
		||||
    expect(errors[0].property).toEqual('email');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should fail with an invalid email', () => {
 | 
			
		||||
    const dto = plainToInstance(LoginCredentialDto, { email: 'invalid.com', password: 'password' });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(1);
 | 
			
		||||
    expect(errors[0].property).toEqual('email');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should make the email all lowercase', () => {
 | 
			
		||||
    const dto = plainToInstance(LoginCredentialDto, { email: 'TeSt@ImMiCh.com', password: 'password' });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(0);
 | 
			
		||||
    expect(dto.email).toEqual('test@immich.com');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should fail without a password', () => {
 | 
			
		||||
    const dto = plainToInstance(LoginCredentialDto, { email: 'test@immich.com', password: '' });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(1);
 | 
			
		||||
    expect(errors[0].property).toEqual('password');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										15
									
								
								server/libs/domain/src/auth/dto/login-credential.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/libs/domain/src/auth/dto/login-credential.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { Transform } from 'class-transformer';
 | 
			
		||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class LoginCredentialDto {
 | 
			
		||||
  @IsEmail()
 | 
			
		||||
  @ApiProperty({ example: 'testuser@email.com' })
 | 
			
		||||
  @Transform(({ value }) => value.toLowerCase())
 | 
			
		||||
  email!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @ApiProperty({ example: 'password' })
 | 
			
		||||
  password!: string;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								server/libs/domain/src/auth/dto/sign-up.dto.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								server/libs/domain/src/auth/dto/sign-up.dto.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import { plainToInstance } from 'class-transformer';
 | 
			
		||||
import { validateSync } from 'class-validator';
 | 
			
		||||
import { SignUpDto } from './sign-up.dto';
 | 
			
		||||
 | 
			
		||||
describe('SignUpDto', () => {
 | 
			
		||||
  it('should require all fields', () => {
 | 
			
		||||
    const dto = plainToInstance(SignUpDto, {
 | 
			
		||||
      email: '',
 | 
			
		||||
      password: '',
 | 
			
		||||
      firstName: '',
 | 
			
		||||
      lastName: '',
 | 
			
		||||
    });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(4);
 | 
			
		||||
    expect(errors[0].property).toEqual('email');
 | 
			
		||||
    expect(errors[1].property).toEqual('password');
 | 
			
		||||
    expect(errors[2].property).toEqual('firstName');
 | 
			
		||||
    expect(errors[3].property).toEqual('lastName');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should require a valid email', () => {
 | 
			
		||||
    const dto = plainToInstance(SignUpDto, {
 | 
			
		||||
      email: 'immich.com',
 | 
			
		||||
      password: 'password',
 | 
			
		||||
      firstName: 'first name',
 | 
			
		||||
      lastName: 'last name',
 | 
			
		||||
    });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(1);
 | 
			
		||||
    expect(errors[0].property).toEqual('email');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should make the email all lowercase', () => {
 | 
			
		||||
    const dto = plainToInstance(SignUpDto, {
 | 
			
		||||
      email: 'TeSt@ImMiCh.com',
 | 
			
		||||
      password: 'password',
 | 
			
		||||
      firstName: 'first name',
 | 
			
		||||
      lastName: 'last name',
 | 
			
		||||
    });
 | 
			
		||||
    const errors = validateSync(dto);
 | 
			
		||||
    expect(errors).toHaveLength(0);
 | 
			
		||||
    expect(dto.email).toEqual('test@immich.com');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										25
									
								
								server/libs/domain/src/auth/dto/sign-up.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								server/libs/domain/src/auth/dto/sign-up.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { Transform } from 'class-transformer';
 | 
			
		||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class SignUpDto {
 | 
			
		||||
  @IsEmail()
 | 
			
		||||
  @ApiProperty({ example: 'testuser@email.com' })
 | 
			
		||||
  @Transform(({ value }) => value.toLowerCase())
 | 
			
		||||
  email!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @ApiProperty({ example: 'password' })
 | 
			
		||||
  password!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @ApiProperty({ example: 'Admin' })
 | 
			
		||||
  firstName!: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @ApiProperty({ example: 'Doe' })
 | 
			
		||||
  lastName!: string;
 | 
			
		||||
}
 | 
			
		||||
@@ -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';
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,19 @@
 | 
			
		||||
import { UserEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export class AdminSignupResponseDto {
 | 
			
		||||
  id!: string;
 | 
			
		||||
  email!: string;
 | 
			
		||||
  firstName!: string;
 | 
			
		||||
  lastName!: string;
 | 
			
		||||
  createdAt!: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function mapAdminSignupResponse(entity: UserEntity): AdminSignupResponseDto {
 | 
			
		||||
  return {
 | 
			
		||||
    id: entity.id,
 | 
			
		||||
    email: entity.email,
 | 
			
		||||
    firstName: entity.firstName,
 | 
			
		||||
    lastName: entity.lastName,
 | 
			
		||||
    createdAt: entity.createdAt,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								server/libs/domain/src/auth/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/libs/domain/src/auth/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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';
 | 
			
		||||
@@ -0,0 +1,41 @@
 | 
			
		||||
import { UserEntity } from '@app/infra/db/entities';
 | 
			
		||||
import { ApiResponseProperty } from '@nestjs/swagger';
 | 
			
		||||
 | 
			
		||||
export class LoginResponseDto {
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  accessToken!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  userId!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  userEmail!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  firstName!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  lastName!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  profileImagePath!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  isAdmin!: boolean;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  shouldChangePassword!: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
 | 
			
		||||
  return {
 | 
			
		||||
    accessToken: accessToken,
 | 
			
		||||
    userId: entity.id,
 | 
			
		||||
    userEmail: entity.email,
 | 
			
		||||
    firstName: entity.firstName,
 | 
			
		||||
    lastName: entity.lastName,
 | 
			
		||||
    isAdmin: entity.isAdmin,
 | 
			
		||||
    profileImagePath: entity.profileImagePath,
 | 
			
		||||
    shouldChangePassword: entity.shouldChangePassword,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,13 @@
 | 
			
		||||
import { ApiResponseProperty } from '@nestjs/swagger';
 | 
			
		||||
 | 
			
		||||
export class LogoutResponseDto {
 | 
			
		||||
  constructor(successful: boolean) {
 | 
			
		||||
    this.successful = successful;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  successful!: boolean;
 | 
			
		||||
 | 
			
		||||
  @ApiResponseProperty()
 | 
			
		||||
  redirectUri!: string;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
 | 
			
		||||
export class ValidateAccessTokenResponseDto {
 | 
			
		||||
  constructor(authStatus: boolean) {
 | 
			
		||||
    this.authStatus = authStatus;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ApiProperty({ type: 'boolean' })
 | 
			
		||||
  authStatus!: boolean;
 | 
			
		||||
}
 | 
			
		||||
@@ -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,
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
									
								
								server/libs/domain/src/oauth/dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/oauth/dto/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
export * from './oauth-auth-code.dto';
 | 
			
		||||
export * from './oauth-config.dto';
 | 
			
		||||
							
								
								
									
										9
									
								
								server/libs/domain/src/oauth/dto/oauth-auth-code.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/libs/domain/src/oauth/dto/oauth-auth-code.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class OAuthCallbackDto {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @ApiProperty()
 | 
			
		||||
  url!: string;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								server/libs/domain/src/oauth/dto/oauth-config.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/libs/domain/src/oauth/dto/oauth-config.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class OAuthConfigDto {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @ApiProperty()
 | 
			
		||||
  redirectUri!: string;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								server/libs/domain/src/oauth/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/libs/domain/src/oauth/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
export * from './dto';
 | 
			
		||||
export * from './oauth.constants';
 | 
			
		||||
export * from './oauth.service';
 | 
			
		||||
export * from './response-dto';
 | 
			
		||||
							
								
								
									
										1
									
								
								server/libs/domain/src/oauth/oauth.constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/libs/domain/src/oauth/oauth.constants.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export const MOBILE_REDIRECT = 'app.immich:/';
 | 
			
		||||
							
								
								
									
										107
									
								
								server/libs/domain/src/oauth/oauth.core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								server/libs/domain/src/oauth/oauth.core.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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
									
								
								server/libs/domain/src/oauth/oauth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								server/libs/domain/src/oauth/oauth.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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
									
								
								server/libs/domain/src/oauth/oauth.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								server/libs/domain/src/oauth/oauth.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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
									
								
								server/libs/domain/src/oauth/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/libs/domain/src/oauth/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export * from './oauth-config-response.dto';
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
export class OAuthConfigResponseDto {
 | 
			
		||||
  enabled!: boolean;
 | 
			
		||||
  passwordLoginEnabled!: boolean;
 | 
			
		||||
  url?: string;
 | 
			
		||||
  buttonText?: string;
 | 
			
		||||
  autoLaunch?: boolean;
 | 
			
		||||
}
 | 
			
		||||
@@ -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';
 | 
			
		||||
 
 | 
			
		||||
@@ -18,3 +18,5 @@ export const supportedPresetTokens = [
 | 
			
		||||
  '{{y}}-{{MMM}}-{{dd}}/{{filename}}',
 | 
			
		||||
  '{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
 | 
			
		||||
@@ -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,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';
 | 
			
		||||
 
 | 
			
		||||
@@ -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';
 | 
			
		||||
 
 | 
			
		||||
@@ -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' }),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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;',
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user