mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
13d6bd67b1
commit
b7a0cf2470
18 changed files with 469 additions and 192 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue