mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 20:25:32 -04: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
@ -6,6 +6,7 @@ import {
|
|||||||
startOAuth,
|
startOAuth,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
|
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
|
||||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||||
@ -21,18 +22,30 @@ const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redire
|
|||||||
|
|
||||||
const redirect = async (url: string, cookies?: string[]) => {
|
const redirect = async (url: string, cookies?: string[]) => {
|
||||||
const { headers } = await request(url)
|
const { headers } = await request(url)
|
||||||
.get('/')
|
.get('')
|
||||||
.set('Cookie', cookies || []);
|
.set('Cookie', cookies || []);
|
||||||
return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
|
return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to generate a code challenge from the verifier
|
||||||
|
const generateCodeChallenge = async (codeVerifier: string): Promise<string> => {
|
||||||
|
const hashed = createHash('sha256').update(codeVerifier).digest();
|
||||||
|
return hashed.toString('base64url');
|
||||||
|
};
|
||||||
|
|
||||||
const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => {
|
const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => {
|
||||||
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login` } });
|
const state = randomBytes(16).toString('base64url');
|
||||||
|
const codeVerifier = randomBytes(64).toString('base64url');
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
const { url } = await startOAuth({
|
||||||
|
oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login`, state, codeChallenge },
|
||||||
|
});
|
||||||
|
|
||||||
// login
|
// login
|
||||||
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
|
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
|
||||||
const response2 = await request(authServer.external + response1.location)
|
const response2 = await request(authServer.external + response1.location)
|
||||||
.post('/')
|
.post('')
|
||||||
.set('Cookie', response1.cookies)
|
.set('Cookie', response1.cookies)
|
||||||
.type('form')
|
.type('form')
|
||||||
.send({ prompt: 'login', login: sub, password: 'password' });
|
.send({ prompt: 'login', login: sub, password: 'password' });
|
||||||
@ -40,7 +53,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
|
|||||||
// approve
|
// approve
|
||||||
const response3 = await redirect(response2.header.location, response1.cookies);
|
const response3 = await redirect(response2.header.location, response1.cookies);
|
||||||
const response4 = await request(authServer.external + response3.location)
|
const response4 = await request(authServer.external + response3.location)
|
||||||
.post('/')
|
.post('')
|
||||||
.type('form')
|
.type('form')
|
||||||
.set('Cookie', response3.cookies)
|
.set('Cookie', response3.cookies)
|
||||||
.send({ prompt: 'consent' });
|
.send({ prompt: 'consent' });
|
||||||
@ -51,9 +64,9 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
|
|||||||
expect(redirectUrl).toBeDefined();
|
expect(redirectUrl).toBeDefined();
|
||||||
const params = new URL(redirectUrl).searchParams;
|
const params = new URL(redirectUrl).searchParams;
|
||||||
expect(params.get('code')).toBeDefined();
|
expect(params.get('code')).toBeDefined();
|
||||||
expect(params.get('state')).toBeDefined();
|
expect(params.get('state')).toBe(state);
|
||||||
|
|
||||||
return redirectUrl;
|
return { url: redirectUrl, state, codeVerifier };
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) => {
|
const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) => {
|
||||||
@ -119,9 +132,42 @@ describe(`/oauth`, () => {
|
|||||||
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
|
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto register the user by default', async () => {
|
it(`should throw an error if the state is not provided`, async () => {
|
||||||
const url = await loginWithOAuth('oauth-auto-register');
|
const { url } = await loginWithOAuth('oauth-auto-register');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('OAuth state is missing'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should throw an error if the state mismatches`, async () => {
|
||||||
|
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { state } = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/callback')
|
||||||
|
.send({ ...callbackParams, state });
|
||||||
|
expect(status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should throw an error if the codeVerifier is not provided`, async () => {
|
||||||
|
const { url, state } = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { status, body } = await request(app).post('/oauth/callback').send({ url, state });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('OAuth code verifier is missing'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should throw an error if the codeVerifier doesn't match the challenge`, async () => {
|
||||||
|
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { codeVerifier } = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/callback')
|
||||||
|
.send({ ...callbackParams, codeVerifier });
|
||||||
|
console.log(body);
|
||||||
|
expect(status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto register the user by default', async () => {
|
||||||
|
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
@ -132,16 +178,30 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow passing state and codeVerifier via cookies', async () => {
|
||||||
|
const { url, state, codeVerifier } = await loginWithOAuth('oauth-auto-register');
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/callback')
|
||||||
|
.set('Cookie', [`immich_oauth_state=${state}`, `immich_oauth_code_verifier=${codeVerifier}`])
|
||||||
|
.send({ url });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
accessToken: expect.any(String),
|
||||||
|
userId: expect.any(String),
|
||||||
|
userEmail: 'oauth-auto-register@immich.app',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle a user without an email', async () => {
|
it('should handle a user without an email', async () => {
|
||||||
const url = await loginWithOAuth(OAuthUser.NO_EMAIL);
|
const callbackParams = await loginWithOAuth(OAuthUser.NO_EMAIL);
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('OAuth profile does not have an email address'));
|
expect(body).toEqual(errorDto.badRequest('OAuth profile does not have an email address'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set the quota from a claim', async () => {
|
it('should set the quota from a claim', async () => {
|
||||||
const url = await loginWithOAuth(OAuthUser.WITH_QUOTA);
|
const callbackParams = await loginWithOAuth(OAuthUser.WITH_QUOTA);
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
@ -154,8 +214,8 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set the storage label from a claim', async () => {
|
it('should set the storage label from a claim', async () => {
|
||||||
const url = await loginWithOAuth(OAuthUser.WITH_USERNAME);
|
const callbackParams = await loginWithOAuth(OAuthUser.WITH_USERNAME);
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
@ -176,8 +236,8 @@ describe(`/oauth`, () => {
|
|||||||
buttonText: 'Login with Immich',
|
buttonText: 'Login with Immich',
|
||||||
signingAlgorithm: 'RS256',
|
signingAlgorithm: 'RS256',
|
||||||
});
|
});
|
||||||
const url = await loginWithOAuth('oauth-RS256-token');
|
const callbackParams = await loginWithOAuth('oauth-RS256-token');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
@ -196,8 +256,8 @@ describe(`/oauth`, () => {
|
|||||||
buttonText: 'Login with Immich',
|
buttonText: 'Login with Immich',
|
||||||
profileSigningAlgorithm: 'RS256',
|
profileSigningAlgorithm: 'RS256',
|
||||||
});
|
});
|
||||||
const url = await loginWithOAuth('oauth-signed-profile');
|
const callbackParams = await loginWithOAuth('oauth-signed-profile');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
userId: expect.any(String),
|
userId: expect.any(String),
|
||||||
@ -213,8 +273,8 @@ describe(`/oauth`, () => {
|
|||||||
buttonText: 'Login with Immich',
|
buttonText: 'Login with Immich',
|
||||||
signingAlgorithm: 'something-that-does-not-work',
|
signingAlgorithm: 'something-that-does-not-work',
|
||||||
});
|
});
|
||||||
const url = await loginWithOAuth('oauth-signed-bad');
|
const callbackParams = await loginWithOAuth('oauth-signed-bad');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(500);
|
expect(status).toBe(500);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error',
|
||||||
@ -235,8 +295,8 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not auto register the user', async () => {
|
it('should not auto register the user', async () => {
|
||||||
const url = await loginWithOAuth('oauth-no-auto-register');
|
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
||||||
});
|
});
|
||||||
@ -247,8 +307,8 @@ describe(`/oauth`, () => {
|
|||||||
email: 'oauth-user3@immich.app',
|
email: 'oauth-user3@immich.app',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
});
|
});
|
||||||
const url = await loginWithOAuth('oauth-user3');
|
const callbackParams = await loginWithOAuth('oauth-user3');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
userId,
|
userId,
|
||||||
@ -286,13 +346,15 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should auto register the user by default', async () => {
|
it('should auto register the user by default', async () => {
|
||||||
const url = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
|
const callbackParams = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
|
||||||
expect(url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
|
expect(callbackParams.url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
|
||||||
|
|
||||||
// simulate redirecting back to mobile app
|
// simulate redirecting back to mobile app
|
||||||
const redirectUri = url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
|
const url = callbackParams.url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
|
||||||
|
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send({ url: redirectUri });
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/callback')
|
||||||
|
.send({ ...callbackParams, url });
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
|
@ -13,6 +13,8 @@ class OAuthService {
|
|||||||
|
|
||||||
Future<String?> getOAuthServerUrl(
|
Future<String?> getOAuthServerUrl(
|
||||||
String serverUrl,
|
String serverUrl,
|
||||||
|
String state,
|
||||||
|
String codeChallenge,
|
||||||
) async {
|
) async {
|
||||||
// Resolve API server endpoint from user provided serverUrl
|
// Resolve API server endpoint from user provided serverUrl
|
||||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||||
@ -22,7 +24,11 @@ class OAuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final dto = await _apiService.oAuthApi.startOAuth(
|
final dto = await _apiService.oAuthApi.startOAuth(
|
||||||
OAuthConfigDto(redirectUri: redirectUri),
|
OAuthConfigDto(
|
||||||
|
redirectUri: redirectUri,
|
||||||
|
state: state,
|
||||||
|
codeChallenge: codeChallenge,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final authUrl = dto?.url;
|
final authUrl = dto?.url;
|
||||||
@ -31,7 +37,11 @@ class OAuthService {
|
|||||||
return authUrl;
|
return authUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LoginResponseDto?> oAuthLogin(String oauthUrl) async {
|
Future<LoginResponseDto?> oAuthLogin(
|
||||||
|
String oauthUrl,
|
||||||
|
String state,
|
||||||
|
String codeVerifier,
|
||||||
|
) async {
|
||||||
String result = await FlutterWebAuth2.authenticate(
|
String result = await FlutterWebAuth2.authenticate(
|
||||||
url: oauthUrl,
|
url: oauthUrl,
|
||||||
callbackUrlScheme: callbackUrlScheme,
|
callbackUrlScheme: callbackUrlScheme,
|
||||||
@ -49,6 +59,8 @@ class OAuthService {
|
|||||||
return await _apiService.oAuthApi.finishOAuth(
|
return await _apiService.oAuthApi.finishOAuth(
|
||||||
OAuthCallbackDto(
|
OAuthCallbackDto(
|
||||||
url: result,
|
url: result,
|
||||||
|
state: state,
|
||||||
|
codeVerifier: codeVerifier,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -203,13 +206,32 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String generateRandomString(int length) {
|
||||||
|
final random = Random.secure();
|
||||||
|
return base64Url
|
||||||
|
.encode(List<int>.generate(32, (i) => random.nextInt(256)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
|
||||||
|
var bytes = utf8.encode(codeVerifier);
|
||||||
|
var digest = sha256.convert(bytes);
|
||||||
|
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|
||||||
oAuthLogin() async {
|
oAuthLogin() async {
|
||||||
var oAuthService = ref.watch(oAuthServiceProvider);
|
var oAuthService = ref.watch(oAuthServiceProvider);
|
||||||
String? oAuthServerUrl;
|
String? oAuthServerUrl;
|
||||||
|
|
||||||
|
final state = generateRandomString(32);
|
||||||
|
final codeVerifier = generateRandomString(64);
|
||||||
|
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
oAuthServerUrl = await oAuthService
|
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
|
||||||
.getOAuthServerUrl(sanitizeUrl(serverEndpointController.text));
|
sanitizeUrl(serverEndpointController.text),
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
);
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
@ -230,8 +252,11 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (oAuthServerUrl != null) {
|
if (oAuthServerUrl != null) {
|
||||||
try {
|
try {
|
||||||
final loginResponseDto =
|
final loginResponseDto = await oAuthService.oAuthLogin(
|
||||||
await oAuthService.oAuthLogin(oAuthServerUrl);
|
oAuthServerUrl,
|
||||||
|
state,
|
||||||
|
codeVerifier,
|
||||||
|
);
|
||||||
|
|
||||||
if (loginResponseDto == null) {
|
if (loginResponseDto == null) {
|
||||||
return;
|
return;
|
||||||
|
43
mobile/openapi/lib/model/o_auth_callback_dto.dart
generated
43
mobile/openapi/lib/model/o_auth_callback_dto.dart
generated
@ -14,25 +14,36 @@ class OAuthCallbackDto {
|
|||||||
/// Returns a new [OAuthCallbackDto] instance.
|
/// Returns a new [OAuthCallbackDto] instance.
|
||||||
OAuthCallbackDto({
|
OAuthCallbackDto({
|
||||||
required this.url,
|
required this.url,
|
||||||
|
required this.state,
|
||||||
|
required this.codeVerifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
String url;
|
String url;
|
||||||
|
String state;
|
||||||
|
String codeVerifier;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is OAuthCallbackDto &&
|
bool operator ==(Object other) =>
|
||||||
other.url == url;
|
identical(this, other) ||
|
||||||
|
other is OAuthCallbackDto &&
|
||||||
|
other.url == url &&
|
||||||
|
other.state == state &&
|
||||||
|
other.codeVerifier == codeVerifier;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(url.hashCode);
|
(url.hashCode) + (state.hashCode) + (codeVerifier.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'OAuthCallbackDto[url=$url]';
|
String toString() =>
|
||||||
|
'OAuthCallbackDto[url=$url, state=$state, codeVerifier=$codeVerifier]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'url'] = this.url;
|
json[r'url'] = this.url;
|
||||||
|
json[r'state'] = this.state;
|
||||||
|
json[r'codeVerifier'] = this.codeVerifier;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,12 +57,17 @@ class OAuthCallbackDto {
|
|||||||
|
|
||||||
return OAuthCallbackDto(
|
return OAuthCallbackDto(
|
||||||
url: mapValueOfType<String>(json, r'url')!,
|
url: mapValueOfType<String>(json, r'url')!,
|
||||||
|
state: mapValueOfType<String>(json, r'state')!,
|
||||||
|
codeVerifier: mapValueOfType<String>(json, r'codeVerifier')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<OAuthCallbackDto> listFromJson(dynamic json, {bool growable = false,}) {
|
static List<OAuthCallbackDto> listFromJson(
|
||||||
|
dynamic json, {
|
||||||
|
bool growable = false,
|
||||||
|
}) {
|
||||||
final result = <OAuthCallbackDto>[];
|
final result = <OAuthCallbackDto>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
for (final row in json) {
|
||||||
@ -79,13 +95,19 @@ class OAuthCallbackDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maps a json object with a list of OAuthCallbackDto-objects as value to a dart map
|
// maps a json object with a list of OAuthCallbackDto-objects as value to a dart map
|
||||||
static Map<String, List<OAuthCallbackDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
static Map<String, List<OAuthCallbackDto>> mapListFromJson(
|
||||||
|
dynamic json, {
|
||||||
|
bool growable = false,
|
||||||
|
}) {
|
||||||
final map = <String, List<OAuthCallbackDto>>{};
|
final map = <String, List<OAuthCallbackDto>>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
// ignore: parameter_assignments
|
// ignore: parameter_assignments
|
||||||
json = json.cast<String, dynamic>();
|
json = json.cast<String, dynamic>();
|
||||||
for (final entry in json.entries) {
|
for (final entry in json.entries) {
|
||||||
map[entry.key] = OAuthCallbackDto.listFromJson(entry.value, growable: growable,);
|
map[entry.key] = OAuthCallbackDto.listFromJson(
|
||||||
|
entry.value,
|
||||||
|
growable: growable,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@ -94,6 +116,7 @@ class OAuthCallbackDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'url',
|
'url',
|
||||||
|
'state',
|
||||||
|
'codeVerifier',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
43
mobile/openapi/lib/model/o_auth_config_dto.dart
generated
43
mobile/openapi/lib/model/o_auth_config_dto.dart
generated
@ -14,25 +14,36 @@ class OAuthConfigDto {
|
|||||||
/// Returns a new [OAuthConfigDto] instance.
|
/// Returns a new [OAuthConfigDto] instance.
|
||||||
OAuthConfigDto({
|
OAuthConfigDto({
|
||||||
required this.redirectUri,
|
required this.redirectUri,
|
||||||
|
required this.state,
|
||||||
|
required this.codeChallenge,
|
||||||
});
|
});
|
||||||
|
|
||||||
String redirectUri;
|
String redirectUri;
|
||||||
|
String state;
|
||||||
|
String codeChallenge;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is OAuthConfigDto &&
|
bool operator ==(Object other) =>
|
||||||
other.redirectUri == redirectUri;
|
identical(this, other) ||
|
||||||
|
other is OAuthConfigDto &&
|
||||||
|
other.redirectUri == redirectUri &&
|
||||||
|
other.state == state &&
|
||||||
|
other.codeChallenge == codeChallenge;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(redirectUri.hashCode);
|
(redirectUri.hashCode) + (state.hashCode) + (codeChallenge.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'OAuthConfigDto[redirectUri=$redirectUri]';
|
String toString() =>
|
||||||
|
'OAuthConfigDto[redirectUri=$redirectUri, state=$state, codeChallenge=$codeChallenge]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'redirectUri'] = this.redirectUri;
|
json[r'redirectUri'] = this.redirectUri;
|
||||||
|
json[r'state'] = this.state;
|
||||||
|
json[r'codeChallenge'] = this.codeChallenge;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,12 +57,17 @@ class OAuthConfigDto {
|
|||||||
|
|
||||||
return OAuthConfigDto(
|
return OAuthConfigDto(
|
||||||
redirectUri: mapValueOfType<String>(json, r'redirectUri')!,
|
redirectUri: mapValueOfType<String>(json, r'redirectUri')!,
|
||||||
|
state: mapValueOfType<String>(json, r'state')!,
|
||||||
|
codeChallenge: mapValueOfType<String>(json, r'codeChallenge')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<OAuthConfigDto> listFromJson(dynamic json, {bool growable = false,}) {
|
static List<OAuthConfigDto> listFromJson(
|
||||||
|
dynamic json, {
|
||||||
|
bool growable = false,
|
||||||
|
}) {
|
||||||
final result = <OAuthConfigDto>[];
|
final result = <OAuthConfigDto>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
for (final row in json) {
|
||||||
@ -79,13 +95,19 @@ class OAuthConfigDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maps a json object with a list of OAuthConfigDto-objects as value to a dart map
|
// maps a json object with a list of OAuthConfigDto-objects as value to a dart map
|
||||||
static Map<String, List<OAuthConfigDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
static Map<String, List<OAuthConfigDto>> mapListFromJson(
|
||||||
|
dynamic json, {
|
||||||
|
bool growable = false,
|
||||||
|
}) {
|
||||||
final map = <String, List<OAuthConfigDto>>{};
|
final map = <String, List<OAuthConfigDto>>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
// ignore: parameter_assignments
|
// ignore: parameter_assignments
|
||||||
json = json.cast<String, dynamic>();
|
json = json.cast<String, dynamic>();
|
||||||
for (final entry in json.entries) {
|
for (final entry in json.entries) {
|
||||||
map[entry.key] = OAuthConfigDto.listFromJson(entry.value, growable: growable,);
|
map[entry.key] = OAuthConfigDto.listFromJson(
|
||||||
|
entry.value,
|
||||||
|
growable: growable,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@ -94,6 +116,7 @@ class OAuthConfigDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'redirectUri',
|
'redirectUri',
|
||||||
|
'state',
|
||||||
|
'codeChallenge',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,7 +303,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.4+2"
|
version: "0.3.4+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||||
|
@ -22,6 +22,7 @@ dependencies:
|
|||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
connectivity_plus: ^6.1.3
|
connectivity_plus: ^6.1.3
|
||||||
crop_image: ^1.0.16
|
crop_image: ^1.0.16
|
||||||
|
crypto: ^3.0.6
|
||||||
device_info_plus: ^11.3.3
|
device_info_plus: ^11.3.3
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
easy_image_viewer: ^1.5.1
|
easy_image_viewer: ^1.5.1
|
||||||
|
@ -10354,6 +10354,12 @@
|
|||||||
},
|
},
|
||||||
"OAuthCallbackDto": {
|
"OAuthCallbackDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"codeVerifier": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@ -10365,8 +10371,14 @@
|
|||||||
},
|
},
|
||||||
"OAuthConfigDto": {
|
"OAuthConfigDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"codeChallenge": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"redirectUri": {
|
"redirectUri": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
@ -688,12 +688,16 @@ export type TestEmailResponseDto = {
|
|||||||
};
|
};
|
||||||
export type OAuthConfigDto = {
|
export type OAuthConfigDto = {
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
state?: string;
|
||||||
|
codeChallenge?: string;
|
||||||
};
|
};
|
||||||
export type OAuthAuthorizeResponseDto = {
|
export type OAuthAuthorizeResponseDto = {
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
export type OAuthCallbackDto = {
|
export type OAuthCallbackDto = {
|
||||||
url: string;
|
url: string;
|
||||||
|
state?: string;
|
||||||
|
codeVerifier?: string;
|
||||||
};
|
};
|
||||||
export type PartnerResponseDto = {
|
export type PartnerResponseDto = {
|
||||||
avatarColor: UserAvatarColor;
|
avatarColor: UserAvatarColor;
|
||||||
|
49
server/package-lock.json
generated
49
server/package-lock.json
generated
@ -52,7 +52,7 @@
|
|||||||
"nestjs-kysely": "^1.1.0",
|
"nestjs-kysely": "^1.1.0",
|
||||||
"nestjs-otel": "^6.0.0",
|
"nestjs-otel": "^6.0.0",
|
||||||
"nodemailer": "^6.9.13",
|
"nodemailer": "^6.9.13",
|
||||||
"openid-client": "^5.4.3",
|
"openid-client": "^6.3.3",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@ -11370,9 +11370,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "4.15.9",
|
"version": "6.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.8.tgz",
|
||||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
"integrity": "sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@ -11879,18 +11879,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"yallist": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/luxon": {
|
"node_modules/luxon": {
|
||||||
"version": "3.6.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
|
||||||
@ -12750,6 +12738,14 @@
|
|||||||
"set-blocking": "^2.0.0"
|
"set-blocking": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
"node_modules/nwsapi": {
|
"node_modules/nwsapi": {
|
||||||
"version": "2.2.20",
|
"version": "2.2.20",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
|
||||||
@ -12869,29 +12865,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "6.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.3.3.tgz",
|
||||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
"integrity": "sha512-lTK8AV8SjqCM4qznLX0asVESAwzV39XTVdfMAM185ekuaZCnkWdPzcxMTXNlsm9tsUAMa1Q30MBmKAykdT1LWw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^4.15.9",
|
"jose": "^6.0.6",
|
||||||
"lru-cache": "^6.0.0",
|
"oauth4webapi": "^3.3.0"
|
||||||
"object-hash": "^2.2.0",
|
|
||||||
"oidc-token-hash": "^5.0.3"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openid-client/node_modules/object-hash": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
@ -77,7 +77,7 @@
|
|||||||
"nestjs-kysely": "^1.1.0",
|
"nestjs-kysely": "^1.1.0",
|
||||||
"nestjs-otel": "^6.0.0",
|
"nestjs-otel": "^6.0.0",
|
||||||
"nodemailer": "^6.9.13",
|
"nodemailer": "^6.9.13",
|
||||||
"openid-client": "^5.4.3",
|
"openid-client": "^6.3.3",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
@ -29,17 +29,35 @@ export class OAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('authorize')
|
@Post('authorize')
|
||||||
startOAuth(@Body() dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
|
async startOAuth(
|
||||||
return this.service.authorize(dto);
|
@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')
|
@Post('callback')
|
||||||
async finishOAuth(
|
async finishOAuth(
|
||||||
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Body() dto: OAuthCallbackDto,
|
@Body() dto: OAuthCallbackDto,
|
||||||
@GetLoginDetails() loginDetails: LoginDetails,
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
): Promise<LoginResponseDto> {
|
): 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, {
|
return respondWithCookie(res, body, {
|
||||||
isSecure: loginDetails.isSecure,
|
isSecure: loginDetails.isSecure,
|
||||||
values: [
|
values: [
|
||||||
@ -52,8 +70,12 @@ export class OAuthController {
|
|||||||
|
|
||||||
@Post('link')
|
@Post('link')
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
linkOAuthAccount(
|
||||||
return this.service.link(auth, dto);
|
@Req() request: Request,
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Body() dto: OAuthCallbackDto,
|
||||||
|
): Promise<UserAdminResponseDto> {
|
||||||
|
return this.service.link(auth, dto, request.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('unlink')
|
@Post('unlink')
|
||||||
|
@ -3,11 +3,11 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||||
import { ImmichCookie } from 'src/enum';
|
import { ImmichCookie } from 'src/enum';
|
||||||
import { toEmail } from 'src/validation';
|
import { Optional, toEmail } from 'src/validation';
|
||||||
|
|
||||||
export type CookieResponse = {
|
export type CookieResponse = {
|
||||||
isSecure: boolean;
|
isSecure: boolean;
|
||||||
values: Array<{ key: ImmichCookie; value: string }>;
|
values: Array<{ key: ImmichCookie; value: string | null }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthDto {
|
export class AuthDto {
|
||||||
@ -87,12 +87,28 @@ export class OAuthCallbackDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
url!: string;
|
url!: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsString()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsString()
|
||||||
|
codeVerifier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OAuthConfigDto {
|
export class OAuthConfigDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
redirectUri!: string;
|
redirectUri!: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsString()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsString()
|
||||||
|
codeChallenge?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OAuthAuthorizeResponseDto {
|
export class OAuthAuthorizeResponseDto {
|
||||||
|
@ -8,6 +8,8 @@ export enum ImmichCookie {
|
|||||||
AUTH_TYPE = 'immich_auth_type',
|
AUTH_TYPE = 'immich_auth_type',
|
||||||
IS_AUTHENTICATED = 'immich_is_authenticated',
|
IS_AUTHENTICATED = 'immich_is_authenticated',
|
||||||
SHARED_LINK_TOKEN = 'immich_shared_link_token',
|
SHARED_LINK_TOKEN = 'immich_shared_link_token',
|
||||||
|
OAUTH_STATE = 'immich_oauth_state',
|
||||||
|
OAUTH_CODE_VERIFIER = 'immich_oauth_code_verifier',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ImmichHeader {
|
export enum ImmichHeader {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
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';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|
||||||
export type OAuthConfig = {
|
export type OAuthConfig = {
|
||||||
@ -12,7 +12,7 @@ export type OAuthConfig = {
|
|||||||
scope: string;
|
scope: string;
|
||||||
signingAlgorithm: string;
|
signingAlgorithm: string;
|
||||||
};
|
};
|
||||||
export type OAuthProfile = UserinfoResponse;
|
export type OAuthProfile = UserInfoResponse;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthRepository {
|
export class OAuthRepository {
|
||||||
@ -20,30 +20,47 @@ export class OAuthRepository {
|
|||||||
this.logger.setContext(OAuthRepository.name);
|
this.logger.setContext(OAuthRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
async authorize(config: OAuthConfig, redirectUrl: string, state?: string, codeChallenge?: string) {
|
||||||
custom.setHttpOptionsDefaults({ timeout: 30_000 });
|
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import(
|
||||||
}
|
'openid-client'
|
||||||
|
);
|
||||||
async authorize(config: OAuthConfig, redirectUrl: string) {
|
|
||||||
const client = await this.getClient(config);
|
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,
|
redirect_uri: redirectUrl,
|
||||||
scope: config.scope,
|
scope: config.scope,
|
||||||
state: generators.state(),
|
state,
|
||||||
});
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
}).toString();
|
||||||
|
return { url, state, codeVerifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLogoutEndpoint(config: OAuthConfig) {
|
async getLogoutEndpoint(config: OAuthConfig) {
|
||||||
const client = await this.getClient(config);
|
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 client = await this.getClient(config);
|
||||||
const params = client.callbackParams(url);
|
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await client.callback(redirectUrl, params, { state: params.state });
|
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||||
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||||
if (!profile.sub) {
|
if (!profile.sub) {
|
||||||
throw new Error('Unexpected profile response, no `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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,14 +105,20 @@ export class OAuthRepository {
|
|||||||
signingAlgorithm,
|
signingAlgorithm,
|
||||||
}: OAuthConfig) {
|
}: OAuthConfig) {
|
||||||
try {
|
try {
|
||||||
const issuer = await Issuer.discover(issuerUrl);
|
const { allowInsecureRequests, discovery } = await import('openid-client');
|
||||||
return new issuer.Client({
|
return await discovery(
|
||||||
client_id: clientId,
|
new URL(issuerUrl),
|
||||||
client_secret: clientSecret,
|
clientId,
|
||||||
response_types: ['code'],
|
{
|
||||||
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
|
client_secret: clientSecret,
|
||||||
id_token_signed_response_alg: signingAlgorithm,
|
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) {
|
} catch (error: any | AggregateError) {
|
||||||
this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors);
|
this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors);
|
||||||
throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error });
|
throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error });
|
||||||
|
@ -55,7 +55,7 @@ describe(AuthService.name, () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
({ sut, mocks } = newTestService(AuthService));
|
({ 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.getProfile.mockResolvedValue({ sub, email });
|
||||||
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||||
});
|
});
|
||||||
@ -64,16 +64,6 @@ describe(AuthService.name, () => {
|
|||||||
expect(sut).toBeDefined();
|
expect(sut).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('onBootstrap', () => {
|
|
||||||
it('should init the repo', () => {
|
|
||||||
mocks.oauth.init.mockResolvedValue();
|
|
||||||
|
|
||||||
sut.onBootstrap();
|
|
||||||
|
|
||||||
expect(mocks.oauth.init).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('should throw an error if password login is disabled', async () => {
|
it('should throw an error if password login is disabled', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||||
@ -519,16 +509,22 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('callback', () => {
|
describe('callback', () => {
|
||||||
it('should throw an error if OAuth is not enabled', async () => {
|
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 () => {
|
it('should not allow auto registering', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
await expect(
|
||||||
BadRequestException,
|
sut.callback(
|
||||||
);
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@ -541,9 +537,13 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
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.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||||
@ -557,9 +557,13 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.getAdmin.mockResolvedValue(user);
|
mocks.user.getAdmin.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
|
await expect(
|
||||||
BadRequestException,
|
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.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||||
@ -574,9 +578,13 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
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.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||||
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
||||||
@ -592,18 +600,19 @@ describe(AuthService.name, () => {
|
|||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
await expect(
|
||||||
BadRequestException,
|
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.getByEmail).not.toHaveBeenCalled();
|
||||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const url of [
|
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',
|
'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.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
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.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
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 }));
|
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.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
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 }));
|
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.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
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 }));
|
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.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
sut.callback(
|
||||||
);
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).resolves.toEqual(oauthResponse(user));
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -705,9 +735,13 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(
|
||||||
oauthResponse(user),
|
sut.callback(
|
||||||
);
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).resolves.toEqual(oauthResponse(user));
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -779,7 +813,11 @@ describe(AuthService.name, () => {
|
|||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
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 });
|
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
|
||||||
});
|
});
|
||||||
@ -792,9 +830,9 @@ describe(AuthService.name, () => {
|
|||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||||
|
|
||||||
await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
await expect(
|
||||||
BadRequestException,
|
sut.link(auth, { url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, {}),
|
||||||
);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
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 { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
import { OnEvent } from 'src/decorators';
|
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
LoginCredentialDto,
|
LoginCredentialDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
OAuthAuthorizeResponseDto,
|
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
SignUpDto,
|
SignUpDto,
|
||||||
@ -52,11 +50,6 @@ export type ValidateRequest = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService extends BaseService {
|
export class AuthService extends BaseService {
|
||||||
@OnEvent({ name: 'app.bootstrap' })
|
|
||||||
onBootstrap() {
|
|
||||||
this.oauthRepository.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
||||||
const config = await this.getConfig({ withCache: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
if (!config.passwordLogin.enabled) {
|
if (!config.passwordLogin.enabled) {
|
||||||
@ -176,20 +169,35 @@ export class AuthService extends BaseService {
|
|||||||
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authorize(dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
|
async authorize(dto: OAuthConfigDto) {
|
||||||
const { oauth } = await this.getConfig({ withCache: false });
|
const { oauth } = await this.getConfig({ withCache: false });
|
||||||
|
|
||||||
if (!oauth.enabled) {
|
if (!oauth.enabled) {
|
||||||
throw new BadRequestException('OAuth is not enabled');
|
throw new BadRequestException('OAuth is not enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await this.oauthRepository.authorize(oauth, this.resolveRedirectUri(oauth, dto.redirectUri));
|
return await this.oauthRepository.authorize(
|
||||||
return { url };
|
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 { 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;
|
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
||||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
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 { oauth } = await this.getConfig({ withCache: false });
|
||||||
const { sub: oauthId } = await this.oauthRepository.getProfile(
|
const { sub: oauthId } = await this.oauthRepository.getProfile(oauth, dto.url, expectedState, codeVerifier);
|
||||||
oauth,
|
|
||||||
dto.url,
|
|
||||||
this.resolveRedirectUri(oauth, dto.url),
|
|
||||||
);
|
|
||||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||||
if (duplicate && duplicate.id !== auth.user.id) {
|
if (duplicate && duplicate.id !== auth.user.id) {
|
||||||
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
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;
|
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> {
|
async validateSharedLink(key: string | string[]): Promise<AuthDto> {
|
||||||
key = Array.isArray(key) ? key[0] : key;
|
key = Array.isArray(key) ? key[0] : key;
|
||||||
|
|
||||||
@ -399,11 +423,9 @@ export class AuthService extends BaseService {
|
|||||||
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
||||||
url: string,
|
url: string,
|
||||||
) {
|
) {
|
||||||
const redirectUri = url.split('?')[0];
|
if (mobileOverrideEnabled && mobileRedirectUri) {
|
||||||
const isMobile = redirectUri.startsWith('app.immich:/');
|
return url.replace(/app\.immich:\/+oauth-callback/, mobileRedirectUri);
|
||||||
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
|
||||||
return mobileRedirectUri;
|
|
||||||
}
|
}
|
||||||
return redirectUri;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
|
|||||||
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
|
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
|
||||||
[ImmichCookie.AUTH_TYPE]: defaults,
|
[ImmichCookie.AUTH_TYPE]: defaults,
|
||||||
[ImmichCookie.ACCESS_TOKEN]: 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
|
// no httpOnly so that the client can know the auth state
|
||||||
[ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false },
|
[ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false },
|
||||||
[ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
|
[ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
|
||||||
|
Loading…
x
Reference in New Issue
Block a user