refactor(server)*: tsconfigs (#2689)

* refactor(server): tsconfigs

* chore: dummy commit

* fix: start.sh

* chore: restore original entry scripts
This commit is contained in:
Jason Rasmussen
2023-06-08 11:01:07 -04:00
committed by GitHub
parent a2130aa6c5
commit 8ebac41318
465 changed files with 209 additions and 332 deletions

View File

@@ -0,0 +1,2 @@
export * from './oauth-auth-code.dto';
export * from './oauth-config.dto';

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
url!: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class OAuthConfigDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
redirectUri!: string;
}

View File

@@ -0,0 +1,4 @@
export * from './dto';
export * from './oauth.constants';
export * from './oauth.service';
export * from './response-dto';

View File

@@ -0,0 +1 @@
export const MOBILE_REDIRECT = 'app.immich:/';

View File

@@ -0,0 +1,107 @@
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;
}
}

View File

@@ -0,0 +1,217 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { generators, Issuer } from 'openid-client';
import {
authStub,
loginResponseStub,
newCryptoRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
newUserTokenRepositoryMock,
systemConfigStub,
userEntityStub,
userTokenEntityStub,
} from '@test';
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');
});
});
});

View File

@@ -0,0 +1,92 @@
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();
}
}

View File

@@ -0,0 +1 @@
export * from './oauth-config-response.dto';

View File

@@ -0,0 +1,7 @@
export class OAuthConfigResponseDto {
enabled!: boolean;
passwordLoginEnabled!: boolean;
url?: string;
buttonText?: string;
autoLaunch?: boolean;
}