1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-11 06:10:28 +02:00

feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim (#7548)

* feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim

* feat(server/web): fix format and use domain.util constants

* address some pr feedback

* simplify oauth storage quota logic

* adding tests and pr feedback

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Sam Holton 2024-03-01 19:46:07 -05:00 committed by GitHub
parent 8b02f18e99
commit 7303fab9d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 177 additions and 18 deletions

View File

@ -67,9 +67,11 @@ Once you have a new OAuth client application configured, Immich can be configure
| Client Secret | string | (required) | Required. Client Secret (previous step) |
| 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 |
| 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 |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| Storage Claim | string | preferred_username | Claim mapping for the user's storage label |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |

View File

@ -95,13 +95,16 @@ The default configuration looks like this:
"issuerUrl": "",
"clientId": "",
"clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile",
"signingAlgorithm": "RS256",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "immich_quota",
"defaultStorageQuota": 0,
"buttonText": "Login with OAuth",
"autoRegister": true,
"autoLaunch": false
"autoLaunch": false,
"mobileOverrideEnabled": false,
"mobileRedirectUri": ""
},
"passwordLogin": {
"enabled": true

Binary file not shown.

View File

@ -9930,6 +9930,9 @@
"clientSecret": {
"type": "string"
},
"defaultStorageQuota": {
"type": "number"
},
"enabled": {
"type": "boolean"
},
@ -9950,6 +9953,9 @@
},
"storageLabelClaim": {
"type": "string"
},
"storageQuotaClaim": {
"type": "string"
}
},
"required": [
@ -9958,13 +9964,15 @@
"buttonText",
"clientId",
"clientSecret",
"defaultStorageQuota",
"enabled",
"issuerUrl",
"mobileOverrideEnabled",
"mobileRedirectUri",
"scope",
"signingAlgorithm",
"storageLabelClaim"
"storageLabelClaim",
"storageQuotaClaim"
],
"type": "object"
},

View File

@ -877,6 +877,7 @@ export type SystemConfigOAuthDto = {
buttonText: string;
clientId: string;
clientSecret: string;
defaultStorageQuota: number;
enabled: boolean;
issuerUrl: string;
mobileOverrideEnabled: boolean;
@ -884,6 +885,7 @@ export type SystemConfigOAuthDto = {
scope: string;
signingAlgorithm: string;
storageLabelClaim: string;
storageQuotaClaim: string;
};
export type SystemConfigPasswordLoginDto = {
enabled: boolean;

View File

@ -15,6 +15,7 @@ import {
newUserTokenRepositoryMock,
sharedLinkStub,
systemConfigStub,
userDto,
userStub,
userTokenStub,
} from '@test';
@ -62,14 +63,13 @@ describe('AuthService', () => {
let userTokenMock: jest.Mocked<IUserTokenRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>;
let keyMock: jest.Mocked<IKeyRepository>;
let callbackMock: jest.Mock;
afterEach(() => {
jest.resetModules();
});
let callbackMock: jest.Mock;
let userinfoMock: jest.Mock;
beforeEach(async () => {
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
userinfoMock = jest.fn().mockResolvedValue({ sub, email });
jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({
@ -83,7 +83,7 @@ describe('AuthService', () => {
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
callback: callbackMock,
userinfo: jest.fn().mockResolvedValue({ sub, email }),
userinfo: userinfoMock,
}),
} as any);
@ -491,6 +491,74 @@ describe('AuthService', () => {
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
it('should use the default quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should ignore an invalid storage quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: 'abc' });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should ignore a negative quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: -5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should ignore a 0 quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: 0 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should use a valid storage quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: 5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithStorageQuotaClaim);
});
});
describe('link', () => {

View File

@ -7,11 +7,13 @@ import {
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { isNumber, isString } from 'class-validator';
import cookieParser from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { AccessCore, Permission } from '../access';
import { HumanReadableSize } from '../domain.util';
import {
IAccessRepository,
ICryptoRepository,
@ -64,6 +66,12 @@ interface OAuthProfile extends UserinfoResponse {
email: string;
}
interface ClaimOptions<T> {
key: string;
default: T;
isValid: (value: unknown) => boolean;
}
@Injectable()
export class AuthService {
private access: AccessCore;
@ -234,9 +242,11 @@ export class AuthService {
}
}
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth;
// register new user
if (!user) {
if (!config.oauth.autoRegister) {
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
@ -246,17 +256,24 @@ export class AuthService {
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`);
let storageLabel: string | null = profile[config.oauth.storageLabelClaim as keyof OAuthProfile] as string;
if (typeof storageLabel !== 'string') {
storageLabel = null;
}
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: isString,
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => isNumber(value) && value > 0,
});
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.userCore.createUser({
name: userName,
email: profile.email,
oauthId: profile.sub,
storageLabel,
quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
storageLabel: storageLabel || null,
});
}
@ -443,4 +460,9 @@ export class AuthService {
}
return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie];
}
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {
const value = profile[options.key as keyof OAuthProfile];
return options.isValid(value) ? (value as T) : options.default;
}
}

View File

@ -1,4 +1,4 @@
import { IsBoolean, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator';
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
@ -23,6 +23,10 @@ export class SystemConfigOAuthDto {
@IsString()
clientSecret!: string;
@IsNumber()
@Min(0)
defaultStorageQuota!: number;
@IsBoolean()
enabled!: boolean;
@ -47,4 +51,7 @@ export class SystemConfigOAuthDto {
@IsString()
storageLabelClaim!: string;
@IsString()
storageQuotaClaim!: string;
}

View File

@ -93,6 +93,7 @@ export const defaults = Object.freeze<SystemConfig>({
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: 0,
enabled: false,
issuerUrl: '',
mobileOverrideEnabled: false,
@ -100,6 +101,7 @@ export const defaults = Object.freeze<SystemConfig>({
scope: 'openid email profile',
signingAlgorithm: 'RS256',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
},
passwordLogin: {
enabled: true,

View File

@ -93,6 +93,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: 0,
enabled: false,
issuerUrl: '',
mobileOverrideEnabled: false,
@ -100,6 +101,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
scope: 'openid email profile',
signingAlgorithm: 'RS256',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
},
passwordLogin: {
enabled: true,

View File

@ -80,6 +80,7 @@ export enum SystemConfigKey {
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_CLIENT_ID = 'oauth.clientId',
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
OAUTH_DEFAULT_STORAGE_QUOTA = 'oauth.defaultStorageQuota',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
@ -87,6 +88,7 @@ export enum SystemConfigKey {
OAUTH_SCOPE = 'oauth.scope',
OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm',
OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim',
OAUTH_STORAGE_QUOTA_CLAIM = 'oauth.storageQuotaClaim',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
@ -227,6 +229,7 @@ export interface SystemConfig {
buttonText: string;
clientId: string;
clientSecret: string;
defaultStorageQuota: number;
enabled: boolean;
issuerUrl: string;
mobileOverrideEnabled: boolean;
@ -234,6 +237,7 @@ export interface SystemConfig {
scope: string;
signingAlgorithm: string;
storageLabelClaim: string;
storageQuotaClaim: string;
};
passwordLogin: {
enabled: boolean;

View File

@ -22,6 +22,11 @@ export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
{ key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
],
withDefaultStorageQuota: [
{ key: SystemConfigKey.OAUTH_ENABLED, value: true },
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 },
],
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }],
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }],
};

View File

@ -23,6 +23,20 @@ export const userDto = {
name: 'User with quota',
quotaSizeInBytes: 42,
},
userWithDefaultStorageQuota: {
email: 'test@immich.com',
name: ' ',
oauthId: 'my-auth-user-sub',
quotaSizeInBytes: 1_073_741_824,
storageLabel: null,
},
userWithStorageQuotaClaim: {
email: 'test@immich.com',
name: ' ',
oauthId: 'my-auth-user-sub',
quotaSizeInBytes: 5_368_709_120,
storageLabel: null,
},
};
export const userStub = {

View File

@ -3,7 +3,7 @@
</script>
Apply the current
<a href={`${AppRoute.ADMIN_SETTINGS}?open=storageTemplate`} class="text-immich-primary dark:text-immich-dark-primary"
<a href="{AppRoute.ADMIN_SETTINGS}?open=storageTemplate" class="text-immich-primary dark:text-immich-dark-primary"
>Storage template</a
>
to previously uploaded assets

View File

@ -130,6 +130,26 @@
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"