mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
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:
parent
fe5c8ed0fb
commit
81804186a4
3 changed files with 258 additions and 19 deletions
|
|
@ -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.
|
||||
|
||||
:::
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue