feat(server): sync OAuth claims on login (#8073)

Sync role and quota from the IdP on subsequent OAuth logins. Absent
claims are left unchanged. Storage label sync requires a non-default
claim. Quota `-1` means unlimited.

Signed-off-by: Thomas DELORGE <thomas.delorge@orbeet.io>
This commit is contained in:
Thomas DELORGE 2026-06-12 10:09:45 +02:00
parent fe5c8ed0fb
commit 81804186a4
No known key found for this signature in database
GPG key ID: A4DACB5302BE0CA0
3 changed files with 258 additions and 19 deletions

View file

@ -931,7 +931,7 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should ignore a negative quota', async () => {
it('should ignore an invalid negative quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
@ -950,6 +950,25 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should set unlimited quota for -1', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -1 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: null }));
});
it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
@ -1170,6 +1189,100 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
});
it('should sync the storage quota on subsequent logins when the claim is present', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', quotaSizeInBytes: 1_073_741_824 });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_quota: 5 }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.syncUsage).toHaveBeenCalledWith(user.id);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
quotaSizeInBytes: 5_368_709_120,
updatedAt: expect.any(Date),
});
});
it('should sync unlimited quota on subsequent logins when the claim is -1', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', quotaSizeInBytes: 1_073_741_824 });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_quota: -1 }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.syncUsage).not.toHaveBeenCalled();
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
quotaSizeInBytes: null,
updatedAt: expect.any(Date),
});
});
it('should not sync the storage label on subsequent logins with the default claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', storageLabel: 'custom-label' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, preferred_username: 'idp-username' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.user.getByStorageLabel).not.toHaveBeenCalled();
});
it('should sync the storage label on subsequent logins when a custom claim is configured', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', storageLabel: 'custom-label' });
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
});
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_label: 'synced-label' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
mocks.user.update.mockResolvedValue({ ...user, storageLabel: 'synced-label' });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
storageLabel: 'synced-label',
updatedAt: expect.any(Date),
});
});
it('should promote an existing user to admin if the role claim contains admin on login', async () => {
const user = UserFactory.create({ isAdmin: false, oauthId: 'oauth-id' });
@ -1229,6 +1342,26 @@ describe(AuthService.name, () => {
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should not update claims on subsequent logins when they are absent from the profile', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', isAdmin: true, quotaSizeInBytes: 1_073_741_824 });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.user.syncUsage).not.toHaveBeenCalled();
});
it('should re-evaluate the role claim for a user linked by email', async () => {
const user = UserFactory.create({ isAdmin: false });
const profile = OAuthProfileFactory.create({ immich_role: 'admin' });

View file

@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import sanitize from 'sanitize-filename';
import { defaults, SystemConfig } from 'src/config';
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import {
@ -39,7 +41,18 @@ export interface LoginDetails {
interface ClaimOptions<T> {
key: string;
default: T;
isValid: (value: unknown) => boolean;
isValid: (value: unknown) => value is T;
parse?: (raw: unknown) => unknown;
}
type OAuthClaimsConfig = Pick<
SystemConfig['oauth'],
'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim'
>;
interface ParsedOAuthClaims {
storageLabel?: string | null;
quotaSizeInBytes?: number | null;
}
export type ValidateRequest = {
@ -305,7 +318,7 @@ export class AuthService extends BaseService {
idToken: oauthBearerToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
const { autoRegister, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
@ -343,16 +356,7 @@ export class AuthService extends BaseService {
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: (value: unknown): value is string => typeof value === 'string',
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
const claims = this.parseOAuthClaims(profile, oauth, true);
user = await this.createUser({
name:
@ -362,10 +366,12 @@ export class AuthService extends BaseService {
normalizedEmail,
email: normalizedEmail,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,
quotaSizeInBytes: claims.quotaSizeInBytes ?? null,
storageLabel: claims.storageLabel ?? null,
isAdmin,
});
} else {
user = await this.syncOAuthClaims(user, profile, oauth);
}
if (!user.profileImagePath && profile.picture) {
@ -628,9 +634,105 @@ export class AuthService extends BaseService {
return mapLoginResponse(user, token);
}
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;
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>, useDefault: boolean): T | undefined {
const raw = profile[options.key as keyof OAuthProfile];
const value = options.parse ? options.parse(raw) : raw;
if (options.isValid(value)) {
return value;
}
return useDefault ? options.default : undefined;
}
private parseOAuthClaims(profile: OAuthProfile, oauth: OAuthClaimsConfig, useDefaults: boolean): ParsedOAuthClaims {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
const claims: ParsedOAuthClaims = {};
// Sync label only when a non-default claim is configured.
const syncStorageLabel = storageLabelClaim !== defaults.oauth.storageLabelClaim;
if (useDefaults || (syncStorageLabel && storageLabelClaim in profile)) {
const storageLabel = this.getClaim(
profile,
{
key: storageLabelClaim,
default: '',
isValid: (value: unknown): value is string => typeof value === 'string',
},
useDefaults,
);
if (storageLabel !== undefined) {
claims.storageLabel = useDefaults ? storageLabel || null : this.formatStorageLabel(storageLabel);
}
}
if (useDefaults || storageQuotaClaim in profile) {
const storageQuota = this.getClaim(
profile,
{
key: storageQuotaClaim,
default: defaultStorageQuota,
parse: Number,
isValid: (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && (value === -1 || value >= 0),
},
useDefaults,
);
if (storageQuota !== undefined) {
claims.quotaSizeInBytes =
storageQuota === null || storageQuota === -1 ? null : storageQuota * HumanReadableSize.GiB;
}
}
return claims;
}
private formatStorageLabel(label: string): string | null {
if (!label) {
return null;
}
return sanitize(label.replaceAll('.', ''));
}
private async syncOAuthClaims(
user: UserAdmin,
profile: OAuthProfile,
oauth: OAuthClaimsConfig,
): Promise<UserAdmin> {
const claims = this.parseOAuthClaims(profile, oauth, false);
const updates: {
storageLabel?: string | null;
quotaSizeInBytes?: number | null;
} = {};
if (claims.storageLabel !== undefined && claims.storageLabel !== user.storageLabel) {
if (claims.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(claims.storageLabel);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`);
} else {
updates.storageLabel = claims.storageLabel;
}
} else {
updates.storageLabel = null;
}
}
if (claims.quotaSizeInBytes !== undefined && claims.quotaSizeInBytes !== user.quotaSizeInBytes) {
updates.quotaSizeInBytes = claims.quotaSizeInBytes;
}
if (Object.keys(updates).length === 0) {
return user;
}
if (updates.quotaSizeInBytes) {
await this.userRepository.syncUsage(user.id);
}
return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() });
}
private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined {