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:
parent
8b02f18e99
commit
7303fab9d9
@ -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 |
|
||||
|
||||
|
@ -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
|
||||
|
BIN
mobile/openapi/doc/SystemConfigOAuthDto.md
generated
BIN
mobile/openapi/doc/SystemConfigOAuthDto.md
generated
Binary file not shown.
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.
BIN
mobile/openapi/test/system_config_o_auth_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_o_auth_dto_test.dart
generated
Binary file not shown.
@ -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"
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
5
server/test/fixtures/system-config.stub.ts
vendored
5
server/test/fixtures/system-config.stub.ts
vendored
@ -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 }],
|
||||
};
|
||||
|
14
server/test/fixtures/user.stub.ts
vendored
14
server/test/fixtures/user.stub.ts
vendored
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user