feat: oauth role claim (#19758)

This commit is contained in:
Daniel Dietzler 2025-07-07 00:45:32 +02:00 committed by GitHub
parent 2f5d75ce21
commit 4ce9bce414
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 160 additions and 3 deletions

View File

@ -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) | | 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) | | 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**¹** | | 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**¹** | | 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) | | 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 | | Button Text | string | Login with OAuth | Text for the OAuth button on the web |

View File

@ -227,6 +227,21 @@ describe(`/oauth`, () => {
expect(user.storageLabel).toBe('user-username'); 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 () => { it('should work with RS256 signed tokens', async () => {
await setupOAuth(admin.accessToken, { await setupOAuth(admin.accessToken, {
enabled: true, enabled: true,

View File

@ -12,6 +12,7 @@ export enum OAuthUser {
NO_NAME = 'no-name', NO_NAME = 'no-name',
WITH_QUOTA = 'with-quota', WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username', WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
} }
const claims = [ const claims = [
@ -34,6 +35,12 @@ const claims = [
preferred_username: 'user-quota', preferred_username: 'user-quota',
immich_quota: 25, immich_quota: 25,
}, },
{
sub: OAuthUser.WITH_ROLE,
email: 'oauth-with-role@immich.app',
email_verified: true,
immich_role: 'admin',
},
]; ];
const withDefaultClaims = (sub: string) => ({ const withDefaultClaims = (sub: string) => ({
@ -64,7 +71,15 @@ const setup = async () => {
claims: { claims: {
openid: ['sub'], openid: ['sub'],
email: ['email', 'email_verified'], 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: { features: {
jwtUserinfo: { jwtUserinfo: {

View File

@ -196,6 +196,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 OAuth provider does not allow a mobile URI, like ''{callback}''", "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": "OAuth",
"oauth_settings_description": "Manage OAuth login settings", "oauth_settings_description": "Manage OAuth login settings",
"oauth_settings_more_details": "For more details about this feature, refer to the <link>docs</link>.", "oauth_settings_more_details": "For more details about this feature, refer to the <link>docs</link>.",

View File

@ -24,6 +24,7 @@ class SystemConfigOAuthDto {
required this.mobileOverrideEnabled, required this.mobileOverrideEnabled,
required this.mobileRedirectUri, required this.mobileRedirectUri,
required this.profileSigningAlgorithm, required this.profileSigningAlgorithm,
required this.roleClaim,
required this.scope, required this.scope,
required this.signingAlgorithm, required this.signingAlgorithm,
required this.storageLabelClaim, required this.storageLabelClaim,
@ -55,6 +56,8 @@ class SystemConfigOAuthDto {
String profileSigningAlgorithm; String profileSigningAlgorithm;
String roleClaim;
String scope; String scope;
String signingAlgorithm; String signingAlgorithm;
@ -81,6 +84,7 @@ class SystemConfigOAuthDto {
other.mobileOverrideEnabled == mobileOverrideEnabled && other.mobileOverrideEnabled == mobileOverrideEnabled &&
other.mobileRedirectUri == mobileRedirectUri && other.mobileRedirectUri == mobileRedirectUri &&
other.profileSigningAlgorithm == profileSigningAlgorithm && other.profileSigningAlgorithm == profileSigningAlgorithm &&
other.roleClaim == roleClaim &&
other.scope == scope && other.scope == scope &&
other.signingAlgorithm == signingAlgorithm && other.signingAlgorithm == signingAlgorithm &&
other.storageLabelClaim == storageLabelClaim && other.storageLabelClaim == storageLabelClaim &&
@ -102,6 +106,7 @@ class SystemConfigOAuthDto {
(mobileOverrideEnabled.hashCode) + (mobileOverrideEnabled.hashCode) +
(mobileRedirectUri.hashCode) + (mobileRedirectUri.hashCode) +
(profileSigningAlgorithm.hashCode) + (profileSigningAlgorithm.hashCode) +
(roleClaim.hashCode) +
(scope.hashCode) + (scope.hashCode) +
(signingAlgorithm.hashCode) + (signingAlgorithm.hashCode) +
(storageLabelClaim.hashCode) + (storageLabelClaim.hashCode) +
@ -110,7 +115,7 @@ class SystemConfigOAuthDto {
(tokenEndpointAuthMethod.hashCode); (tokenEndpointAuthMethod.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -129,6 +134,7 @@ class SystemConfigOAuthDto {
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled; json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
json[r'mobileRedirectUri'] = this.mobileRedirectUri; json[r'mobileRedirectUri'] = this.mobileRedirectUri;
json[r'profileSigningAlgorithm'] = this.profileSigningAlgorithm; json[r'profileSigningAlgorithm'] = this.profileSigningAlgorithm;
json[r'roleClaim'] = this.roleClaim;
json[r'scope'] = this.scope; json[r'scope'] = this.scope;
json[r'signingAlgorithm'] = this.signingAlgorithm; json[r'signingAlgorithm'] = this.signingAlgorithm;
json[r'storageLabelClaim'] = this.storageLabelClaim; json[r'storageLabelClaim'] = this.storageLabelClaim;
@ -158,6 +164,7 @@ class SystemConfigOAuthDto {
mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!, mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!, mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
profileSigningAlgorithm: mapValueOfType<String>(json, r'profileSigningAlgorithm')!, profileSigningAlgorithm: mapValueOfType<String>(json, r'profileSigningAlgorithm')!,
roleClaim: mapValueOfType<String>(json, r'roleClaim')!,
scope: mapValueOfType<String>(json, r'scope')!, scope: mapValueOfType<String>(json, r'scope')!,
signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!, signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!,
storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!, storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!,
@ -222,6 +229,7 @@ class SystemConfigOAuthDto {
'mobileOverrideEnabled', 'mobileOverrideEnabled',
'mobileRedirectUri', 'mobileRedirectUri',
'profileSigningAlgorithm', 'profileSigningAlgorithm',
'roleClaim',
'scope', 'scope',
'signingAlgorithm', 'signingAlgorithm',
'storageLabelClaim', 'storageLabelClaim',

View File

@ -14654,6 +14654,9 @@
"profileSigningAlgorithm": { "profileSigningAlgorithm": {
"type": "string" "type": "string"
}, },
"roleClaim": {
"type": "string"
},
"scope": { "scope": {
"type": "string" "type": "string"
}, },
@ -14690,6 +14693,7 @@
"mobileOverrideEnabled", "mobileOverrideEnabled",
"mobileRedirectUri", "mobileRedirectUri",
"profileSigningAlgorithm", "profileSigningAlgorithm",
"roleClaim",
"scope", "scope",
"signingAlgorithm", "signingAlgorithm",
"storageLabelClaim", "storageLabelClaim",

View File

@ -1398,6 +1398,7 @@ export type SystemConfigOAuthDto = {
mobileOverrideEnabled: boolean; mobileOverrideEnabled: boolean;
mobileRedirectUri: string; mobileRedirectUri: string;
profileSigningAlgorithm: string; profileSigningAlgorithm: string;
roleClaim: string;
scope: string; scope: string;
signingAlgorithm: string; signingAlgorithm: string;
storageLabelClaim: string; storageLabelClaim: string;

View File

@ -101,6 +101,7 @@ export interface SystemConfig {
timeout: number; timeout: number;
storageLabelClaim: string; storageLabelClaim: string;
storageQuotaClaim: string; storageQuotaClaim: string;
roleClaim: string;
}; };
passwordLogin: { passwordLogin: {
enabled: boolean; enabled: boolean;
@ -263,6 +264,7 @@ export const defaults = Object.freeze<SystemConfig>({
profileSigningAlgorithm: 'none', profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota', storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST, tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST,
timeout: 30_000, timeout: 30_000,
}, },

View File

@ -395,6 +395,9 @@ class SystemConfigOAuthDto {
@IsString() @IsString()
storageQuotaClaim!: string; storageQuotaClaim!: string;
@IsString()
roleClaim!: string;
} }
class SystemConfigPasswordLoginDto { class SystemConfigPasswordLoginDto {

View File

@ -711,6 +711,7 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email, email: user.email,
isAdmin: false,
name: ' ', name: ' ',
oauthId: user.oauthId, oauthId: user.oauthId,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
@ -739,6 +740,7 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email, email: user.email,
isAdmin: false,
name: ' ', name: ' ',
oauthId: user.oauthId, oauthId: user.oauthId,
quotaSizeInBytes: 5_368_709_120, quotaSizeInBytes: 5_368_709_120,
@ -805,6 +807,93 @@ describe(AuthService.name, () => {
expect(mocks.user.update).not.toHaveBeenCalled(); expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).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', () => { describe('link', () => {

View File

@ -250,7 +250,7 @@ export class AuthService extends BaseService {
const { oauth } = await this.getConfig({ withCache: false }); const { oauth } = await this.getConfig({ withCache: false });
const url = this.resolveRedirectUri(oauth, dto.url); const url = this.resolveRedirectUri(oauth, dto.url);
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier); 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)}`); 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);
@ -290,6 +290,11 @@ export class AuthService extends BaseService {
default: defaultStorageQuota, default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0, 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 || ''}`; const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({ user = await this.createUser({
@ -298,6 +303,7 @@ export class AuthService extends BaseService {
oauthId: profile.sub, oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB, quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null, storageLabel: storageLabel || null,
isAdmin: role === 'admin',
}); });
} }

View File

@ -124,6 +124,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
timeout: 30_000, timeout: 30_000,
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota', storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
}, },
passwordLogin: { passwordLogin: {
enabled: true, enabled: true,

View File

@ -167,6 +167,16 @@
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)} isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_role_claim').toUpperCase()}
description={$t('admin.oauth_role_claim_description')}
bind:value={config.oauth.roleClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.roleClaim == savedConfig.oauth.roleClaim)}
/>
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_quota_claim').toUpperCase()} label={$t('admin.oauth_storage_quota_claim').toUpperCase()}