mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat: oauth role claim (#19758)
This commit is contained in:
parent
2f5d75ce21
commit
4ce9bce414
13 changed files with 151 additions and 2 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>.",
|
||||||
|
|
|
||||||
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.
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,9 @@ class SystemConfigOAuthDto {
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
storageQuotaClaim!: string;
|
storageQuotaClaim!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
roleClaim!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemConfigPasswordLoginDto {
|
class SystemConfigPasswordLoginDto {
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue