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

@ -81,7 +81,11 @@ Once you have a new OAuth client application configured, Immich can be configure
:::note Claim Options [1]
Claim is only used on user creation and not synchronized after that.
Claims are applied when a user is first registered. On subsequent logins, claims present in the OAuth profile are synchronized to the Immich user profile. Claims that are absent from the profile are left unchanged.
The storage label is an exception: with the default claim (`preferred_username`), it is only set at registration and is not updated on later logins. To keep the storage label in sync with the IdP, configure a different Storage Label Claim (for example `uid`).
The storage quota claim is a number in GiB: `-1` for unlimited, `0` to block uploads, any positive value for a fixed quota.
:::

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 {