mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:27:08 -04: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