mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 07:01:59 +00:00
feat(server): userinfo signing (#10756)
* feat(server): userinfo signing * chore: e2e tests
This commit is contained in:
parent
3cb42de931
commit
25a380d023
17 changed files with 1430 additions and 32 deletions
|
@ -26,6 +26,8 @@ services:
|
|||
volumes:
|
||||
- upload:/usr/src/app/upload
|
||||
- ./test-assets:/test-assets
|
||||
extra_hosts:
|
||||
- 'auth-server:host-gateway'
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
|
|
1023
e2e/package-lock.json
generated
1023
e2e/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -24,6 +24,7 @@
|
|||
"@playwright/test": "^1.44.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/oidc-provider": "^8.5.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
@ -35,7 +36,9 @@
|
|||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^54.0.0",
|
||||
"exiftool-vendored": "^27.0.0",
|
||||
"jose": "^5.6.3",
|
||||
"luxon": "^3.4.4",
|
||||
"oidc-provider": "^8.5.1",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
|
|
|
@ -1,12 +1,85 @@
|
|||
import {
|
||||
LoginResponseDto,
|
||||
SystemConfigOAuthDto,
|
||||
getConfigDefaults,
|
||||
getMyUser,
|
||||
startOAuth,
|
||||
updateConfig,
|
||||
} from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
|
||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const authServer = {
|
||||
internal: 'http://auth-server:3000',
|
||||
external: 'http://127.0.0.1:3000',
|
||||
};
|
||||
|
||||
const redirect = async (url: string, cookies?: string[]) => {
|
||||
const { headers } = await request(url)
|
||||
.get('/')
|
||||
.set('Cookie', cookies || []);
|
||||
return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
|
||||
};
|
||||
|
||||
const loginWithOAuth = async (sub: OAuthUser | string) => {
|
||||
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: `${baseUrl}/auth/login` } });
|
||||
|
||||
// login
|
||||
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
|
||||
const response2 = await request(authServer.external + response1.location)
|
||||
.post('/')
|
||||
.set('Cookie', response1.cookies)
|
||||
.type('form')
|
||||
.send({ prompt: 'login', login: sub, password: 'password' });
|
||||
|
||||
// approve
|
||||
const response3 = await redirect(response2.header.location, response1.cookies);
|
||||
const response4 = await request(authServer.external + response3.location)
|
||||
.post('/')
|
||||
.type('form')
|
||||
.set('Cookie', response3.cookies)
|
||||
.send({ prompt: 'consent' });
|
||||
|
||||
const response5 = await redirect(response4.header.location, response3.cookies.slice(1));
|
||||
const redirectUrl = response5.location;
|
||||
|
||||
expect(redirectUrl).toBeDefined();
|
||||
const params = new URL(redirectUrl).searchParams;
|
||||
expect(params.get('code')).toBeDefined();
|
||||
expect(params.get('state')).toBeDefined();
|
||||
|
||||
return redirectUrl;
|
||||
};
|
||||
|
||||
const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) => {
|
||||
const options = { headers: asBearerAuth(token) };
|
||||
const defaults = await getConfigDefaults(options);
|
||||
const merged = {
|
||||
...defaults.oauth,
|
||||
buttonText: 'Login with Immich',
|
||||
issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
|
||||
...dto,
|
||||
};
|
||||
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
|
||||
};
|
||||
|
||||
describe(`/oauth`, () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
await utils.adminSetup();
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.DEFAULT,
|
||||
clientSecret: OAuthClient.DEFAULT,
|
||||
buttonText: 'Login with Immich',
|
||||
storageLabelClaim: 'immich_username',
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /oauth/authorize', () => {
|
||||
|
@ -15,5 +88,171 @@ describe(`/oauth`, () => {
|
|||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
|
||||
});
|
||||
|
||||
it('should return a redirect uri', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/oauth/authorize')
|
||||
.send({ redirectUri: 'http://127.0.0.1:2283/auth/login' });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) });
|
||||
|
||||
const params = new URL(body.url).searchParams;
|
||||
expect(params.get('client_id')).toBe('client-default');
|
||||
expect(params.get('response_type')).toBe('code');
|
||||
expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2283/auth/login');
|
||||
expect(params.get('state')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /oauth/callback', () => {
|
||||
it(`should throw an error if a url is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
|
||||
});
|
||||
|
||||
it(`should throw an error if the url is empty`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
|
||||
});
|
||||
|
||||
it('should auto register the user by default', async () => {
|
||||
const url = await loginWithOAuth('oauth-auto-register');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
accessToken: expect.any(String),
|
||||
isAdmin: false,
|
||||
name: 'OAuth User',
|
||||
userEmail: 'oauth-auto-register@immich.app',
|
||||
userId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a user without an email', async () => {
|
||||
const url = await loginWithOAuth(OAuthUser.NO_EMAIL);
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('OAuth profile does not have an email address'));
|
||||
});
|
||||
|
||||
it('should set the quota from a claim', async () => {
|
||||
const url = await loginWithOAuth(OAuthUser.WITH_QUOTA);
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
accessToken: expect.any(String),
|
||||
userId: expect.any(String),
|
||||
userEmail: 'oauth-with-quota@immich.app',
|
||||
});
|
||||
|
||||
const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
|
||||
expect(user.quotaSizeInBytes).toBe(25 * 2 ** 30); // 25 GiB;
|
||||
});
|
||||
|
||||
it('should set the storage label from a claim', async () => {
|
||||
const url = await loginWithOAuth(OAuthUser.WITH_USERNAME);
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
accessToken: expect.any(String),
|
||||
userId: expect.any(String),
|
||||
userEmail: 'oauth-with-username@immich.app',
|
||||
});
|
||||
|
||||
const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
|
||||
expect(user.storageLabel).toBe('user-username');
|
||||
});
|
||||
|
||||
it('should work with RS256 signed tokens', async () => {
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.RS256_TOKENS,
|
||||
clientSecret: OAuthClient.RS256_TOKENS,
|
||||
autoRegister: true,
|
||||
buttonText: 'Login with Immich',
|
||||
signingAlgorithm: 'RS256',
|
||||
});
|
||||
const url = await loginWithOAuth('oauth-RS256-token');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
accessToken: expect.any(String),
|
||||
isAdmin: false,
|
||||
name: 'OAuth User',
|
||||
userEmail: 'oauth-RS256-token@immich.app',
|
||||
userId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with RS256 signed user profiles', async () => {
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.RS256_PROFILE,
|
||||
clientSecret: OAuthClient.RS256_PROFILE,
|
||||
buttonText: 'Login with Immich',
|
||||
profileSigningAlgorithm: 'RS256',
|
||||
});
|
||||
const url = await loginWithOAuth('oauth-signed-profile');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
userId: expect.any(String),
|
||||
userEmail: 'oauth-signed-profile@immich.app',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for an invalid token algorithm', async () => {
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.DEFAULT,
|
||||
clientSecret: OAuthClient.DEFAULT,
|
||||
buttonText: 'Login with Immich',
|
||||
signingAlgorithm: 'something-that-does-not-work',
|
||||
});
|
||||
const url = await loginWithOAuth('oauth-signed-bad');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(500);
|
||||
expect(body).toMatchObject({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to finish oauth',
|
||||
statusCode: 500,
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoRegister: false', () => {
|
||||
beforeAll(async () => {
|
||||
await setupOAuth(admin.accessToken, {
|
||||
enabled: true,
|
||||
clientId: OAuthClient.DEFAULT,
|
||||
clientSecret: OAuthClient.DEFAULT,
|
||||
autoRegister: false,
|
||||
buttonText: 'Login with Immich',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not auto register the user', async () => {
|
||||
const url = await loginWithOAuth('oauth-no-auto-register');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
||||
});
|
||||
|
||||
it('should link to an existing user by email', async () => {
|
||||
const { userId } = await utils.userSetup(admin.accessToken, {
|
||||
name: 'OAuth User 3',
|
||||
email: 'oauth-user3@immich.app',
|
||||
password: 'password',
|
||||
});
|
||||
const url = await loginWithOAuth('oauth-user3');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
userId,
|
||||
userEmail: 'oauth-user3@immich.app',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
117
e2e/src/setup/auth-server.ts
Normal file
117
e2e/src/setup/auth-server.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { exportJWK, generateKeyPair } from 'jose';
|
||||
import Provider from 'oidc-provider';
|
||||
|
||||
export enum OAuthClient {
|
||||
DEFAULT = 'client-default',
|
||||
RS256_TOKENS = 'client-RS256-tokens',
|
||||
RS256_PROFILE = 'client-RS256-profile',
|
||||
}
|
||||
|
||||
export enum OAuthUser {
|
||||
NO_EMAIL = 'no-email',
|
||||
NO_NAME = 'no-name',
|
||||
WITH_QUOTA = 'with-quota',
|
||||
WITH_USERNAME = 'with-username',
|
||||
}
|
||||
|
||||
const claims = [
|
||||
{ sub: OAuthUser.NO_EMAIL },
|
||||
{
|
||||
sub: OAuthUser.NO_NAME,
|
||||
email: 'oauth-no-name@immich.app',
|
||||
email_verified: true,
|
||||
},
|
||||
{
|
||||
sub: OAuthUser.WITH_USERNAME,
|
||||
email: 'oauth-with-username@immich.app',
|
||||
email_verified: true,
|
||||
immich_username: 'user-username',
|
||||
},
|
||||
{
|
||||
sub: OAuthUser.WITH_QUOTA,
|
||||
email: 'oauth-with-quota@immich.app',
|
||||
email_verified: true,
|
||||
preferred_username: 'user-quota',
|
||||
immich_quota: 25,
|
||||
},
|
||||
];
|
||||
|
||||
const withDefaultClaims = (sub: string) => ({
|
||||
sub,
|
||||
email: `${sub}@immich.app`,
|
||||
name: 'OAuth User',
|
||||
given_name: `OAuth`,
|
||||
family_name: 'User',
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||
|
||||
const setup = async () => {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
|
||||
const port = 3000;
|
||||
const host = '0.0.0.0';
|
||||
const oidc = new Provider(`http://${host}:${port}`, {
|
||||
renderError: async (ctx, out, error) => {
|
||||
console.error(out);
|
||||
console.error(error);
|
||||
ctx.body = 'Internal Server Error';
|
||||
},
|
||||
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
email: ['email', 'email_verified'],
|
||||
profile: ['name', 'given_name', 'family_name', 'preferred_username', 'immich_quota', 'immich_username'],
|
||||
},
|
||||
features: {
|
||||
jwtUserinfo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
names: {
|
||||
session: 'oidc.session',
|
||||
interaction: 'oidc.interaction',
|
||||
resume: 'oidc.resume',
|
||||
state: 'oidc.state',
|
||||
},
|
||||
},
|
||||
pkce: {
|
||||
required: () => false,
|
||||
},
|
||||
jwks: { keys: [await exportJWK(privateKey)] },
|
||||
clients: [
|
||||
{
|
||||
client_id: OAuthClient.DEFAULT,
|
||||
client_secret: OAuthClient.DEFAULT,
|
||||
redirect_uris: ['http://127.0.0.1:2283/auth/login'],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
},
|
||||
{
|
||||
client_id: OAuthClient.RS256_TOKENS,
|
||||
client_secret: OAuthClient.RS256_TOKENS,
|
||||
redirect_uris: ['http://127.0.0.1:2283/auth/login'],
|
||||
grant_types: ['authorization_code'],
|
||||
id_token_signed_response_alg: 'RS256',
|
||||
jwks: { keys: [await exportJWK(publicKey)] },
|
||||
},
|
||||
{
|
||||
client_id: OAuthClient.RS256_PROFILE,
|
||||
client_secret: OAuthClient.RS256_PROFILE,
|
||||
redirect_uris: ['http://127.0.0.1:2283/auth/login'],
|
||||
grant_types: ['authorization_code'],
|
||||
userinfo_signed_response_alg: 'RS256',
|
||||
jwks: { keys: [await exportJWK(publicKey)] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||
const app = oidc.listen(port, host, onStart);
|
||||
return () => app.close();
|
||||
};
|
||||
|
||||
export default setup;
|
|
@ -53,8 +53,7 @@ type AdminSetupOptions = { onboarding?: boolean };
|
|||
type AssetData = { bytes?: Buffer; filename: string };
|
||||
|
||||
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich';
|
||||
const baseUrl = 'http://127.0.0.1:2283';
|
||||
|
||||
export const baseUrl = 'http://127.0.0.1:2283';
|
||||
export const shareUrl = `${baseUrl}/share`;
|
||||
export const app = `${baseUrl}/api`;
|
||||
// TODO move test assets into e2e/assets
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// skip `docker compose up` if `make e2e` was already run
|
||||
const globalSetup: string[] = [];
|
||||
const globalSetup: string[] = ['src/setup/auth-server.ts'];
|
||||
try {
|
||||
await fetch('http://127.0.0.1:2283/api/server-info/ping');
|
||||
} catch {
|
||||
globalSetup.push('src/setup.ts');
|
||||
globalSetup.push('src/setup/docker-compose.ts');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
|
|
BIN
mobile/openapi/lib/model/system_config_o_auth_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_o_auth_dto.dart
generated
Binary file not shown.
|
@ -11030,6 +11030,9 @@
|
|||
"mobileRedirectUri": {
|
||||
"type": "string"
|
||||
},
|
||||
"profileSigningAlgorithm": {
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -11054,6 +11057,7 @@
|
|||
"issuerUrl",
|
||||
"mobileOverrideEnabled",
|
||||
"mobileRedirectUri",
|
||||
"profileSigningAlgorithm",
|
||||
"scope",
|
||||
"signingAlgorithm",
|
||||
"storageLabelClaim",
|
||||
|
|
|
@ -1063,6 +1063,7 @@ export type SystemConfigOAuthDto = {
|
|||
issuerUrl: string;
|
||||
mobileOverrideEnabled: boolean;
|
||||
mobileRedirectUri: string;
|
||||
profileSigningAlgorithm: string;
|
||||
scope: string;
|
||||
signingAlgorithm: string;
|
||||
storageLabelClaim: string;
|
||||
|
|
|
@ -146,6 +146,7 @@ export interface SystemConfig {
|
|||
mobileRedirectUri: string;
|
||||
scope: string;
|
||||
signingAlgorithm: string;
|
||||
profileSigningAlgorithm: string;
|
||||
storageLabelClaim: string;
|
||||
storageQuotaClaim: string;
|
||||
};
|
||||
|
@ -289,6 +290,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||
mobileRedirectUri: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
profileSigningAlgorithm: 'none',
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
},
|
||||
|
|
|
@ -349,6 +349,10 @@ class SystemConfigOAuthDto {
|
|||
@IsNotEmpty()
|
||||
signingAlgorithm!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
profileSigningAlgorithm!: string;
|
||||
|
||||
@IsString()
|
||||
storageLabelClaim!: string;
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { isNumber, isString } from 'class-validator';
|
|||
import cookieParser from 'cookie';
|
||||
import { DateTime } from 'luxon';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
|
||||
import { Issuer, UserinfoResponse, custom, generators } from 'openid-client';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
|
@ -45,9 +45,7 @@ export interface LoginDetails {
|
|||
deviceOS: string;
|
||||
}
|
||||
|
||||
interface OAuthProfile extends UserinfoResponse {
|
||||
email: string;
|
||||
}
|
||||
type OAuthProfile = UserinfoResponse;
|
||||
|
||||
interface ClaimOptions<T> {
|
||||
key: string;
|
||||
|
@ -192,34 +190,35 @@ export class AuthService {
|
|||
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
const profile = await this.getOAuthProfile(config, dto.url);
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth;
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user = await this.userRepository.getByOAuthId(profile.sub);
|
||||
|
||||
// link existing user
|
||||
if (!user) {
|
||||
// link by email
|
||||
if (!user && profile.email) {
|
||||
const emailUser = await this.userRepository.getByEmail(profile.email);
|
||||
if (emailUser) {
|
||||
if (emailUser.oauthId) {
|
||||
throw new BadRequestException('User already exists, but is linked to another account.');
|
||||
} else {
|
||||
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
|
||||
}
|
||||
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
|
||||
}
|
||||
}
|
||||
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth;
|
||||
|
||||
// register new user
|
||||
if (!user) {
|
||||
if (!autoRegister) {
|
||||
this.logger.warn(
|
||||
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
`Unable to register ${profile.sub}/${profile.email || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
);
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
|
||||
this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`);
|
||||
if (!profile.email) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
|
||||
|
||||
const storageLabel = this.getClaim(profile, {
|
||||
key: storageLabelClaim,
|
||||
|
@ -299,23 +298,21 @@ export class AuthService {
|
|||
}
|
||||
|
||||
private async getOAuthClient(config: SystemConfig) {
|
||||
const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm } = config.oauth;
|
||||
const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm, profileSigningAlgorithm } = config.oauth;
|
||||
|
||||
if (!enabled) {
|
||||
throw new BadRequestException('OAuth2 is not enabled');
|
||||
}
|
||||
|
||||
const metadata: ClientMetadata = {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
response_types: ['code'],
|
||||
};
|
||||
|
||||
try {
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
metadata.id_token_signed_response_alg = signingAlgorithm;
|
||||
|
||||
return new issuer.Client(metadata);
|
||||
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,
|
||||
});
|
||||
} 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 });
|
||||
|
|
|
@ -112,6 +112,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||
mobileRedirectUri: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
profileSigningAlgorithm: 'none',
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
},
|
||||
|
|
|
@ -144,6 +144,16 @@
|
|||
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
|
||||
desc={$t('admin.oauth_profile_signing_algorithm_description')}
|
||||
bind:value={config.oauth.profileSigningAlgorithm}
|
||||
required={true}
|
||||
disabled={disabled || !config.oauth.enabled}
|
||||
isEdited={!(config.oauth.profileSigningAlgorithm == savedConfig.oauth.profileSigningAlgorithm)}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
||||
|
|
|
@ -170,6 +170,8 @@
|
|||
"oauth_mobile_redirect_uri": "Mobile redirect URI",
|
||||
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
|
||||
"oauth_mobile_redirect_uri_override_description": "Enable when 'app.immich:/' is an invalid redirect URI.",
|
||||
"oauth_profile_signing_algorithm": "Profile signing algorithm",
|
||||
"oauth_profile_signing_algorithm_description": "Algorithm used to sign the user profile.",
|
||||
"oauth_scope": "Scope",
|
||||
"oauth_settings": "OAuth",
|
||||
"oauth_settings_description": "Manage OAuth login settings",
|
||||
|
|
Loading…
Reference in a new issue