import { BadRequestException, Inject, Injectable, InternalServerErrorException, UnauthorizedException, } from '@nestjs/common'; import { isNumber, isString } from 'class-validator'; import cookieParser from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserCore } from 'src/cores/user.core'; import { AuthDto, ChangePasswordDto, ImmichCookie, ImmichHeader, LoginCredentialDto, LogoutResponseDto, OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { HumanReadableSize } from 'src/utils/bytes'; export interface LoginDetails { isSecure: boolean; clientIp: string; deviceType: string; deviceOS: string; } interface OAuthProfile extends UserinfoResponse { email: string; } interface ClaimOptions { key: string; default: T; isValid: (value: unknown) => boolean; } @Injectable() export class AuthService { private configCore: SystemConfigCore; private userCore: UserCore; constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ISessionRepository) private sessionRepository: ISessionRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { this.logger.setContext(AuthService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); this.userCore = UserCore.create(cryptoRepository, userRepository); custom.setHttpOptionsDefaults({ timeout: 30_000 }); } async login(dto: LoginCredentialDto, details: LoginDetails) { const config = await this.configCore.getConfig({ withCache: false }); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); } let user = await this.userRepository.getByEmail(dto.email, true); if (user) { const isAuthenticated = this.validatePassword(dto.password, user); if (!isAuthenticated) { user = null; } } if (!user) { this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`); throw new UnauthorizedException('Incorrect email or password'); } return this.createLoginResponse(user, details); } async logout(auth: AuthDto, authType: AuthType): Promise { if (auth.session) { await this.sessionRepository.delete(auth.session.id); } return { successful: true, redirectUri: await this.getLogoutEndpoint(authType), }; } async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise { const { password, newPassword } = dto; const user = await this.userRepository.getByEmail(auth.user.email, true); if (!user) { throw new UnauthorizedException(); } const valid = this.validatePassword(password, user); if (!valid) { throw new BadRequestException('Wrong password'); } const hashedPassword = await this.cryptoRepository.hashBcrypt(newPassword, SALT_ROUNDS); const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword }); return mapUserAdmin(updatedUser); } async adminSignUp(dto: SignUpDto): Promise { const adminUser = await this.userRepository.getAdmin(); if (adminUser) { throw new BadRequestException('The server already has an admin'); } const admin = await this.userCore.createUser({ isAdmin: true, email: dto.email, name: dto.name, password: dto.password, storageLabel: 'admin', }); return mapUserAdmin(admin); } async validate(headers: IncomingHttpHeaders, params: Record): Promise { const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || params.key) as string; const session = (headers[ImmichHeader.USER_TOKEN] || headers[ImmichHeader.SESSION_TOKEN] || params.sessionKey || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string; if (shareKey) { return this.validateSharedLink(shareKey); } if (session) { return this.validateSession(session); } if (apiKey) { return this.validateApiKey(apiKey); } throw new UnauthorizedException('Authentication required'); } getMobileRedirect(url: string) { return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; } async authorize(dto: OAuthConfigDto): Promise { const config = await this.configCore.getConfig({ withCache: false }); if (!config.oauth.enabled) { throw new BadRequestException('OAuth is not enabled'); } const client = await this.getOAuthClient(config); const url = client.authorizationUrl({ redirect_uri: this.normalize(config, dto.redirectUri), scope: config.oauth.scope, state: generators.state(), }); return { url }; } async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { const config = await this.configCore.getConfig({ withCache: false }); const profile = await this.getOAuthProfile(config, dto.url); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user = await this.userRepository.getByOAuthId(profile.sub); // link existing user if (!user) { const emailUser = await this.userRepository.getByEmail(profile.email); if (emailUser && !emailUser.oauthId) { user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub }); } } const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth; // register new user if (!user) { if (!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}`); this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`); const storageLabel = this.getClaim(profile, { key: storageLabelClaim, default: '', isValid: isString, }); const storageQuota = this.getClaim(profile, { key: storageQuotaClaim, default: defaultStorageQuota, isValid: (value: unknown) => isNumber(value) && value >= 0, }); const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; user = await this.userCore.createUser({ name: userName, email: profile.email, oauthId: profile.sub, quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, storageLabel: storageLabel || null, }); } return this.createLoginResponse(user, loginDetails); } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { const config = await this.configCore.getConfig({ withCache: false }); const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); const duplicate = await this.userRepository.getByOAuthId(oauthId); if (duplicate && duplicate.id !== auth.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.'); } const user = await this.userRepository.update(auth.user.id, { oauthId }); return mapUserAdmin(user); } async unlink(auth: AuthDto): Promise { const user = await this.userRepository.update(auth.user.id, { oauthId: '' }); return mapUserAdmin(user); } private async getLogoutEndpoint(authType: AuthType): Promise { if (authType !== AuthType.OAUTH) { return LOGIN_URL; } const config = await this.configCore.getConfig({ withCache: false }); 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 { const redirectUri = this.normalize(config, url.split('?')[0]); const client = await this.getOAuthClient(config); const params = client.callbackParams(url); try { const tokens = await client.callback(redirectUri, params, { state: params.state }); return client.userinfo(tokens.access_token || ''); } catch (error: Error | any) { if (error.message.includes('unexpected JWT alg received')) { this.logger.warn( [ 'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.', 'Or, that you have specified a signing key in your OAuth provider.', ].join(' '), ); } throw error; } } private async getOAuthClient(config: SystemConfig) { const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm } = config.oauth; if (!enabled) { throw new BadRequestException('OAuth2 is not enabled'); } const metadata: ClientMetadata = { client_id: clientId, client_secret: clientSecret, response_types: ['code'], }; try { const issuer = await Issuer.discover(issuerUrl); metadata.id_token_signed_response_alg = signingAlgorithm; return new issuer.Client(metadata); } catch (error: any | AggregateError) { this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); } } 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') { return token; } return null; } private getCookieToken(headers: IncomingHttpHeaders): string | null { const cookies = cookieParser.parse(headers.cookie || ''); return cookies[ImmichCookie.ACCESS_TOKEN] || null; } async validateSharedLink(key: string | string[]): Promise { key = Array.isArray(key) ? key[0] : key; const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const sharedLink = await this.sharedLinkRepository.getByKey(bytes); if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { const user = sharedLink.user; if (user) { return { user, sharedLink }; } } throw new UnauthorizedException('Invalid share key'); } private async validateApiKey(key: string): Promise { const hashedKey = this.cryptoRepository.hashSha256(key); const apiKey = await this.keyRepository.getKey(hashedKey); if (apiKey?.user) { return { user: apiKey.user, apiKey }; } 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 validateSession(tokenValue: string): Promise { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const session = await this.sessionRepository.getByToken(hashedToken); if (session?.user) { const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); if (diff.hours > 1) { await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } return { user: session.user, session: session }; } throw new UnauthorizedException('Invalid user token'); } private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); await this.sessionRepository.create({ token, user, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, }); return mapLoginResponse(user, key); } private getClaim(profile: OAuthProfile, options: ClaimOptions): T { const value = profile[options.key as keyof OAuthProfile]; return options.isValid(value) ? (value as T) : options.default; } }