feat: add oauth2 code verifier

* fix: ensure oauth state param matches before finishing oauth flow

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* chore: upgrade openid-client to v6

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* feat: use PKCE for oauth2 on supported clients

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* feat: use state and PKCE in mobile app

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: remove obsolete oauth repository init

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: rewrite callback url if mobile redirect url is enabled

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: propagate oidc client error cause when oauth callback fails

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: adapt auth service tests to required state and PKCE params

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: update sdk types

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: adapt oauth e2e test to work with PKCE

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: allow insecure (http) oauth clients

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

---------

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Tin Pecirep 2025-04-23 16:05:00 +02:00 committed by Zack Pollard
parent 13d6bd67b1
commit b7a0cf2470
18 changed files with 469 additions and 192 deletions

View file

@ -7,13 +7,11 @@ import { join } from 'node:path';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database';
import { OnEvent } from 'src/decorators';
import {
AuthDto,
ChangePasswordDto,
LoginCredentialDto,
LogoutResponseDto,
OAuthAuthorizeResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
SignUpDto,
@ -52,11 +50,6 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
@OnEvent({ name: 'app.bootstrap' })
onBootstrap() {
this.oauthRepository.init();
}
async login(dto: LoginCredentialDto, details: LoginDetails) {
const config = await this.getConfig({ withCache: false });
if (!config.passwordLogin.enabled) {
@ -176,20 +169,35 @@ export class AuthService extends BaseService {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
async authorize(dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
async authorize(dto: OAuthConfigDto) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const url = await this.oauthRepository.authorize(oauth, this.resolveRedirectUri(oauth, dto.redirectUri));
return { url };
return await this.oauthRepository.authorize(
oauth,
this.resolveRedirectUri(oauth, dto.redirectUri),
dto.state,
dto.codeChallenge,
);
}
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
}
const codeVerifier = dto.codeVerifier ?? this.getCookieCodeVerifier(headers);
if (!codeVerifier?.length) {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url));
const url = this.resolveRedirectUri(oauth, dto.url);
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
@ -271,13 +279,19 @@ export class AuthService extends BaseService {
}
}
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
async link(auth: AuthDto, dto: OAuthCallbackDto, headers: IncomingHttpHeaders): Promise<UserAdminResponseDto> {
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
}
const codeVerifier = dto.codeVerifier ?? this.getCookieCodeVerifier(headers);
if (!codeVerifier?.length) {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const { sub: oauthId } = await this.oauthRepository.getProfile(
oauth,
dto.url,
this.resolveRedirectUri(oauth, dto.url),
);
const { sub: oauthId } = await this.oauthRepository.getProfile(oauth, dto.url, expectedState, codeVerifier);
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}).`);
@ -320,6 +334,16 @@ export class AuthService extends BaseService {
return cookies[ImmichCookie.ACCESS_TOKEN] || null;
}
private getCookieOauthState(headers: IncomingHttpHeaders): string | null {
const cookies = parse(headers.cookie || '');
return cookies[ImmichCookie.OAUTH_STATE] || null;
}
private getCookieCodeVerifier(headers: IncomingHttpHeaders): string | null {
const cookies = parse(headers.cookie || '');
return cookies[ImmichCookie.OAUTH_CODE_VERIFIER] || null;
}
async validateSharedLink(key: string | string[]): Promise<AuthDto> {
key = Array.isArray(key) ? key[0] : key;
@ -399,11 +423,9 @@ export class AuthService extends BaseService {
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
url: string,
) {
const redirectUri = url.split('?')[0];
const isMobile = redirectUri.startsWith('app.immich:/');
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
if (mobileOverrideEnabled && mobileRedirectUri) {
return url.replace(/app\.immich:\/+oauth-callback/, mobileRedirectUri);
}
return redirectUri;
return url;
}
}