refactor(server): auth/oauth (#3242)

* refactor(server): auth/oauth

* fix: show server error message on login failure
This commit is contained in:
Jason Rasmussen
2023-07-15 00:03:56 -04:00
committed by GitHub
parent 9ef41bf1c7
commit 08c7054845
30 changed files with 453 additions and 730 deletions
+133 -9
View File
@@ -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: '' });
});
});
});