mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:29:32 -05:00 
			
		
		
		
	refactor(server): auth/oauth (#3242)
* refactor(server): auth/oauth * fix: show server error message on login failure
This commit is contained in:
		
							parent
							
								
									9ef41bf1c7
								
							
						
					
					
						commit
						08c7054845
					
				@ -1,3 +1,5 @@
 | 
				
			|||||||
 | 
					export const MOBILE_REDIRECT = 'app.immich:/';
 | 
				
			||||||
 | 
					export const LOGIN_URL = '/auth/login?autoLaunch=0';
 | 
				
			||||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
 | 
					export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
 | 
				
			||||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
 | 
					export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
 | 
				
			||||||
export const IMMICH_API_KEY_NAME = 'api_key';
 | 
					export const IMMICH_API_KEY_NAME = 'api_key';
 | 
				
			||||||
 | 
				
			|||||||
@ -1,62 +0,0 @@
 | 
				
			|||||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
 | 
					 | 
				
			||||||
import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
					 | 
				
			||||||
import { ISystemConfigRepository } from '../system-config';
 | 
					 | 
				
			||||||
import { SystemConfigCore } from '../system-config/system-config.core';
 | 
					 | 
				
			||||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
 | 
					 | 
				
			||||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
 | 
					 | 
				
			||||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface LoginDetails {
 | 
					 | 
				
			||||||
  isSecure: boolean;
 | 
					 | 
				
			||||||
  clientIp: string;
 | 
					 | 
				
			||||||
  deviceType: string;
 | 
					 | 
				
			||||||
  deviceOS: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class AuthCore {
 | 
					 | 
				
			||||||
  private userTokenCore: UserTokenCore;
 | 
					 | 
				
			||||||
  constructor(
 | 
					 | 
				
			||||||
    private cryptoRepository: ICryptoRepository,
 | 
					 | 
				
			||||||
    configRepository: ISystemConfigRepository,
 | 
					 | 
				
			||||||
    userTokenRepository: IUserTokenRepository,
 | 
					 | 
				
			||||||
    private config: SystemConfig,
 | 
					 | 
				
			||||||
  ) {
 | 
					 | 
				
			||||||
    this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
 | 
					 | 
				
			||||||
    const configCore = new SystemConfigCore(configRepository);
 | 
					 | 
				
			||||||
    configCore.config$.subscribe((config) => (this.config = config));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  isPasswordLoginEnabled() {
 | 
					 | 
				
			||||||
    return this.config.passwordLogin.enabled;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
 | 
					 | 
				
			||||||
    const maxAge = 400 * 24 * 3600; // 400 days
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let authTypeCookie = '';
 | 
					 | 
				
			||||||
    let accessTokenCookie = '';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (isSecure) {
 | 
					 | 
				
			||||||
      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
					 | 
				
			||||||
      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
					 | 
				
			||||||
      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return [accessTokenCookie, authTypeCookie];
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
 | 
					 | 
				
			||||||
    const accessToken = await this.userTokenCore.create(user, loginDetails);
 | 
					 | 
				
			||||||
    const response = mapLoginResponse(user, accessToken);
 | 
					 | 
				
			||||||
    const cookie = this.getCookies(response, authType, loginDetails);
 | 
					 | 
				
			||||||
    return { response, cookie };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  validatePassword(inputPassword: string, user: UserEntity): boolean {
 | 
					 | 
				
			||||||
    if (!user || !user.password) {
 | 
					 | 
				
			||||||
      return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
 | 
					import { UserEntity } from '@app/infra/entities';
 | 
				
			||||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 | 
					import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  authStub,
 | 
					  authStub,
 | 
				
			||||||
@ -23,10 +23,10 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
				
			|||||||
import { ISharedLinkRepository } from '../shared-link';
 | 
					import { ISharedLinkRepository } from '../shared-link';
 | 
				
			||||||
import { ISystemConfigRepository } from '../system-config';
 | 
					import { ISystemConfigRepository } from '../system-config';
 | 
				
			||||||
import { IUserRepository } from '../user';
 | 
					import { IUserRepository } from '../user';
 | 
				
			||||||
import { IUserTokenRepository } from '../user-token';
 | 
					 | 
				
			||||||
import { AuthType } from './auth.constant';
 | 
					import { AuthType } from './auth.constant';
 | 
				
			||||||
import { AuthService } from './auth.service';
 | 
					import { AuthService } from './auth.service';
 | 
				
			||||||
import { AuthUserDto, SignUpDto } from './dto';
 | 
					import { AuthUserDto, SignUpDto } from './dto';
 | 
				
			||||||
 | 
					import { IUserTokenRepository } from './user-token.repository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
 | 
					// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -55,7 +55,6 @@ describe('AuthService', () => {
 | 
				
			|||||||
  let shareMock: jest.Mocked<ISharedLinkRepository>;
 | 
					  let shareMock: jest.Mocked<ISharedLinkRepository>;
 | 
				
			||||||
  let keyMock: jest.Mocked<IKeyRepository>;
 | 
					  let keyMock: jest.Mocked<IKeyRepository>;
 | 
				
			||||||
  let callbackMock: jest.Mock;
 | 
					  let callbackMock: jest.Mock;
 | 
				
			||||||
  let create: (config: SystemConfig) => AuthService;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  afterEach(() => {
 | 
					  afterEach(() => {
 | 
				
			||||||
    jest.resetModules();
 | 
					    jest.resetModules();
 | 
				
			||||||
@ -87,9 +86,7 @@ describe('AuthService', () => {
 | 
				
			|||||||
    shareMock = newSharedLinkRepositoryMock();
 | 
					    shareMock = newSharedLinkRepositoryMock();
 | 
				
			||||||
    keyMock = newKeyRepositoryMock();
 | 
					    keyMock = newKeyRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
 | 
					    sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock);
 | 
				
			||||||
 | 
					 | 
				
			||||||
    sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should be defined', () => {
 | 
					  it('should be defined', () => {
 | 
				
			||||||
@ -98,8 +95,7 @@ describe('AuthService', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  describe('login', () => {
 | 
					  describe('login', () => {
 | 
				
			||||||
    it('should throw an error if password login is disabled', async () => {
 | 
					    it('should throw an error if password login is disabled', async () => {
 | 
				
			||||||
      sut = create(systemConfigStub.disabled);
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.disabled);
 | 
				
			||||||
 | 
					 | 
				
			||||||
      await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
 | 
					      await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -191,8 +187,8 @@ describe('AuthService', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  describe('logout', () => {
 | 
					  describe('logout', () => {
 | 
				
			||||||
    it('should return the end session endpoint', async () => {
 | 
					    it('should return the end session endpoint', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.enabled);
 | 
				
			||||||
      const authUser = { id: '123' } as AuthUserDto;
 | 
					      const authUser = { id: '123' } as AuthUserDto;
 | 
				
			||||||
 | 
					 | 
				
			||||||
      await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
 | 
					      await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
 | 
				
			||||||
        successful: true,
 | 
					        successful: true,
 | 
				
			||||||
        redirectUri: 'http://end-session-endpoint',
 | 
					        redirectUri: 'http://end-session-endpoint',
 | 
				
			||||||
@ -385,4 +381,132 @@ describe('AuthService', () => {
 | 
				
			|||||||
      expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
 | 
					      expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('getMobileRedirect', () => {
 | 
				
			||||||
 | 
					    it('should pass along the query params', () => {
 | 
				
			||||||
 | 
					      expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should work if called without query params', () => {
 | 
				
			||||||
 | 
					      expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('generateConfig', () => {
 | 
				
			||||||
 | 
					    it('should work when oauth is not configured', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.disabled);
 | 
				
			||||||
 | 
					      await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
 | 
				
			||||||
 | 
					        enabled: false,
 | 
				
			||||||
 | 
					        passwordLoginEnabled: false,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should generate the config', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.enabled);
 | 
				
			||||||
 | 
					      await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
 | 
				
			||||||
 | 
					        enabled: true,
 | 
				
			||||||
 | 
					        buttonText: 'OAuth',
 | 
				
			||||||
 | 
					        url: 'http://authorization-url',
 | 
				
			||||||
 | 
					        autoLaunch: false,
 | 
				
			||||||
 | 
					        passwordLoginEnabled: true,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('callback', () => {
 | 
				
			||||||
 | 
					    it('should throw an error if OAuth is not enabled', async () => {
 | 
				
			||||||
 | 
					      await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not allow auto registering', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
 | 
				
			||||||
 | 
					      userMock.getByEmail.mockResolvedValue(null);
 | 
				
			||||||
 | 
					      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
 | 
				
			||||||
 | 
					        BadRequestException,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should link an existing user', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
 | 
				
			||||||
 | 
					      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
 | 
				
			||||||
 | 
					        loginResponseStub.user1oauth,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
				
			||||||
 | 
					      expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should allow auto registering by default', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.enabled);
 | 
				
			||||||
 | 
					      userMock.getByEmail.mockResolvedValue(null);
 | 
				
			||||||
 | 
					      userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					      userMock.create.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
 | 
				
			||||||
 | 
					        loginResponseStub.user1oauth,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
 | 
				
			||||||
 | 
					      expect(userMock.create).toHaveBeenCalledTimes(1);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should use the mobile redirect override', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.override);
 | 
				
			||||||
 | 
					      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.override);
 | 
				
			||||||
 | 
					      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('link', () => {
 | 
				
			||||||
 | 
					    it('should link an account', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.enabled);
 | 
				
			||||||
 | 
					      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('should not link an already linked oauth.sub', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.enabled);
 | 
				
			||||||
 | 
					      userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
 | 
				
			||||||
 | 
					        BadRequestException,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userMock.update).not.toHaveBeenCalled();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('unlink', () => {
 | 
				
			||||||
 | 
					    it('should unlink an account', async () => {
 | 
				
			||||||
 | 
					      configMock.load.mockResolvedValue(systemConfigStub.enabled);
 | 
				
			||||||
 | 
					      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await sut.unlink(authStub.user1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { SystemConfig } from '@app/infra/entities';
 | 
					import { SystemConfig, UserEntity } from '@app/infra/entities';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  BadRequestException,
 | 
					  BadRequestException,
 | 
				
			||||||
  Inject,
 | 
					  Inject,
 | 
				
			||||||
@ -9,99 +9,112 @@ import {
 | 
				
			|||||||
} from '@nestjs/common';
 | 
					} from '@nestjs/common';
 | 
				
			||||||
import cookieParser from 'cookie';
 | 
					import cookieParser from 'cookie';
 | 
				
			||||||
import { IncomingHttpHeaders } from 'http';
 | 
					import { IncomingHttpHeaders } from 'http';
 | 
				
			||||||
 | 
					import { DateTime } from 'luxon';
 | 
				
			||||||
 | 
					import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
 | 
				
			||||||
import { IKeyRepository } from '../api-key';
 | 
					import { IKeyRepository } from '../api-key';
 | 
				
			||||||
import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
					import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
				
			||||||
import { OAuthCore } from '../oauth/oauth.core';
 | 
					 | 
				
			||||||
import { ISharedLinkRepository } from '../shared-link';
 | 
					import { ISharedLinkRepository } from '../shared-link';
 | 
				
			||||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 | 
					import { ISystemConfigRepository } from '../system-config';
 | 
				
			||||||
import { IUserRepository, UserCore } from '../user';
 | 
					import { SystemConfigCore } from '../system-config/system-config.core';
 | 
				
			||||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
 | 
					import { IUserRepository, UserCore, UserResponseDto } from '../user';
 | 
				
			||||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
 | 
					import {
 | 
				
			||||||
import { AuthCore, LoginDetails } from './auth.core';
 | 
					  AuthType,
 | 
				
			||||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
 | 
					  IMMICH_ACCESS_COOKIE,
 | 
				
			||||||
 | 
					  IMMICH_API_KEY_HEADER,
 | 
				
			||||||
 | 
					  IMMICH_AUTH_TYPE_COOKIE,
 | 
				
			||||||
 | 
					  LOGIN_URL,
 | 
				
			||||||
 | 
					  MOBILE_REDIRECT,
 | 
				
			||||||
 | 
					} from './auth.constant';
 | 
				
			||||||
 | 
					import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto } from './dto';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AdminSignupResponseDto,
 | 
					  AdminSignupResponseDto,
 | 
				
			||||||
  AuthDeviceResponseDto,
 | 
					  AuthDeviceResponseDto,
 | 
				
			||||||
  LoginResponseDto,
 | 
					  LoginResponseDto,
 | 
				
			||||||
  LogoutResponseDto,
 | 
					  LogoutResponseDto,
 | 
				
			||||||
  mapAdminSignupResponse,
 | 
					  mapAdminSignupResponse,
 | 
				
			||||||
 | 
					  mapLoginResponse,
 | 
				
			||||||
  mapUserToken,
 | 
					  mapUserToken,
 | 
				
			||||||
 | 
					  OAuthConfigResponseDto,
 | 
				
			||||||
} from './response-dto';
 | 
					} from './response-dto';
 | 
				
			||||||
 | 
					import { IUserTokenRepository } from './user-token.repository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface LoginDetails {
 | 
				
			||||||
 | 
					  isSecure: boolean;
 | 
				
			||||||
 | 
					  clientIp: string;
 | 
				
			||||||
 | 
					  deviceType: string;
 | 
				
			||||||
 | 
					  deviceOS: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface LoginResponse {
 | 
				
			||||||
 | 
					  response: LoginResponseDto;
 | 
				
			||||||
 | 
					  cookie: string[];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface OAuthProfile extends UserinfoResponse {
 | 
				
			||||||
 | 
					  email: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class AuthService {
 | 
					export class AuthService {
 | 
				
			||||||
  private userTokenCore: UserTokenCore;
 | 
					 | 
				
			||||||
  private authCore: AuthCore;
 | 
					 | 
				
			||||||
  private oauthCore: OAuthCore;
 | 
					 | 
				
			||||||
  private userCore: UserCore;
 | 
					  private userCore: UserCore;
 | 
				
			||||||
 | 
					  private configCore: SystemConfigCore;
 | 
				
			||||||
  private logger = new Logger(AuthService.name);
 | 
					  private logger = new Logger(AuthService.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
 | 
					    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
    @Inject(IUserRepository) userRepository: IUserRepository,
 | 
					    @Inject(IUserRepository) userRepository: IUserRepository,
 | 
				
			||||||
    @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
 | 
					    @Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
 | 
				
			||||||
    @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
 | 
					    @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
 | 
				
			||||||
    @Inject(IKeyRepository) private keyRepository: IKeyRepository,
 | 
					    @Inject(IKeyRepository) private keyRepository: IKeyRepository,
 | 
				
			||||||
    @Inject(INITIAL_SYSTEM_CONFIG)
 | 
					 | 
				
			||||||
    initialConfig: SystemConfig,
 | 
					 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
 | 
					    this.configCore = new SystemConfigCore(configRepository);
 | 
				
			||||||
    this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
 | 
					 | 
				
			||||||
    this.oauthCore = new OAuthCore(configRepository, initialConfig);
 | 
					 | 
				
			||||||
    this.userCore = new UserCore(userRepository, cryptoRepository);
 | 
					    this.userCore = new UserCore(userRepository, cryptoRepository);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    custom.setHttpOptionsDefaults({ timeout: 30000 });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async login(
 | 
					  async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
 | 
				
			||||||
    loginCredential: LoginCredentialDto,
 | 
					    const config = await this.configCore.getConfig();
 | 
				
			||||||
    loginDetails: LoginDetails,
 | 
					    if (!config.passwordLogin.enabled) {
 | 
				
			||||||
  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
 | 
					 | 
				
			||||||
    if (!this.authCore.isPasswordLoginEnabled()) {
 | 
					 | 
				
			||||||
      throw new UnauthorizedException('Password login has been disabled');
 | 
					      throw new UnauthorizedException('Password login has been disabled');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let user = await this.userCore.getByEmail(loginCredential.email, true);
 | 
					    let user = await this.userCore.getByEmail(dto.email, true);
 | 
				
			||||||
    if (user) {
 | 
					    if (user) {
 | 
				
			||||||
      const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
 | 
					      const isAuthenticated = this.validatePassword(dto.password, user);
 | 
				
			||||||
      if (!isAuthenticated) {
 | 
					      if (!isAuthenticated) {
 | 
				
			||||||
        user = null;
 | 
					        user = null;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!user) {
 | 
					    if (!user) {
 | 
				
			||||||
      this.logger.warn(
 | 
					      this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
 | 
				
			||||||
        `Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      throw new BadRequestException('Incorrect email or password');
 | 
					      throw new BadRequestException('Incorrect email or password');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
 | 
					    return this.createLoginResponse(user, AuthType.PASSWORD, details);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
 | 
					  async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
 | 
				
			||||||
    if (authUser.accessTokenId) {
 | 
					    if (authUser.accessTokenId) {
 | 
				
			||||||
      await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
 | 
					      await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (authType === AuthType.OAUTH) {
 | 
					    return {
 | 
				
			||||||
      const url = await this.oauthCore.getLogoutEndpoint();
 | 
					      successful: true,
 | 
				
			||||||
      if (url) {
 | 
					      redirectUri: await this.getLogoutEndpoint(authType),
 | 
				
			||||||
        return { successful: true, redirectUri: url };
 | 
					    };
 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
 | 
					  async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
 | 
				
			||||||
    const { password, newPassword } = dto;
 | 
					    const { password, newPassword } = dto;
 | 
				
			||||||
    const user = await this.userCore.getByEmail(authUser.email, true);
 | 
					    const user = await this.userCore.getByEmail(authUser.email, true);
 | 
				
			||||||
    if (!user) {
 | 
					    if (!user) {
 | 
				
			||||||
      throw new UnauthorizedException();
 | 
					      throw new UnauthorizedException();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const valid = this.authCore.validatePassword(password, user);
 | 
					    const valid = this.validatePassword(password, user);
 | 
				
			||||||
    if (!valid) {
 | 
					    if (!valid) {
 | 
				
			||||||
      throw new BadRequestException('Wrong password');
 | 
					      throw new BadRequestException('Wrong password');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -109,7 +122,7 @@ export class AuthService {
 | 
				
			|||||||
    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
 | 
					    return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
 | 
					  async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
 | 
				
			||||||
    const adminUser = await this.userCore.getAdmin();
 | 
					    const adminUser = await this.userCore.getAdmin();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (adminUser) {
 | 
					    if (adminUser) {
 | 
				
			||||||
@ -133,7 +146,7 @@ export class AuthService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
 | 
					  async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
 | 
				
			||||||
    const shareKey = (headers['x-immich-share-key'] || params.key) as string;
 | 
					    const shareKey = (headers['x-immich-share-key'] || params.key) as string;
 | 
				
			||||||
    const userToken = (headers['x-immich-user-token'] ||
 | 
					    const userToken = (headers['x-immich-user-token'] ||
 | 
				
			||||||
      params.userToken ||
 | 
					      params.userToken ||
 | 
				
			||||||
@ -146,7 +159,7 @@ export class AuthService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (userToken) {
 | 
					    if (userToken) {
 | 
				
			||||||
      return this.userTokenCore.validate(userToken);
 | 
					      return this.validateUserToken(userToken);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (apiKey) {
 | 
					    if (apiKey) {
 | 
				
			||||||
@ -157,24 +170,155 @@ export class AuthService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
 | 
					  async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
 | 
				
			||||||
    const userTokens = await this.userTokenCore.getAll(authUser.id);
 | 
					    const userTokens = await this.userTokenRepository.getAll(authUser.id);
 | 
				
			||||||
    return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
 | 
					    return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
 | 
					  async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
 | 
				
			||||||
    await this.userTokenCore.delete(authUser.id, deviceId);
 | 
					    await this.userTokenRepository.delete(authUser.id, deviceId);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async logoutDevices(authUser: AuthUserDto): Promise<void> {
 | 
					  async logoutDevices(authUser: AuthUserDto): Promise<void> {
 | 
				
			||||||
    const devices = await this.userTokenCore.getAll(authUser.id);
 | 
					    const devices = await this.userTokenRepository.getAll(authUser.id);
 | 
				
			||||||
    for (const device of devices) {
 | 
					    for (const device of devices) {
 | 
				
			||||||
      if (device.id === authUser.accessTokenId) {
 | 
					      if (device.id === authUser.accessTokenId) {
 | 
				
			||||||
        continue;
 | 
					        continue;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      await this.userTokenCore.delete(authUser.id, device.id);
 | 
					      await this.userTokenRepository.delete(authUser.id, device.id);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getMobileRedirect(url: string) {
 | 
				
			||||||
 | 
					    return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
 | 
				
			||||||
 | 
					    const config = await this.configCore.getConfig();
 | 
				
			||||||
 | 
					    const response = {
 | 
				
			||||||
 | 
					      enabled: config.oauth.enabled,
 | 
				
			||||||
 | 
					      passwordLoginEnabled: config.passwordLogin.enabled,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!response.enabled) {
 | 
				
			||||||
 | 
					      return response;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const { scope, buttonText, autoLaunch } = config.oauth;
 | 
				
			||||||
 | 
					    const url = (await this.getOAuthClient(config)).authorizationUrl({
 | 
				
			||||||
 | 
					      redirect_uri: this.normalize(config, dto.redirectUri),
 | 
				
			||||||
 | 
					      scope,
 | 
				
			||||||
 | 
					      state: generators.state(),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return { ...response, buttonText, url, autoLaunch };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async callback(
 | 
				
			||||||
 | 
					    dto: OAuthCallbackDto,
 | 
				
			||||||
 | 
					    loginDetails: LoginDetails,
 | 
				
			||||||
 | 
					  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
 | 
				
			||||||
 | 
					    const config = await this.configCore.getConfig();
 | 
				
			||||||
 | 
					    const profile = await this.getOAuthProfile(config, dto.url);
 | 
				
			||||||
 | 
					    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
 | 
				
			||||||
 | 
					    let user = await this.userCore.getByOAuthId(profile.sub);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // link existing user
 | 
				
			||||||
 | 
					    if (!user) {
 | 
				
			||||||
 | 
					      const emailUser = await this.userCore.getByEmail(profile.email);
 | 
				
			||||||
 | 
					      if (emailUser) {
 | 
				
			||||||
 | 
					        user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // register new user
 | 
				
			||||||
 | 
					    if (!user) {
 | 
				
			||||||
 | 
					      if (!config.oauth.autoRegister) {
 | 
				
			||||||
 | 
					        this.logger.warn(
 | 
				
			||||||
 | 
					          `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        throw new BadRequestException(`User does not exist and auto registering is disabled.`);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
 | 
				
			||||||
 | 
					      user = await this.userCore.createUser({
 | 
				
			||||||
 | 
					        firstName: profile.given_name || '',
 | 
				
			||||||
 | 
					        lastName: profile.family_name || '',
 | 
				
			||||||
 | 
					        email: profile.email,
 | 
				
			||||||
 | 
					        oauthId: profile.sub,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return this.createLoginResponse(user, AuthType.OAUTH, loginDetails);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
 | 
				
			||||||
 | 
					    const config = await this.configCore.getConfig();
 | 
				
			||||||
 | 
					    const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
 | 
				
			||||||
 | 
					    const duplicate = await this.userCore.getByOAuthId(oauthId);
 | 
				
			||||||
 | 
					    if (duplicate && duplicate.id !== user.id) {
 | 
				
			||||||
 | 
					      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
 | 
				
			||||||
 | 
					      throw new BadRequestException('This OAuth account has already been linked to another user.');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return this.userCore.updateUser(user, user.id, { oauthId });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async unlink(user: AuthUserDto): Promise<UserResponseDto> {
 | 
				
			||||||
 | 
					    return this.userCore.updateUser(user, user.id, { oauthId: '' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async getLogoutEndpoint(authType: AuthType): Promise<string> {
 | 
				
			||||||
 | 
					    if (authType !== AuthType.OAUTH) {
 | 
				
			||||||
 | 
					      return LOGIN_URL;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const config = await this.configCore.getConfig();
 | 
				
			||||||
 | 
					    if (!config.oauth.enabled) {
 | 
				
			||||||
 | 
					      return LOGIN_URL;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const client = await this.getOAuthClient(config);
 | 
				
			||||||
 | 
					    return client.issuer.metadata.end_session_endpoint || LOGIN_URL;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async getOAuthProfile(config: SystemConfig, url: string): Promise<OAuthProfile> {
 | 
				
			||||||
 | 
					    const redirectUri = this.normalize(config, url.split('?')[0]);
 | 
				
			||||||
 | 
					    const client = await this.getOAuthClient(config);
 | 
				
			||||||
 | 
					    const params = client.callbackParams(url);
 | 
				
			||||||
 | 
					    const tokens = await client.callback(redirectUri, params, { state: params.state });
 | 
				
			||||||
 | 
					    return client.userinfo<OAuthProfile>(tokens.access_token || '');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async getOAuthClient(config: SystemConfig) {
 | 
				
			||||||
 | 
					    const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!enabled) {
 | 
				
			||||||
 | 
					      throw new BadRequestException('OAuth2 is not enabled');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const metadata: ClientMetadata = {
 | 
				
			||||||
 | 
					      client_id: clientId,
 | 
				
			||||||
 | 
					      client_secret: clientSecret,
 | 
				
			||||||
 | 
					      response_types: ['code'],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const issuer = await Issuer.discover(issuerUrl);
 | 
				
			||||||
 | 
					    const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
 | 
				
			||||||
 | 
					    if (algorithms[0] === 'HS256') {
 | 
				
			||||||
 | 
					      metadata.id_token_signed_response_alg = algorithms[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return new issuer.Client(metadata);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private normalize(config: SystemConfig, redirectUri: string) {
 | 
				
			||||||
 | 
					    const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
 | 
				
			||||||
 | 
					    const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth;
 | 
				
			||||||
 | 
					    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
 | 
				
			||||||
 | 
					      return mobileRedirectUri;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return redirectUri;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private getBearerToken(headers: IncomingHttpHeaders): string | null {
 | 
					  private getBearerToken(headers: IncomingHttpHeaders): string | null {
 | 
				
			||||||
    const [type, token] = (headers.authorization || '').split(' ');
 | 
					    const [type, token] = (headers.authorization || '').split(' ');
 | 
				
			||||||
    if (type.toLowerCase() === 'bearer') {
 | 
					    if (type.toLowerCase() === 'bearer') {
 | 
				
			||||||
@ -232,4 +376,68 @@ export class AuthService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    throw new UnauthorizedException('Invalid API key');
 | 
					    throw new UnauthorizedException('Invalid API key');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private validatePassword(inputPassword: string, user: UserEntity): boolean {
 | 
				
			||||||
 | 
					    if (!user || !user.password) {
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async validateUserToken(tokenValue: string): Promise<AuthUserDto> {
 | 
				
			||||||
 | 
					    const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
 | 
				
			||||||
 | 
					    let token = await this.userTokenRepository.getByToken(hashedToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (token?.user) {
 | 
				
			||||||
 | 
					      const now = DateTime.now();
 | 
				
			||||||
 | 
					      const updatedAt = DateTime.fromJSDate(token.updatedAt);
 | 
				
			||||||
 | 
					      const diff = now.diff(updatedAt, ['hours']);
 | 
				
			||||||
 | 
					      if (diff.hours > 1) {
 | 
				
			||||||
 | 
					        token = await this.userTokenRepository.save({ ...token, updatedAt: new Date() });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        ...token.user,
 | 
				
			||||||
 | 
					        isPublicUser: false,
 | 
				
			||||||
 | 
					        isAllowUpload: true,
 | 
				
			||||||
 | 
					        isAllowDownload: true,
 | 
				
			||||||
 | 
					        isShowExif: true,
 | 
				
			||||||
 | 
					        accessTokenId: token.id,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    throw new UnauthorizedException('Invalid user token');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
 | 
				
			||||||
 | 
					    const key = this.cryptoRepository.randomBytes(32).toString('base64').replace(/\W/g, '');
 | 
				
			||||||
 | 
					    const token = this.cryptoRepository.hashSha256(key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.userTokenRepository.create({
 | 
				
			||||||
 | 
					      token,
 | 
				
			||||||
 | 
					      user,
 | 
				
			||||||
 | 
					      deviceOS: loginDetails.deviceOS,
 | 
				
			||||||
 | 
					      deviceType: loginDetails.deviceType,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const response = mapLoginResponse(user, key);
 | 
				
			||||||
 | 
					    const cookie = this.getCookies(response, authType, loginDetails);
 | 
				
			||||||
 | 
					    return { response, cookie };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
 | 
				
			||||||
 | 
					    const maxAge = 400 * 24 * 3600; // 400 days
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let authTypeCookie = '';
 | 
				
			||||||
 | 
					    let accessTokenCookie = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isSecure) {
 | 
				
			||||||
 | 
					      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
				
			||||||
 | 
					      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
				
			||||||
 | 
					      authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return [accessTokenCookie, authTypeCookie];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
export * from './auth-user.dto';
 | 
					export * from './auth-user.dto';
 | 
				
			||||||
export * from './change-password.dto';
 | 
					export * from './change-password.dto';
 | 
				
			||||||
export * from './login-credential.dto';
 | 
					export * from './login-credential.dto';
 | 
				
			||||||
 | 
					export * from './oauth-auth-code.dto';
 | 
				
			||||||
 | 
					export * from './oauth-config.dto';
 | 
				
			||||||
export * from './sign-up.dto';
 | 
					export * from './sign-up.dto';
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
export * from './auth.constant';
 | 
					export * from './auth.constant';
 | 
				
			||||||
export * from './auth.core';
 | 
					 | 
				
			||||||
export * from './auth.service';
 | 
					export * from './auth.service';
 | 
				
			||||||
export * from './dto';
 | 
					export * from './dto';
 | 
				
			||||||
export * from './response-dto';
 | 
					export * from './response-dto';
 | 
				
			||||||
 | 
					export * from './user-token.repository';
 | 
				
			||||||
 | 
				
			|||||||
@ -2,4 +2,5 @@ export * from './admin-signup-response.dto';
 | 
				
			|||||||
export * from './auth-device-response.dto';
 | 
					export * from './auth-device-response.dto';
 | 
				
			||||||
export * from './login-response.dto';
 | 
					export * from './login-response.dto';
 | 
				
			||||||
export * from './logout-response.dto';
 | 
					export * from './logout-response.dto';
 | 
				
			||||||
 | 
					export * from './oauth-config-response.dto';
 | 
				
			||||||
export * from './validate-asset-token-response.dto';
 | 
					export * from './validate-asset-token-response.dto';
 | 
				
			||||||
 | 
				
			|||||||
@ -7,7 +7,6 @@ import { FacialRecognitionService } from './facial-recognition';
 | 
				
			|||||||
import { JobService } from './job';
 | 
					import { JobService } from './job';
 | 
				
			||||||
import { MediaService } from './media';
 | 
					import { MediaService } from './media';
 | 
				
			||||||
import { MetadataService } from './metadata';
 | 
					import { MetadataService } from './metadata';
 | 
				
			||||||
import { OAuthService } from './oauth';
 | 
					 | 
				
			||||||
import { PartnerService } from './partner';
 | 
					import { PartnerService } from './partner';
 | 
				
			||||||
import { PersonService } from './person';
 | 
					import { PersonService } from './person';
 | 
				
			||||||
import { SearchService } from './search';
 | 
					import { SearchService } from './search';
 | 
				
			||||||
@ -29,7 +28,6 @@ const providers: Provider[] = [
 | 
				
			|||||||
  JobService,
 | 
					  JobService,
 | 
				
			||||||
  MediaService,
 | 
					  MediaService,
 | 
				
			||||||
  MetadataService,
 | 
					  MetadataService,
 | 
				
			||||||
  OAuthService,
 | 
					 | 
				
			||||||
  PersonService,
 | 
					  PersonService,
 | 
				
			||||||
  PartnerService,
 | 
					  PartnerService,
 | 
				
			||||||
  SearchService,
 | 
					  SearchService,
 | 
				
			||||||
 | 
				
			|||||||
@ -13,7 +13,6 @@ export * from './facial-recognition';
 | 
				
			|||||||
export * from './job';
 | 
					export * from './job';
 | 
				
			||||||
export * from './media';
 | 
					export * from './media';
 | 
				
			||||||
export * from './metadata';
 | 
					export * from './metadata';
 | 
				
			||||||
export * from './oauth';
 | 
					 | 
				
			||||||
export * from './partner';
 | 
					export * from './partner';
 | 
				
			||||||
export * from './person';
 | 
					export * from './person';
 | 
				
			||||||
export * from './search';
 | 
					export * from './search';
 | 
				
			||||||
@ -25,4 +24,3 @@ export * from './storage-template';
 | 
				
			|||||||
export * from './system-config';
 | 
					export * from './system-config';
 | 
				
			||||||
export * from './tag';
 | 
					export * from './tag';
 | 
				
			||||||
export * from './user';
 | 
					export * from './user';
 | 
				
			||||||
export * from './user-token';
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,2 +0,0 @@
 | 
				
			|||||||
export * from './oauth-auth-code.dto';
 | 
					 | 
				
			||||||
export * from './oauth-config.dto';
 | 
					 | 
				
			||||||
@ -1,4 +0,0 @@
 | 
				
			|||||||
export * from './dto';
 | 
					 | 
				
			||||||
export * from './oauth.constants';
 | 
					 | 
				
			||||||
export * from './oauth.service';
 | 
					 | 
				
			||||||
export * from './response-dto';
 | 
					 | 
				
			||||||
@ -1 +0,0 @@
 | 
				
			|||||||
export const MOBILE_REDIRECT = 'app.immich:/';
 | 
					 | 
				
			||||||
@ -1,107 +0,0 @@
 | 
				
			|||||||
import { SystemConfig } from '@app/infra/entities';
 | 
					 | 
				
			||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
 | 
					 | 
				
			||||||
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
 | 
					 | 
				
			||||||
import { ISystemConfigRepository } from '../system-config';
 | 
					 | 
				
			||||||
import { SystemConfigCore } from '../system-config/system-config.core';
 | 
					 | 
				
			||||||
import { OAuthConfigDto } from './dto';
 | 
					 | 
				
			||||||
import { MOBILE_REDIRECT } from './oauth.constants';
 | 
					 | 
				
			||||||
import { OAuthConfigResponseDto } from './response-dto';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type OAuthProfile = UserinfoResponse & {
 | 
					 | 
				
			||||||
  email: string;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@Injectable()
 | 
					 | 
				
			||||||
export class OAuthCore {
 | 
					 | 
				
			||||||
  private readonly logger = new Logger(OAuthCore.name);
 | 
					 | 
				
			||||||
  private configCore: SystemConfigCore;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor(configRepository: ISystemConfigRepository, private config: SystemConfig) {
 | 
					 | 
				
			||||||
    this.configCore = new SystemConfigCore(configRepository);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    custom.setHttpOptionsDefaults({
 | 
					 | 
				
			||||||
      timeout: 30000,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.configCore.config$.subscribe((config) => (this.config = config));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
 | 
					 | 
				
			||||||
    const response = {
 | 
					 | 
				
			||||||
      enabled: this.config.oauth.enabled,
 | 
					 | 
				
			||||||
      passwordLoginEnabled: this.config.passwordLogin.enabled,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!response.enabled) {
 | 
					 | 
				
			||||||
      return response;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const { scope, buttonText, autoLaunch } = this.config.oauth;
 | 
					 | 
				
			||||||
    const url = (await this.getClient()).authorizationUrl({
 | 
					 | 
				
			||||||
      redirect_uri: this.normalize(dto.redirectUri),
 | 
					 | 
				
			||||||
      scope,
 | 
					 | 
				
			||||||
      state: generators.state(),
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return { ...response, buttonText, url, autoLaunch };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async callback(url: string): Promise<OAuthProfile> {
 | 
					 | 
				
			||||||
    const redirectUri = this.normalize(url.split('?')[0]);
 | 
					 | 
				
			||||||
    const client = await this.getClient();
 | 
					 | 
				
			||||||
    const params = client.callbackParams(url);
 | 
					 | 
				
			||||||
    const tokens = await client.callback(redirectUri, params, { state: params.state });
 | 
					 | 
				
			||||||
    return await client.userinfo<OAuthProfile>(tokens.access_token || '');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  isAutoRegisterEnabled() {
 | 
					 | 
				
			||||||
    return this.config.oauth.autoRegister;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  asUser(profile: OAuthProfile) {
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
      firstName: profile.given_name || '',
 | 
					 | 
				
			||||||
      lastName: profile.family_name || '',
 | 
					 | 
				
			||||||
      email: profile.email,
 | 
					 | 
				
			||||||
      oauthId: profile.sub,
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async getLogoutEndpoint(): Promise<string | null> {
 | 
					 | 
				
			||||||
    if (!this.config.oauth.enabled) {
 | 
					 | 
				
			||||||
      return null;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async getClient() {
 | 
					 | 
				
			||||||
    const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!enabled) {
 | 
					 | 
				
			||||||
      throw new BadRequestException('OAuth2 is not enabled');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const metadata: ClientMetadata = {
 | 
					 | 
				
			||||||
      client_id: clientId,
 | 
					 | 
				
			||||||
      client_secret: clientSecret,
 | 
					 | 
				
			||||||
      response_types: ['code'],
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const issuer = await Issuer.discover(issuerUrl);
 | 
					 | 
				
			||||||
    const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
 | 
					 | 
				
			||||||
    if (algorithms[0] === 'HS256') {
 | 
					 | 
				
			||||||
      metadata.id_token_signed_response_alg = algorithms[0];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return new issuer.Client(metadata);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private normalize(redirectUri: string) {
 | 
					 | 
				
			||||||
    const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
 | 
					 | 
				
			||||||
    const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
 | 
					 | 
				
			||||||
    if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
 | 
					 | 
				
			||||||
      return mobileRedirectUri;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return redirectUri;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,217 +0,0 @@
 | 
				
			|||||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
 | 
					 | 
				
			||||||
import { BadRequestException } from '@nestjs/common';
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  authStub,
 | 
					 | 
				
			||||||
  loginResponseStub,
 | 
					 | 
				
			||||||
  newCryptoRepositoryMock,
 | 
					 | 
				
			||||||
  newSystemConfigRepositoryMock,
 | 
					 | 
				
			||||||
  newUserRepositoryMock,
 | 
					 | 
				
			||||||
  newUserTokenRepositoryMock,
 | 
					 | 
				
			||||||
  systemConfigStub,
 | 
					 | 
				
			||||||
  userEntityStub,
 | 
					 | 
				
			||||||
  userTokenEntityStub,
 | 
					 | 
				
			||||||
} from '@test';
 | 
					 | 
				
			||||||
import { generators, Issuer } from 'openid-client';
 | 
					 | 
				
			||||||
import { OAuthService } from '.';
 | 
					 | 
				
			||||||
import { LoginDetails } from '../auth';
 | 
					 | 
				
			||||||
import { ICryptoRepository } from '../crypto';
 | 
					 | 
				
			||||||
import { ISystemConfigRepository } from '../system-config';
 | 
					 | 
				
			||||||
import { IUserRepository } from '../user';
 | 
					 | 
				
			||||||
import { IUserTokenRepository } from '../user-token';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const email = 'user@immich.com';
 | 
					 | 
				
			||||||
const sub = 'my-auth-user-sub';
 | 
					 | 
				
			||||||
const loginDetails: LoginDetails = {
 | 
					 | 
				
			||||||
  isSecure: true,
 | 
					 | 
				
			||||||
  clientIp: '127.0.0.1',
 | 
					 | 
				
			||||||
  deviceOS: '',
 | 
					 | 
				
			||||||
  deviceType: '',
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe('OAuthService', () => {
 | 
					 | 
				
			||||||
  let sut: OAuthService;
 | 
					 | 
				
			||||||
  let userMock: jest.Mocked<IUserRepository>;
 | 
					 | 
				
			||||||
  let cryptoMock: jest.Mocked<ICryptoRepository>;
 | 
					 | 
				
			||||||
  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
					 | 
				
			||||||
  let userTokenMock: jest.Mocked<IUserTokenRepository>;
 | 
					 | 
				
			||||||
  let callbackMock: jest.Mock;
 | 
					 | 
				
			||||||
  let create: (config: SystemConfig) => OAuthService;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  beforeEach(async () => {
 | 
					 | 
				
			||||||
    callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    jest.spyOn(generators, 'state').mockReturnValue('state');
 | 
					 | 
				
			||||||
    jest.spyOn(Issuer, 'discover').mockResolvedValue({
 | 
					 | 
				
			||||||
      id_token_signing_alg_values_supported: ['HS256'],
 | 
					 | 
				
			||||||
      Client: jest.fn().mockResolvedValue({
 | 
					 | 
				
			||||||
        issuer: {
 | 
					 | 
				
			||||||
          metadata: {
 | 
					 | 
				
			||||||
            end_session_endpoint: 'http://end-session-endpoint',
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
 | 
					 | 
				
			||||||
        callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
 | 
					 | 
				
			||||||
        callback: callbackMock,
 | 
					 | 
				
			||||||
        userinfo: jest.fn().mockResolvedValue({ sub, email }),
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    } as any);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    cryptoMock = newCryptoRepositoryMock();
 | 
					 | 
				
			||||||
    configMock = newSystemConfigRepositoryMock();
 | 
					 | 
				
			||||||
    userMock = newUserRepositoryMock();
 | 
					 | 
				
			||||||
    userTokenMock = newUserTokenRepositoryMock();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    sut = create(systemConfigStub.disabled);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  it('should be defined', () => {
 | 
					 | 
				
			||||||
    expect(sut).toBeDefined();
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('getMobileRedirect', () => {
 | 
					 | 
				
			||||||
    it('should pass along the query params', () => {
 | 
					 | 
				
			||||||
      expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should work if called without query params', () => {
 | 
					 | 
				
			||||||
      expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('generateConfig', () => {
 | 
					 | 
				
			||||||
    it('should work when oauth is not configured', async () => {
 | 
					 | 
				
			||||||
      await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
 | 
					 | 
				
			||||||
        enabled: false,
 | 
					 | 
				
			||||||
        passwordLoginEnabled: false,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should generate the config', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
      await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
 | 
					 | 
				
			||||||
        enabled: true,
 | 
					 | 
				
			||||||
        buttonText: 'OAuth',
 | 
					 | 
				
			||||||
        url: 'http://authorization-url',
 | 
					 | 
				
			||||||
        autoLaunch: false,
 | 
					 | 
				
			||||||
        passwordLoginEnabled: true,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('login', () => {
 | 
					 | 
				
			||||||
    it('should throw an error if OAuth is not enabled', async () => {
 | 
					 | 
				
			||||||
      await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should not allow auto registering', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.noAutoRegister);
 | 
					 | 
				
			||||||
      userMock.getByEmail.mockResolvedValue(null);
 | 
					 | 
				
			||||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
 | 
					 | 
				
			||||||
        BadRequestException,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should link an existing user', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.noAutoRegister);
 | 
					 | 
				
			||||||
      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
 | 
					 | 
				
			||||||
        loginResponseStub.user1oauth,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
 | 
					 | 
				
			||||||
      expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should allow auto registering by default', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      userMock.getByEmail.mockResolvedValue(null);
 | 
					 | 
				
			||||||
      userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
      userMock.create.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
 | 
					 | 
				
			||||||
        loginResponseStub.user1oauth,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
 | 
					 | 
				
			||||||
      expect(userMock.create).toHaveBeenCalledTimes(1);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should use the mobile redirect override', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.override);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.override);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await sut.login({ url: `app.immich:///?code=abc123` }, loginDetails);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('link', () => {
 | 
					 | 
				
			||||||
    it('should link an account', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should not link an already linked oauth.sub', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
 | 
					 | 
				
			||||||
        BadRequestException,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(userMock.update).not.toHaveBeenCalled();
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('unlink', () => {
 | 
					 | 
				
			||||||
    it('should unlink an account', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      userMock.update.mockResolvedValue(userEntityStub.user1);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await sut.unlink(authStub.user1);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  describe('getLogoutEndpoint', () => {
 | 
					 | 
				
			||||||
    it('should return null if OAuth is not configured', async () => {
 | 
					 | 
				
			||||||
      await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should get the session endpoint from the discovery document', async () => {
 | 
					 | 
				
			||||||
      sut = create(systemConfigStub.enabled);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@ -1,92 +0,0 @@
 | 
				
			|||||||
import { SystemConfig } from '@app/infra/entities';
 | 
					 | 
				
			||||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
 | 
					 | 
				
			||||||
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
 | 
					 | 
				
			||||||
import { AuthCore, LoginDetails } from '../auth/auth.core';
 | 
					 | 
				
			||||||
import { ICryptoRepository } from '../crypto';
 | 
					 | 
				
			||||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 | 
					 | 
				
			||||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
 | 
					 | 
				
			||||||
import { IUserTokenRepository } from '../user-token';
 | 
					 | 
				
			||||||
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
 | 
					 | 
				
			||||||
import { MOBILE_REDIRECT } from './oauth.constants';
 | 
					 | 
				
			||||||
import { OAuthCore } from './oauth.core';
 | 
					 | 
				
			||||||
import { OAuthConfigResponseDto } from './response-dto';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@Injectable()
 | 
					 | 
				
			||||||
export class OAuthService {
 | 
					 | 
				
			||||||
  private authCore: AuthCore;
 | 
					 | 
				
			||||||
  private oauthCore: OAuthCore;
 | 
					 | 
				
			||||||
  private userCore: UserCore;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private readonly logger = new Logger(OAuthService.name);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor(
 | 
					 | 
				
			||||||
    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
 | 
					 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					 | 
				
			||||||
    @Inject(IUserRepository) userRepository: IUserRepository,
 | 
					 | 
				
			||||||
    @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
 | 
					 | 
				
			||||||
    @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
 | 
					 | 
				
			||||||
  ) {
 | 
					 | 
				
			||||||
    this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
 | 
					 | 
				
			||||||
    this.userCore = new UserCore(userRepository, cryptoRepository);
 | 
					 | 
				
			||||||
    this.oauthCore = new OAuthCore(configRepository, initialConfig);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getMobileRedirect(url: string) {
 | 
					 | 
				
			||||||
    return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
 | 
					 | 
				
			||||||
    return this.oauthCore.generateConfig(dto);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async login(
 | 
					 | 
				
			||||||
    dto: OAuthCallbackDto,
 | 
					 | 
				
			||||||
    loginDetails: LoginDetails,
 | 
					 | 
				
			||||||
  ): Promise<{ response: LoginResponseDto; cookie: string[] }> {
 | 
					 | 
				
			||||||
    const profile = await this.oauthCore.callback(dto.url);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
 | 
					 | 
				
			||||||
    let user = await this.userCore.getByOAuthId(profile.sub);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // link existing user
 | 
					 | 
				
			||||||
    if (!user) {
 | 
					 | 
				
			||||||
      const emailUser = await this.userCore.getByEmail(profile.email);
 | 
					 | 
				
			||||||
      if (emailUser) {
 | 
					 | 
				
			||||||
        user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // register new user
 | 
					 | 
				
			||||||
    if (!user) {
 | 
					 | 
				
			||||||
      if (!this.oauthCore.isAutoRegisterEnabled()) {
 | 
					 | 
				
			||||||
        this.logger.warn(
 | 
					 | 
				
			||||||
          `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        throw new BadRequestException(`User does not exist and auto registering is disabled.`);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
 | 
					 | 
				
			||||||
      user = await this.userCore.createUser(this.oauthCore.asUser(profile));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
 | 
					 | 
				
			||||||
    const { sub: oauthId } = await this.oauthCore.callback(dto.url);
 | 
					 | 
				
			||||||
    const duplicate = await this.userCore.getByOAuthId(oauthId);
 | 
					 | 
				
			||||||
    if (duplicate && duplicate.id !== user.id) {
 | 
					 | 
				
			||||||
      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
 | 
					 | 
				
			||||||
      throw new BadRequestException('This OAuth account has already been linked to another user.');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return this.userCore.updateUser(user, user.id, { oauthId });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
 | 
					 | 
				
			||||||
    return this.userCore.updateUser(user, user.id, { oauthId: '' });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  public async getLogoutEndpoint(): Promise<string | null> {
 | 
					 | 
				
			||||||
    return this.oauthCore.getLogoutEndpoint();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1 +0,0 @@
 | 
				
			|||||||
export * from './oauth-config-response.dto';
 | 
					 | 
				
			||||||
@ -4,7 +4,6 @@ import {
 | 
				
			|||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
  newSystemConfigRepositoryMock,
 | 
					  newSystemConfigRepositoryMock,
 | 
				
			||||||
  newUserRepositoryMock,
 | 
					  newUserRepositoryMock,
 | 
				
			||||||
  systemConfigStub,
 | 
					 | 
				
			||||||
  userEntityStub,
 | 
					  userEntityStub,
 | 
				
			||||||
} from '@test';
 | 
					} from '@test';
 | 
				
			||||||
import { when } from 'jest-when';
 | 
					import { when } from 'jest-when';
 | 
				
			||||||
@ -12,6 +11,7 @@ import { StorageTemplateService } from '.';
 | 
				
			|||||||
import { IAssetRepository } from '../asset';
 | 
					import { IAssetRepository } from '../asset';
 | 
				
			||||||
import { IStorageRepository } from '../storage/storage.repository';
 | 
					import { IStorageRepository } from '../storage/storage.repository';
 | 
				
			||||||
import { ISystemConfigRepository } from '../system-config';
 | 
					import { ISystemConfigRepository } from '../system-config';
 | 
				
			||||||
 | 
					import { defaults } from '../system-config/system-config.core';
 | 
				
			||||||
import { IUserRepository } from '../user';
 | 
					import { IUserRepository } from '../user';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe(StorageTemplateService.name, () => {
 | 
					describe(StorageTemplateService.name, () => {
 | 
				
			||||||
@ -31,7 +31,7 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
    userMock = newUserRepositoryMock();
 | 
					    userMock = newUserRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
 | 
					    sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('handle template migration', () => {
 | 
					  describe('handle template migration', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -16,7 +16,7 @@ import { ISystemConfigRepository } from './system-config.repository';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
 | 
					export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const defaults = Object.freeze<SystemConfig>({
 | 
					export const defaults = Object.freeze<SystemConfig>({
 | 
				
			||||||
  ffmpeg: {
 | 
					  ffmpeg: {
 | 
				
			||||||
    crf: 23,
 | 
					    crf: 23,
 | 
				
			||||||
    threads: 0,
 | 
					    threads: 0,
 | 
				
			||||||
 | 
				
			|||||||
@ -7,9 +7,9 @@ import {
 | 
				
			|||||||
  VideoCodec,
 | 
					  VideoCodec,
 | 
				
			||||||
} from '@app/infra/entities';
 | 
					} from '@app/infra/entities';
 | 
				
			||||||
import { BadRequestException } from '@nestjs/common';
 | 
					import { BadRequestException } from '@nestjs/common';
 | 
				
			||||||
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test';
 | 
					import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
 | 
				
			||||||
import { IJobRepository, JobName, QueueName } from '../job';
 | 
					import { IJobRepository, JobName, QueueName } from '../job';
 | 
				
			||||||
import { SystemConfigValidator } from './system-config.core';
 | 
					import { defaults, SystemConfigValidator } from './system-config.core';
 | 
				
			||||||
import { ISystemConfigRepository } from './system-config.repository';
 | 
					import { ISystemConfigRepository } from './system-config.repository';
 | 
				
			||||||
import { SystemConfigService } from './system-config.service';
 | 
					import { SystemConfigService } from './system-config.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -81,7 +81,7 @@ describe(SystemConfigService.name, () => {
 | 
				
			|||||||
    it('should return the default config', () => {
 | 
					    it('should return the default config', () => {
 | 
				
			||||||
      configMock.load.mockResolvedValue(updates);
 | 
					      configMock.load.mockResolvedValue(updates);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(sut.getDefaults()).toEqual(systemConfigStub.defaults);
 | 
					      expect(sut.getDefaults()).toEqual(defaults);
 | 
				
			||||||
      expect(configMock.load).not.toHaveBeenCalled();
 | 
					      expect(configMock.load).not.toHaveBeenCalled();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
@ -89,12 +89,9 @@ describe(SystemConfigService.name, () => {
 | 
				
			|||||||
  describe('addValidator', () => {
 | 
					  describe('addValidator', () => {
 | 
				
			||||||
    it('should call the validator on config changes', async () => {
 | 
					    it('should call the validator on config changes', async () => {
 | 
				
			||||||
      const validator: SystemConfigValidator = jest.fn();
 | 
					      const validator: SystemConfigValidator = jest.fn();
 | 
				
			||||||
 | 
					 | 
				
			||||||
      sut.addValidator(validator);
 | 
					      sut.addValidator(validator);
 | 
				
			||||||
 | 
					      await sut.updateConfig(defaults);
 | 
				
			||||||
      await sut.updateConfig(systemConfigStub.defaults);
 | 
					      expect(validator).toHaveBeenCalledWith(defaults);
 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults);
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -102,7 +99,7 @@ describe(SystemConfigService.name, () => {
 | 
				
			|||||||
    it('should return the default config', async () => {
 | 
					    it('should return the default config', async () => {
 | 
				
			||||||
      configMock.load.mockResolvedValue([]);
 | 
					      configMock.load.mockResolvedValue([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults);
 | 
					      await expect(sut.getConfig()).resolves.toEqual(defaults);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should merge the overrides', async () => {
 | 
					    it('should merge the overrides', async () => {
 | 
				
			||||||
@ -172,7 +169,7 @@ describe(SystemConfigService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      await sut.refreshConfig();
 | 
					      await sut.refreshConfig();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults);
 | 
					      expect(changeMock).toHaveBeenCalledWith(defaults);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      subscription.unsubscribe();
 | 
					      subscription.unsubscribe();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
				
			|||||||
@ -1,2 +0,0 @@
 | 
				
			|||||||
export * from './user-token.core';
 | 
					 | 
				
			||||||
export * from './user-token.repository';
 | 
					 | 
				
			||||||
@ -1,57 +0,0 @@
 | 
				
			|||||||
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
 | 
					 | 
				
			||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
 | 
					 | 
				
			||||||
import { DateTime } from 'luxon';
 | 
					 | 
				
			||||||
import { LoginDetails } from '../auth';
 | 
					 | 
				
			||||||
import { ICryptoRepository } from '../crypto';
 | 
					 | 
				
			||||||
import { IUserTokenRepository } from './user-token.repository';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@Injectable()
 | 
					 | 
				
			||||||
export class UserTokenCore {
 | 
					 | 
				
			||||||
  constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async validate(tokenValue: string) {
 | 
					 | 
				
			||||||
    const hashedToken = this.crypto.hashSha256(tokenValue);
 | 
					 | 
				
			||||||
    let token = await this.repository.getByToken(hashedToken);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (token?.user) {
 | 
					 | 
				
			||||||
      const now = DateTime.now();
 | 
					 | 
				
			||||||
      const updatedAt = DateTime.fromJSDate(token.updatedAt);
 | 
					 | 
				
			||||||
      const diff = now.diff(updatedAt, ['hours']);
 | 
					 | 
				
			||||||
      if (diff.hours > 1) {
 | 
					 | 
				
			||||||
        token = await this.repository.save({ ...token, updatedAt: new Date() });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        ...token.user,
 | 
					 | 
				
			||||||
        isPublicUser: false,
 | 
					 | 
				
			||||||
        isAllowUpload: true,
 | 
					 | 
				
			||||||
        isAllowDownload: true,
 | 
					 | 
				
			||||||
        isShowExif: true,
 | 
					 | 
				
			||||||
        accessTokenId: token.id,
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    throw new UnauthorizedException('Invalid user token');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
 | 
					 | 
				
			||||||
    const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
 | 
					 | 
				
			||||||
    const token = this.crypto.hashSha256(key);
 | 
					 | 
				
			||||||
    await this.repository.create({
 | 
					 | 
				
			||||||
      token,
 | 
					 | 
				
			||||||
      user,
 | 
					 | 
				
			||||||
      deviceOS: loginDetails.deviceOS,
 | 
					 | 
				
			||||||
      deviceType: loginDetails.deviceType,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return key;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async delete(userId: string, id: string): Promise<void> {
 | 
					 | 
				
			||||||
    await this.repository.delete(userId, id);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getAll(userId: string): Promise<UserTokenEntity[]> {
 | 
					 | 
				
			||||||
    return this.repository.getAll(userId);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,11 +1,11 @@
 | 
				
			|||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  AuthService,
 | 
				
			||||||
  AuthUserDto,
 | 
					  AuthUserDto,
 | 
				
			||||||
  LoginDetails,
 | 
					  LoginDetails,
 | 
				
			||||||
  LoginResponseDto,
 | 
					  LoginResponseDto,
 | 
				
			||||||
  OAuthCallbackDto,
 | 
					  OAuthCallbackDto,
 | 
				
			||||||
  OAuthConfigDto,
 | 
					  OAuthConfigDto,
 | 
				
			||||||
  OAuthConfigResponseDto,
 | 
					  OAuthConfigResponseDto,
 | 
				
			||||||
  OAuthService,
 | 
					 | 
				
			||||||
  UserResponseDto,
 | 
					  UserResponseDto,
 | 
				
			||||||
} from '@app/domain';
 | 
					} from '@app/domain';
 | 
				
			||||||
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
 | 
					import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
 | 
				
			||||||
@ -19,7 +19,7 @@ import { UseValidation } from '../app.utils';
 | 
				
			|||||||
@Authenticated()
 | 
					@Authenticated()
 | 
				
			||||||
@UseValidation()
 | 
					@UseValidation()
 | 
				
			||||||
export class OAuthController {
 | 
					export class OAuthController {
 | 
				
			||||||
  constructor(private service: OAuthService) {}
 | 
					  constructor(private service: AuthService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @PublicRoute()
 | 
					  @PublicRoute()
 | 
				
			||||||
  @Get('mobile-redirect')
 | 
					  @Get('mobile-redirect')
 | 
				
			||||||
@ -44,7 +44,7 @@ export class OAuthController {
 | 
				
			|||||||
    @Body() dto: OAuthCallbackDto,
 | 
					    @Body() dto: OAuthCallbackDto,
 | 
				
			||||||
    @GetLoginDetails() loginDetails: LoginDetails,
 | 
					    @GetLoginDetails() loginDetails: LoginDetails,
 | 
				
			||||||
  ): Promise<LoginResponseDto> {
 | 
					  ): Promise<LoginResponseDto> {
 | 
				
			||||||
    const { response, cookie } = await this.service.login(dto, loginDetails);
 | 
					    const { response, cookie } = await this.service.callback(dto, loginDetails);
 | 
				
			||||||
    res.header('Set-Cookie', cookie);
 | 
					    res.header('Set-Cookie', cookie);
 | 
				
			||||||
    return response;
 | 
					    return response;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { IUserTokenRepository } from '@app/domain/user-token';
 | 
					import { IUserTokenRepository } from '@app/domain';
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
					import { InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
import { Repository } from 'typeorm';
 | 
					import { Repository } from 'typeorm';
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,6 @@ import {
 | 
				
			|||||||
  AuthUserDto,
 | 
					  AuthUserDto,
 | 
				
			||||||
  ExifResponseDto,
 | 
					  ExifResponseDto,
 | 
				
			||||||
  mapUser,
 | 
					  mapUser,
 | 
				
			||||||
  QueueName,
 | 
					 | 
				
			||||||
  SearchResult,
 | 
					  SearchResult,
 | 
				
			||||||
  SharedLinkResponseDto,
 | 
					  SharedLinkResponseDto,
 | 
				
			||||||
  TagResponseDto,
 | 
					  TagResponseDto,
 | 
				
			||||||
@ -19,19 +18,17 @@ import {
 | 
				
			|||||||
  AssetEntity,
 | 
					  AssetEntity,
 | 
				
			||||||
  AssetFaceEntity,
 | 
					  AssetFaceEntity,
 | 
				
			||||||
  AssetType,
 | 
					  AssetType,
 | 
				
			||||||
  AudioCodec,
 | 
					 | 
				
			||||||
  ExifEntity,
 | 
					  ExifEntity,
 | 
				
			||||||
  PartnerEntity,
 | 
					  PartnerEntity,
 | 
				
			||||||
  PersonEntity,
 | 
					  PersonEntity,
 | 
				
			||||||
  SharedLinkEntity,
 | 
					  SharedLinkEntity,
 | 
				
			||||||
  SharedLinkType,
 | 
					  SharedLinkType,
 | 
				
			||||||
  SystemConfig,
 | 
					  SystemConfigEntity,
 | 
				
			||||||
 | 
					  SystemConfigKey,
 | 
				
			||||||
  TagEntity,
 | 
					  TagEntity,
 | 
				
			||||||
  TagType,
 | 
					  TagType,
 | 
				
			||||||
  TranscodePolicy,
 | 
					 | 
				
			||||||
  UserEntity,
 | 
					  UserEntity,
 | 
				
			||||||
  UserTokenEntity,
 | 
					  UserTokenEntity,
 | 
				
			||||||
  VideoCodec,
 | 
					 | 
				
			||||||
} from '@app/infra/entities';
 | 
					} from '@app/infra/entities';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const today = new Date();
 | 
					const today = new Date();
 | 
				
			||||||
@ -704,91 +701,28 @@ export const keyStub = {
 | 
				
			|||||||
  } as APIKeyEntity),
 | 
					  } as APIKeyEntity),
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const systemConfigStub = {
 | 
					export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
 | 
				
			||||||
  defaults: Object.freeze({
 | 
					  defaults: [],
 | 
				
			||||||
    ffmpeg: {
 | 
					  enabled: [
 | 
				
			||||||
      crf: 23,
 | 
					    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
 | 
				
			||||||
      threads: 0,
 | 
					    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
 | 
				
			||||||
      preset: 'ultrafast',
 | 
					    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
 | 
				
			||||||
      targetAudioCodec: AudioCodec.AAC,
 | 
					    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
 | 
				
			||||||
      targetResolution: '720',
 | 
					  ],
 | 
				
			||||||
      targetVideoCodec: VideoCodec.H264,
 | 
					  disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }],
 | 
				
			||||||
      maxBitrate: '0',
 | 
					  noAutoRegister: [
 | 
				
			||||||
      twoPass: false,
 | 
					    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
 | 
				
			||||||
      transcode: TranscodePolicy.REQUIRED,
 | 
					    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
 | 
				
			||||||
    },
 | 
					    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false },
 | 
				
			||||||
    job: {
 | 
					    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
 | 
				
			||||||
      [QueueName.BACKGROUND_TASK]: { concurrency: 5 },
 | 
					  ],
 | 
				
			||||||
      [QueueName.CLIP_ENCODING]: { concurrency: 2 },
 | 
					  override: [
 | 
				
			||||||
      [QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
 | 
					    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
 | 
				
			||||||
      [QueueName.OBJECT_TAGGING]: { concurrency: 2 },
 | 
					    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
 | 
				
			||||||
      [QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
 | 
					    { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true },
 | 
				
			||||||
      [QueueName.SEARCH]: { concurrency: 5 },
 | 
					    { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
 | 
				
			||||||
      [QueueName.SIDECAR]: { concurrency: 5 },
 | 
					    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
 | 
				
			||||||
      [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
 | 
					  ],
 | 
				
			||||||
      [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
 | 
					 | 
				
			||||||
      [QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    oauth: {
 | 
					 | 
				
			||||||
      autoLaunch: false,
 | 
					 | 
				
			||||||
      autoRegister: true,
 | 
					 | 
				
			||||||
      buttonText: 'Login with OAuth',
 | 
					 | 
				
			||||||
      clientId: '',
 | 
					 | 
				
			||||||
      clientSecret: '',
 | 
					 | 
				
			||||||
      enabled: false,
 | 
					 | 
				
			||||||
      issuerUrl: '',
 | 
					 | 
				
			||||||
      mobileOverrideEnabled: false,
 | 
					 | 
				
			||||||
      mobileRedirectUri: '',
 | 
					 | 
				
			||||||
      scope: 'openid email profile',
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    passwordLogin: {
 | 
					 | 
				
			||||||
      enabled: true,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    storageTemplate: {
 | 
					 | 
				
			||||||
      template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
  } as SystemConfig),
 | 
					 | 
				
			||||||
  enabled: Object.freeze({
 | 
					 | 
				
			||||||
    passwordLogin: {
 | 
					 | 
				
			||||||
      enabled: true,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    oauth: {
 | 
					 | 
				
			||||||
      enabled: true,
 | 
					 | 
				
			||||||
      autoRegister: true,
 | 
					 | 
				
			||||||
      buttonText: 'OAuth',
 | 
					 | 
				
			||||||
      autoLaunch: false,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
  } as SystemConfig),
 | 
					 | 
				
			||||||
  disabled: Object.freeze({
 | 
					 | 
				
			||||||
    passwordLogin: {
 | 
					 | 
				
			||||||
      enabled: false,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    oauth: {
 | 
					 | 
				
			||||||
      enabled: false,
 | 
					 | 
				
			||||||
      buttonText: 'OAuth',
 | 
					 | 
				
			||||||
      issuerUrl: 'http://issuer,',
 | 
					 | 
				
			||||||
      autoLaunch: false,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
  } as SystemConfig),
 | 
					 | 
				
			||||||
  noAutoRegister: {
 | 
					 | 
				
			||||||
    oauth: {
 | 
					 | 
				
			||||||
      enabled: true,
 | 
					 | 
				
			||||||
      autoRegister: false,
 | 
					 | 
				
			||||||
      autoLaunch: false,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    passwordLogin: { enabled: true },
 | 
					 | 
				
			||||||
  } as SystemConfig,
 | 
					 | 
				
			||||||
  override: {
 | 
					 | 
				
			||||||
    oauth: {
 | 
					 | 
				
			||||||
      enabled: true,
 | 
					 | 
				
			||||||
      autoRegister: true,
 | 
					 | 
				
			||||||
      autoLaunch: false,
 | 
					 | 
				
			||||||
      buttonText: 'OAuth',
 | 
					 | 
				
			||||||
      mobileOverrideEnabled: true,
 | 
					 | 
				
			||||||
      mobileRedirectUri: 'http://mobile-redirect',
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    passwordLogin: { enabled: true },
 | 
					 | 
				
			||||||
  } as SystemConfig,
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const loginResponseStub = {
 | 
					export const loginResponseStub = {
 | 
				
			||||||
 | 
				
			|||||||
@ -2,13 +2,13 @@
 | 
				
			|||||||
  import { goto } from '$app/navigation';
 | 
					  import { goto } from '$app/navigation';
 | 
				
			||||||
  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
					  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
				
			||||||
  import { AppRoute } from '$lib/constants';
 | 
					  import { AppRoute } from '$lib/constants';
 | 
				
			||||||
  import { handleError } from '$lib/utils/handle-error';
 | 
					  import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
 | 
				
			||||||
  import { api, oauth, OAuthConfigResponseDto } from '@api';
 | 
					  import { OAuthConfigResponseDto, api, oauth } from '@api';
 | 
				
			||||||
  import { createEventDispatcher, onMount } from 'svelte';
 | 
					  import { createEventDispatcher, onMount } from 'svelte';
 | 
				
			||||||
  import { fade } from 'svelte/transition';
 | 
					  import { fade } from 'svelte/transition';
 | 
				
			||||||
  import Button from '../elements/buttons/button.svelte';
 | 
					  import Button from '../elements/buttons/button.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let error: string;
 | 
					  let errorMessage: string;
 | 
				
			||||||
  let email = '';
 | 
					  let email = '';
 | 
				
			||||||
  let password = '';
 | 
					  let password = '';
 | 
				
			||||||
  let oauthError: string;
 | 
					  let oauthError: string;
 | 
				
			||||||
@ -53,7 +53,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const login = async () => {
 | 
					  const login = async () => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      error = '';
 | 
					      errorMessage = '';
 | 
				
			||||||
      loading = true;
 | 
					      loading = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const { data } = await api.authenticationApi.login({
 | 
					      const { data } = await api.authenticationApi.login({
 | 
				
			||||||
@ -70,8 +70,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      dispatch('success');
 | 
					      dispatch('success');
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    } catch (e) {
 | 
					    } catch (error) {
 | 
				
			||||||
      error = 'Incorrect email or password';
 | 
					      errorMessage = (await getServerErrorMessage(error)) || 'Incorrect email or password';
 | 
				
			||||||
      loading = false;
 | 
					      loading = false;
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -80,9 +80,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{#if authConfig.passwordLoginEnabled}
 | 
					{#if authConfig.passwordLoginEnabled}
 | 
				
			||||||
  <form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
 | 
					  <form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
 | 
				
			||||||
    {#if error}
 | 
					    {#if errorMessage}
 | 
				
			||||||
      <p class="text-red-400" transition:fade>
 | 
					      <p class="text-red-400" transition:fade>
 | 
				
			||||||
        {error}
 | 
					        {errorMessage}
 | 
				
			||||||
      </p>
 | 
					      </p>
 | 
				
			||||||
    {/if}
 | 
					    {/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -2,13 +2,7 @@ import type { ApiError } from '@api';
 | 
				
			|||||||
import { CanceledError } from 'axios';
 | 
					import { CanceledError } from 'axios';
 | 
				
			||||||
import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
 | 
					import { notificationController, NotificationType } from '../components/shared-components/notification/notification';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function handleError(error: unknown, message: string) {
 | 
					export async function getServerErrorMessage(error: unknown) {
 | 
				
			||||||
  if (error instanceof CanceledError) {
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  console.error(`[handleError]: ${message}`, error);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let data = (error as ApiError)?.response?.data;
 | 
					  let data = (error as ApiError)?.response?.data;
 | 
				
			||||||
  if (data instanceof Blob) {
 | 
					  if (data instanceof Blob) {
 | 
				
			||||||
    const response = await data.text();
 | 
					    const response = await data.text();
 | 
				
			||||||
@ -19,7 +13,17 @@ export async function handleError(error: unknown, message: string) {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let serverMessage = data?.message;
 | 
					  return data?.message || null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function handleError(error: unknown, message: string) {
 | 
				
			||||||
 | 
					  if (error instanceof CanceledError) {
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.error(`[handleError]: ${message}`, error);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let serverMessage = await getServerErrorMessage(error);
 | 
				
			||||||
  if (serverMessage) {
 | 
					  if (serverMessage) {
 | 
				
			||||||
    serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
 | 
					    serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user