mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix: review notes, new register endpoint
This commit is contained in:
@@ -205,30 +205,39 @@ describe(`/oauth`, () => {
|
|||||||
expect(status).toBeGreaterThanOrEqual(400);
|
expect(status).toBeGreaterThanOrEqual(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto register the user by default', async () => {
|
it('should return a link token for a new OAuth user', async () => {
|
||||||
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
const response = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(response.status).toBe(403);
|
||||||
expect(body).toMatchObject({
|
expect(response.body.message).toBe('oauth_account_link_required');
|
||||||
accessToken: expect.any(String),
|
const setCookie = response.headers['set-cookie'] as unknown as string[];
|
||||||
isAdmin: false,
|
expect(setCookie.some((cookie) => cookie.startsWith('immich_oauth_link_token='))).toBe(true);
|
||||||
name: 'OAuth User',
|
|
||||||
userEmail: 'oauth-auto-register@immich.app',
|
|
||||||
userId: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow passing state and codeVerifier via cookies', async () => {
|
it('should allow passing state and codeVerifier via cookies', async () => {
|
||||||
const { url, state, codeVerifier } = await loginWithOAuth('oauth-auto-register');
|
const { url, state, codeVerifier } = await loginWithOAuth('oauth-auto-register');
|
||||||
const { status, body } = await request(app)
|
const response = await request(app)
|
||||||
.post('/oauth/callback')
|
.post('/oauth/callback')
|
||||||
.set('Cookie', [`immich_oauth_state=${state}`, `immich_oauth_code_verifier=${codeVerifier}`])
|
.set('Cookie', [`immich_oauth_state=${state}`, `immich_oauth_code_verifier=${codeVerifier}`])
|
||||||
.send({ url });
|
.send({ url });
|
||||||
expect(status).toBe(201);
|
expect(response.status).toBe(403);
|
||||||
expect(body).toMatchObject({
|
expect(response.body.message).toBe('oauth_account_link_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register a new user via POST /auth/register using the link token cookie', async () => {
|
||||||
|
const callbackParams = await loginWithOAuth('oauth-register-flow');
|
||||||
|
const callbackResponse = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
|
expect(callbackResponse.status).toBe(403);
|
||||||
|
const setCookie = callbackResponse.headers['set-cookie'] as unknown as string[];
|
||||||
|
const linkCookie = setCookie.find((cookie) => cookie.startsWith('immich_oauth_link_token='));
|
||||||
|
expect(linkCookie).toBeDefined();
|
||||||
|
|
||||||
|
const registerResponse = await request(app).post('/auth/register').set('Cookie', linkCookie!);
|
||||||
|
expect(registerResponse.status).toBe(201);
|
||||||
|
expect(registerResponse.body).toMatchObject({
|
||||||
accessToken: expect.any(String),
|
accessToken: expect.any(String),
|
||||||
|
userEmail: 'oauth-register-flow@immich.app',
|
||||||
userId: expect.any(String),
|
userId: expect.any(String),
|
||||||
userEmail: 'oauth-auto-register@immich.app',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,11 +358,11 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not auto register the user', async () => {
|
it('should still create a link token when auto register is disabled', async () => {
|
||||||
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
|
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
const response = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(400);
|
expect(response.status).toBe(403);
|
||||||
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
expect(response.body.message).toBe('oauth_account_link_required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not auto-link to an existing user by email', async () => {
|
it('should not auto-link to an existing user by email', async () => {
|
||||||
@@ -445,24 +454,18 @@ describe(`/oauth`, () => {
|
|||||||
expect(params.get('state')).toBeDefined();
|
expect(params.get('state')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto register the user by default', async () => {
|
it('should return a link token for a new OAuth user via mobile redirect', async () => {
|
||||||
const callbackParams = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
|
const callbackParams = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
|
||||||
expect(callbackParams.url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
|
expect(callbackParams.url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
|
||||||
|
|
||||||
// simulate redirecting back to mobile app
|
// simulate redirecting back to mobile app
|
||||||
const url = callbackParams.url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
|
const url = callbackParams.url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const response = await request(app)
|
||||||
.post('/oauth/callback')
|
.post('/oauth/callback')
|
||||||
.send({ ...callbackParams, url });
|
.send({ ...callbackParams, url });
|
||||||
expect(status).toBe(201);
|
expect(response.status).toBe(403);
|
||||||
expect(body).toMatchObject({
|
expect(response.body.message).toBe('oauth_account_link_required');
|
||||||
accessToken: expect.any(String),
|
|
||||||
isAdmin: false,
|
|
||||||
name: 'OAuth User',
|
|
||||||
userEmail: 'oauth-mobile-override@immich.app',
|
|
||||||
userId: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -474,14 +477,9 @@ describe(`/oauth`, () => {
|
|||||||
clientSecret: OAuthClient.DEFAULT,
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
});
|
});
|
||||||
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
const response = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
expect(status).toBe(201);
|
expect(response.status).toBe(403);
|
||||||
expect(body).toMatchObject({
|
expect(response.body.message).toBe('oauth_account_link_required');
|
||||||
accessToken: expect.any(String),
|
|
||||||
name: 'ID Token User',
|
|
||||||
userEmail: 'oauth-id-token-claims@immich.app',
|
|
||||||
userId: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -853,6 +853,7 @@
|
|||||||
"create_link_to_share": "Create link to share",
|
"create_link_to_share": "Create link to share",
|
||||||
"create_link_to_share_description": "Let anyone with the link see the selected photo(s)",
|
"create_link_to_share_description": "Let anyone with the link see the selected photo(s)",
|
||||||
"create_new": "CREATE NEW",
|
"create_new": "CREATE NEW",
|
||||||
|
"create_new_account": "Create new account",
|
||||||
"create_new_face": "Create new face",
|
"create_new_face": "Create new face",
|
||||||
"create_new_person": "Create new person",
|
"create_new_person": "Create new person",
|
||||||
"create_new_person_hint": "Assign selected assets to a new person",
|
"create_new_person_hint": "Assign selected assets to a new person",
|
||||||
|
|||||||
@@ -4660,6 +4660,35 @@
|
|||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/auth/register": {
|
||||||
|
"post": {
|
||||||
|
"description": "Create a new user from a pending OAuth link token (requires OAuth auto-register to be enabled).",
|
||||||
|
"operationId": "register",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LoginResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Register via OAuth",
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/session/lock": {
|
"/auth/session/lock": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Remove elevated access to locked assets from the current session.",
|
"description": "Remove elevated access to locked assets from the current session.",
|
||||||
@@ -21130,6 +21159,10 @@
|
|||||||
"description": "Whether OAuth auto-launch is enabled",
|
"description": "Whether OAuth auto-launch is enabled",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"oauthAutoRegister": {
|
||||||
|
"description": "Whether OAuth auto-register is enabled",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"description": "Whether OCR is enabled",
|
"description": "Whether OCR is enabled",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -21168,6 +21201,7 @@
|
|||||||
"map",
|
"map",
|
||||||
"oauth",
|
"oauth",
|
||||||
"oauthAutoLaunch",
|
"oauthAutoLaunch",
|
||||||
|
"oauthAutoRegister",
|
||||||
"ocr",
|
"ocr",
|
||||||
"passwordLogin",
|
"passwordLogin",
|
||||||
"reverseGeocoding",
|
"reverseGeocoding",
|
||||||
|
|||||||
@@ -2047,6 +2047,8 @@ export type ServerFeaturesDto = {
|
|||||||
oauth: boolean;
|
oauth: boolean;
|
||||||
/** Whether OAuth auto-launch is enabled */
|
/** Whether OAuth auto-launch is enabled */
|
||||||
oauthAutoLaunch: boolean;
|
oauthAutoLaunch: boolean;
|
||||||
|
/** Whether OAuth auto-register is enabled */
|
||||||
|
oauthAutoRegister: boolean;
|
||||||
/** Whether OCR is enabled */
|
/** Whether OCR is enabled */
|
||||||
ocr: boolean;
|
ocr: boolean;
|
||||||
/** Whether password login is enabled */
|
/** Whether password login is enabled */
|
||||||
@@ -4304,6 +4306,18 @@ export function changePinCode({ pinCodeChangeDto }: {
|
|||||||
body: pinCodeChangeDto
|
body: pinCodeChangeDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Register via OAuth
|
||||||
|
*/
|
||||||
|
export function register(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 201;
|
||||||
|
data: LoginResponseDto;
|
||||||
|
}>("/auth/register", {
|
||||||
|
...opts,
|
||||||
|
method: "POST"
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Lock auth session
|
* Lock auth session
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -137,6 +137,44 @@ describe(AuthController.name, () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should clear the link token cookie on successful login when it was present', async () => {
|
||||||
|
const loginResponse = mediumFactory.loginResponse();
|
||||||
|
service.login.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/auth/login')
|
||||||
|
.set('Cookie', 'immich_oauth_link_token=plain')
|
||||||
|
.send({ name: 'admin', email: 'admin@local', password: 'password' });
|
||||||
|
|
||||||
|
expect(status).toEqual(201);
|
||||||
|
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
|
||||||
|
expect(cookies).toMatch(/immich_oauth_link_token=;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the link token cookie when login fails', async () => {
|
||||||
|
service.login.mockRejectedValue(new Error('Incorrect email or password'));
|
||||||
|
|
||||||
|
const { headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/auth/login')
|
||||||
|
.set('Cookie', 'immich_oauth_link_token=plain')
|
||||||
|
.send({ name: 'admin', email: 'admin@local', password: 'wrong' });
|
||||||
|
|
||||||
|
const cookies = (headers['set-cookie'] as unknown as string[] | undefined)?.join('\n') ?? '';
|
||||||
|
expect(cookies).toMatch(/immich_oauth_link_token=;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set a link token cookie header when no link token was present', async () => {
|
||||||
|
const loginResponse = mediumFactory.loginResponse();
|
||||||
|
service.login.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
|
const { headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ name: 'admin', email: 'admin@local', password: 'password' });
|
||||||
|
|
||||||
|
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
|
||||||
|
expect(cookies).not.toMatch(/immich_oauth_link_token=/);
|
||||||
|
});
|
||||||
|
|
||||||
it('should auth cookies on a secure connection', async () => {
|
it('should auth cookies on a secure connection', async () => {
|
||||||
const loginResponse = mediumFactory.loginResponse();
|
const loginResponse = mediumFactory.loginResponse();
|
||||||
service.login.mockResolvedValue(loginResponse);
|
service.login.mockResolvedValue(loginResponse);
|
||||||
@@ -175,6 +213,33 @@ describe(AuthController.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /auth/register', () => {
|
||||||
|
it('should clear the link token cookie on successful register', async () => {
|
||||||
|
const loginResponse = mediumFactory.loginResponse();
|
||||||
|
service.register.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
|
const { headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/auth/register')
|
||||||
|
.set('Cookie', 'immich_oauth_link_token=plain')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
|
||||||
|
expect(cookies).toMatch(/immich_oauth_link_token=;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the link token cookie when register fails', async () => {
|
||||||
|
service.register.mockRejectedValue(new Error('Missing OAuth link token'));
|
||||||
|
|
||||||
|
const { headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/auth/register')
|
||||||
|
.set('Cookie', 'immich_oauth_link_token=plain')
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const cookies = (headers['set-cookie'] as unknown as string[] | undefined)?.join('\n') ?? '';
|
||||||
|
expect(cookies).toMatch(/immich_oauth_link_token=;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /auth/logout', () => {
|
describe('POST /auth/logout', () => {
|
||||||
it('should be an authenticated route', async () => {
|
it('should be an authenticated route', async () => {
|
||||||
await request(ctx.getHttpServer()).post('/auth/logout');
|
await request(ctx.getHttpServer()).post('/auth/logout');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { parse as parseCookie } from 'cookie';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
@@ -39,18 +40,51 @@ export class AuthController {
|
|||||||
@Body() loginCredential: LoginCredentialDto,
|
@Body() loginCredential: LoginCredentialDto,
|
||||||
@GetLoginDetails() loginDetails: LoginDetails,
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const body = await this.service.login(loginCredential, loginDetails, request.headers);
|
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
|
||||||
if (request.cookies?.[ImmichCookie.OAuthLinkToken]) {
|
try {
|
||||||
res.clearCookie(ImmichCookie.OAuthLinkToken);
|
const body = await this.service.login(loginCredential, loginDetails, request.headers);
|
||||||
|
return respondWithCookie(res, body, {
|
||||||
|
isSecure: loginDetails.isSecure,
|
||||||
|
values: [
|
||||||
|
{ key: ImmichCookie.AccessToken, value: body.accessToken },
|
||||||
|
{ key: ImmichCookie.AuthType, value: AuthType.Password },
|
||||||
|
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (hadLinkCookie) {
|
||||||
|
res.clearCookie(ImmichCookie.OAuthLinkToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Register via OAuth',
|
||||||
|
description: 'Create a new user from a pending OAuth link token (requires OAuth auto-register to be enabled).',
|
||||||
|
history: new HistoryBuilder().added('v2'),
|
||||||
|
})
|
||||||
|
async register(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
|
): Promise<LoginResponseDto> {
|
||||||
|
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
|
||||||
|
try {
|
||||||
|
const body = await this.service.register(loginDetails, request.headers);
|
||||||
|
return respondWithCookie(res, body, {
|
||||||
|
isSecure: loginDetails.isSecure,
|
||||||
|
values: [
|
||||||
|
{ key: ImmichCookie.AccessToken, value: body.accessToken },
|
||||||
|
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
|
||||||
|
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (hadLinkCookie) {
|
||||||
|
res.clearCookie(ImmichCookie.OAuthLinkToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return respondWithCookie(res, body, {
|
|
||||||
isSecure: loginDetails.isSecure,
|
|
||||||
values: [
|
|
||||||
{ key: ImmichCookie.AccessToken, value: body.accessToken },
|
|
||||||
{ key: ImmichCookie.AuthType, value: AuthType.Password },
|
|
||||||
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('admin-sign-up')
|
@Post('admin-sign-up')
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ const ServerFeaturesSchema = z
|
|||||||
importFaces: z.boolean().describe('Whether face import is enabled'),
|
importFaces: z.boolean().describe('Whether face import is enabled'),
|
||||||
oauth: z.boolean().describe('Whether OAuth is enabled'),
|
oauth: z.boolean().describe('Whether OAuth is enabled'),
|
||||||
oauthAutoLaunch: z.boolean().describe('Whether OAuth auto-launch is enabled'),
|
oauthAutoLaunch: z.boolean().describe('Whether OAuth auto-launch is enabled'),
|
||||||
|
oauthAutoRegister: z.boolean().describe('Whether OAuth auto-register is enabled'),
|
||||||
passwordLogin: z.boolean().describe('Whether password login is enabled'),
|
passwordLogin: z.boolean().describe('Whether password login is enabled'),
|
||||||
sidecar: z.boolean().describe('Whether sidecar files are supported'),
|
sidecar: z.boolean().describe('Whether sidecar files are supported'),
|
||||||
search: z.boolean().describe('Whether search is enabled'),
|
search: z.boolean().describe('Whether search is enabled'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
"oauthSub" varchar NOT NULL,
|
"oauthSub" varchar NOT NULL,
|
||||||
"oauthSid" varchar,
|
"oauthSid" varchar,
|
||||||
"email" varchar NOT NULL,
|
"email" varchar NOT NULL,
|
||||||
|
"profile" jsonb NOT NULL,
|
||||||
"expiresAt" timestamp with time zone NOT NULL,
|
"expiresAt" timestamp with time zone NOT NULL,
|
||||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
|
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
|
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
|
||||||
|
|
||||||
|
export type OAuthLinkTokenProfile = {
|
||||||
|
name: string;
|
||||||
|
storageLabel: string | null;
|
||||||
|
storageQuotaInGiB: number | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
picture: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
@Table({ name: 'oauth_link_token' })
|
@Table({ name: 'oauth_link_token' })
|
||||||
export class OAuthLinkTokenTable {
|
export class OAuthLinkTokenTable {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
@@ -17,6 +25,9 @@ export class OAuthLinkTokenTable {
|
|||||||
@Column()
|
@Column()
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb' })
|
||||||
|
profile!: OAuthLinkTokenProfile;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone' })
|
@Column({ type: 'timestamp with time zone' })
|
||||||
expiresAt!: Timestamp;
|
expiresAt!: Timestamp;
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ describe(AuthService.name, () => {
|
|||||||
oauthSub: 'oauth-sub-123',
|
oauthSub: 'oauth-sub-123',
|
||||||
oauthSid: null,
|
oauthSid: null,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
profile: {
|
||||||
|
name: 'OAuth User',
|
||||||
|
storageLabel: null,
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
token: Buffer.from('hashed'),
|
token: Buffer.from('hashed'),
|
||||||
expiresAt: new Date(Date.now() + 600_000),
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -108,7 +115,7 @@ describe(AuthService.name, () => {
|
|||||||
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
|
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
|
||||||
|
|
||||||
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
|
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: 'oauth-sub-123' });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, expect.objectContaining({ oauthId: 'oauth-sub-123' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should propagate oauthSid from link token to the session', async () => {
|
it('should propagate oauthSid from link token to the session', async () => {
|
||||||
@@ -121,6 +128,13 @@ describe(AuthService.name, () => {
|
|||||||
oauthSub: 'oauth-sub-123',
|
oauthSub: 'oauth-sub-123',
|
||||||
oauthSid: 'idp-sid-456',
|
oauthSid: 'idp-sid-456',
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
profile: {
|
||||||
|
name: 'OAuth User',
|
||||||
|
storageLabel: null,
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
token: Buffer.from('hashed'),
|
token: Buffer.from('hashed'),
|
||||||
expiresAt: new Date(Date.now() + 600_000),
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -132,14 +146,253 @@ describe(AuthService.name, () => {
|
|||||||
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-456' }));
|
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-456' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject login with invalid link token cookie', async () => {
|
it('should silently fall back to normal login when the link token is invalid or expired', async () => {
|
||||||
const user = UserFactory.create({ password: 'immich_password' });
|
const user = UserFactory.create({ password: 'immich_password' });
|
||||||
|
const session = SessionFactory.create();
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
|
mocks.session.create.mockResolvedValue(session);
|
||||||
mocks.oauthLinkToken.consumeToken.mockResolvedValue(null as any);
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue(null as any);
|
||||||
|
|
||||||
await expect(sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=bad-token' })).rejects.toThrow(
|
await expect(
|
||||||
'Invalid or expired link token',
|
sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=bad-token' }),
|
||||||
|
).resolves.toMatchObject({ userId: user.id });
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when the link token points to a sub already linked to another user', async () => {
|
||||||
|
const user = UserFactory.create({ password: 'immich_password' });
|
||||||
|
const otherUser = UserFactory.create({ oauthId: 'oauth-sub-123' });
|
||||||
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: null,
|
||||||
|
email: user.email,
|
||||||
|
profile: {
|
||||||
|
name: 'OAuth User',
|
||||||
|
storageLabel: null,
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(otherUser);
|
||||||
|
|
||||||
|
await expect(sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' })).rejects.toThrow(
|
||||||
|
'This OAuth account has already been linked to another user.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize the storage label when linking from an OAuth profile', async () => {
|
||||||
|
const user = UserFactory.create({ password: 'immich_password' });
|
||||||
|
const session = SessionFactory.create();
|
||||||
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
|
mocks.session.create.mockResolvedValue(session);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: null,
|
||||||
|
email: user.email,
|
||||||
|
profile: {
|
||||||
|
name: 'OAuth User',
|
||||||
|
storageLabel: '../evil/path',
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
|
||||||
|
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
|
||||||
|
|
||||||
|
const updateCall = mocks.user.update.mock.calls[0][1];
|
||||||
|
expect(updateCall.storageLabel).not.toContain('/');
|
||||||
|
expect(updateCall.storageLabel).not.toContain('.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should throw if auto-register is disabled', async () => {
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
|
|
||||||
|
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
|
||||||
|
'OAuth auto-register is disabled',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if link token cookie is missing', async () => {
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
|
|
||||||
|
await expect(sut.register(loginDetails, {})).rejects.toThrow('Missing OAuth link token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if the sub is already linked', async () => {
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: null,
|
||||||
|
email: 'new@immich.cloud',
|
||||||
|
profile: {
|
||||||
|
name: 'New User',
|
||||||
|
storageLabel: null,
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-sub-123' }));
|
||||||
|
|
||||||
|
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
|
||||||
|
'This OAuth account has already been linked to another user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a user from the link token and apply the profile', async () => {
|
||||||
|
const newUser = UserFactory.create();
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: 'idp-sid',
|
||||||
|
email: 'new@immich.cloud',
|
||||||
|
profile: {
|
||||||
|
name: 'New User',
|
||||||
|
storageLabel: 'shiny',
|
||||||
|
storageQuotaInGiB: 5,
|
||||||
|
isAdmin: true,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
|
mocks.user.create.mockResolvedValue(newUser);
|
||||||
|
mocks.user.update.mockResolvedValue(newUser);
|
||||||
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
|
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
|
||||||
|
|
||||||
|
expect(mocks.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ email: 'new@immich.cloud', name: 'New User', isAdmin: true }),
|
||||||
|
);
|
||||||
|
expect(mocks.user.update).toHaveBeenCalledWith(
|
||||||
|
newUser.id,
|
||||||
|
expect.objectContaining({
|
||||||
|
oauthId: 'oauth-sub-123',
|
||||||
|
storageLabel: 'shiny',
|
||||||
|
quotaSizeInBytes: 5 * 1024 * 1024 * 1024,
|
||||||
|
isAdmin: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow the first OAuth admin to bootstrap the instance', async () => {
|
||||||
|
const newUser = UserFactory.create({ isAdmin: true });
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: null,
|
||||||
|
email: 'first@immich.cloud',
|
||||||
|
profile: {
|
||||||
|
name: 'First Admin',
|
||||||
|
storageLabel: null,
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: true,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(void 0);
|
||||||
|
mocks.user.create.mockResolvedValue(newUser);
|
||||||
|
mocks.user.update.mockResolvedValue(newUser);
|
||||||
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
|
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
|
||||||
|
|
||||||
|
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||||
|
expect(mocks.user.getAdmin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a non-admin OAuth register when no admin exists yet', async () => {
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: null,
|
||||||
|
email: 'first@immich.cloud',
|
||||||
|
profile: {
|
||||||
|
name: 'Regular User',
|
||||||
|
storageLabel: null,
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(void 0);
|
||||||
|
|
||||||
|
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
|
||||||
|
'The first registered account must the administrator.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize the storage label on register', async () => {
|
||||||
|
const newUser = UserFactory.create();
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
|
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
|
||||||
|
id: 'token-id',
|
||||||
|
oauthSub: 'oauth-sub-123',
|
||||||
|
oauthSid: null,
|
||||||
|
email: 'new@immich.cloud',
|
||||||
|
profile: {
|
||||||
|
name: 'New User',
|
||||||
|
storageLabel: '../sneaky',
|
||||||
|
storageQuotaInGiB: null,
|
||||||
|
isAdmin: false,
|
||||||
|
picture: null,
|
||||||
|
},
|
||||||
|
token: Buffer.from('hashed'),
|
||||||
|
expiresAt: new Date(Date.now() + 600_000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
|
mocks.user.create.mockResolvedValue(newUser);
|
||||||
|
mocks.user.update.mockResolvedValue(newUser);
|
||||||
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
|
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
|
||||||
|
|
||||||
|
const updateCall = mocks.user.update.mock.calls[0][1];
|
||||||
|
expect(updateCall.storageLabel).not.toContain('/');
|
||||||
|
expect(updateCall.storageLabel).not.toContain('.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -739,29 +992,11 @@ describe(AuthService.name, () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow auto registering', async () => {
|
it('should create a link token when the oauth sub is not yet linked', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
sut.callback(
|
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
|
||||||
{},
|
|
||||||
loginDetails,
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when existing user found by email and create a link token', async () => {
|
|
||||||
const user = UserFactory.create();
|
|
||||||
const profile = OAuthProfileFactory.create();
|
const profile = OAuthProfileFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'idp-sid-789' });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'idp-sid-789' });
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
|
||||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -772,20 +1007,18 @@ describe(AuthService.name, () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow(OAuthLinkRequiredException);
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||||
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ oauthSub: profile.sub, oauthSid: 'idp-sid-789' }),
|
expect.objectContaining({ oauthSub: profile.sub, oauthSid: 'idp-sid-789', email: profile.email }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize the email from the OAuth profile before looking up user', async () => {
|
it('should normalize the email from the OAuth profile before storing in the link token', async () => {
|
||||||
const user = UserFactory.create();
|
|
||||||
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
|
const profile = 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({ profile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
|
||||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -794,52 +1027,12 @@ describe(AuthService.name, () => {
|
|||||||
{},
|
{},
|
||||||
loginDetails,
|
loginDetails,
|
||||||
),
|
),
|
||||||
).rejects.toThrow(ForbiddenException);
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@immich.cloud' }));
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not link to a user with a different oauth sub', async () => {
|
it('should throw an error if the OAuth profile does not have an email claim', async () => {
|
||||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
|
||||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
sut.callback(
|
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
|
||||||
{},
|
|
||||||
loginDetails,
|
|
||||||
),
|
|
||||||
).rejects.toThrow(ForbiddenException);
|
|
||||||
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow auto registering by default', async () => {
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
|
||||||
{},
|
|
||||||
loginDetails,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
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 }));
|
||||||
@@ -883,19 +1076,20 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should use the default quota', async () => {
|
it('should use the default quota', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should infer name from given and family names', async () => {
|
it('should infer name from given and family names', async () => {
|
||||||
@@ -903,18 +1097,19 @@ describe(AuthService.name, () => {
|
|||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ name: 'Given Family' }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to email when no username is provided', async () => {
|
it('should fallback to email when no username is provided', async () => {
|
||||||
@@ -922,18 +1117,19 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ name: profile.email }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore an invalid storage quota', async () => {
|
it('should ignore an invalid storage quota', async () => {
|
||||||
@@ -941,18 +1137,19 @@ describe(AuthService.name, () => {
|
|||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
|
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
|
||||||
});
|
});
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore a negative quota', async () => {
|
it('should ignore a negative quota', async () => {
|
||||||
@@ -960,53 +1157,55 @@ describe(AuthService.name, () => {
|
|||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
|
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
|
||||||
});
|
});
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 0 }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 5 }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sync the profile picture', async () => {
|
it('should sync the profile picture', async () => {
|
||||||
@@ -1100,19 +1299,19 @@ describe(AuthService.name, () => {
|
|||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: false }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -1120,18 +1319,19 @@ describe(AuthService.name, () => {
|
|||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a custom role claim', async () => {
|
it('should accept a custom role claim', async () => {
|
||||||
@@ -1141,18 +1341,19 @@ describe(AuthService.name, () => {
|
|||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||||
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
||||||
});
|
});
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
|
||||||
|
|
||||||
await sut.callback(
|
await expect(
|
||||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
sut.callback(
|
||||||
{},
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
loginDetails,
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(OAuthLinkRequiredException);
|
||||||
|
|
||||||
|
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
|
|||||||
import { parse } from 'cookie';
|
import { parse } 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 { SystemConfig } from 'src/config';
|
||||||
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||||
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum';
|
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum';
|
||||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||||
|
import { OAuthLinkTokenProfile } from 'src/schema/tables/oauth-link-token.table';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { isGranted } from 'src/utils/access';
|
import { isGranted } from 'src/utils/access';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
@@ -89,22 +92,46 @@ export class AuthService extends BaseService {
|
|||||||
if (linkTokenCookie) {
|
if (linkTokenCookie) {
|
||||||
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
|
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
|
||||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
||||||
if (!record) {
|
if (record) {
|
||||||
throw new BadRequestException('Invalid or expired link token');
|
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
|
||||||
|
if (duplicate && duplicate.id !== user.id) {
|
||||||
|
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||||
|
}
|
||||||
|
user = await this.applyOAuthProfileToUser(user, record);
|
||||||
|
linkedOAuthSid = record.oauthSid ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
|
|
||||||
if (duplicate && duplicate.id !== user.id) {
|
|
||||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.userRepository.update(user.id, { oauthId: record.oauthSub });
|
|
||||||
linkedOAuthSid = record.oauthSid ?? undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.createLoginResponse(user, details, linkedOAuthSid);
|
return this.createLoginResponse(user, details, linkedOAuthSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async register(details: LoginDetails, headers: IncomingHttpHeaders) {
|
||||||
|
const { oauth } = await this.getConfig({ withCache: false });
|
||||||
|
if (!oauth.enabled || !oauth.autoRegister) {
|
||||||
|
throw new BadRequestException('OAuth auto-register is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
|
||||||
|
if (!linkTokenCookie) {
|
||||||
|
throw new BadRequestException('Missing OAuth link token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.consumeOAuthLinkToken(linkTokenCookie);
|
||||||
|
const existing = await this.userRepository.getByOAuthId(record.oauthSub);
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Registering new user from OAuth: ${record.oauthSub}/${record.email}`);
|
||||||
|
const newUser = await this.createUser({
|
||||||
|
email: record.email,
|
||||||
|
name: record.profile.name,
|
||||||
|
isAdmin: record.profile.isAdmin,
|
||||||
|
});
|
||||||
|
const user = await this.applyOAuthProfileToUser(newUser, record);
|
||||||
|
return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||||
if (auth.session) {
|
if (auth.session) {
|
||||||
await this.sessionRepository.delete(auth.session.id);
|
await this.sessionRepository.delete(auth.session.id);
|
||||||
@@ -343,76 +370,91 @@ export class AuthService extends BaseService {
|
|||||||
codeVerifier,
|
codeVerifier,
|
||||||
);
|
);
|
||||||
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
|
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
|
||||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = 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);
|
const user = await this.userRepository.getByOAuthId(profile.sub);
|
||||||
|
|
||||||
if (!user && normalizedEmail) {
|
if (user) {
|
||||||
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
if (!user.profileImagePath && profile.picture) {
|
||||||
if (emailUser) {
|
await this.syncProfilePicture(user, profile.picture);
|
||||||
const plainToken = this.cryptoRepository.randomBytesAsText(32);
|
|
||||||
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
|
||||||
await this.oauthLinkTokenRepository.create({
|
|
||||||
token: hashedToken,
|
|
||||||
oauthSub: profile.sub,
|
|
||||||
oauthSid: oauthSid ?? null,
|
|
||||||
email: emailUser.email,
|
|
||||||
expiresAt: DateTime.now().plus({ minutes: 10 }).toJSDate(),
|
|
||||||
});
|
|
||||||
throw new OAuthLinkRequiredException(emailUser.email, plainToken);
|
|
||||||
}
|
}
|
||||||
|
return this.createLoginResponse(user, loginDetails, oauthSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// register new user
|
if (!normalizedEmail) {
|
||||||
if (!user) {
|
throw new BadRequestException('OAuth profile does not have an email address');
|
||||||
if (!autoRegister) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
|
|
||||||
);
|
|
||||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizedEmail) {
|
|
||||||
throw new BadRequestException('OAuth profile does not have an email address');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 role = this.getClaim<'admin' | 'user'>(profile, {
|
|
||||||
key: roleClaim,
|
|
||||||
default: 'user',
|
|
||||||
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
|
|
||||||
});
|
|
||||||
|
|
||||||
user = await this.createUser({
|
|
||||||
name:
|
|
||||||
profile.name ||
|
|
||||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
|
||||||
profile.preferred_username ||
|
|
||||||
normalizedEmail,
|
|
||||||
email: normalizedEmail,
|
|
||||||
oauthId: profile.sub,
|
|
||||||
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
|
|
||||||
storageLabel: storageLabel || null,
|
|
||||||
isAdmin: role === 'admin',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.profileImagePath && profile.picture) {
|
const resolvedProfile = this.resolveOAuthProfile(profile, normalizedEmail, oauth);
|
||||||
await this.syncProfilePicture(user, profile.picture);
|
const plainToken = this.cryptoRepository.randomBytesAsText(32);
|
||||||
}
|
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||||
|
await this.oauthLinkTokenRepository.create({
|
||||||
|
token: hashedToken,
|
||||||
|
oauthSub: profile.sub,
|
||||||
|
oauthSid: oauthSid ?? null,
|
||||||
|
email: normalizedEmail,
|
||||||
|
profile: resolvedProfile,
|
||||||
|
expiresAt: DateTime.now().plus({ minutes: 10 }).toJSDate(),
|
||||||
|
});
|
||||||
|
throw new OAuthLinkRequiredException(normalizedEmail, plainToken);
|
||||||
|
}
|
||||||
|
|
||||||
return this.createLoginResponse(user, loginDetails, oauthSid);
|
private resolveOAuthProfile(
|
||||||
|
profile: OAuthProfile,
|
||||||
|
normalizedEmail: string,
|
||||||
|
oauth: SystemConfig['oauth'],
|
||||||
|
): OAuthLinkTokenProfile {
|
||||||
|
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||||
|
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 role = this.getClaim<'admin' | 'user'>(profile, {
|
||||||
|
key: roleClaim,
|
||||||
|
default: 'user',
|
||||||
|
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
profile.name ||
|
||||||
|
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||||
|
profile.preferred_username ||
|
||||||
|
normalizedEmail,
|
||||||
|
storageLabel: storageLabel || null,
|
||||||
|
storageQuotaInGiB: storageQuota,
|
||||||
|
isAdmin: role === 'admin',
|
||||||
|
picture: profile.picture ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeOAuthLinkToken(plainToken: string) {
|
||||||
|
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||||
|
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
||||||
|
if (!record) {
|
||||||
|
throw new BadRequestException('Invalid or expired link token');
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
|
||||||
|
const { profile } = record;
|
||||||
|
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null;
|
||||||
|
const updated = await this.userRepository.update(user.id, {
|
||||||
|
oauthId: record.oauthSub,
|
||||||
|
storageLabel,
|
||||||
|
quotaSizeInBytes: profile.storageQuotaInGiB === null ? null : profile.storageQuotaInGiB * HumanReadableSize.GiB,
|
||||||
|
isAdmin: profile.isAdmin,
|
||||||
|
});
|
||||||
|
if (!updated.profileImagePath && profile.picture) {
|
||||||
|
await this.syncProfilePicture(updated, profile.picture);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncProfilePicture(user: UserAdmin, url: string) {
|
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ describe(ServerService.name, () => {
|
|||||||
reverseGeocoding: true,
|
reverseGeocoding: true,
|
||||||
oauth: false,
|
oauth: false,
|
||||||
oauthAutoLaunch: false,
|
oauthAutoLaunch: false,
|
||||||
|
oauthAutoRegister: true,
|
||||||
ocr: true,
|
ocr: true,
|
||||||
passwordLogin: true,
|
passwordLogin: true,
|
||||||
search: true,
|
search: true,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export class ServerService extends BaseService {
|
|||||||
trash: trash.enabled,
|
trash: trash.enabled,
|
||||||
oauth: oauth.enabled,
|
oauth: oauth.enabled,
|
||||||
oauthAutoLaunch: oauth.autoLaunch,
|
oauthAutoLaunch: oauth.autoLaunch,
|
||||||
|
oauthAutoRegister: oauth.autoRegister,
|
||||||
ocr: isOcrEnabled(machineLearning),
|
ocr: isOcrEnabled(machineLearning),
|
||||||
passwordLogin: passwordLogin.enabled,
|
passwordLogin: passwordLogin.enabled,
|
||||||
configFile: !!configFile,
|
configFile: !!configFile,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getServerErrorMessage } from '$lib/utils/handle-error';
|
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
||||||
import { login } from '@immich/sdk';
|
import { login, register } from '@immich/sdk';
|
||||||
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
|
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
let password = $state('');
|
let password = $state('');
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let registering = $state(false);
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -37,6 +38,19 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
try {
|
||||||
|
registering = true;
|
||||||
|
const user = await register();
|
||||||
|
eventManager.emit('AuthLogin', user);
|
||||||
|
await authManager.refresh();
|
||||||
|
await goto(Route.photos(), { invalidateAll: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_create_user'));
|
||||||
|
registering = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AuthPageLayout title={data.meta.title}>
|
<AuthPageLayout title={data.meta.title}>
|
||||||
@@ -68,5 +82,29 @@
|
|||||||
{$t('oauth_link_password_login_required')}
|
{$t('oauth_link_password_login_required')}
|
||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if featureFlagsManager.value.oauthAutoRegister}
|
||||||
|
{#if featureFlagsManager.value.passwordLogin}
|
||||||
|
<div class="inline-flex w-full items-center justify-center my-4">
|
||||||
|
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
|
||||||
|
<span
|
||||||
|
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
|
||||||
|
>
|
||||||
|
{$t('or')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
shape="round"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
|
||||||
|
loading={registering}
|
||||||
|
onclick={handleRegister}
|
||||||
|
>
|
||||||
|
{$t('create_new_account')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthPageLayout>
|
</AuthPageLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user