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
|
|
@ -29,17 +29,35 @@ export class OAuthController {
|
|||
}
|
||||
|
||||
@Post('authorize')
|
||||
startOAuth(@Body() dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
|
||||
return this.service.authorize(dto);
|
||||
async startOAuth(
|
||||
@Body() dto: OAuthConfigDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<OAuthAuthorizeResponseDto> {
|
||||
const { url, state, codeVerifier } = await this.service.authorize(dto);
|
||||
return respondWithCookie(
|
||||
res,
|
||||
{ url },
|
||||
{
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [
|
||||
{ key: ImmichCookie.OAUTH_STATE, value: state },
|
||||
{ key: ImmichCookie.OAUTH_CODE_VERIFIER, value: codeVerifier },
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Post('callback')
|
||||
async finishOAuth(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const body = await this.service.callback(dto, loginDetails);
|
||||
const body = await this.service.callback(dto, request.headers, loginDetails);
|
||||
res.clearCookie(ImmichCookie.OAUTH_STATE);
|
||||
res.clearCookie(ImmichCookie.OAUTH_CODE_VERIFIER);
|
||||
return respondWithCookie(res, body, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [
|
||||
|
|
@ -52,8 +70,12 @@ export class OAuthController {
|
|||
|
||||
@Post('link')
|
||||
@Authenticated()
|
||||
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.link(auth, dto);
|
||||
linkOAuthAccount(
|
||||
@Req() request: Request,
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
): Promise<UserAdminResponseDto> {
|
||||
return this.service.link(auth, dto, request.headers);
|
||||
}
|
||||
|
||||
@Post('unlink')
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { Transform } from 'class-transformer';
|
|||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { toEmail } from 'src/validation';
|
||||
import { Optional, toEmail } from 'src/validation';
|
||||
|
||||
export type CookieResponse = {
|
||||
isSecure: boolean;
|
||||
values: Array<{ key: ImmichCookie; value: string }>;
|
||||
values: Array<{ key: ImmichCookie; value: string | null }>;
|
||||
};
|
||||
|
||||
export class AuthDto {
|
||||
|
|
@ -87,12 +87,28 @@ export class OAuthCallbackDto {
|
|||
@IsString()
|
||||
@ApiProperty()
|
||||
url!: string;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
codeVerifier?: string;
|
||||
}
|
||||
|
||||
export class OAuthConfigDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
redirectUri!: string;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
codeChallenge?: string;
|
||||
}
|
||||
|
||||
export class OAuthAuthorizeResponseDto {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export enum ImmichCookie {
|
|||
AUTH_TYPE = 'immich_auth_type',
|
||||
IS_AUTHENTICATED = 'immich_is_authenticated',
|
||||
SHARED_LINK_TOKEN = 'immich_shared_link_token',
|
||||
OAUTH_STATE = 'immich_oauth_state',
|
||||
OAUTH_CODE_VERIFIER = 'immich_oauth_code_verifier',
|
||||
}
|
||||
|
||||
export enum ImmichHeader {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { custom, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||
import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' };
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
export type OAuthConfig = {
|
||||
|
|
@ -12,7 +12,7 @@ export type OAuthConfig = {
|
|||
scope: string;
|
||||
signingAlgorithm: string;
|
||||
};
|
||||
export type OAuthProfile = UserinfoResponse;
|
||||
export type OAuthProfile = UserInfoResponse;
|
||||
|
||||
@Injectable()
|
||||
export class OAuthRepository {
|
||||
|
|
@ -20,30 +20,47 @@ export class OAuthRepository {
|
|||
this.logger.setContext(OAuthRepository.name);
|
||||
}
|
||||
|
||||
init() {
|
||||
custom.setHttpOptionsDefaults({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async authorize(config: OAuthConfig, redirectUrl: string) {
|
||||
async authorize(config: OAuthConfig, redirectUrl: string, state?: string, codeChallenge?: string) {
|
||||
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import(
|
||||
'openid-client'
|
||||
);
|
||||
const client = await this.getClient(config);
|
||||
return client.authorizationUrl({
|
||||
state ??= randomState();
|
||||
let codeVerifier: string | null;
|
||||
if (codeChallenge) {
|
||||
codeVerifier = null;
|
||||
} else {
|
||||
codeVerifier = randomPKCECodeVerifier();
|
||||
codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||
}
|
||||
const url = buildAuthorizationUrl(client, {
|
||||
redirect_uri: redirectUrl,
|
||||
scope: config.scope,
|
||||
state: generators.state(),
|
||||
});
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
}).toString();
|
||||
return { url, state, codeVerifier };
|
||||
}
|
||||
|
||||
async getLogoutEndpoint(config: OAuthConfig) {
|
||||
const client = await this.getClient(config);
|
||||
return client.issuer.metadata.end_session_endpoint;
|
||||
return client.serverMetadata().end_session_endpoint;
|
||||
}
|
||||
|
||||
async getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise<OAuthProfile> {
|
||||
async getProfile(
|
||||
config: OAuthConfig,
|
||||
url: string,
|
||||
expectedState: string,
|
||||
codeVerifier: string,
|
||||
): Promise<OAuthProfile> {
|
||||
const { authorizationCodeGrant, fetchUserInfo, ...oidc } = await import('openid-client');
|
||||
const client = await this.getClient(config);
|
||||
const params = client.callbackParams(url);
|
||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||
|
||||
try {
|
||||
const tokens = await client.callback(redirectUrl, params, { state: params.state });
|
||||
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||
if (!profile.sub) {
|
||||
throw new Error('Unexpected profile response, no `sub`');
|
||||
}
|
||||
|
|
@ -59,6 +76,11 @@ export class OAuthRepository {
|
|||
);
|
||||
}
|
||||
|
||||
if (error.code === 'OAUTH_INVALID_RESPONSE') {
|
||||
this.logger.warn(`Invalid response from authorization server. Cause: ${error.cause?.message}`);
|
||||
throw error.cause;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -83,14 +105,20 @@ export class OAuthRepository {
|
|||
signingAlgorithm,
|
||||
}: OAuthConfig) {
|
||||
try {
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
return new issuer.Client({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
response_types: ['code'],
|
||||
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
|
||||
id_token_signed_response_alg: signingAlgorithm,
|
||||
});
|
||||
const { allowInsecureRequests, discovery } = await import('openid-client');
|
||||
return await discovery(
|
||||
new URL(issuerUrl),
|
||||
clientId,
|
||||
{
|
||||
client_secret: clientSecret,
|
||||
response_types: ['code'],
|
||||
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
|
||||
id_token_signed_response_alg: signingAlgorithm,
|
||||
timeout: 30_000,
|
||||
},
|
||||
undefined,
|
||||
{ execute: [allowInsecureRequests] },
|
||||
);
|
||||
} 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 });
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe(AuthService.name, () => {
|
|||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AuthService));
|
||||
|
||||
mocks.oauth.authorize.mockResolvedValue('access-token');
|
||||
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
|
||||
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||
});
|
||||
|
|
@ -64,16 +64,6 @@ describe(AuthService.name, () => {
|
|||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('onBootstrap', () => {
|
||||
it('should init the repo', () => {
|
||||
mocks.oauth.init.mockResolvedValue();
|
||||
|
||||
sut.onBootstrap();
|
||||
|
||||
expect(mocks.oauth.init).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
|
|
@ -519,16 +509,22 @@ describe(AuthService.name, () => {
|
|||
|
||||
describe('callback', () => {
|
||||
it('should throw an error if OAuth is not enabled', async () => {
|
||||
await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
sut.callback({ url: '', state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow auto registering', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -541,9 +537,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||
|
|
@ -557,9 +557,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||
|
|
@ -574,9 +578,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -592,18 +600,19 @@ describe(AuthService.name, () => {
|
|||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.user.getByEmail).not.toHaveBeenCalled();
|
||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const url of [
|
||||
'app.immich:/',
|
||||
'app.immich://',
|
||||
'app.immich:///',
|
||||
'app.immich:/oauth-callback?code=abc123',
|
||||
'app.immich://oauth-callback?code=abc123',
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
|
|
@ -615,9 +624,14 @@ describe(AuthService.name, () => {
|
|||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await sut.callback({ url }, loginDetails);
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
|
||||
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect');
|
||||
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({}),
|
||||
'http://mobile-redirect?code=abc123',
|
||||
'xyz789',
|
||||
'foo',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -630,9 +644,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
|
@ -647,9 +665,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
|
@ -664,9 +686,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
|
@ -681,9 +707,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
|
|
@ -705,9 +735,13 @@ describe(AuthService.name, () => {
|
|||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse(user),
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
|
|
@ -779,7 +813,11 @@ describe(AuthService.name, () => {
|
|||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' });
|
||||
await sut.link(
|
||||
auth,
|
||||
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
|
||||
});
|
||||
|
|
@ -792,9 +830,9 @@ describe(AuthService.name, () => {
|
|||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||
|
||||
await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
sut.link(auth, { url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, {}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
|
|||
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
|
||||
[ImmichCookie.AUTH_TYPE]: defaults,
|
||||
[ImmichCookie.ACCESS_TOKEN]: defaults,
|
||||
[ImmichCookie.OAUTH_STATE]: defaults,
|
||||
[ImmichCookie.OAUTH_CODE_VERIFIER]: defaults,
|
||||
// no httpOnly so that the client can know the auth state
|
||||
[ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false },
|
||||
[ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue