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
+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>),
);