From 4ce9bce414b0a66cd2fc083ff3edb79bc2578d29 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Mon, 7 Jul 2025 00:45:32 +0200
Subject: [PATCH] feat: oauth role claim (#19758)
---
docs/docs/administration/oauth.md | 1 +
e2e/src/api/specs/oauth.e2e-spec.ts | 15 ++++
e2e/src/setup/auth-server.ts | 17 +++-
i18n/en.json | 2 +
.../lib/model/system_config_o_auth_dto.dart | 10 ++-
open-api/immich-openapi-specs.json | 4 +
open-api/typescript-sdk/src/fetch-client.ts | 1 +
server/src/config.ts | 2 +
server/src/dtos/system-config.dto.ts | 3 +
server/src/services/auth.service.spec.ts | 89 +++++++++++++++++++
server/src/services/auth.service.ts | 8 +-
.../services/system-config.service.spec.ts | 1 +
.../settings/auth/auth-settings.svelte | 10 +++
13 files changed, 160 insertions(+), 3 deletions(-)
diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md
index 5105d2eb75..833b70f77a 100644
--- a/docs/docs/administration/oauth.md
+++ b/docs/docs/administration/oauth.md
@@ -62,6 +62,7 @@ Once you have a new OAuth client application configured, Immich can be configure
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
+| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts
index 9e4d64892e..58fc43a2d5 100644
--- a/e2e/src/api/specs/oauth.e2e-spec.ts
+++ b/e2e/src/api/specs/oauth.e2e-spec.ts
@@ -227,6 +227,21 @@ describe(`/oauth`, () => {
expect(user.storageLabel).toBe('user-username');
});
+ it('should set the admin status from a role claim', async () => {
+ const callbackParams = await loginWithOAuth(OAuthUser.WITH_ROLE);
+ const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
+ expect(status).toBe(201);
+ expect(body).toMatchObject({
+ accessToken: expect.any(String),
+ userId: expect.any(String),
+ userEmail: 'oauth-with-role@immich.app',
+ isAdmin: true,
+ });
+
+ const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
+ expect(user.isAdmin).toBe(true);
+ });
+
it('should work with RS256 signed tokens', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
diff --git a/e2e/src/setup/auth-server.ts b/e2e/src/setup/auth-server.ts
index 575e97d291..489bda2ee4 100644
--- a/e2e/src/setup/auth-server.ts
+++ b/e2e/src/setup/auth-server.ts
@@ -12,6 +12,7 @@ export enum OAuthUser {
NO_NAME = 'no-name',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
+ WITH_ROLE = 'with-role',
}
const claims = [
@@ -34,6 +35,12 @@ const claims = [
preferred_username: 'user-quota',
immich_quota: 25,
},
+ {
+ sub: OAuthUser.WITH_ROLE,
+ email: 'oauth-with-role@immich.app',
+ email_verified: true,
+ immich_role: 'admin',
+ },
];
const withDefaultClaims = (sub: string) => ({
@@ -64,7 +71,15 @@ const setup = async () => {
claims: {
openid: ['sub'],
email: ['email', 'email_verified'],
- profile: ['name', 'given_name', 'family_name', 'preferred_username', 'immich_quota', 'immich_username'],
+ profile: [
+ 'name',
+ 'given_name',
+ 'family_name',
+ 'preferred_username',
+ 'immich_quota',
+ 'immich_username',
+ 'immich_role',
+ ],
},
features: {
jwtUserinfo: {
diff --git a/i18n/en.json b/i18n/en.json
index 91a55cc85f..e5c995f957 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -196,6 +196,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 OAuth provider does not allow a mobile URI, like ''{callback}''",
+ "oauth_role_claim": "Role Claim",
+ "oauth_role_claim_description": "Automatically grant admin access based on the presence of this claim. The claim may have either 'user' or 'admin'.",
"oauth_settings": "OAuth",
"oauth_settings_description": "Manage OAuth login settings",
"oauth_settings_more_details": "For more details about this feature, refer to the docs.",
diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart
index e100e8e5ca..c8f91be1f1 100644
--- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart
+++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart
@@ -24,6 +24,7 @@ class SystemConfigOAuthDto {
required this.mobileOverrideEnabled,
required this.mobileRedirectUri,
required this.profileSigningAlgorithm,
+ required this.roleClaim,
required this.scope,
required this.signingAlgorithm,
required this.storageLabelClaim,
@@ -55,6 +56,8 @@ class SystemConfigOAuthDto {
String profileSigningAlgorithm;
+ String roleClaim;
+
String scope;
String signingAlgorithm;
@@ -81,6 +84,7 @@ class SystemConfigOAuthDto {
other.mobileOverrideEnabled == mobileOverrideEnabled &&
other.mobileRedirectUri == mobileRedirectUri &&
other.profileSigningAlgorithm == profileSigningAlgorithm &&
+ other.roleClaim == roleClaim &&
other.scope == scope &&
other.signingAlgorithm == signingAlgorithm &&
other.storageLabelClaim == storageLabelClaim &&
@@ -102,6 +106,7 @@ class SystemConfigOAuthDto {
(mobileOverrideEnabled.hashCode) +
(mobileRedirectUri.hashCode) +
(profileSigningAlgorithm.hashCode) +
+ (roleClaim.hashCode) +
(scope.hashCode) +
(signingAlgorithm.hashCode) +
(storageLabelClaim.hashCode) +
@@ -110,7 +115,7 @@ class SystemConfigOAuthDto {
(tokenEndpointAuthMethod.hashCode);
@override
- String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]';
+ String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, roleClaim=$roleClaim, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]';
Map toJson() {
final json = {};
@@ -129,6 +134,7 @@ class SystemConfigOAuthDto {
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
json[r'mobileRedirectUri'] = this.mobileRedirectUri;
json[r'profileSigningAlgorithm'] = this.profileSigningAlgorithm;
+ json[r'roleClaim'] = this.roleClaim;
json[r'scope'] = this.scope;
json[r'signingAlgorithm'] = this.signingAlgorithm;
json[r'storageLabelClaim'] = this.storageLabelClaim;
@@ -158,6 +164,7 @@ class SystemConfigOAuthDto {
mobileOverrideEnabled: mapValueOfType(json, r'mobileOverrideEnabled')!,
mobileRedirectUri: mapValueOfType(json, r'mobileRedirectUri')!,
profileSigningAlgorithm: mapValueOfType(json, r'profileSigningAlgorithm')!,
+ roleClaim: mapValueOfType(json, r'roleClaim')!,
scope: mapValueOfType(json, r'scope')!,
signingAlgorithm: mapValueOfType(json, r'signingAlgorithm')!,
storageLabelClaim: mapValueOfType(json, r'storageLabelClaim')!,
@@ -222,6 +229,7 @@ class SystemConfigOAuthDto {
'mobileOverrideEnabled',
'mobileRedirectUri',
'profileSigningAlgorithm',
+ 'roleClaim',
'scope',
'signingAlgorithm',
'storageLabelClaim',
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 0dc0c43ec8..7a44a5cf6f 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -14654,6 +14654,9 @@
"profileSigningAlgorithm": {
"type": "string"
},
+ "roleClaim": {
+ "type": "string"
+ },
"scope": {
"type": "string"
},
@@ -14690,6 +14693,7 @@
"mobileOverrideEnabled",
"mobileRedirectUri",
"profileSigningAlgorithm",
+ "roleClaim",
"scope",
"signingAlgorithm",
"storageLabelClaim",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 24f9a6d75d..9eb9990d2c 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1398,6 +1398,7 @@ export type SystemConfigOAuthDto = {
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;
+ roleClaim: string;
scope: string;
signingAlgorithm: string;
storageLabelClaim: string;
diff --git a/server/src/config.ts b/server/src/config.ts
index ae4bdcd906..1fcc2e9782 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -101,6 +101,7 @@ export interface SystemConfig {
timeout: number;
storageLabelClaim: string;
storageQuotaClaim: string;
+ roleClaim: string;
};
passwordLogin: {
enabled: boolean;
@@ -263,6 +264,7 @@ export const defaults = Object.freeze({
profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
+ roleClaim: 'immich_role',
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST,
timeout: 30_000,
},
diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts
index 03ef9192db..b0385984b4 100644
--- a/server/src/dtos/system-config.dto.ts
+++ b/server/src/dtos/system-config.dto.ts
@@ -395,6 +395,9 @@ class SystemConfigOAuthDto {
@IsString()
storageQuotaClaim!: string;
+
+ @IsString()
+ roleClaim!: string;
}
class SystemConfigPasswordLoginDto {
diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts
index 3568bb9d6b..85c9f07815 100644
--- a/server/src/services/auth.service.spec.ts
+++ b/server/src/services/auth.service.spec.ts
@@ -711,6 +711,7 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
+ isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 0,
@@ -739,6 +740,7 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
+ isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 5_368_709_120,
@@ -805,6 +807,93 @@ describe(AuthService.name, () => {
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
});
+
+ it('should only allow "admin" and "user" for the role claim', async () => {
+ const user = factory.userAdmin({ oauthId: 'oauth-id' });
+
+ mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
+ mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
+ mocks.user.getByEmail.mockResolvedValue(void 0);
+ mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
+ mocks.user.getByOAuthId.mockResolvedValue(void 0);
+ mocks.user.create.mockResolvedValue(user);
+ mocks.session.create.mockResolvedValue(factory.session());
+
+ await expect(
+ sut.callback(
+ { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
+ {},
+ loginDetails,
+ ),
+ ).resolves.toEqual(oauthResponse(user));
+
+ expect(mocks.user.create).toHaveBeenCalledWith({
+ email: user.email,
+ name: ' ',
+ oauthId: user.oauthId,
+ quotaSizeInBytes: null,
+ storageLabel: null,
+ isAdmin: false,
+ });
+ });
+
+ it('should create an admin user if the role claim is set to admin', async () => {
+ const user = factory.userAdmin({ oauthId: 'oauth-id' });
+
+ mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
+ mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
+ mocks.user.getByEmail.mockResolvedValue(void 0);
+ mocks.user.getByOAuthId.mockResolvedValue(void 0);
+ mocks.user.create.mockResolvedValue(user);
+ mocks.session.create.mockResolvedValue(factory.session());
+
+ await expect(
+ sut.callback(
+ { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
+ {},
+ loginDetails,
+ ),
+ ).resolves.toEqual(oauthResponse(user));
+
+ expect(mocks.user.create).toHaveBeenCalledWith({
+ email: user.email,
+ name: ' ',
+ oauthId: user.oauthId,
+ quotaSizeInBytes: null,
+ storageLabel: null,
+ isAdmin: true,
+ });
+ });
+
+ it('should accept a custom role claim', async () => {
+ const user = factory.userAdmin({ oauthId: 'oauth-id' });
+
+ mocks.systemMetadata.get.mockResolvedValue({
+ oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
+ });
+ mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' });
+ mocks.user.getByEmail.mockResolvedValue(void 0);
+ mocks.user.getByOAuthId.mockResolvedValue(void 0);
+ mocks.user.create.mockResolvedValue(user);
+ mocks.session.create.mockResolvedValue(factory.session());
+
+ await expect(
+ sut.callback(
+ { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
+ {},
+ loginDetails,
+ ),
+ ).resolves.toEqual(oauthResponse(user));
+
+ expect(mocks.user.create).toHaveBeenCalledWith({
+ email: user.email,
+ name: ' ',
+ oauthId: user.oauthId,
+ quotaSizeInBytes: null,
+ storageLabel: null,
+ isAdmin: true,
+ });
+ });
});
describe('link', () => {
diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts
index 70da8d81d3..ec3415ec8c 100644
--- a/server/src/services/auth.service.ts
+++ b/server/src/services/auth.service.ts
@@ -250,7 +250,7 @@ export class AuthService extends BaseService {
const { oauth } = await this.getConfig({ withCache: false });
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, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
@@ -290,6 +290,11 @@ export class AuthService extends BaseService {
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
+ const role = this.getClaim<'admin' | 'user'>(profile, {
+ key: roleClaim,
+ default: 'user',
+ isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
+ });
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({
@@ -298,6 +303,7 @@ export class AuthService extends BaseService {
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,
+ isAdmin: role === 'admin',
});
}
diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts
index 87bd92129e..c7b98cc990 100644
--- a/server/src/services/system-config.service.spec.ts
+++ b/server/src/services/system-config.service.spec.ts
@@ -124,6 +124,7 @@ const updatedConfig = Object.freeze({
timeout: 30_000,
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
+ roleClaim: 'immich_role',
},
passwordLogin: {
enabled: true,
diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
index f0dbc1a2da..a1926b4020 100644
--- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
@@ -167,6 +167,16 @@
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
+
+