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)} /> + +