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:
|
volumes:
|
||||||
- upload:/usr/src/app/upload
|
- upload:/usr/src/app/upload
|
||||||
- ./test-assets:/test-assets
|
- ./test-assets:/test-assets
|
||||||
|
extra_hosts:
|
||||||
|
- 'auth-server:host-gateway'
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- database
|
- 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",
|
"@playwright/test": "^1.44.1",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/oidc-provider": "^8.5.1",
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
@ -35,7 +36,9 @@
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^54.0.0",
|
"eslint-plugin-unicorn": "^54.0.0",
|
||||||
"exiftool-vendored": "^27.0.0",
|
"exiftool-vendored": "^27.0.0",
|
||||||
|
"jose": "^5.6.3",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
|
"oidc-provider": "^8.5.1",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
|
|
|
@ -1,12 +1,85 @@
|
||||||
|
import {
|
||||||
|
LoginResponseDto,
|
||||||
|
SystemConfigOAuthDto,
|
||||||
|
getConfigDefaults,
|
||||||
|
getMyUser,
|
||||||
|
startOAuth,
|
||||||
|
updateConfig,
|
||||||
|
} from '@immich/sdk';
|
||||||
import { errorDto } from 'src/responses';
|
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 request from 'supertest';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
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`, () => {
|
describe(`/oauth`, () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await utils.resetDatabase();
|
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', () => {
|
describe('POST /oauth/authorize', () => {
|
||||||
|
@ -15,5 +88,171 @@ describe(`/oauth`, () => {
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
|
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 };
|
type AssetData = { bytes?: Buffer; filename: string };
|
||||||
|
|
||||||
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich';
|
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 shareUrl = `${baseUrl}/share`;
|
||||||
export const app = `${baseUrl}/api`;
|
export const app = `${baseUrl}/api`;
|
||||||
// TODO move test assets into e2e/assets
|
// TODO move test assets into e2e/assets
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
// skip `docker compose up` if `make e2e` was already run
|
// skip `docker compose up` if `make e2e` was already run
|
||||||
const globalSetup: string[] = [];
|
const globalSetup: string[] = ['src/setup/auth-server.ts'];
|
||||||
try {
|
try {
|
||||||
await fetch('http://127.0.0.1:2283/api/server-info/ping');
|
await fetch('http://127.0.0.1:2283/api/server-info/ping');
|
||||||
} catch {
|
} catch {
|
||||||
globalSetup.push('src/setup.ts');
|
globalSetup.push('src/setup/docker-compose.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
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": {
|
"mobileRedirectUri": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"profileSigningAlgorithm": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -11054,6 +11057,7 @@
|
||||||
"issuerUrl",
|
"issuerUrl",
|
||||||
"mobileOverrideEnabled",
|
"mobileOverrideEnabled",
|
||||||
"mobileRedirectUri",
|
"mobileRedirectUri",
|
||||||
|
"profileSigningAlgorithm",
|
||||||
"scope",
|
"scope",
|
||||||
"signingAlgorithm",
|
"signingAlgorithm",
|
||||||
"storageLabelClaim",
|
"storageLabelClaim",
|
||||||
|
|
|
@ -1063,6 +1063,7 @@ export type SystemConfigOAuthDto = {
|
||||||
issuerUrl: string;
|
issuerUrl: string;
|
||||||
mobileOverrideEnabled: boolean;
|
mobileOverrideEnabled: boolean;
|
||||||
mobileRedirectUri: string;
|
mobileRedirectUri: string;
|
||||||
|
profileSigningAlgorithm: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
signingAlgorithm: string;
|
signingAlgorithm: string;
|
||||||
storageLabelClaim: string;
|
storageLabelClaim: string;
|
||||||
|
|
|
@ -146,6 +146,7 @@ export interface SystemConfig {
|
||||||
mobileRedirectUri: string;
|
mobileRedirectUri: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
signingAlgorithm: string;
|
signingAlgorithm: string;
|
||||||
|
profileSigningAlgorithm: string;
|
||||||
storageLabelClaim: string;
|
storageLabelClaim: string;
|
||||||
storageQuotaClaim: string;
|
storageQuotaClaim: string;
|
||||||
};
|
};
|
||||||
|
@ -289,6 +290,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||||
mobileRedirectUri: '',
|
mobileRedirectUri: '',
|
||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
signingAlgorithm: 'RS256',
|
signingAlgorithm: 'RS256',
|
||||||
|
profileSigningAlgorithm: 'none',
|
||||||
storageLabelClaim: 'preferred_username',
|
storageLabelClaim: 'preferred_username',
|
||||||
storageQuotaClaim: 'immich_quota',
|
storageQuotaClaim: 'immich_quota',
|
||||||
},
|
},
|
||||||
|
|
|
@ -349,6 +349,10 @@ class SystemConfigOAuthDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
signingAlgorithm!: string;
|
signingAlgorithm!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
profileSigningAlgorithm!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
storageLabelClaim!: string;
|
storageLabelClaim!: string;
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { isNumber, isString } from 'class-validator';
|
||||||
import cookieParser from 'cookie';
|
import cookieParser from 'cookie';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { IncomingHttpHeaders } from 'node:http';
|
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 { SystemConfig } from 'src/config';
|
||||||
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
|
@ -45,9 +45,7 @@ export interface LoginDetails {
|
||||||
deviceOS: string;
|
deviceOS: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OAuthProfile extends UserinfoResponse {
|
type OAuthProfile = UserinfoResponse;
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClaimOptions<T> {
|
interface ClaimOptions<T> {
|
||||||
key: string;
|
key: string;
|
||||||
|
@ -192,34 +190,35 @@ export class AuthService {
|
||||||
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
||||||
const config = await this.configCore.getConfig({ withCache: false });
|
const config = await this.configCore.getConfig({ withCache: false });
|
||||||
const profile = await this.getOAuthProfile(config, dto.url);
|
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)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user = await this.userRepository.getByOAuthId(profile.sub);
|
let user = await this.userRepository.getByOAuthId(profile.sub);
|
||||||
|
|
||||||
// link existing user
|
// link by email
|
||||||
if (!user) {
|
if (!user && profile.email) {
|
||||||
const emailUser = await this.userRepository.getByEmail(profile.email);
|
const emailUser = await this.userRepository.getByEmail(profile.email);
|
||||||
if (emailUser) {
|
if (emailUser) {
|
||||||
if (emailUser.oauthId) {
|
if (emailUser.oauthId) {
|
||||||
throw new BadRequestException('User already exists, but is linked to another account.');
|
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
|
// register new user
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (!autoRegister) {
|
if (!autoRegister) {
|
||||||
this.logger.warn(
|
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.`);
|
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
|
if (!profile.email) {
|
||||||
this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`);
|
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, {
|
const storageLabel = this.getClaim(profile, {
|
||||||
key: storageLabelClaim,
|
key: storageLabelClaim,
|
||||||
|
@ -299,23 +298,21 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOAuthClient(config: SystemConfig) {
|
private async getOAuthClient(config: SystemConfig) {
|
||||||
const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm } = config.oauth;
|
const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm, profileSigningAlgorithm } = config.oauth;
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
throw new BadRequestException('OAuth2 is not enabled');
|
throw new BadRequestException('OAuth2 is not enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: ClientMetadata = {
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
response_types: ['code'],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issuer = await Issuer.discover(issuerUrl);
|
const issuer = await Issuer.discover(issuerUrl);
|
||||||
metadata.id_token_signed_response_alg = signingAlgorithm;
|
return new issuer.Client({
|
||||||
|
client_id: clientId,
|
||||||
return new issuer.Client(metadata);
|
client_secret: clientSecret,
|
||||||
|
response_types: ['code'],
|
||||||
|
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
|
||||||
|
id_token_signed_response_alg: signingAlgorithm,
|
||||||
|
});
|
||||||
} 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 });
|
||||||
|
|
|
@ -112,6 +112,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
mobileRedirectUri: '',
|
mobileRedirectUri: '',
|
||||||
scope: 'openid email profile',
|
scope: 'openid email profile',
|
||||||
signingAlgorithm: 'RS256',
|
signingAlgorithm: 'RS256',
|
||||||
|
profileSigningAlgorithm: 'none',
|
||||||
storageLabelClaim: 'preferred_username',
|
storageLabelClaim: 'preferred_username',
|
||||||
storageQuotaClaim: 'immich_quota',
|
storageQuotaClaim: 'immich_quota',
|
||||||
},
|
},
|
||||||
|
|
|
@ -144,6 +144,16 @@
|
||||||
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
|
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
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
||||||
|
|
|
@ -170,6 +170,8 @@
|
||||||
"oauth_mobile_redirect_uri": "Mobile redirect URI",
|
"oauth_mobile_redirect_uri": "Mobile redirect URI",
|
||||||
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
|
"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_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_scope": "Scope",
|
||||||
"oauth_settings": "OAuth",
|
"oauth_settings": "OAuth",
|
||||||
"oauth_settings_description": "Manage OAuth login settings",
|
"oauth_settings_description": "Manage OAuth login settings",
|
||||||
|
|
Loading…
Reference in a new issue