chore: migrate oauth claim validation to zod (#32373)
(cherry picked from commit 0808f24a37cf376a509f1b4046cd52fd2d72b20d)
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import sanitize from 'sanitize-filename';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import type { UserMetadataItem } from 'src/types.js';
|
import type { UserMetadataItem } from 'src/types.js';
|
||||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
|
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
|
||||||
import { ImmichCookie, UserMetadataKey } from 'src/enum.js';
|
import { ImmichCookie, UserMetadataKey } from 'src/enum.js';
|
||||||
|
import { HumanReadableSize } from 'src/utils/bytes.js';
|
||||||
import { toEmail } from 'src/validation.js';
|
import { toEmail } from 'src/validation.js';
|
||||||
|
|
||||||
export type CookieResponse = {
|
export type CookieResponse = {
|
||||||
@@ -23,6 +25,47 @@ export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.
|
|||||||
|
|
||||||
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
|
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
|
||||||
|
|
||||||
|
const storageLabelClaimSchema = z.string().transform((label) => sanitize(label));
|
||||||
|
const quotaSizeInBytesSchema = z
|
||||||
|
.int()
|
||||||
|
.min(-1)
|
||||||
|
.nullable()
|
||||||
|
.transform((quota) => (quota === null || quota === -1 ? null : quota * HumanReadableSize.GiB));
|
||||||
|
const userRoleSchema = z.union([z.literal('user'), z.literal('admin')]);
|
||||||
|
|
||||||
|
export const OAuthProfileSchema = z.object({
|
||||||
|
defaults: z.object({
|
||||||
|
immich_quota_bytes: quotaSizeInBytesSchema,
|
||||||
|
}),
|
||||||
|
sub: z.string(),
|
||||||
|
email: z.string().trim().toLowerCase().optional().catch(undefined),
|
||||||
|
email_verified: z.coerce.boolean().optional().catch(undefined),
|
||||||
|
name: z.string().trim().optional().catch(undefined),
|
||||||
|
given_name: z.string().trim().optional().catch(undefined),
|
||||||
|
family_name: z.string().trim().optional().catch(undefined),
|
||||||
|
preferred_username: z.string().trim().optional().catch(undefined),
|
||||||
|
picture: z.string().optional().catch(undefined),
|
||||||
|
immich_storage_label: storageLabelClaimSchema.optional().catch(undefined),
|
||||||
|
immich_quota_bytes: quotaSizeInBytesSchema.optional().catch(undefined),
|
||||||
|
immich_role: z
|
||||||
|
.union([userRoleSchema, z.array(userRoleSchema)])
|
||||||
|
.transform((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = Array.isArray(value) ? value : [value];
|
||||||
|
if (roles.includes('admin')) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'user';
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
});
|
||||||
|
export type OAuthProfile = z.infer<typeof OAuthProfileSchema>;
|
||||||
|
|
||||||
const LoginCredentialSchema = z
|
const LoginCredentialSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
|
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
|
||||||
|
|||||||
@@ -15,26 +15,32 @@ import {
|
|||||||
randomState,
|
randomState,
|
||||||
skipSubjectCheck,
|
skipSubjectCheck,
|
||||||
} from 'openid-client';
|
} from 'openid-client';
|
||||||
|
import { SystemConfig } from 'src/dtos/config.dto.js';
|
||||||
import { OAuthTokenEndpointAuthMethod } from 'src/enum.js';
|
import { OAuthTokenEndpointAuthMethod } from 'src/enum.js';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||||
|
|
||||||
export type OAuthConfig = {
|
export type OAuthConfig = Pick<
|
||||||
clientId: string;
|
SystemConfig['oauth'],
|
||||||
clientSecret?: string;
|
| 'defaultStorageQuota'
|
||||||
issuerUrl: string;
|
| 'storageLabelClaim'
|
||||||
accountManagementUrl: string;
|
| 'storageQuotaClaim'
|
||||||
endSessionEndpoint: string;
|
| 'roleClaim'
|
||||||
mobileOverrideEnabled: boolean;
|
| 'clientId'
|
||||||
mobileRedirectUri: string;
|
| 'clientSecret'
|
||||||
profileSigningAlgorithm: string;
|
| 'issuerUrl'
|
||||||
prompt: string;
|
| 'accountManagementUrl'
|
||||||
scope: string;
|
| 'endSessionEndpoint'
|
||||||
signingAlgorithm: string;
|
| 'mobileOverrideEnabled'
|
||||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
| 'mobileRedirectUri'
|
||||||
timeout: number;
|
| 'profileSigningAlgorithm'
|
||||||
allowInsecureRequests: boolean;
|
| 'prompt'
|
||||||
};
|
| 'scope'
|
||||||
export type OAuthProfile = UserInfoResponse;
|
| 'signingAlgorithm'
|
||||||
|
| 'tokenEndpointAuthMethod'
|
||||||
|
| 'timeout'
|
||||||
|
| 'allowInsecureRequests'
|
||||||
|
>;
|
||||||
|
export type OAuthRawProfile = UserInfoResponse;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthRepository {
|
export class OAuthRepository {
|
||||||
@@ -84,23 +90,23 @@ export class OAuthRepository {
|
|||||||
url: string,
|
url: string,
|
||||||
expectedState: string,
|
expectedState: string,
|
||||||
codeVerifier: string,
|
codeVerifier: string,
|
||||||
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
|
): Promise<{ rawProfile: OAuthRawProfile; sid?: string; idToken?: string }> {
|
||||||
const client = await this.getClient(config);
|
const client = await this.getClient(config);
|
||||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||||
|
|
||||||
let profile: OAuthProfile;
|
let rawProfile: OAuthRawProfile;
|
||||||
const tokenClaims = tokens.claims();
|
const tokenClaims = tokens.claims();
|
||||||
if (tokenClaims && 'email' in tokenClaims) {
|
if (tokenClaims && 'email' in tokenClaims) {
|
||||||
this.logger.debug('Using ID token claims instead of userinfo endpoint');
|
this.logger.debug('Using ID token claims instead of userinfo endpoint');
|
||||||
profile = tokenClaims as OAuthProfile;
|
rawProfile = tokenClaims;
|
||||||
} else {
|
} else {
|
||||||
profile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
|
rawProfile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile.sub) {
|
if (!rawProfile.sub) {
|
||||||
throw new Error('Unexpected profile response, no `sub`');
|
throw new Error('Unexpected profile response, no `sub`');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ export class OAuthRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { profile, sid, idToken: tokens.id_token };
|
return { rawProfile, sid, idToken: tokens.id_token };
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
if (error.message.includes('unexpected JWT alg received')) {
|
if (error.message.includes('unexpected JWT alg received')) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -138,7 +144,7 @@ export class OAuthRepository {
|
|||||||
return response.arrayBuffer();
|
return response.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performnce
|
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performance
|
||||||
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
|
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
|
||||||
const client = await this.getClient(config);
|
const client = await this.getClient(config);
|
||||||
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
|
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
|
||||||
|
|||||||
@@ -719,7 +719,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should not allow auto registering', async () => {
|
it('should not allow auto registering', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -734,10 +734,10 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should link an existing user', async () => {
|
it('should link an existing user', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const profile = OAuthProfileFactory.create();
|
const rawProfile = OAuthProfileFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -749,15 +749,19 @@ describe(AuthService.name, () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should store the OAuth bearer token on the new session', async () => {
|
it('should store the OAuth bearer token on the new session', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const profile = OAuthProfileFactory.create();
|
const rawProfile = OAuthProfileFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
|
rawProfile,
|
||||||
|
sid: 'oauth-sid',
|
||||||
|
idToken: 'oauth-bearer-token',
|
||||||
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -775,10 +779,10 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should normalize the email from the OAuth profile before linking', async () => {
|
it('should normalize the email from the OAuth profile before linking', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
|
const rawProfile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -790,14 +794,14 @@ describe(AuthService.name, () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
|
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not link to a user with a different oauth sub', async () => {
|
it('should not link to a user with a different oauth sub', async () => {
|
||||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
|
||||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
|
|
||||||
@@ -818,7 +822,7 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await sut.callback(
|
await sut.callback(
|
||||||
@@ -837,7 +841,7 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: { sub: 'sub' } });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -859,7 +863,7 @@ describe(AuthService.name, () => {
|
|||||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
|
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||||
@@ -877,7 +881,9 @@ describe(AuthService.name, () => {
|
|||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
|
rawProfile: OAuthProfileFactory.create(),
|
||||||
|
});
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
@@ -893,7 +899,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should infer name from given and family names', async () => {
|
it('should infer name from given and family names', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
rawProfile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
@@ -910,10 +916,10 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to email when no username is provided', async () => {
|
it('should fallback to email when no username is provided', async () => {
|
||||||
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
|
const rawProfile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||||
@@ -925,13 +931,13 @@ describe(AuthService.name, () => {
|
|||||||
loginDetails,
|
loginDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
|
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: rawProfile.email }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore an invalid storage quota', async () => {
|
it('should ignore an invalid storage quota', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
|
rawProfile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
|
||||||
});
|
});
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
@@ -950,7 +956,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should ignore an invalid negative quota', async () => {
|
it('should ignore an invalid negative quota', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
|
rawProfile: OAuthProfileFactory.create({ immich_quota: -5 }),
|
||||||
});
|
});
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
@@ -969,7 +975,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should set unlimited quota for -1', async () => {
|
it('should set unlimited quota for -1', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_quota: -1 }),
|
rawProfile: OAuthProfileFactory.create({ immich_quota: -1 }),
|
||||||
});
|
});
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
@@ -987,7 +993,9 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should set quota for 0 quota', async () => {
|
it('should set quota for 0 quota', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
|
rawProfile: OAuthProfileFactory.create({ immich_quota: 0 }),
|
||||||
|
});
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||||
@@ -1004,7 +1012,9 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should use a valid storage quota', async () => {
|
it('should use a valid storage quota', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
|
rawProfile: OAuthProfileFactory.create({ immich_quota: 5 }),
|
||||||
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
@@ -1023,11 +1033,11 @@ describe(AuthService.name, () => {
|
|||||||
it('should sync the profile picture', async () => {
|
it('should sync the profile picture', async () => {
|
||||||
const fileId = newUuid();
|
const fileId = newUuid();
|
||||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
const rawProfile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||||
const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]);
|
const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||||
mocks.oauth.getProfilePicture.mockResolvedValue(pictureBytes.buffer);
|
mocks.oauth.getProfilePicture.mockResolvedValue(pictureBytes.buffer);
|
||||||
@@ -1044,7 +1054,7 @@ describe(AuthService.name, () => {
|
|||||||
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
|
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
|
||||||
profileChangedAt: expect.any(Date),
|
profileChangedAt: expect.any(Date),
|
||||||
});
|
});
|
||||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(rawProfile.picture);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(
|
||||||
Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength),
|
Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength),
|
||||||
expect.objectContaining({ processInvalidImages: false }),
|
expect.objectContaining({ processInvalidImages: false }),
|
||||||
@@ -1058,10 +1068,10 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should not update the user when thumbnail processing fails on the OAuth picture', async () => {
|
it('should not update the user when thumbnail processing fails on the OAuth picture', async () => {
|
||||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
const rawProfile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.oauth.getProfilePicture.mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer);
|
mocks.oauth.getProfilePicture.mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer);
|
||||||
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
|
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
|
||||||
@@ -1085,7 +1095,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({
|
rawProfile: OAuthProfileFactory.create({
|
||||||
sub: oauthId,
|
sub: oauthId,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||||
@@ -1108,7 +1118,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
rawProfile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
@@ -1128,7 +1138,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should create an admin user if the role claim is set to admin', async () => {
|
it('should create an admin user if the role claim is set to admin', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
rawProfile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
@@ -1149,7 +1159,7 @@ describe(AuthService.name, () => {
|
|||||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
||||||
});
|
});
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
rawProfile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
@@ -1168,7 +1178,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should create an admin user if the role claim is an array containing admin', async () => {
|
it('should create an admin user if the role claim is an array containing admin', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }),
|
rawProfile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
@@ -1187,7 +1197,7 @@ describe(AuthService.name, () => {
|
|||||||
it('should create a standard user if the role claim is an array containing only user', async () => {
|
it('should create a standard user if the role claim is an array containing only user', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_role: ['user'] }),
|
rawProfile: OAuthProfileFactory.create({ immich_role: ['user'] }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
@@ -1209,7 +1219,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: 5 }),
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: 5 }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 });
|
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 });
|
||||||
@@ -1233,7 +1243,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: -1 }),
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: -1 }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null });
|
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null });
|
||||||
@@ -1257,7 +1267,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: user.oauthId!, preferred_username: 'idp-username' }),
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, preferred_username: 'idp-username' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -1279,7 +1289,7 @@ describe(AuthService.name, () => {
|
|||||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
|
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
|
||||||
});
|
});
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced-label' }),
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced-label' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
|
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
|
||||||
@@ -1305,7 +1315,7 @@ describe(AuthService.name, () => {
|
|||||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
|
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
|
||||||
});
|
});
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced.label' }),
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced.label' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
|
mocks.user.getByStorageLabel.mockResolvedValue(void 0);
|
||||||
@@ -1330,7 +1340,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }),
|
rawProfile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue({ ...user, isAdmin: true });
|
mocks.user.update.mockResolvedValue({ ...user, isAdmin: true });
|
||||||
@@ -1351,7 +1361,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }),
|
rawProfile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue({ ...user, isAdmin: false });
|
mocks.user.update.mockResolvedValue({ ...user, isAdmin: false });
|
||||||
@@ -1372,7 +1382,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: oauthId }),
|
rawProfile: OAuthProfileFactory.create({ sub: oauthId }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -1391,7 +1401,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ sub: user.oauthId! }),
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
|
||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -1406,15 +1416,38 @@ describe(AuthService.name, () => {
|
|||||||
expect(mocks.user.syncUsage).not.toHaveBeenCalled();
|
expect(mocks.user.syncUsage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update quota to default value on subsequent logins when it is absent from the profile', async () => {
|
||||||
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
|
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
|
mocks.user.update.mockResolvedValueOnce({ ...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.update).toHaveBeenCalledWith(
|
||||||
|
user.id,
|
||||||
|
expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should re-evaluate the role claim for a user linked by email', async () => {
|
it('should re-evaluate the role claim for a user linked by email', async () => {
|
||||||
const user = UserFactory.create({ isAdmin: false });
|
const user = UserFactory.create({ isAdmin: false });
|
||||||
const profile = OAuthProfileFactory.create({ immich_role: 'admin' });
|
const rawProfile = OAuthProfileFactory.create({ immich_role: 'admin' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub });
|
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub });
|
||||||
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub, isAdmin: true });
|
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub, isAdmin: true });
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await sut.callback(
|
await sut.callback(
|
||||||
@@ -1423,7 +1456,7 @@ describe(AuthService.name, () => {
|
|||||||
loginDetails,
|
loginDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1432,10 +1465,10 @@ describe(AuthService.name, () => {
|
|||||||
it('should link an account', async () => {
|
it('should link an account', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||||
const profile = OAuthProfileFactory.create();
|
const rawProfile = OAuthProfileFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.link(
|
await sut.link(
|
||||||
@@ -1444,7 +1477,7 @@ describe(AuthService.name, () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
|
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: rawProfile.sub });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should link an account and update the session with the oauthSid', async () => {
|
it('should link an account and update the session with the oauthSid', async () => {
|
||||||
@@ -1454,7 +1487,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: { sub: 'sub' },
|
rawProfile: { sub: 'sub' },
|
||||||
sid: session.oauthSid ?? undefined,
|
sid: session.oauthSid ?? undefined,
|
||||||
idToken: session.oauthBearerToken ?? undefined,
|
idToken: session.oauthBearerToken ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -1480,7 +1513,7 @@ describe(AuthService.name, () => {
|
|||||||
const auth = { user: authUser, apiKey: authApiKey };
|
const auth = { user: authUser, apiKey: authApiKey };
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
|
||||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
|
|||||||
import { parseCookie } from 'cookie';
|
import { parseCookie } from 'cookie';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { IncomingHttpHeaders } from 'node:http';
|
import { IncomingHttpHeaders } from 'node:http';
|
||||||
import sanitize from 'sanitize-filename';
|
|
||||||
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants.js';
|
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants.js';
|
||||||
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
|
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +13,8 @@ import {
|
|||||||
OAuthBackchannelLogoutDto,
|
OAuthBackchannelLogoutDto,
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
|
OAuthProfile,
|
||||||
|
OAuthProfileSchema,
|
||||||
PinCodeChangeDto,
|
PinCodeChangeDto,
|
||||||
PinCodeResetDto,
|
PinCodeResetDto,
|
||||||
PinCodeSetupDto,
|
PinCodeSetupDto,
|
||||||
@@ -23,10 +24,9 @@ import {
|
|||||||
import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
|
import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
|
||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js';
|
||||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum.js';
|
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum.js';
|
||||||
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
|
import { OAuthConfig, OAuthRawProfile } from 'src/repositories/oauth.repository.js';
|
||||||
import { BaseService } from 'src/services/base.service.js';
|
import { BaseService } from 'src/services/base.service.js';
|
||||||
import { isGranted } from 'src/utils/access.js';
|
import { isGranted } from 'src/utils/access.js';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes.js';
|
|
||||||
import { generateProfileImage } from 'src/utils/profile-image.js';
|
import { generateProfileImage } from 'src/utils/profile-image.js';
|
||||||
import { getUserAgentDetails } from 'src/utils/request.js';
|
import { getUserAgentDetails } from 'src/utils/request.js';
|
||||||
|
|
||||||
@@ -38,18 +38,10 @@ export interface LoginDetails {
|
|||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimOptions<T> {
|
type OAuthClaimsConfig = Pick<
|
||||||
key: string;
|
SystemConfig['oauth'],
|
||||||
isValid: (value: unknown) => value is T;
|
'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim' | 'roleClaim'
|
||||||
parse?: (raw: unknown) => T;
|
>;
|
||||||
}
|
|
||||||
|
|
||||||
type OAuthClaimsConfig = Pick<SystemConfig['oauth'], 'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim'>;
|
|
||||||
|
|
||||||
interface ParsedOAuthClaims {
|
|
||||||
storageLabel?: string | null;
|
|
||||||
quotaSizeInBytes?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ValidateRequest = {
|
export type ValidateRequest = {
|
||||||
headers: IncomingHttpHeaders;
|
headers: IncomingHttpHeaders;
|
||||||
@@ -294,6 +286,18 @@ export class AuthService extends BaseService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseOAuthClaims(profile: OAuthRawProfile, oauth: OAuthConfig) {
|
||||||
|
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||||
|
|
||||||
|
return OAuthProfileSchema.parse({
|
||||||
|
...profile,
|
||||||
|
defaults: { immich_quota_bytes: defaultStorageQuota },
|
||||||
|
immich_storage_label: profile[storageLabelClaim],
|
||||||
|
immich_quota_bytes: profile[storageQuotaClaim],
|
||||||
|
immich_role: profile[roleClaim],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
|
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
|
||||||
const { oauth } = await this.getConfig({ withCache: false });
|
const { oauth } = await this.getConfig({ withCache: false });
|
||||||
if (!oauth.enabled) {
|
if (!oauth.enabled) {
|
||||||
@@ -312,18 +316,18 @@ export class AuthService extends BaseService {
|
|||||||
|
|
||||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||||
const {
|
const {
|
||||||
profile,
|
rawProfile,
|
||||||
sid: oauthSid,
|
sid: oauthSid,
|
||||||
idToken: oauthBearerToken,
|
idToken: oauthBearerToken,
|
||||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
|
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
|
||||||
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
|
const profile = this.parseOAuthClaims(rawProfile, oauth);
|
||||||
const { autoRegister, roleClaim } = oauth;
|
const { autoRegister } = oauth;
|
||||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
||||||
|
|
||||||
// link by email
|
// link by email
|
||||||
if (!user && normalizedEmail) {
|
if (!user && profile.email) {
|
||||||
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
const emailUser = await this.userRepository.getByEmail(profile.email);
|
||||||
if (emailUser) {
|
if (emailUser) {
|
||||||
if (emailUser.oauthId) {
|
if (emailUser.oauthId) {
|
||||||
this.logger.debug('OAuth login conflict: email already linked to different account');
|
this.logger.debug('OAuth login conflict: email already linked to different account');
|
||||||
@@ -333,10 +337,9 @@ export class AuthService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = this.getRoleClaim(profile, roleClaim);
|
const isAdmin = profile.immich_role === 'admin';
|
||||||
const isAdmin = role === 'admin';
|
|
||||||
|
|
||||||
if (user && role && isAdmin !== user.isAdmin) {
|
if (user && profile.immich_role && isAdmin !== user.isAdmin) {
|
||||||
user = await this.userRepository.update(user.id, { isAdmin });
|
user = await this.userRepository.update(user.id, { isAdmin });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,29 +349,28 @@ export class AuthService extends BaseService {
|
|||||||
} else {
|
} else {
|
||||||
if (!autoRegister) {
|
if (!autoRegister) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
|
`Unable to register ${profile.sub}/${profile.email || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
|
||||||
);
|
);
|
||||||
throw new BadRequestException('OAuth authentication failed');
|
throw new BadRequestException('OAuth authentication failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedEmail) {
|
if (!profile.email) {
|
||||||
throw new BadRequestException('OAuth profile does not have an email address');
|
throw new BadRequestException('OAuth profile does not have an email address');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
|
this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
|
||||||
|
|
||||||
const claims = this.parseOAuthClaims(profile, oauth, true);
|
|
||||||
|
|
||||||
user = await this.createUser({
|
user = await this.createUser({
|
||||||
name:
|
name:
|
||||||
profile.name ||
|
profile.name ||
|
||||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||||
profile.preferred_username ||
|
profile.preferred_username ||
|
||||||
normalizedEmail,
|
profile.email,
|
||||||
email: normalizedEmail,
|
email: profile.email,
|
||||||
oauthId: profile.sub,
|
oauthId: profile.sub,
|
||||||
quotaSizeInBytes: claims.quotaSizeInBytes ?? null,
|
quotaSizeInBytes:
|
||||||
storageLabel: claims.storageLabel ?? null,
|
profile.immich_quota_bytes === undefined ? profile.defaults.immich_quota_bytes : profile.immich_quota_bytes,
|
||||||
|
storageLabel: profile.immich_storage_label ?? null,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -415,11 +417,13 @@ export class AuthService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { oauth } = await this.getConfig({ withCache: false });
|
const { oauth } = await this.getConfig({ withCache: false });
|
||||||
const {
|
const { rawProfile, sid, idToken } = await this.oauthRepository.getProfileAndOAuthSid(
|
||||||
profile: { sub: oauthId },
|
oauth,
|
||||||
sid,
|
dto.url,
|
||||||
idToken,
|
expectedState,
|
||||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
|
codeVerifier,
|
||||||
|
);
|
||||||
|
const { sub: oauthId } = this.parseOAuthClaims(rawProfile, oauth);
|
||||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||||
if (duplicate && duplicate.id !== auth.user.id) {
|
if (duplicate && duplicate.id !== auth.user.id) {
|
||||||
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
||||||
@@ -611,73 +615,30 @@ export class AuthService extends BaseService {
|
|||||||
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T | undefined {
|
|
||||||
const raw = profile[options.key as keyof OAuthProfile];
|
|
||||||
const value = options.parse ? options.parse(raw) : raw;
|
|
||||||
return options.isValid(value) ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseOAuthClaims(profile: OAuthProfile, oauth: OAuthClaimsConfig, useDefaults: boolean): ParsedOAuthClaims {
|
|
||||||
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
|
||||||
const claims: ParsedOAuthClaims = {};
|
|
||||||
|
|
||||||
const storageLabel = this.getClaim(profile, {
|
|
||||||
key: storageLabelClaim,
|
|
||||||
isValid: (value): value is string => typeof value === 'string',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useDefaults) {
|
|
||||||
claims.storageLabel = storageLabel || null;
|
|
||||||
}
|
|
||||||
// Default claim (preferred_username) is registration-only; login sync is opt-in.
|
|
||||||
else if (storageLabelClaim !== defaults.oauth.storageLabelClaim && storageLabel !== undefined) {
|
|
||||||
claims.storageLabel = this.formatStorageLabel(storageLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageQuota = this.getClaim(profile, {
|
|
||||||
key: storageQuotaClaim,
|
|
||||||
parse: Number,
|
|
||||||
isValid: (value): value is number =>
|
|
||||||
typeof value === 'number' && Number.isFinite(value) && (value === -1 || value >= 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (storageQuota !== undefined) {
|
|
||||||
claims.quotaSizeInBytes = storageQuota === -1 ? null : storageQuota * HumanReadableSize.GiB;
|
|
||||||
} else if (useDefaults) {
|
|
||||||
claims.quotaSizeInBytes =
|
|
||||||
defaultStorageQuota === null || defaultStorageQuota === -1 ? null : defaultStorageQuota * HumanReadableSize.GiB;
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatStorageLabel(label: string): string | null {
|
|
||||||
if (!label) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitize(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncOAuthClaims(user: UserAdmin, profile: OAuthProfile, oauth: OAuthClaimsConfig): Promise<UserAdmin> {
|
private async syncOAuthClaims(user: UserAdmin, profile: OAuthProfile, oauth: OAuthClaimsConfig): Promise<UserAdmin> {
|
||||||
const claims = this.parseOAuthClaims(profile, oauth, false);
|
|
||||||
const updates: {
|
const updates: {
|
||||||
storageLabel?: string | null;
|
storageLabel?: string | null;
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (claims.storageLabel && claims.storageLabel !== user.storageLabel) {
|
if (
|
||||||
const duplicate = await this.userRepository.getByStorageLabel(claims.storageLabel);
|
oauth.storageLabelClaim !== defaults.oauth.storageLabelClaim &&
|
||||||
|
profile.immich_storage_label &&
|
||||||
|
profile.immich_storage_label !== user.storageLabel
|
||||||
|
) {
|
||||||
|
const duplicate = await this.userRepository.getByStorageLabel(profile.immich_storage_label);
|
||||||
|
|
||||||
if (duplicate && duplicate.id !== user.id) {
|
if (duplicate && duplicate.id !== user.id) {
|
||||||
this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`);
|
this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`);
|
||||||
} else {
|
} else {
|
||||||
updates.storageLabel = claims.storageLabel;
|
updates.storageLabel = profile.immich_storage_label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (claims.quotaSizeInBytes !== undefined && claims.quotaSizeInBytes !== user.quotaSizeInBytes) {
|
if (profile.immich_quota_bytes === undefined && user.quotaSizeInBytes !== profile.defaults.immich_quota_bytes) {
|
||||||
updates.quotaSizeInBytes = claims.quotaSizeInBytes;
|
updates.quotaSizeInBytes = profile.defaults.immich_quota_bytes;
|
||||||
|
} else if (profile.immich_quota_bytes !== undefined && profile.immich_quota_bytes !== user.quotaSizeInBytes) {
|
||||||
|
updates.quotaSizeInBytes = profile.immich_quota_bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
@@ -691,19 +652,6 @@ export class AuthService extends BaseService {
|
|||||||
return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() });
|
return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined {
|
|
||||||
const value = profile[roleClaim as keyof OAuthProfile];
|
|
||||||
const roles = Array.isArray(value) ? value : [value];
|
|
||||||
const isRole = (role: string) => roles.includes(role);
|
|
||||||
|
|
||||||
if (isRole('admin')) {
|
|
||||||
return 'admin';
|
|
||||||
}
|
|
||||||
if (isRole('user')) {
|
|
||||||
return 'user';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveRedirectUri(
|
private resolveRedirectUri(
|
||||||
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
|
import { OAuthRawProfile } from 'src/repositories/oauth.repository.js';
|
||||||
import { OAuthProfileLike } from 'test/factories/types.js';
|
import { OAuthProfileLike } from 'test/factories/types.js';
|
||||||
import { newUuid } from 'test/small.factory.js';
|
import { newUuid } from 'test/small.factory.js';
|
||||||
|
|
||||||
export class OAuthProfileFactory {
|
export class OAuthProfileFactory {
|
||||||
private constructor(private value: OAuthProfile) {}
|
private constructor(private value: OAuthRawProfile) {}
|
||||||
|
|
||||||
static create(dto: OAuthProfileLike = {}) {
|
static create(dto: OAuthProfileLike = {}) {
|
||||||
return OAuthProfileFactory.from(dto).build();
|
return OAuthProfileFactory.from(dto).build();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Selectable } from 'kysely';
|
import { Selectable } from 'kysely';
|
||||||
import { PersonUser } from 'src/database.js';
|
import { PersonUser } from 'src/database.js';
|
||||||
import { PersonUserRole } from 'src/dtos/person.dto.js';
|
import { PersonUserRole } from 'src/dtos/person.dto.js';
|
||||||
import { OAuthProfile } from 'src/repositories/oauth.repository.js';
|
import { OAuthRawProfile } from 'src/repositories/oauth.repository.js';
|
||||||
import { ActivityTable } from 'src/schema/tables/activity.table.js';
|
import { ActivityTable } from 'src/schema/tables/activity.table.js';
|
||||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table.js';
|
import { AlbumUserTable } from 'src/schema/tables/album-user.table.js';
|
||||||
import { AlbumTable } from 'src/schema/tables/album.table.js';
|
import { AlbumTable } from 'src/schema/tables/album.table.js';
|
||||||
@@ -48,4 +48,4 @@ export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
|||||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||||
export type PasskeyLike = Partial<Selectable<PasskeyTable>>;
|
export type PasskeyLike = Partial<Selectable<PasskeyTable>>;
|
||||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||||
export type OAuthProfileLike = Partial<OAuthProfile>;
|
export type OAuthProfileLike = Partial<OAuthRawProfile>;
|
||||||
|
|||||||
Reference in New Issue
Block a user