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