mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00: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
17 changed files with 208 additions and 21 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue