feat: passkeys (#32194)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 6233a79bf7e4cec11e9ea1094908ff3b228fcbe0)
This commit is contained in:
Jason Rasmussen
2026-10-22 05:23:22 +03:00
committed by Codex
parent e66d5a70e7
commit b12be9119f
59 changed files with 3578 additions and 84 deletions
+2
View File
@@ -61,7 +61,9 @@
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.222.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@simplewebauthn/server": "14.0.3",
"@socket.io/redis-adapter": "^8.3.0",
"aaguids": "^0.0.71",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
+2 -1
View File
@@ -60,7 +60,7 @@ export const reverseGeocodeMaxDistance = 25_000;
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/custom.css', '/favicon.ico'];
export const FACE_THUMBNAIL_SIZE = 250;
@@ -175,6 +175,7 @@ export const endpointTags: Record<ApiTag, string> = {
'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.',
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
[ApiTag.Passkeys]: 'A passkey is a WebAuthn credential that can be used to login without a password.',
[ApiTag.People]:
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
[ApiTag.Plugins]:
@@ -1,20 +1,24 @@
import request from 'supertest';
import { AppController } from 'src/controllers/app.controller.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { SystemConfigService } from 'src/services/system-config.service.js';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
describe(AppController.name, () => {
let ctx: ControllerContext;
const passkeyService = mockBaseService(PasskeyService);
beforeAll(async () => {
ctx = await controllerSetup(AppController, [
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
{ provide: PasskeyService, useValue: passkeyService },
]);
return () => ctx.close();
});
beforeEach(() => {
ctx.reset();
passkeyService.resetAllMocks();
});
describe('GET /.well-known/immich', () => {
@@ -34,6 +38,13 @@ describe(AppController.name, () => {
});
});
describe('GET /.well-known/webauthn', () => {
it('should not be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/.well-known/webauthn');
expect(ctx.authenticate).not.toHaveBeenCalled();
});
});
describe('GET /custom.css', () => {
it('should not be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/custom.css');
+12 -1
View File
@@ -1,11 +1,15 @@
import { Controller, Get, Header } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { Authenticated } from 'src/middleware/auth.guard.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { SystemConfigService } from 'src/services/system-config.service.js';
@Controller()
export class AppController {
constructor(private service: SystemConfigService) {}
constructor(
private service: SystemConfigService,
private passkeyService: PasskeyService,
) {}
@ApiExcludeEndpoint()
@Get('.well-known/immich')
@@ -18,6 +22,13 @@ export class AppController {
};
}
@ApiExcludeEndpoint()
@Get('.well-known/webauthn')
@Authenticated({ public: true })
getWebAuthnWellKnown() {
return this.passkeyService.getWellKnown();
}
@ApiExcludeEndpoint()
@Get('custom.css')
@Authenticated({ public: true })
+2
View File
@@ -25,6 +25,7 @@ import { NotificationAdminController } from 'src/controllers/notification-admin.
import { NotificationController } from 'src/controllers/notification.controller.js';
import { OAuthController } from 'src/controllers/oauth.controller.js';
import { PartnerController } from 'src/controllers/partner.controller.js';
import { PasskeyController } from 'src/controllers/passkey.controller.js';
import { PersonController } from 'src/controllers/person.controller.js';
import { PluginController } from 'src/controllers/plugin.controller.js';
import { QueueController } from 'src/controllers/queue.controller.js';
@@ -73,6 +74,7 @@ export const controllers = [
NotificationAdminController,
OAuthController,
PartnerController,
PasskeyController,
PersonController,
PluginController,
QueueController,
@@ -0,0 +1,168 @@
import request from 'supertest';
import { PasskeyController } from 'src/controllers/passkey.controller.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { factory } from 'test/small.factory.js';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
const registrationResponse = {
id: 'Y3JlZGVudGlhbC1pZA',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation-object',
},
};
const authenticationResponse = {
id: 'Y3JlZGVudGlhbC1pZA',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
authenticatorData: 'authenticator-data',
signature: 'signature',
},
};
describe(PasskeyController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PasskeyService);
beforeAll(async () => {
ctx = await controllerSetup(PasskeyController, [{ provide: PasskeyService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /passkeys/registration/start', () => {
it('should respond with 200', async () => {
service.startRegistration.mockResolvedValue({ challenge: 'challenge' } as any);
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/registration/start');
expect(status).toEqual(200);
expect(headers['set-cookie']).toBeUndefined();
});
});
describe('POST /passkeys/registration/finish', () => {
it('should require a response', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/passkeys/registration/finish')
.send({ name: 'Passkey' });
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['response'], message: 'Invalid input: expected nonoptional, received undefined' },
]),
);
});
it('should respond with 201', async () => {
service.finishRegistration.mockResolvedValue({} as any);
const { status, headers } = await request(ctx.getHttpServer())
.post('/passkeys/registration/finish')
.send({ name: 'Passkey', response: registrationResponse });
expect(status).toEqual(201);
expect(headers['set-cookie']).toBeUndefined();
});
});
describe('POST /passkeys/authentication/start', () => {
it('should respond with 200', async () => {
service.startAuthentication.mockResolvedValue({ challenge: 'challenge' } as any);
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/authentication/start');
expect(status).toEqual(200);
expect(headers['set-cookie']).toBeUndefined();
});
});
describe('POST /passkeys/authentication/finish', () => {
it('should require a response', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/passkeys/authentication/finish').send({});
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['response'], message: 'Invalid input: expected object, received undefined' },
]),
);
});
it('should respond with 201 and set the auth cookies', async () => {
service.finishAuthentication.mockResolvedValue({ accessToken: 'token' } as any);
const { status, headers } = await request(ctx.getHttpServer())
.post('/passkeys/authentication/finish')
.send({ response: authenticationResponse });
expect(status).toEqual(201);
expect(headers['set-cookie']).toEqual([
expect.stringContaining('immich_access_token=token; Max-Age=34560000; Path=/; Expires='),
expect.stringContaining('immich_auth_type=passkey; Max-Age=34560000; Path=/; Expires='),
expect.stringContaining('immich_is_authenticated=true; Max-Age=34560000; Path=/; Expires='),
]);
});
});
describe('GET /passkeys', () => {
it('should reject an invalid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys').query({ id: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('GET /passkeys/:id', () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys/123');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('PATCH /passkeys/:id', () => {
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).patch(`/passkeys/${factory.uuid()}`).send({});
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['name'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it('should reject an empty name', async () => {
const { status, body } = await request(ctx.getHttpServer())
.patch(`/passkeys/${factory.uuid()}`)
.send({ name: '' });
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['name'], message: 'Too small: expected string to have >=1 characters' },
]),
);
expect(service.update).not.toHaveBeenCalled();
});
it('should allow a null name', async () => {
const auth = factory.auth();
const id = factory.uuid();
ctx.authenticate.mockResolvedValue(auth);
service.update.mockResolvedValue({} as any);
const { status } = await request(ctx.getHttpServer()).patch(`/passkeys/${id}`).send({ name: null });
expect(status).toEqual(200);
expect(service.update).toHaveBeenCalledWith(auth, id, { name: null });
});
});
describe('DELETE /passkeys/:id', () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete('/passkeys/123');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
@@ -0,0 +1,152 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import type { LoginDetails } from 'src/services/auth.service.js';
import { Endpoint, HistoryBuilder } from 'src/decorators.js';
import { type AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
import {
PasskeyAuthenticationFinishDto,
PasskeyAuthenticationOptionsDto,
PasskeyRegistrationFinishDto,
PasskeyRegistrationOptionsDto,
PasskeyResponseDto,
PasskeySearchDto,
PasskeyUpdateDto,
} from 'src/dtos/passkey.dto.js';
import { ApiTag, AuthType, ImmichCookie, Permission } from 'src/enum.js';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { respondWithCookie } from 'src/utils/response.js';
import { UUIDParamDto } from 'src/validation.js';
@ApiTags(ApiTag.Passkeys)
@Controller('passkeys')
export class PasskeyController {
constructor(private service: PasskeyService) {}
@Post('registration/start')
@Authenticated({ permission: false })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Start passkey registration',
description: 'Generate the options used by the browser to create a new passkey for the current user.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
startRegistration(@Req() request: Request, @Auth() auth: AuthDto): Promise<PasskeyRegistrationOptionsDto> {
return this.service.startRegistration(auth, request.headers);
}
@Post('registration/finish')
@Authenticated({ permission: false })
@Endpoint({
summary: 'Finish passkey registration',
description: 'Verify the response from the authenticator and save the new passkey for the current user.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
finishRegistration(
@Req() request: Request,
@Auth() auth: AuthDto,
@Body() dto: PasskeyRegistrationFinishDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<PasskeyResponseDto> {
return this.service.finishRegistration(auth, dto, request.headers, loginDetails);
}
@Post('authentication/start')
@Authenticated({ public: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Start passkey authentication',
description: 'Generate the options used by the browser to sign in with a passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
startAuthentication(@Req() request: Request): Promise<PasskeyAuthenticationOptionsDto> {
return this.service.startAuthentication(request.headers);
}
@Post('authentication/finish')
@Authenticated({ public: true })
@Endpoint({
summary: 'Finish passkey authentication',
description: 'Verify the response from the authenticator and receive a session token.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
async finishAuthentication(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Body() dto: PasskeyAuthenticationFinishDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.finishAuthentication(dto, request.headers, loginDetails);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Passkey },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
}
@Get()
@Authenticated({ permission: Permission.PasskeyRead })
@Endpoint({
summary: 'Search passkeys',
description: 'Search the passkeys of the current user.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
searchPasskeys(@Auth() auth: AuthDto, @Query() dto: PasskeySearchDto): Promise<PasskeyResponseDto[]> {
return this.service.search(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.PasskeyRead })
@Endpoint({
summary: 'Retrieve a passkey',
description: 'Retrieve a passkey by its ID. The current user must own this passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
getPasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PasskeyResponseDto> {
return this.service.get(auth, id);
}
@Patch(':id')
@Authenticated({ permission: Permission.PasskeyUpdate })
@Endpoint({
summary: 'Update a passkey',
description: 'Update the name of a passkey by its ID. The current user must own this passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
updatePasskey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PasskeyUpdateDto,
): Promise<PasskeyResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.PasskeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a passkey',
description: 'Delete a passkey by its ID. The current user must own this passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
deletePasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}
+12
View File
@@ -82,6 +82,17 @@ export type ApiKey = {
permissions: Permission[];
};
export type Passkey = {
id: string;
name: string | null;
userId: string;
credentialId: string;
transports: string[];
createdAt: Date;
updatedAt: Date;
usedAt: Date | null;
};
export type Tag = {
id: string;
value: string;
@@ -409,6 +420,7 @@ export const columns = {
],
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
passkey: ['id', 'name', 'userId', 'credentialId', 'transports', 'createdAt', 'updatedAt', 'usedAt'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
pluginMethod: [
'plugin_method.name',
+43
View File
@@ -17,6 +17,7 @@ const PUBLIC_PROPERTIES = [
'oauth.autoLaunch',
'oauth.buttonText',
'oauth.enabled',
'passkey.enabled',
'passwordLogin.enabled',
'server.loginPageMessage',
];
@@ -75,3 +76,45 @@ describe('config visibility', () => {
}
});
});
const parsePasskey = (passkey: Record<string, unknown>) => AdminConfigDto.schema.safeParse({ ...defaults, passkey });
const passkeyMessages = (passkey: Record<string, unknown>) =>
parsePasskey(passkey).error?.issues.map((issue) => issue.message);
describe('passkey config', () => {
it('should allow enabling without a domain', () => {
expect(parsePasskey({ enabled: true, domain: null, additionalDomains: [] }).success).toBe(true);
});
it('should reject domains that are not https', () => {
expect(passkeyMessages({ enabled: true, domain: 'ftp://immich.example', additionalDomains: [] })).toEqual([
'Invalid URL',
]);
});
it('should reject domains with a path', () => {
expect(
passkeyMessages({ enabled: true, domain: null, additionalDomains: ['https://immich.tailnet.example/photos'] }),
).toEqual(['Additional domain must not include a path, query, or fragment']);
});
it('should reject duplicate additional domains', () => {
expect(
passkeyMessages({
enabled: true,
domain: null,
additionalDomains: ['https://immich.tailnet.example', 'https://immich.tailnet.example/'],
}),
).toEqual(['Duplicate domain']);
});
it('should reject the passkey domain as an additional domain', () => {
expect(
passkeyMessages({
enabled: true,
domain: 'https://immich.example',
additionalDomains: ['https://immich.example/'],
}),
).toEqual(['The passkey domain is always allowed and must not be listed as an additional domain']);
});
});
+47 -2
View File
@@ -58,6 +58,18 @@ const cronExpressionSchema = z
})
.describe('Cron expression');
const httpsDomain = (label: string) =>
z
.url({ protocol: /^https$/, hostname: z.regexes.domain })
.normalize()
.refine((url) => {
try {
return new URL(url).href === `${new URL(url).origin}/`;
} catch {
return true;
}
}, `${label} must not include a path, query, or fragment`);
const emptyOrUrl = (error: string) =>
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
@@ -322,6 +334,29 @@ const AdminConfigSchemaWithVisibility = z
return value;
})
.meta({ id: 'AdminConfigOAuthDto' }),
passkey: z
.object({
enabled: configBool.describe('Enabled').meta({ visibility: Public }),
domain: httpsDomain('Passkey domain').nullable().describe('Passkey domain'),
additionalDomains: z
.array(httpsDomain('Additional domain'))
.refine(
(domains) => domains.length === new Set(domains.map((domain) => new URL(domain).origin)).size,
'Duplicate domain',
)
.describe('Additional domains'),
})
.superRefine((value, ctx) => {
const origin = value.domain && new URL(value.domain).origin;
if (origin && value.additionalDomains.some((domain) => new URL(domain).origin === origin)) {
ctx.addIssue({
code: 'custom',
message: 'The passkey domain is always allowed and must not be listed as an additional domain',
input: value.additionalDomains,
});
}
})
.meta({ id: 'AdminConfigPasskeyDto' }),
passwordLogin: z
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
.meta({ id: 'AdminConfigPasswordLoginDto' }),
@@ -469,7 +504,12 @@ const applyVisibilityRecursive = (
let visible: z.ZodType | undefined;
if (Object.keys(shape).length > 0) {
visible = z.object(shape).meta({
const isComplete = Object.keys(shape).length === Object.keys(object.shape).length;
const rebuilt =
schema instanceof z.ZodPipe && isComplete
? z.object(shape).pipe(schema.def.out as z.ZodTransform<unknown, Record<string, unknown>>)
: z.object(shape);
visible = rebuilt.meta({
...(id && { id: `${visibility}${id.slice(Admin.length)}` }),
...((override ?? description) && { description: override ?? description }),
});
@@ -498,7 +538,7 @@ const stripVisibilityMetadata = <T extends z.ZodType>(schema: T): T => {
return schema;
};
const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType<SystemConfig>;
const AdminConfigSchema = AdminConfigSchemaWithVisibility;
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
@@ -682,6 +722,11 @@ export const defaults = Object.freeze<SystemConfig>({
timeout: 30_000,
allowInsecureRequests: false,
},
passkey: {
enabled: true,
domain: null,
additionalDomains: [],
},
passwordLogin: {
enabled: true,
},
+70
View File
@@ -0,0 +1,70 @@
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
import { Passkey } from 'src/database.js';
import { isoDatetimeToDate } from 'src/validation.js';
const PasskeyRegistrationOptionsSchema = z
.looseRecord(z.string(), z.any())
.describe("Response returned by @simplewebauthn/server's `generateRegistrationOptions`")
.meta({ id: 'PasskeyRegistrationOptionsDto' });
const PasskeyAuthenticationOptionsSchema = z
.looseRecord(z.string(), z.any())
.describe("Response returned by @simplewebauthn/server's `generateAuthenticationOptions`")
.meta({ id: 'PasskeyAuthenticationOptionsDto' });
const PasskeyRegistrationFinishSchema = z
.object({
name: z.string().optional().describe('Passkey name'),
response: z.unknown().describe("Response returned by @simplewebauthn/browser's `startAuthentication()`"),
})
.meta({ id: 'PasskeyRegistrationFinishDto' });
const PasskeyAuthenticationFinishSchema = z
.object({
response: z
.looseObject({
id: z.base64url().describe('Credential ID'),
response: z.looseObject({ userHandle: z.base64url().optional().describe('User handle') }),
})
.describe("Response returned by @simplewebauthn/browser's `startAuthentication()`"),
})
.meta({ id: 'PasskeyAuthenticationFinishDto' });
const PasskeySearchSchema = z
.object({
id: z.uuidv4().optional().describe('Filter by passkey ID'),
})
.meta({ id: 'PasskeySearchDto' });
const PasskeyUpdateSchema = z
.object({
name: z.string().min(1).nullable().describe('Passkey name'),
})
.meta({ id: 'PasskeyUpdateDto' });
const PasskeyResponseSchema = z
.object({
id: z.uuidv4().describe('Passkey ID'),
name: z.string().nullable().describe('Passkey name'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
usedAt: isoDatetimeToDate.nullable().describe('Last used date'),
})
.meta({ id: 'PasskeyResponseDto' });
export class PasskeyRegistrationOptionsDto extends createZodDto(PasskeyRegistrationOptionsSchema) {}
export class PasskeyAuthenticationOptionsDto extends createZodDto(PasskeyAuthenticationOptionsSchema) {}
export class PasskeyRegistrationFinishDto extends createZodDto(PasskeyRegistrationFinishSchema) {}
export class PasskeyAuthenticationFinishDto extends createZodDto(PasskeyAuthenticationFinishSchema) {}
export class PasskeySearchDto extends createZodDto(PasskeySearchSchema) {}
export class PasskeyUpdateDto extends createZodDto(PasskeyUpdateSchema) {}
export class PasskeyResponseDto extends createZodDto(PasskeyResponseSchema) {}
export const mapPasskey = (passkey: Passkey): PasskeyResponseDto => ({
id: passkey.id,
name: passkey.name,
createdAt: passkey.createdAt,
updatedAt: passkey.updatedAt,
usedAt: passkey.usedAt,
});
+13
View File
@@ -5,6 +5,12 @@ import { asIntegerEnum } from 'src/utils/helpers.js';
export enum AuthType {
Password = 'password',
OAuth = 'oauth',
Passkey = 'passkey',
}
export enum AuthChallengeType {
PasskeyRegistration = 'PasskeyRegistration',
PasskeyAuthentication = 'PasskeyAuthentication',
}
export enum ImmichCookie {
@@ -244,6 +250,10 @@ export enum Permission {
PartnerUpdate = 'partner.update',
PartnerDelete = 'partner.delete',
PasskeyRead = 'passkey.read',
PasskeyUpdate = 'passkey.update',
PasskeyDelete = 'passkey.delete',
PersonCreate = 'person.create',
PersonRead = 'person.read',
PersonUpdate = 'person.update',
@@ -892,6 +902,8 @@ export enum JobName {
AuditTableCleanup = 'AuditTableCleanup',
AuthChallengeCleanup = 'AuthChallengeCleanup',
DatabaseBackup = 'DatabaseBackup',
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
@@ -1271,6 +1283,7 @@ export enum ApiTag {
NotificationsAdmin = 'Notifications (admin)',
ClusterGroups = 'Cluster groups',
Partners = 'Partners',
Passkeys = 'Passkeys',
People = 'People',
Plugins = 'Plugins',
Queues = 'Queues',
+9
View File
@@ -320,6 +320,15 @@ where
"partner"."sharedById" in ($1)
and "partner"."sharedWithId" = $2
-- AccessRepository.passkey.checkOwnerAccess
select
"passkey"."id"
from
"passkey"
where
"passkey"."id" in ($1)
and "passkey"."userId" = $2
-- AccessRepository.session.checkOwnerAccess
select
"session"."id"
@@ -0,0 +1,16 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AuthChallengeRepository.consume
delete from "auth_challenge"
where
"challenge" = $1
and "type" = $2
and "expiresAt" > $3
returning
"id",
"userId"
-- AuthChallengeRepository.deleteExpired
delete from "auth_challenge"
where
"expiresAt" <= $1
+46
View File
@@ -0,0 +1,46 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PasskeyRepository.delete
delete from "passkey"
where
"id" = $1::uuid
-- PasskeyRepository.getByCredentialId
select
*
from
"passkey"
where
"credentialId" = $1
-- PasskeyRepository.get
select
"id",
"name",
"userId",
"credentialId",
"transports",
"createdAt",
"updatedAt",
"usedAt"
from
"passkey"
where
"id" = $1::uuid
-- PasskeyRepository.search
select
"id",
"name",
"userId",
"credentialId",
"transports",
"createdAt",
"updatedAt",
"usedAt"
from
"passkey"
where
"userId" = $1
order by
"createdAt" desc
@@ -528,6 +528,26 @@ class ClusterGroupRequestAccess {
}
}
class PasskeyAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, passkeyIds: Set<string>) {
if (passkeyIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('passkey')
.select('passkey.id')
.where('passkey.id', 'in', [...passkeyIds])
.where('passkey.userId', '=', userId)
.execute()
.then((passkeys) => new Set(passkeys.map((passkey) => passkey.id)));
}
}
class PersonAccess {
constructor(private db: Kysely<DB>) {}
@@ -673,6 +693,7 @@ export class AccessRepository {
clusterGroupRequest: ClusterGroupRequestAccess;
person: PersonAccess;
partner: PartnerAccess;
passkey: PasskeyAccess;
session: SessionAccess;
stack: StackAccess;
tag: TagAccess;
@@ -692,6 +713,7 @@ export class AccessRepository {
this.clusterGroupRequest = new ClusterGroupRequestAccess(db);
this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db);
this.passkey = new PasskeyAccess(db);
this.session = new SessionAccess(db);
this.stack = new StackAccess(db);
this.tag = new TagAccess(db);
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import type { Insertable, Kysely } from 'kysely';
import { DummyValue, GenerateSql } from 'src/decorators.js';
import { AuthChallengeType } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
@Injectable()
export class AuthChallengeRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<AuthChallengeTable>) {
return this.db.insertInto('auth_challenge').values(dto).executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ type: AuthChallengeType.PasskeyRegistration, challenge: DummyValue.STRING }] })
consume({ type, challenge }: { type: AuthChallengeType; challenge: string }) {
return this.db
.deleteFrom('auth_challenge')
.where('challenge', '=', challenge)
.where('type', '=', type)
.where('expiresAt', '>', new Date())
.returning(['id', 'userId'])
.executeTakeFirst();
}
@GenerateSql()
async deleteExpired() {
const result = await this.db.deleteFrom('auth_challenge').where('expiresAt', '<=', new Date()).executeTakeFirst();
return Number(result.numDeletedRows);
}
}
+6
View File
@@ -8,6 +8,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CronRepository } from 'src/repositories/cron.repository.js';
@@ -31,6 +32,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
@@ -52,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository.js';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository.js';
import { ViewRepository } from 'src/repositories/view-repository.js';
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
@@ -66,6 +69,7 @@ export const repositories = [
AssetEditRepository,
AssetFileRepository,
AssetJobRepository,
AuthChallengeRepository,
ConfigRepository,
CronRepository,
CryptoRepository,
@@ -89,6 +93,7 @@ export const repositories = [
OcrRepository,
ClusterGroupRepository,
PartnerRepository,
PasskeyRepository,
PersonRepository,
PersonUserRepository,
PluginRepository,
@@ -110,6 +115,7 @@ export const repositories = [
ViewRepository,
VersionHistoryRepository,
VideoStreamRepository,
WebAuthnRepository,
WebsocketRepository,
WorkflowRepository,
];
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import type { Insertable, Kysely, Updateable } from 'kysely';
import { columns } from 'src/database.js';
import { DummyValue, GenerateSql } from 'src/decorators.js';
import { DB } from 'src/schema/index.js';
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
import { asUuid } from 'src/utils/database.js';
export type PasskeySearchOptions = {
id?: string;
userId?: string;
};
@Injectable()
export class PasskeyRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<PasskeyTable>) {
return this.db.insertInto('passkey').values(dto).returning(columns.passkey).executeTakeFirstOrThrow();
}
update(id: string, dto: Updateable<PasskeyTable>) {
return this.db
.updateTable('passkey')
.set(dto)
.where('id', '=', asUuid(id))
.returning(columns.passkey)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string) {
await this.db.deleteFrom('passkey').where('id', '=', asUuid(id)).execute();
}
@GenerateSql({ params: [DummyValue.STRING] })
getByCredentialId(credentialId: string) {
return this.db.selectFrom('passkey').selectAll().where('credentialId', '=', credentialId).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.db.selectFrom('passkey').select(columns.passkey).where('id', '=', asUuid(id)).executeTakeFirst();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID }] })
search(options: PasskeySearchOptions) {
return this.db
.selectFrom('passkey')
.select(columns.passkey)
.$if(!!options.id, (qb) => qb.where('id', '=', asUuid(options.id!)))
.$if(!!options.userId, (qb) => qb.where('userId', '=', options.userId!))
.orderBy('createdAt', 'desc')
.execute();
}
}
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import {
type GenerateAuthenticationOptionsOpts,
type GenerateRegistrationOptionsOpts,
type VerifyAuthenticationResponseOpts,
type VerifyRegistrationResponseOpts,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
@Injectable()
export class WebAuthnRepository {
generateRegistrationOptions(options: GenerateRegistrationOptionsOpts) {
return generateRegistrationOptions(options);
}
verifyRegistrationResponse(options: VerifyRegistrationResponseOpts) {
return verifyRegistrationResponse(options);
}
generateAuthenticationOptions(options: GenerateAuthenticationOptionsOpts) {
return generateAuthenticationOptions(options);
}
verifyAuthenticationResponse(options: VerifyAuthenticationResponseOpts) {
return verifyAuthenticationResponse(options);
}
}
+7
View File
@@ -50,6 +50,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js';
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table.js';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table.js';
import { AssetTable } from 'src/schema/tables/asset.table.js';
import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table.js';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
@@ -66,6 +67,7 @@ import { NotificationTable } from 'src/schema/tables/notification.table.js';
import { OcrSearchTable } from 'src/schema/tables/ocr-search.table.js';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table.js';
import { PartnerTable } from 'src/schema/tables/partner.table.js';
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table.js';
import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table.js';
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
@@ -121,6 +123,7 @@ export class ImmichDatabase {
AssetOcrTable,
AssetOcrAuditTable,
AssetTable,
AuthChallengeTable,
AssetFileTable,
AssetExifTable,
ClusterGroupTable,
@@ -139,6 +142,7 @@ export class ImmichDatabase {
OcrSearchTable,
PartnerAuditTable,
PartnerTable,
PasskeyTable,
PersonTable,
PersonAuditTable,
PersonUserTable,
@@ -223,6 +227,7 @@ export interface DB {
asset: AssetTable;
asset_audit: AssetAuditTable;
auth_challenge: AuthChallengeTable;
asset_edit: AssetEditTable;
asset_edit_audit: AssetEditAuditTable;
asset_exif: AssetExifTable;
@@ -263,6 +268,8 @@ export interface DB {
partner: PartnerTable;
partner_audit: PartnerAuditTable;
passkey: PasskeyTable;
person: PersonTable;
person_audit: PersonAuditTable;
person_user: PersonUserTable;
@@ -0,0 +1,48 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "passkey" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying,
"credentialId" character varying NOT NULL,
"publicKey" bytea NOT NULL,
"counter" bigint NOT NULL DEFAULT 0,
"transports" character varying[] NOT NULL,
"backedUp" boolean NOT NULL DEFAULT false,
"deviceType" character varying NOT NULL,
"userId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"usedAt" timestamp with time zone,
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "passkey_credentialId_uq" UNIQUE ("credentialId"),
CONSTRAINT "passkey_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "passkey_userId_idx" ON "passkey" ("userId");`.execute(db);
await sql`CREATE TABLE "auth_challenge" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"type" character varying NOT NULL,
"challenge" character varying NOT NULL,
"userId" uuid,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"expiresAt" timestamp with time zone NOT NULL,
CONSTRAINT "auth_challenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "auth_challenge_challenge_uq" UNIQUE ("challenge"),
CONSTRAINT "auth_challenge_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "auth_challenge_userId_idx" ON "auth_challenge" ("userId");`.execute(db);
await sql`CREATE INDEX "passkey_updateId_idx" ON "passkey" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "passkey_updatedAt"
BEFORE UPDATE ON "passkey"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_passkey_updatedAt', '{"type":"trigger","name":"passkey_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"passkey_updatedAt\\"\\n BEFORE UPDATE ON \\"passkey\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "passkey_updatedAt" ON "passkey";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_passkey_updatedAt';`.execute(db);
await sql`DROP TABLE "passkey";`.execute(db);
await sql`DROP TABLE "auth_challenge";`.execute(db);
}
+1
View File
@@ -99,3 +99,4 @@
1790616293884-PersonSharing
1790693088454-AddPersonUserTableSharedBySharedWithConstraint
1792213725597-AddAssetFaceAssetState
1792511451454-AddPasskeyTable
@@ -0,0 +1,32 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
type Generated,
PrimaryGeneratedColumn,
Table,
type Timestamp,
} from '@immich/sql-tools';
import { AuthChallengeType } from 'src/enum.js';
import { UserTable } from 'src/schema/tables/user.table.js';
@Table('auth_challenge')
export class AuthChallengeTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'character varying' })
type!: AuthChallengeType;
@Column({ unique: true })
challenge!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
userId!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp;
}
+56
View File
@@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
type Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { ColumnType } from 'kysely';
import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js';
import { UserTable } from 'src/schema/tables/user.table.js';
@Table('passkey')
@UpdatedAtTrigger('passkey_updatedAt')
export class PasskeyTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ nullable: true })
name!: string | null;
@Column({ unique: true })
credentialId!: string;
@Column({ type: 'bytea' })
publicKey!: Buffer;
@Column({ type: 'bigint', default: 0 })
counter!: Generated<ColumnType<number>>;
@Column({ array: true, type: 'character varying' })
transports!: string[];
@Column({ type: 'boolean', default: false })
backedUp!: Generated<boolean>;
@Column()
deviceType!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ type: 'timestamp with time zone', nullable: true })
usedAt!: Timestamp | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
-23
View File
@@ -19,7 +19,6 @@ import {
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
mapLoginResponse,
} from 'src/dtos/auth.dto.js';
import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js';
@@ -612,28 +611,6 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(
user: UserAdmin,
loginDetails: LoginDetails,
oauthSid?: string,
oauthBearerToken?: string,
) {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
await this.sessionRepository.create({
token: hashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
appVersion: loginDetails.appVersion,
userId: user.id,
oauthSid: oauthSid ?? null,
oauthBearerToken: oauthBearerToken ?? null,
});
return mapLoginResponse(user, token);
}
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;
+36
View File
@@ -1,10 +1,12 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely';
import sanitize from 'sanitize-filename';
import type { LoginDetails } from 'src/services/auth.service.js';
import type { ClassConstructor } from 'src/types.js';
import { SALT_ROUNDS } from 'src/constants.js';
import { StorageCore } from 'src/cores/storage.core.js';
import { UserAdmin } from 'src/database.js';
import { mapLoginResponse } from 'src/dtos/auth.dto.js';
import { SystemConfig } from 'src/dtos/config.dto.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { ActivityRepository } from 'src/repositories/activity.repository.js';
@@ -16,6 +18,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CronRepository } from 'src/repositories/cron.repository.js';
@@ -39,6 +42,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
@@ -60,6 +64,7 @@ import { UserRepository } from 'src/repositories/user.repository.js';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository.js';
import { ViewRepository } from 'src/repositories/view-repository.js';
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
import { UserTable } from 'src/schema/tables/user.table.js';
@@ -85,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
AssetEditRepository,
AssetFileRepository,
AssetJobRepository,
AuthChallengeRepository,
ClusterGroupRepository,
ConfigRepository,
CronRepository,
@@ -107,6 +113,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
OAuthRepository,
OcrRepository,
PartnerRepository,
PasskeyRepository,
PersonRepository,
PersonUserRepository,
PluginRepository,
@@ -128,6 +135,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
VersionHistoryRepository,
VideoStreamRepository,
ViewRepository,
WebAuthnRepository,
WebsocketRepository,
WorkflowRepository,
] as const;
@@ -148,6 +156,7 @@ export class BaseService {
protected assetEditRepository: AssetEditRepository,
protected assetFileRepository: AssetFileRepository,
protected assetJobRepository: AssetJobRepository,
protected authChallengeRepository: AuthChallengeRepository,
protected clusterGroupRepository: ClusterGroupRepository,
protected configRepository: ConfigRepository,
protected cronRepository: CronRepository,
@@ -170,6 +179,7 @@ export class BaseService {
protected oauthRepository: OAuthRepository,
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,
protected passkeyRepository: PasskeyRepository,
protected personRepository: PersonRepository,
protected personUserRepository: PersonUserRepository,
protected pluginRepository: PluginRepository,
@@ -191,6 +201,7 @@ export class BaseService {
protected versionRepository: VersionHistoryRepository,
protected videoStreamRepository: VideoStreamRepository,
protected viewRepository: ViewRepository,
protected webAuthnRepository: WebAuthnRepository,
protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository,
) {
@@ -220,6 +231,7 @@ export class BaseService {
ctx.assetEditRepository,
ctx.assetFileRepository,
ctx.assetJobRepository,
ctx.authChallengeRepository,
ctx.clusterGroupRepository,
ctx.configRepository,
ctx.cronRepository,
@@ -242,6 +254,7 @@ export class BaseService {
ctx.oauthRepository,
ctx.ocrRepository,
ctx.partnerRepository,
ctx.passkeyRepository,
ctx.personRepository,
ctx.personUserRepository,
ctx.pluginRepository,
@@ -263,6 +276,7 @@ export class BaseService {
ctx.versionRepository,
ctx.videoStreamRepository,
ctx.viewRepository,
ctx.webAuthnRepository,
ctx.websocketRepository,
ctx.workflowRepository,
);
@@ -348,4 +362,26 @@ export class BaseService {
return user;
}
protected async createLoginResponse(
user: UserAdmin,
loginDetails: LoginDetails,
oauthSid?: string,
oauthBearerToken?: string,
) {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
await this.sessionRepository.create({
token: hashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
appVersion: loginDetails.appVersion,
userId: user.id,
oauthSid: oauthSid ?? null,
oauthBearerToken: oauthBearerToken ?? null,
});
return mapLoginResponse(user, token);
}
}
+2
View File
@@ -26,6 +26,7 @@ import { NotificationAdminService } from 'src/services/notification-admin.servic
import { NotificationService } from 'src/services/notification.service.js';
import { OcrService } from 'src/services/ocr.service.js';
import { PartnerService } from 'src/services/partner.service.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { PersonService } from 'src/services/person.service.js';
import { PluginService } from 'src/services/plugin.service.js';
import { QueueService } from 'src/services/queue.service.js';
@@ -81,6 +82,7 @@ export const services = [
OcrService,
ClusterGroupService,
PartnerService,
PasskeyService,
PersonService,
PluginService,
QueueService,
+693
View File
@@ -0,0 +1,693 @@
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { VerifiedAuthenticationResponse, VerifiedRegistrationResponse } from '@simplewebauthn/server';
import { InsertResult } from 'kysely';
import { AuthChallengeType, JobStatus } from 'src/enum.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { AuthFactory } from 'test/factories/auth.factory.js';
import { PasskeyFactory } from 'test/factories/passkey.factory.js';
import { SessionFactory } from 'test/factories/session.factory.js';
import { UserFactory } from 'test/factories/user.factory.js';
import { newUuid } from 'test/small.factory.js';
import { ServiceMocks, newTestService } from 'test/utils.js';
const loginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
deviceOS: '',
deviceType: '',
appVersion: null,
};
const headers = { origin: 'https://immich.example' };
const registrationOptions = {
rp: { id: 'immich.example', name: 'Immich' },
user: { id: 'user-handle', name: 'user@immich.cloud', displayName: 'User' },
challenge: 'challenge',
pubKeyCredParams: [{ alg: -7, type: 'public-key' as const }],
};
const authenticationOptions = {
challenge: 'challenge',
rpId: 'immich.example',
};
const registrationResponse = {
id: 'credential-id',
rawId: 'credential-id',
type: 'public-key' as const,
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation-object',
transports: ['internal'],
},
};
const registrationResult: VerifiedRegistrationResponse = {
verified: true,
registrationInfo: {
fmt: 'none',
aaguid: '00000000-0000-0000-0000-000000000000',
credential: {
id: 'credential-id',
publicKey: new Uint8Array([1, 2, 3]),
counter: 0,
transports: ['internal'],
},
credentialType: 'public-key',
attestationObject: new Uint8Array(),
userVerified: true,
credentialDeviceType: 'multiDevice',
credentialBackedUp: true,
origin: 'https://immich.example',
},
};
const authenticationResult: VerifiedAuthenticationResponse = {
verified: true,
authenticationInfo: {
credentialID: 'credential-id',
newCounter: 5,
userVerified: true,
credentialDeviceType: 'multiDevice',
credentialBackedUp: true,
origin: 'https://immich.example',
rpID: 'immich.example',
},
};
const newAuthenticationResponse = (userId?: string) => ({
id: 'credential-id',
rawId: 'credential-id',
type: 'public-key' as const,
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
authenticatorData: 'authenticator-data',
signature: 'signature',
userHandle: userId ? Buffer.from(userId).toString('base64url') : undefined,
},
});
describe(PasskeyService.name, () => {
let sut: PasskeyService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(PasskeyService));
mocks.systemMetadata.get.mockResolvedValue({
passkey: { enabled: true, domain: 'https://immich.example', additionalDomains: [] },
});
mocks.authChallenge.create.mockResolvedValue(new InsertResult(void 0, 1n));
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: null });
});
describe('handleCleanup', () => {
it('should delete expired challenges', async () => {
mocks.authChallenge.deleteExpired.mockResolvedValue(3);
await expect(sut.handleCleanup()).resolves.toBe(JobStatus.Success);
expect(mocks.authChallenge.deleteExpired).toHaveBeenCalled();
});
});
describe('startRegistration', () => {
it('should require a session', async () => {
await expect(sut.startRegistration(AuthFactory.create(), headers)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
});
it('should throw an error if passkeys are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
await expect(sut.startRegistration(AuthFactory.from().session().build(), headers)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
});
it('should use the android origins when the origin header is missing', async () => {
mocks.passkey.search.mockResolvedValue([]);
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
await expect(sut.startRegistration(AuthFactory.from().session().build(), {})).resolves.toEqual(
registrationOptions,
);
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
expect.objectContaining({ rpID: 'my.immich.app' }),
);
});
it('should use the origin hostname when no relying party ID is configured', async () => {
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: true, domain: null, additionalDomains: [] } });
mocks.authChallenge.create.mockResolvedValue(new InsertResult(void 0, 1n));
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: null });
mocks.passkey.search.mockResolvedValue([]);
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
await expect(
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://photos.immich.example' }),
).resolves.toEqual(registrationOptions);
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
expect.objectContaining({ rpID: 'photos.immich.example' }),
);
});
it('should reject an invalid origin header', async () => {
await expect(
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'not a url' }),
).rejects.toThrow('Origin header is invalid');
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
});
it('should reject an origin that is not trusted', async () => {
await expect(
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://evil.example' }),
).rejects.toThrow('Origin is not trusted');
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
});
it('should reject a subdomain of the relying party that is not listed', async () => {
await expect(
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://photos.immich.example' }),
).rejects.toThrow('Origin is not trusted');
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
});
it('should accept the relying party on any port', async () => {
mocks.passkey.search.mockResolvedValue([]);
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
await expect(
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://immich.example:2283' }),
).resolves.toEqual(registrationOptions);
});
it('should accept a trusted origin', async () => {
mocks.systemMetadata.get.mockResolvedValue({
passkey: {
enabled: true,
domain: 'https://immich.example',
additionalDomains: ['https://immich.tailnet.example/'],
},
});
mocks.passkey.search.mockResolvedValue([]);
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
await expect(
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://immich.tailnet.example' }),
).resolves.toEqual(registrationOptions);
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
expect.objectContaining({ rpID: 'immich.example' }),
);
});
it('should generate registration options', async () => {
const auth = AuthFactory.from().session().build();
const passkey = PasskeyFactory.create({ userId: auth.user.id });
mocks.passkey.search.mockResolvedValue([passkey]);
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
await expect(sut.startRegistration(auth, headers)).resolves.toEqual(registrationOptions);
expect(mocks.passkey.search).toHaveBeenCalledWith({ userId: auth.user.id });
expect(mocks.authChallenge.create).toHaveBeenCalledWith({
type: AuthChallengeType.PasskeyRegistration,
challenge: registrationOptions.challenge,
userId: auth.user.id,
expiresAt: expect.any(Date),
});
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
expect.objectContaining({
rpID: 'immich.example',
userName: auth.user.email,
userDisplayName: auth.user.name,
excludeCredentials: [{ id: passkey.credentialId, transports: passkey.transports }],
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
}),
);
});
});
describe('finishRegistration', () => {
it('should require a session', async () => {
await expect(
sut.finishRegistration(AuthFactory.create(), { response: registrationResponse }, headers, loginDetails),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.webAuthn.verifyRegistrationResponse).not.toHaveBeenCalled();
});
it('should throw an error if passkeys are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
await expect(
sut.finishRegistration(
AuthFactory.from().session().build(),
{ response: registrationResponse },
headers,
loginDetails,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should reject an invalid response', async () => {
mocks.webAuthn.verifyRegistrationResponse.mockRejectedValue(new Error('Invalid signature'));
await expect(
sut.finishRegistration(
AuthFactory.from().session().build(),
{ response: registrationResponse },
headers,
loginDetails,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.passkey.create).not.toHaveBeenCalled();
});
it('should reject an unverified response', async () => {
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue({ verified: false });
await expect(
sut.finishRegistration(
AuthFactory.from().session().build(),
{ response: registrationResponse },
headers,
loginDetails,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.passkey.create).not.toHaveBeenCalled();
});
it('should reject a credential that is already registered', async () => {
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
await expect(
sut.finishRegistration(
AuthFactory.from().session().build(),
{ response: registrationResponse },
headers,
loginDetails,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.passkey.create).not.toHaveBeenCalled();
});
it('should verify against the android app origins when the origin header is missing', async () => {
const auth = AuthFactory.from().session().build();
const passkey = PasskeyFactory.create({ userId: auth.user.id });
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(passkey);
await expect(sut.finishRegistration(auth, { response: registrationResponse }, {}, loginDetails)).resolves.toEqual(
expect.objectContaining({ id: passkey.id }),
);
expect(mocks.webAuthn.verifyRegistrationResponse).toHaveBeenCalledWith(
expect.objectContaining({
expectedOrigin: [
'android:apk-key-hash:hsXEVd-vSYWSOo81rbMdDJ4LlX1_lMLSr2okOKqWACA',
'android:apk-key-hash:WiLBg0dUBfVJxOufsmwuk6PvnFdmFQp684yNP-UVzNY',
],
expectedRPID: 'my.immich.app',
}),
);
});
it('should consume a challenge issued to the user', async () => {
const auth = AuthFactory.from().session().build();
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: auth.user.id });
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
const { expectedChallenge } = mocks.webAuthn.verifyRegistrationResponse.mock.calls[0][0];
await expect((expectedChallenge as (challenge: string) => Promise<boolean>)('challenge')).resolves.toBe(true);
expect(mocks.authChallenge.consume).toHaveBeenCalledWith({
type: AuthChallengeType.PasskeyRegistration,
challenge: 'challenge',
});
});
it('should reject a challenge issued to another user', async () => {
const auth = AuthFactory.from().session().build();
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: newUuid() });
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
const { expectedChallenge } = mocks.webAuthn.verifyRegistrationResponse.mock.calls[0][0];
await expect((expectedChallenge as (challenge: string) => Promise<boolean>)('challenge')).resolves.toBe(false);
});
it('should reject an unknown challenge', async () => {
const auth = AuthFactory.from().session().build();
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
mocks.authChallenge.consume.mockResolvedValue(void 0);
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
const { expectedChallenge } = mocks.webAuthn.verifyRegistrationResponse.mock.calls[0][0];
await expect((expectedChallenge as (challenge: string) => Promise<boolean>)('challenge')).resolves.toBe(false);
});
it('should create a passkey', async () => {
const auth = AuthFactory.from().session().build();
const passkey = PasskeyFactory.create({ userId: auth.user.id, name: 'My passkey' });
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(passkey);
await expect(
sut.finishRegistration(auth, { name: 'My passkey', response: registrationResponse }, headers, loginDetails),
).resolves.toEqual({
id: passkey.id,
name: passkey.name,
createdAt: passkey.createdAt,
updatedAt: passkey.updatedAt,
usedAt: passkey.usedAt,
});
expect(mocks.webAuthn.verifyRegistrationResponse).toHaveBeenCalledWith({
response: registrationResponse,
expectedChallenge: expect.any(Function),
expectedOrigin: 'https://immich.example',
expectedRPID: 'immich.example',
});
expect(mocks.passkey.create).toHaveBeenCalledWith({
userId: auth.user.id,
name: 'My passkey',
credentialId: 'credential-id',
publicKey: Buffer.from([1, 2, 3]),
counter: 0,
transports: ['internal'],
backedUp: true,
deviceType: 'multiDevice',
});
});
it('should use a default name', async () => {
const auth = AuthFactory.from().session().build();
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
expect(mocks.passkey.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Passkey' }));
});
it('should name the passkey after a known authenticator', async () => {
const auth = AuthFactory.from().session().build();
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue({
...registrationResult,
registrationInfo: { ...registrationResult.registrationInfo, aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd' },
});
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
expect(mocks.passkey.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Apple Passwords' }));
});
it('should name the passkey after the device for an unknown authenticator', async () => {
const auth = AuthFactory.from().session().build();
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
await sut.finishRegistration(auth, { response: registrationResponse }, headers, {
...loginDetails,
deviceType: 'Chrome',
deviceOS: 'Linux',
});
expect(mocks.passkey.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Chrome on Linux' }));
});
});
describe('startAuthentication', () => {
it('should throw an error if passkeys are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
await expect(sut.startAuthentication(headers)).rejects.toBeInstanceOf(BadRequestException);
});
it('should generate authentication options', async () => {
mocks.webAuthn.generateAuthenticationOptions.mockResolvedValue(authenticationOptions);
await expect(sut.startAuthentication(headers)).resolves.toEqual(authenticationOptions);
expect(mocks.authChallenge.create).toHaveBeenCalledWith({
type: AuthChallengeType.PasskeyAuthentication,
challenge: authenticationOptions.challenge,
userId: null,
expiresAt: expect.any(Date),
});
expect(mocks.webAuthn.generateAuthenticationOptions).toHaveBeenCalledWith({
rpID: 'immich.example',
userVerification: 'required',
});
});
});
describe('finishAuthentication', () => {
it('should throw an error if passkeys are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should reject an unknown credential', async () => {
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.webAuthn.verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it('should reject a user handle that does not match the credential', async () => {
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse(newUuid()) }, headers, loginDetails),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.webAuthn.verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it('should reject an invalid response', async () => {
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
mocks.webAuthn.verifyAuthenticationResponse.mockRejectedValue(new Error('Invalid signature'));
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.session.create).not.toHaveBeenCalled();
});
it('should reject an unverified response', async () => {
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
mocks.webAuthn.verifyAuthenticationResponse.mockResolvedValue({ ...authenticationResult, verified: false });
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.session.create).not.toHaveBeenCalled();
});
it('should reject a passkey of a deleted user', async () => {
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
mocks.webAuthn.verifyAuthenticationResponse.mockResolvedValue(authenticationResult);
mocks.user.get.mockResolvedValue(void 0);
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.session.create).not.toHaveBeenCalled();
});
it('should log the user in', async () => {
const user = UserFactory.create();
const passkey = PasskeyFactory.create({ userId: user.id, counter: 4 });
mocks.passkey.getByCredentialId.mockResolvedValue(passkey);
mocks.webAuthn.verifyAuthenticationResponse.mockResolvedValue(authenticationResult);
mocks.user.get.mockResolvedValue(user);
mocks.passkey.update.mockResolvedValue(passkey);
mocks.session.create.mockResolvedValue(SessionFactory.create({ userId: user.id }));
await expect(
sut.finishAuthentication({ response: newAuthenticationResponse(user.id) }, headers, loginDetails),
).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id,
userEmail: user.email,
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
isOnboarded: false,
shouldChangePassword: user.shouldChangePassword,
});
expect(mocks.webAuthn.verifyAuthenticationResponse).toHaveBeenCalledWith({
response: newAuthenticationResponse(user.id),
expectedChallenge: expect.any(Function),
expectedOrigin: 'https://immich.example',
expectedRPID: 'immich.example',
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: 4,
transports: passkey.transports,
},
});
expect(mocks.passkey.update).toHaveBeenCalledWith(passkey.id, { counter: 5, usedAt: expect.any(Date) });
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ userId: user.id }));
});
});
describe('getWellKnown', () => {
it('should throw an error if passkeys are disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
await expect(sut.getWellKnown()).rejects.toBeInstanceOf(NotFoundException);
});
it('should return the relying party and trusted origins', async () => {
mocks.systemMetadata.get.mockResolvedValue({
passkey: {
enabled: true,
domain: 'https://immich.example/',
additionalDomains: ['https://immich.tailnet.example/', 'https://photos.immich.example'],
},
});
await expect(sut.getWellKnown()).resolves.toEqual({
origins: ['https://immich.example', 'https://immich.tailnet.example', 'https://photos.immich.example'],
});
});
});
describe('search', () => {
it('should search the passkeys of the current user', async () => {
const auth = AuthFactory.create();
const passkey = PasskeyFactory.create({ userId: auth.user.id });
mocks.passkey.search.mockResolvedValue([passkey]);
await expect(sut.search(auth, { id: passkey.id })).resolves.toEqual([
{
id: passkey.id,
name: passkey.name,
createdAt: passkey.createdAt,
updatedAt: passkey.updatedAt,
usedAt: passkey.usedAt,
},
]);
expect(mocks.passkey.search).toHaveBeenCalledWith({ id: passkey.id, userId: auth.user.id });
});
});
describe('get', () => {
it('should require access', async () => {
await expect(sut.get(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.passkey.get).not.toHaveBeenCalled();
});
it('should get a passkey', async () => {
const auth = AuthFactory.create();
const passkey = PasskeyFactory.create({ userId: auth.user.id });
mocks.access.passkey.checkOwnerAccess.mockResolvedValue(new Set([passkey.id]));
mocks.passkey.get.mockResolvedValue(passkey);
await expect(sut.get(auth, passkey.id)).resolves.toEqual(expect.objectContaining({ id: passkey.id }));
expect(mocks.access.passkey.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([passkey.id]));
expect(mocks.passkey.get).toHaveBeenCalledWith(passkey.id);
});
});
describe('update', () => {
it('should require access', async () => {
await expect(sut.update(AuthFactory.create(), newUuid(), { name: 'New name' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.passkey.update).not.toHaveBeenCalled();
});
it('should update a passkey', async () => {
const auth = AuthFactory.create();
const passkey = PasskeyFactory.create({ userId: auth.user.id });
mocks.access.passkey.checkOwnerAccess.mockResolvedValue(new Set([passkey.id]));
mocks.passkey.update.mockResolvedValue({ ...passkey, name: 'New name' });
await expect(sut.update(auth, passkey.id, { name: 'New name' })).resolves.toEqual(
expect.objectContaining({ id: passkey.id, name: 'New name' }),
);
expect(mocks.passkey.update).toHaveBeenCalledWith(passkey.id, { name: 'New name' });
});
});
describe('delete', () => {
it('should require access', async () => {
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.passkey.delete).not.toHaveBeenCalled();
});
it('should delete a passkey', async () => {
const auth = AuthFactory.create();
const passkey = PasskeyFactory.create({ userId: auth.user.id });
mocks.access.passkey.checkOwnerAccess.mockResolvedValue(new Set([passkey.id]));
mocks.passkey.delete.mockResolvedValue();
await sut.delete(auth, passkey.id);
expect(mocks.passkey.delete).toHaveBeenCalledWith(passkey.id);
});
});
});
+297
View File
@@ -0,0 +1,297 @@
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import aaguids from 'aaguids';
import { DateTime, Duration } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
} from '@simplewebauthn/server';
import type { LoginDetails } from 'src/services/auth.service.js';
import { OnJob } from 'src/decorators.js';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
import { SystemConfig } from 'src/dtos/config.dto.js';
import {
PasskeyAuthenticationFinishDto,
PasskeyAuthenticationOptionsDto,
PasskeyRegistrationFinishDto,
PasskeyRegistrationOptionsDto,
PasskeyResponseDto,
PasskeySearchDto,
PasskeyUpdateDto,
mapPasskey,
} from 'src/dtos/passkey.dto.js';
import { AuthChallengeType, JobName, JobStatus, Permission, QueueName } from 'src/enum.js';
import { BaseService } from 'src/services/base.service.js';
import { findOrFail } from 'src/utils/misc.js';
const RP_NAME = 'Immich';
const CHALLENGE_TTL = Duration.fromObject({ minutes: 5 });
const MOBILE_RP_ID = 'my.immich.app';
// keep in sync with https://my.immich.app/.well-known/assetlinks.json
const ANDROID_CERTIFICATE_FINGERPRINTS = [
'86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20',
'5A:22:C1:83:47:54:05:F5:49:C4:EB:9F:B2:6C:2E:93:A3:EF:9C:57:66:15:0A:7A:F3:8C:8D:3F:E5:15:CC:D6',
];
const ANDROID_ORIGINS = ANDROID_CERTIFICATE_FINGERPRINTS.map(
(fingerprint) => `android:apk-key-hash:${Buffer.from(fingerprint.replaceAll(':', ''), 'hex').toString('base64url')}`,
);
@Injectable()
export class PasskeyService extends BaseService {
async startRegistration(auth: AuthDto, headers: IncomingHttpHeaders): Promise<PasskeyRegistrationOptionsDto> {
this.requireSession(auth);
const config = await this.requireEnabled();
const { rpId } = this.getRelyingParty(config, headers);
const passkeys = await this.passkeyRepository.search({ userId: auth.user.id });
const options = await this.webAuthnRepository.generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userID: new TextEncoder().encode(auth.user.id),
userName: auth.user.email,
userDisplayName: auth.user.name,
attestationType: 'none',
excludeCredentials: passkeys.map(({ credentialId, transports }) => ({ id: credentialId, transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
});
await this.authChallengeRepository.create({
type: AuthChallengeType.PasskeyRegistration,
challenge: options.challenge,
userId: auth.user.id,
expiresAt: DateTime.now().plus(CHALLENGE_TTL).toJSDate(),
});
return options;
}
async finishRegistration(
auth: AuthDto,
dto: PasskeyRegistrationFinishDto,
headers: IncomingHttpHeaders,
loginDetails: LoginDetails,
): Promise<PasskeyResponseDto> {
this.requireSession(auth);
const config = await this.requireEnabled();
const { rpId, origin } = this.getRelyingParty(config, headers);
let result: VerifiedRegistrationResponse;
try {
result = await this.webAuthnRepository.verifyRegistrationResponse({
response: dto.response as RegistrationResponseJSON,
expectedChallenge: async (challenge) => {
const record = await this.authChallengeRepository.consume({
type: AuthChallengeType.PasskeyRegistration,
challenge,
});
return record?.userId === auth.user.id;
},
expectedOrigin: origin,
expectedRPID: rpId,
});
} catch (error: any) {
this.logger.warn(`Passkey registration failed for user ${auth.user.id}: ${error.message}`);
throw new BadRequestException('Passkey registration failed');
}
if (!result.verified) {
throw new BadRequestException('Passkey registration failed');
}
const { aaguid, credential, credentialBackedUp, credentialDeviceType } = result.registrationInfo;
const duplicate = await this.passkeyRepository.getByCredentialId(credential.id);
if (duplicate) {
throw new BadRequestException('Passkey is already registered');
}
const passkey = await this.passkeyRepository.create({
userId: auth.user.id,
name:
dto.name ||
(aaguids as unknown as Record<string, string>)[aaguid] ||
[loginDetails.deviceType, loginDetails.deviceOS].filter(Boolean).join(' on ') ||
'Passkey',
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
backedUp: credentialBackedUp,
deviceType: credentialDeviceType,
});
return mapPasskey(passkey);
}
async startAuthentication(headers: IncomingHttpHeaders): Promise<PasskeyAuthenticationOptionsDto> {
const config = await this.requireEnabled();
const { rpId } = this.getRelyingParty(config, headers);
const options = await this.webAuthnRepository.generateAuthenticationOptions({
rpID: rpId,
userVerification: 'required',
});
await this.authChallengeRepository.create({
type: AuthChallengeType.PasskeyAuthentication,
challenge: options.challenge,
userId: null,
expiresAt: DateTime.now().plus(CHALLENGE_TTL).toJSDate(),
});
return options;
}
async finishAuthentication(
dto: PasskeyAuthenticationFinishDto,
headers: IncomingHttpHeaders,
loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const config = await this.requireEnabled();
const { rpId, origin } = this.getRelyingParty(config, headers);
const passkey = await this.passkeyRepository.getByCredentialId(dto.response.id);
const userHandle = dto.response.response.userHandle;
const userId = userHandle ? Buffer.from(userHandle, 'base64url').toString() : undefined;
if (!passkey || (userId && userId !== passkey.userId)) {
this.logger.warn(`Failed passkey login attempt from ip address ${loginDetails.clientIp}`);
throw new UnauthorizedException('Invalid passkey');
}
let result: VerifiedAuthenticationResponse;
try {
result = await this.webAuthnRepository.verifyAuthenticationResponse({
response: dto.response as unknown as AuthenticationResponseJSON,
expectedChallenge: async (challenge) => {
const record = await this.authChallengeRepository.consume({
type: AuthChallengeType.PasskeyAuthentication,
challenge,
});
return !!record;
},
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports,
},
});
} catch (error: any) {
this.logger.warn(`Failed passkey login attempt from ip address ${loginDetails.clientIp}: ${error.message}`);
throw new UnauthorizedException('Invalid passkey');
}
if (!result.verified) {
throw new UnauthorizedException('Invalid passkey');
}
const user = await this.userRepository.get(passkey.userId, {});
if (!user) {
throw new UnauthorizedException('Invalid passkey');
}
await this.passkeyRepository.update(passkey.id, {
counter: result.authenticationInfo.newCounter,
usedAt: new Date(),
});
return this.createLoginResponse(user, loginDetails);
}
async search(auth: AuthDto, dto: PasskeySearchDto): Promise<PasskeyResponseDto[]> {
const passkeys = await this.passkeyRepository.search({ id: dto.id, userId: auth.user.id });
return passkeys.map((passkey) => mapPasskey(passkey));
}
async get(auth: AuthDto, id: string): Promise<PasskeyResponseDto> {
await this.requireAccess({ auth, permission: Permission.PasskeyRead, ids: [id] });
const passkey = await findOrFail(() => this.passkeyRepository.get(id), 'Passkey');
return mapPasskey(passkey);
}
async update(auth: AuthDto, id: string, dto: PasskeyUpdateDto): Promise<PasskeyResponseDto> {
await this.requireAccess({ auth, permission: Permission.PasskeyUpdate, ids: [id] });
const passkey = await this.passkeyRepository.update(id, { name: dto.name });
return mapPasskey(passkey);
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.PasskeyDelete, ids: [id] });
await this.passkeyRepository.delete(id);
}
private requireSession(auth: AuthDto) {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
}
async getWellKnown(): Promise<{ origins: string[] }> {
const { passkey } = await this.getConfig({ withCache: true });
if (!passkey.enabled || !passkey.domain) {
throw new NotFoundException();
}
return {
origins: [passkey.domain, ...passkey.additionalDomains].map((value) => new URL(value).origin),
};
}
@OnJob({ name: JobName.AuthChallengeCleanup, queue: QueueName.BackgroundTask })
async handleCleanup(): Promise<JobStatus> {
const count = await this.authChallengeRepository.deleteExpired();
if (count > 0) {
this.logger.log(`Deleted ${count} expired auth challenges`);
}
return JobStatus.Success;
}
private async requireEnabled() {
const { passkey } = await this.getConfig({ withCache: false });
if (!passkey.enabled) {
throw new BadRequestException('Passkeys are disabled');
}
return passkey;
}
private getRelyingParty(
config: SystemConfig['passkey'],
headers: IncomingHttpHeaders,
): { origin: string | string[]; rpId: string } {
const origin = headers.origin;
// no origin implies a mobile app
if (!origin) {
return { origin: ANDROID_ORIGINS, rpId: MOBILE_RP_ID };
}
let hostname: string;
try {
hostname = new URL(origin).hostname;
} catch {
throw new BadRequestException('Origin header is invalid');
}
if (!config.domain) {
return { origin, rpId: hostname };
}
const rpId = new URL(config.domain).hostname;
if (hostname !== rpId && config.additionalDomains.every((value) => new URL(value).origin !== origin)) {
throw new BadRequestException('Origin is not trusted');
}
return { origin, rpId };
}
}
@@ -41,6 +41,7 @@ describe(QueueService.name, () => {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuthChallengeCleanup },
{ name: JobName.HlsSessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.MemoryGenerate },
+1
View File
@@ -275,6 +275,7 @@ export class QueueService extends BaseService {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuthChallengeCleanup },
{ name: JobName.HlsSessionCleanup },
{ name: JobName.AuditTableCleanup },
);
@@ -177,6 +177,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
},
passkey: {
enabled: true,
domain: null,
additionalDomains: [],
},
passwordLogin: {
enabled: true,
},
+1
View File
@@ -411,6 +411,7 @@ export type JobItem =
// Cleanup
| { name: JobName.SessionCleanup; data?: IBaseJob }
| { name: JobName.AuthChallengeCleanup; data?: IBaseJob }
| { name: JobName.HlsSessionCleanup; data?: IBaseJob }
// Tags
+6
View File
@@ -377,6 +377,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.partner.checkUpdateAccess(auth.user.id, ids);
}
case Permission.PasskeyRead:
case Permission.PasskeyUpdate:
case Permission.PasskeyDelete: {
return access.passkey.checkOwnerAccess(auth.user.id, ids);
}
case Permission.SessionRead:
case Permission.SessionUpdate:
case Permission.SessionDelete:
+35
View File
@@ -0,0 +1,35 @@
import { Selectable } from 'kysely';
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
import { PasskeyLike } from 'test/factories/types.js';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory.js';
export class PasskeyFactory {
private constructor(private value: Selectable<PasskeyTable>) {}
static create(dto: PasskeyLike = {}) {
return PasskeyFactory.from(dto).build();
}
static from(dto: PasskeyLike = {}) {
return new PasskeyFactory({
id: newUuid(),
name: 'Passkey',
credentialId: 'credential-id',
publicKey: Buffer.from('public-key'),
counter: 0,
transports: ['internal'],
backedUp: false,
deviceType: 'singleDevice',
userId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
usedAt: null,
updateId: newUuidV7(),
...dto,
});
}
build() {
return { ...this.value };
}
}
+2
View File
@@ -14,6 +14,7 @@ import { AssetTable } from 'src/schema/tables/asset.table.js';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js';
import { MemoryTable } from 'src/schema/tables/memory.table.js';
import { PartnerTable } from 'src/schema/tables/partner.table.js';
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { SessionTable } from 'src/schema/tables/session.table.js';
@@ -45,5 +46,6 @@ export type MemoryLike = Partial<Selectable<MemoryTable>>;
export type PartnerLike = Partial<Selectable<PartnerTable>>;
export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type PasskeyLike = Partial<Selectable<PasskeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>;
+41 -1
View File
@@ -30,6 +30,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CronRepository } from 'src/repositories/cron.repository.js';
@@ -50,6 +51,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository.js';
import { NotificationRepository } from 'src/repositories/notification.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
@@ -66,6 +68,7 @@ import { TagRepository } from 'src/repositories/tag.repository.js';
import { TelemetryRepository } from 'src/repositories/telemetry.repository.js';
import { UserRepository } from 'src/repositories/user.repository.js';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
import { DB } from 'src/schema/index.js';
@@ -77,6 +80,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js';
import { AssetTable } from 'src/schema/tables/asset.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
import { MemoryTable } from 'src/schema/tables/memory.table.js';
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
import { PersonTable } from 'src/schema/tables/person.table.js';
import { SessionTable } from 'src/schema/tables/session.table.js';
@@ -177,6 +181,12 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
return { user, result };
}
async newPasskey(dto: Partial<Insertable<PasskeyTable>> & { userId: string }) {
const passkey = mediumFactory.passkeyInsert(dto);
const result = await this.get(PasskeyRepository).create(passkey);
return { passkey, result };
}
async newPartner(dto: { sharedById: string; sharedWithId: string; inTimeline?: boolean }) {
const partner = { inTimeline: true, ...dto };
const result = await this.get(PartnerRepository).create(partner);
@@ -494,7 +504,9 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case LibraryRepository:
case NotificationRepository:
case OcrRepository:
case AuthChallengeRepository:
case PartnerRepository:
case PasskeyRepository:
case PersonRepository:
case PersonUserRepository:
case SearchRepository:
@@ -512,7 +524,8 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
}
case ConfigRepository:
case CryptoRepository: {
case CryptoRepository:
case WebAuthnRepository: {
return new key() as InstanceType<T>;
}
@@ -557,6 +570,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case AlbumRepository:
case AssetRepository:
case AssetJobRepository:
case AuthChallengeRepository:
case ConfigRepository:
case CryptoRepository:
case LibraryRepository:
@@ -565,6 +579,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case NotificationRepository:
case OcrRepository:
case PartnerRepository:
case PasskeyRepository:
case PersonRepository:
case SessionRepository:
case SyncRepository:
@@ -779,6 +794,30 @@ const sessionInsert = ({
};
};
const passkeyInsert = ({
id = newUuid(),
userId,
...passkey
}: Partial<Insertable<PasskeyTable>> & { userId: string }) => {
const defaults: Insertable<PasskeyTable> = {
id,
userId,
name: 'Passkey',
credentialId: `credential-${id}`,
publicKey: Buffer.from('public-key'),
counter: 0,
transports: ['internal'],
backedUp: false,
deviceType: 'singleDevice',
};
return {
...defaults,
...passkey,
id,
};
};
const userInsert = (user: Partial<Insertable<UserTable>> & { clusterGroupId: string }) => {
const id = user.id || newUuid();
@@ -899,6 +938,7 @@ export const mediumFactory = {
faceInsert,
personInsert,
personUserInsert,
passkeyInsert,
sessionInsert,
syncStream,
userInsert,
@@ -0,0 +1,308 @@
import { Kysely } from 'kysely';
import { AuthChallengeType, SystemMetadataKey } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CryptoRepository } from 'src/repositories/crypto.repository.js';
import { DatabaseRepository } from 'src/repositories/database.repository.js';
import { EventRepository } from 'src/repositories/event.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
import { SessionRepository } from 'src/repositories/session.repository.js';
import { StorageRepository } from 'src/repositories/storage.repository.js';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js';
import { TelemetryRepository } from 'src/repositories/telemetry.repository.js';
import { UserRepository } from 'src/repositories/user.repository.js';
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
import { DB } from 'src/schema/index.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { mediumFactory, newMediumService } from 'test/medium.factory.js';
import { factory } from 'test/small.factory.js';
import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>;
const origin = 'https://immich.example';
const headers = { origin };
const registrationResponse = {
id: 'credential-id',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation-object',
},
};
const authenticationResponse = {
id: 'credential-id',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
authenticatorData: 'authenticator-data',
signature: 'signature',
},
};
const setup = (db?: Kysely<DB>) => {
return newMediumService(PasskeyService, {
database: db || defaultDatabase,
real: [
AccessRepository,
AuthChallengeRepository,
ClusterGroupRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,
PasskeyRepository,
SessionRepository,
SystemMetadataRepository,
UserRepository,
WebAuthnRepository,
],
mock: [LoggingRepository, StorageRepository, EventRepository, TelemetryRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
const { ctx } = setup();
await ctx.get(SystemMetadataRepository).set(SystemMetadataKey.SystemConfig, {
passkey: { enabled: true, domain: 'https://immich.example', additionalDomains: [] },
});
});
describe(PasskeyService.name, () => {
describe('startRegistration', () => {
it('should generate registration options', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.startRegistration(factory.auth({ user, session: {} }), headers)).resolves.toEqual(
expect.objectContaining({
challenge: expect.any(String),
rp: { id: 'immich.example', name: 'Immich' },
user: expect.objectContaining({ name: user.email, displayName: user.name }),
excludeCredentials: [],
authenticatorSelection: expect.objectContaining({ residentKey: 'required', userVerification: 'required' }),
}),
);
});
it('should store a single-use challenge for the user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { challenge } = await sut.startRegistration(factory.auth({ user, session: {} }), headers);
const repository = ctx.get(AuthChallengeRepository);
await expect(
repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge }),
).resolves.toBeUndefined();
await expect(repository.consume({ type: AuthChallengeType.PasskeyRegistration, challenge })).resolves.toEqual({
id: expect.any(String),
userId: user.id,
});
await expect(
repository.consume({ type: AuthChallengeType.PasskeyRegistration, challenge }),
).resolves.toBeUndefined();
});
it('should exclude the existing passkeys of the user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await expect(sut.startRegistration(factory.auth({ user, session: {} }), headers)).resolves.toEqual(
expect.objectContaining({
excludeCredentials: [{ id: passkey.credentialId, type: 'public-key', transports: passkey.transports }],
}),
);
});
});
describe('finishRegistration', () => {
it('should reject an invalid response', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(
sut.finishRegistration(
factory.auth({ user, session: {} }),
{ response: registrationResponse },
headers,
mediumFactory.loginDetails(),
),
).rejects.toThrow('Passkey registration failed');
await expect(ctx.get(PasskeyRepository).search({ userId: user.id })).resolves.toEqual([]);
});
});
describe('startAuthentication', () => {
it('should store a single-use challenge without a user', async () => {
const { sut, ctx } = setup();
const { challenge } = await sut.startAuthentication(headers);
const repository = ctx.get(AuthChallengeRepository);
await expect(repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge })).resolves.toEqual({
id: expect.any(String),
userId: null,
});
await expect(
repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge }),
).resolves.toBeUndefined();
});
it('should generate authentication options', async () => {
const { sut } = setup();
await expect(sut.startAuthentication(headers)).resolves.toEqual(
expect.objectContaining({
challenge: expect.any(String),
rpId: 'immich.example',
userVerification: 'required',
}),
);
});
});
describe('finishAuthentication', () => {
it('should reject an unknown credential', async () => {
const { sut } = setup();
await expect(
sut.finishAuthentication({ response: authenticationResponse }, headers, mediumFactory.loginDetails()),
).rejects.toThrow('Invalid passkey');
});
it('should reject an invalid response for a known credential', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id, credentialId: authenticationResponse.id });
await expect(
sut.finishAuthentication({ response: authenticationResponse }, headers, mediumFactory.loginDetails()),
).rejects.toThrow('Invalid passkey');
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
expect.objectContaining({ usedAt: null }),
);
await expect(ctx.get(SessionRepository).getByUserId(user.id)).resolves.toEqual([]);
});
});
describe('search', () => {
it('should return an empty list', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await expect(sut.search(factory.auth({ user }), {})).resolves.toEqual([]);
});
it('should only return the passkeys of the current user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.search(factory.auth({ user }), {})).resolves.toEqual([
expect.objectContaining({ id: passkey.id, name: passkey.name, usedAt: null }),
]);
});
it('should filter by id', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await ctx.newPasskey({ userId: user.id });
await expect(sut.search(factory.auth({ user }), { id: passkey.id })).resolves.toEqual([
expect.objectContaining({ id: passkey.id }),
]);
});
});
describe('get', () => {
it('should reject the passkey of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.get(factory.auth({ user }), passkey.id)).rejects.toThrow('Not found or no passkey.read access');
});
it('should get a passkey', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await expect(sut.get(factory.auth({ user }), passkey.id)).resolves.toEqual({
id: passkey.id,
name: passkey.name,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
usedAt: null,
});
});
});
describe('update', () => {
it('should reject the passkey of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.update(factory.auth({ user }), passkey.id, { name: 'Renamed' })).rejects.toThrow(
'Not found or no passkey.update access',
);
});
it('should rename a passkey', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await expect(sut.update(factory.auth({ user }), passkey.id, { name: 'Renamed' })).resolves.toEqual(
expect.objectContaining({ id: passkey.id, name: 'Renamed' }),
);
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
expect.objectContaining({ name: 'Renamed' }),
);
});
});
describe('delete', () => {
it('should reject the passkey of another user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
await expect(sut.delete(factory.auth({ user }), passkey.id)).rejects.toThrow(
'Not found or no passkey.delete access',
);
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
expect.objectContaining({ id: passkey.id }),
);
});
it('should delete a passkey', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { passkey } = await ctx.newPasskey({ userId: user.id });
await sut.delete(factory.auth({ user }), passkey.id);
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toBeUndefined();
});
});
});
@@ -68,6 +68,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()),
},
passkey: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
session: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
+12
View File
@@ -29,6 +29,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
import { ConfigRepository } from 'src/repositories/config.repository.js';
import { CronRepository } from 'src/repositories/cron.repository.js';
@@ -52,6 +53,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
import { OcrRepository } from 'src/repositories/ocr.repository.js';
import { PartnerRepository } from 'src/repositories/partner.repository.js';
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
import { PersonRepository } from 'src/repositories/person.repository.js';
import { PluginRepository } from 'src/repositories/plugin.repository.js';
@@ -73,6 +75,7 @@ import { UserRepository } from 'src/repositories/user.repository.js';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository.js';
import { ViewRepository } from 'src/repositories/view-repository.js';
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
import { DB } from 'src/schema/index.js';
@@ -246,6 +249,7 @@ export type ServiceOverrides = {
assetEdit: AssetEditRepository;
assetFile: AssetFileRepository;
assetJob: AssetJobRepository;
authChallenge: AuthChallengeRepository;
clusterGroup: ClusterGroupRepository;
config: ConfigRepository;
cron: CronRepository;
@@ -269,6 +273,7 @@ export type ServiceOverrides = {
ocr: OcrRepository;
oauth: OAuthRepository;
partner: PartnerRepository;
passkey: PasskeyRepository;
person: PersonRepository;
personUser: PersonUserRepository;
plugin: PluginRepository;
@@ -290,6 +295,7 @@ export type ServiceOverrides = {
versionHistory: VersionHistoryRepository;
videoStream: VideoStreamRepository;
view: ViewRepository;
webAuthn: WebAuthnRepository;
websocket: WebsocketRepository;
workflow: WorkflowRepository;
};
@@ -333,6 +339,7 @@ export const getMocks = () => {
assetEdit: automock(AssetEditRepository),
assetFile: automock(AssetFileRepository),
assetJob: automock(AssetJobRepository),
authChallenge: automock(AuthChallengeRepository),
clusterGroup: automock(ClusterGroupRepository),
app: automock(AppRepository, { strict: false }),
config: newConfigRepositoryMock(),
@@ -356,6 +363,7 @@ export const getMocks = () => {
ocr: automock(OcrRepository, { strict: false }),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
passkey: automock(PasskeyRepository),
person: automock(PersonRepository, { strict: false }),
personUser: automock(PersonUserRepository, { strict: false }),
plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }),
@@ -380,6 +388,7 @@ export const getMocks = () => {
versionHistory: automock(VersionHistoryRepository),
videoStream: automock(VideoStreamRepository, { strict: false }),
view: automock(ViewRepository),
webAuthn: automock(WebAuthnRepository),
// eslint-disable-next-line no-sparse-arrays
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
workflow: automock(WorkflowRepository, { strict: true }),
@@ -409,6 +418,7 @@ export const newTestService = <T extends BaseService>(
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
overrides.assetFile || (mocks.assetFile as As<AssetFileRepository>),
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
overrides.authChallenge || (mocks.authChallenge as As<AuthChallengeRepository>),
overrides.clusterGroup || (mocks.clusterGroup as As<ClusterGroupRepository>),
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),
overrides.cron || (mocks.cron as As<CronRepository>),
@@ -431,6 +441,7 @@ export const newTestService = <T extends BaseService>(
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),
overrides.passkey || (mocks.passkey as As<PasskeyRepository>),
overrides.person || (mocks.person as As<PersonRepository>),
overrides.personUser || (mocks.personUser as As<PersonUserRepository>),
overrides.plugin || (mocks.plugin as As<PluginRepository>),
@@ -452,6 +463,7 @@ export const newTestService = <T extends BaseService>(
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
overrides.videoStream || (mocks.videoStream as As<VideoStreamRepository>),
overrides.view || (mocks.view as As<ViewRepository>),
overrides.webAuthn || (mocks.webAuthn as As<WebAuthnRepository>),
overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
);