forked from Cutlery/immich
* feat(web,server): disable password login * chore: unit tests * chore: fix import * chore: linting * feat(cli): server command for enable/disable password login * chore: update docs * feat(web): confirm dialogue * chore: linting * chore: linting * chore: linting * chore: linting * chore: linting * chore: fix web test * chore: server unit tests
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
import { SystemConfig } from '@app/database/entities/system-config.entity';
|
|
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
|
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
|
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
|
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
|
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
|
import { UserResponseDto } from '../user/response-dto/user-response.dto';
|
|
import { IUserRepository } from '../user/user-repository';
|
|
import { UserCore } from '../user/user.core';
|
|
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
|
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
|
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
|
|
|
|
type OAuthProfile = UserinfoResponse & {
|
|
email: string;
|
|
};
|
|
|
|
export const MOBILE_REDIRECT = 'app.immich:/';
|
|
|
|
@Injectable()
|
|
export class OAuthService {
|
|
private readonly userCore: UserCore;
|
|
private readonly logger = new Logger(OAuthService.name);
|
|
|
|
constructor(
|
|
private immichJwtService: ImmichJwtService,
|
|
immichConfigService: ImmichConfigService,
|
|
@Inject(IUserRepository) userRepository: IUserRepository,
|
|
@Inject(INITIAL_SYSTEM_CONFIG) private config: SystemConfig,
|
|
) {
|
|
this.userCore = new UserCore(userRepository);
|
|
|
|
custom.setHttpOptionsDefaults({
|
|
timeout: 30000,
|
|
});
|
|
|
|
immichConfigService.config$.subscribe((config) => (this.config = config));
|
|
}
|
|
|
|
public 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 redirectUri = this.normalize(dto.redirectUri);
|
|
const url = (await this.getClient()).authorizationUrl({
|
|
redirect_uri: redirectUri,
|
|
scope,
|
|
state: generators.state(),
|
|
});
|
|
|
|
return { ...response, buttonText, url, autoLaunch };
|
|
}
|
|
|
|
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
|
|
const profile = await this.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.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.immichJwtService.createLoginResponse(user);
|
|
}
|
|
|
|
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
|
const { sub: oauthId } = await this.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> {
|
|
if (!this.config.oauth.enabled) {
|
|
return null;
|
|
}
|
|
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
|
}
|
|
|
|
private async callback(url: string): Promise<any> {
|
|
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 || '');
|
|
}
|
|
|
|
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 === MOBILE_REDIRECT;
|
|
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
|
|
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
|
return mobileRedirectUri;
|
|
}
|
|
return redirectUri;
|
|
}
|
|
}
|