import { UserEntity } from '@app/infra/entities'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { IAccessRepositoryMock, authStub, keyStub, loginResponseStub, newAccessRepositoryMock, newCryptoRepositoryMock, newKeyRepositoryMock, newLibraryRepositoryMock, newSharedLinkRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, newUserTokenRepositoryMock, sharedLinkStub, systemConfigStub, userStub, userTokenStub, } from '@test'; import { IncomingHttpHeaders } from 'http'; import { Issuer, generators } from 'openid-client'; import { Socket } from 'socket.io'; import { ICryptoRepository, IKeyRepository, ILibraryRepository, ISharedLinkRepository, ISystemConfigRepository, IUserRepository, IUserTokenRepository, } from '../repositories'; import { AuthType } from './auth.constant'; import { AuthDto, SignUpDto } from './auth.dto'; import { AuthService } from './auth.service'; // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); const email = 'test@immich.com'; const sub = 'my-auth-user-sub'; const loginDetails = { isSecure: true, clientIp: '127.0.0.1', deviceOS: '', deviceType: '', }; const fixtures = { login: { email, password: 'password', }, }; describe('AuthService', () => { let sut: AuthService; let accessMock: jest.Mocked; let cryptoMock: jest.Mocked; let userMock: jest.Mocked; let libraryMock: jest.Mocked; let configMock: jest.Mocked; let userTokenMock: jest.Mocked; let shareMock: jest.Mocked; let keyMock: jest.Mocked; let callbackMock: jest.Mock; afterEach(() => { jest.resetModules(); }); beforeEach(async () => { callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' }); jest.spyOn(generators, 'state').mockReturnValue('state'); jest.spyOn(Issuer, 'discover').mockResolvedValue({ id_token_signing_alg_values_supported: ['HS256'], Client: jest.fn().mockResolvedValue({ issuer: { metadata: { end_session_endpoint: 'http://end-session-endpoint', }, }, authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'), callbackParams: jest.fn().mockReturnValue({ state: 'state' }), callback: callbackMock, userinfo: jest.fn().mockResolvedValue({ sub, email }), }), } as any); accessMock = newAccessRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); userMock = newUserRepositoryMock(); libraryMock = newLibraryRepositoryMock(); configMock = newSystemConfigRepositoryMock(); userTokenMock = newUserTokenRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); sut = new AuthService(accessMock, cryptoMock, configMock, libraryMock, userMock, userTokenMock, shareMock, keyMock); }); it('should be defined', () => { expect(sut).toBeDefined(); }); describe('login', () => { it('should throw an error if password login is disabled', async () => { configMock.load.mockResolvedValue(systemConfigStub.disabled); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); it('should check the user exists', async () => { userMock.getByEmail.mockResolvedValue(null); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should check the user has a password', async () => { userMock.getByEmail.mockResolvedValue({} as UserEntity); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); userTokenMock.create.mockResolvedValue(userTokenStub.userToken); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should generate the cookie headers (insecure)', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); userTokenMock.create.mockResolvedValue(userTokenStub.userToken); await expect( sut.login(fixtures.login, { clientIp: '127.0.0.1', isSecure: false, deviceOS: '', deviceType: '', }), ).resolves.toEqual(loginResponseStub.user1insecure); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); }); describe('changePassword', () => { it('should change the password', async () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; userMock.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); await sut.changePassword(auth, dto); expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true); expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); it('should throw when auth user email is not found', async () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; userMock.getByEmail.mockResolvedValue(null); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException); }); it('should throw when password does not match existing password', async () => { const auth = { user: { email: 'test@imimch.com' } as UserEntity }; const dto = { password: 'old-password', newPassword: 'new-password' }; cryptoMock.compareBcrypt.mockReturnValue(false); userMock.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); it('should throw when user does not have a password', async () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; userMock.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', } as UserEntity); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); }); describe('logout', () => { it('should return the end session endpoint', async () => { configMock.load.mockResolvedValue(systemConfigStub.enabled); const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, redirectUri: 'http://end-session-endpoint', }); }); it('should return the default redirect', async () => { const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); }); it('should delete the access token', async () => { const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto; await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); expect(userTokenMock.delete).toHaveBeenCalledWith('token123'); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); }); }); describe('adminSignUp', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; it('should only allow one admin', async () => { userMock.getAdmin.mockResolvedValue({} as UserEntity); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); expect(userMock.getAdmin).toHaveBeenCalled(); }); it('should sign up the admin', async () => { userMock.getAdmin.mockResolvedValue(null); userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01') } as UserEntity); await expect(sut.adminSignUp(dto)).resolves.toEqual({ avatarColor: expect.any(String), id: 'admin', createdAt: new Date('2021-01-01'), email: 'test@immich.com', name: 'immich admin', }); expect(userMock.getAdmin).toHaveBeenCalled(); expect(userMock.create).toHaveBeenCalled(); }); }); describe('validate - socket connections', () => { it('should throw token is not provided', async () => { await expect(sut.validate({}, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userStub.user1); userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({ user: userStub.user1, userToken: userTokenStub.userToken, }); }); }); describe('validate - shared key', () => { it('should not accept a non-existent key', async () => { shareMock.getByKey.mockResolvedValue(null); const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should not accept an expired key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should not accept a key without a user', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); userMock.get.mockResolvedValue(null); const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should accept a base64url key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.admin, sharedLink: sharedLinkStub.valid, }); expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); it('should accept a hex key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.admin, sharedLink: sharedLinkStub.valid, }); expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); describe('validate - user token', () => { it('should throw if no token is found', async () => { userTokenMock.getByToken.mockResolvedValue(null); const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should return an auth dto', async () => { userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.user1, userToken: userTokenStub.userToken, }); }); it('should update when access time exceeds an hour', async () => { userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken); userTokenMock.save.mockResolvedValue(userTokenStub.userToken); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.user1, userToken: userTokenStub.userToken, }); expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({ id: 'not_active', token: 'auth_token', userId: 'user-id', createdAt: new Date('2021-01-01'), updatedAt: expect.any(Date), deviceOS: 'Android', deviceType: 'Mobile', }); }); }); describe('validate - api key', () => { it('should throw an error if no api key is found', async () => { keyMock.getKey.mockResolvedValue(null); const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); it('should return an auth dto', async () => { keyMock.getKey.mockResolvedValue(keyStub.admin); const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.admin }); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); describe('getDevices', () => { it('should get the devices', async () => { userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]); await expect(sut.getDevices(authStub.user1)).resolves.toEqual([ { createdAt: '2021-01-01T00:00:00.000Z', current: true, deviceOS: '', deviceType: '', id: 'token-id', updatedAt: expect.any(String), }, { createdAt: '2021-01-01T00:00:00.000Z', current: false, deviceOS: 'Android', deviceType: 'Mobile', id: 'not_active', updatedAt: expect.any(String), }, ]); expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('logoutDevices', () => { it('should logout all devices', async () => { userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]); await sut.logoutDevices(authStub.user1); expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); expect(userTokenMock.delete).toHaveBeenCalledWith('not_active'); expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id'); }); }); describe('logoutDevice', () => { it('should logout the device', async () => { accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); await sut.logoutDevice(authStub.user1, 'token-1'); expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); expect(userTokenMock.delete).toHaveBeenCalledWith('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(userStub.user1); userMock.update.mockResolvedValue(userStub.user1); userTokenMock.create.mockResolvedValue(userTokenStub.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(userStub.user1.id, { oauthId: sub }); }); it('should allow auto registering by default', async () => { configMock.load.mockResolvedValue(systemConfigStub.enabled); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); userTokenMock.create.mockResolvedValue(userTokenStub.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(userStub.user1); userTokenMock.create.mockResolvedValue(userTokenStub.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(userStub.user1); userTokenMock.create.mockResolvedValue(userTokenStub.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(userStub.user1); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.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(userStub.user1); await sut.unlink(authStub.user1); expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); }); }); });