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
+5
View File
@@ -209,6 +209,11 @@ The default configuration looks like this:
"timeout": 30000,
"tokenEndpointAuthMethod": "client_secret_post"
},
"passkey": {
"enabled": true,
"domain": null,
"additionalDomains": []
},
"passwordLogin": {
"enabled": true
},
+3 -3
View File
@@ -28,7 +28,7 @@ test.describe('Registration', () => {
await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('admin@immich.app');
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
@@ -66,7 +66,7 @@ test.describe('Registration', () => {
await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
// change password
await expect(page.getByRole('heading')).toHaveText('Change Password');
@@ -79,7 +79,7 @@ test.describe('Registration', () => {
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
+17
View File
@@ -324,6 +324,13 @@
"oauth_timeout": "Request Timeout",
"oauth_timeout_description": "Timeout for requests in milliseconds",
"ocr_job_description": "Use machine learning to recognize text in images",
"passkey_additional_domains": "Additional domains",
"passkey_additional_domains_description": "Additional, trusted domains (related origin requests). These will only work if the passkey domain is also accessible.",
"passkey_domain": "Passkey domain",
"passkey_domain_description": "Domain used for passkeys (relying party). Leave blank to infer from the origin header. Changing this value will result in existing passkeys no longer working.",
"passkey_enable_description": "Login with a passkey",
"passkey_settings": "Passkey Login",
"passkey_settings_description": "Manage passkey login settings",
"password_enable_description": "Login with email and password",
"password_settings": "Password Login",
"password_settings_description": "Manage password login settings",
@@ -874,6 +881,8 @@
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
"delete_local_dialog_ok_force": "Delete Anyway",
"delete_others": "Delete others",
"delete_passkey": "Delete passkey",
"delete_passkey_prompt": "Are you sure you want to delete this passkey?",
"delete_permanently": "Delete permanently",
"delete_permanently_action_prompt": "{count} deleted permanently",
"delete_shared_link": "Delete shared link",
@@ -943,6 +952,7 @@
"edit_location_action_prompt": "{count} location edited",
"edit_location_dialog_title": "Location",
"edit_name": "Edit name",
"edit_passkey": "Edit passkey",
"edit_people": "Edit people",
"edit_person": "Edit person",
"edit_tag": "Edit tag",
@@ -1287,6 +1297,7 @@
"last": "Last",
"last_months": "{count, plural, one {Last month} other {Last # months}}",
"last_seen": "Last seen",
"last_used": "Last used",
"latest_version": "Latest Version",
"latitude": "Latitude",
"leave": "Leave",
@@ -1379,6 +1390,7 @@
"login_has_been_disabled": "Login has been disabled",
"login_password_changed_error": "There was an error updating your password",
"login_password_changed_success": "Password updated successfully",
"login_with_passkey": "Login with passkey",
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
"logs": "Logs",
@@ -1427,6 +1439,7 @@
"manage_your_api_keys": "Manage your API keys",
"manage_your_devices": "Manage your logged-in devices",
"manage_your_oauth_connection": "Manage your OAuth connection",
"manage_your_passkeys": "Manage your passkeys",
"map": "Map",
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
"map_cannot_get_user_location": "Cannot get user's location",
@@ -1546,6 +1559,7 @@
"new_album": "New Album",
"new_api_key": "New API Key",
"new_feature": "New Feature",
"new_passkey": "New passkey",
"new_password": "New password",
"new_person": "New person",
"new_pin_code": "New PIN code",
@@ -1658,6 +1672,8 @@
"partner_sharing": "Partner Sharing",
"partners": "Partners",
"partners_description": "Share your photos and videos with other users",
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
"passkeys_not_supported": "Passkeys are not supported by this browser",
"password": "Password",
"password_does_not_match": "Password does not match",
"password_required": "Password Required",
@@ -1842,6 +1858,7 @@
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory",
"removed_passkey": "Removed passkey: {name}",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
"repository": "Repository",
+9 -8
View File
@@ -34,9 +34,8 @@ final Map<String, Map<String, Object?>> openApiPatches = {
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
).toJson(),
'ffmpeg': {
'realtime': {'enabled': false},
},
'ffmpeg': {'realtime': {'enabled': false}},
'passkey': {'enabled': false},
},
'ServerConfigDto': {
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
@@ -55,12 +54,14 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'PeopleResponse': {'updateStrategy': 'everyone'},
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
'ServerVersionResponseDto': {'featureLevel': 0},
'UserConfigDto': {
'notifications': {
'smtp': {'enabled': false},
},
},
'WorkflowResponseDto': {'logging': false},
'AdminConfigDto': {
'passkey': {'enabled': false, 'domain': null, 'additionalDomains': const []},
},
'UserConfigDto': {
'notifications': {'smtp': {'enabled': false}},
'passkey': {'enabled': false},
},
};
// ignore: unused-code
+602
View File
@@ -9708,6 +9708,415 @@
"x-immich-state": "Stable"
}
},
"/passkeys": {
"get": {
"description": "Search the passkeys of the current user.",
"operationId": "searchPasskeys",
"parameters": [
{
"name": "id",
"required": false,
"in": "query",
"description": "Filter by passkey ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PasskeyResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Search passkeys",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.read",
"x-immich-state": "Alpha"
}
},
"/passkeys/authentication/finish": {
"post": {
"description": "Verify the response from the authenticator and receive a session token.",
"operationId": "finishAuthentication",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAuthenticationFinishDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponseDto"
}
}
},
"description": ""
}
},
"summary": "Finish passkey authentication",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/authentication/start": {
"post": {
"description": "Generate the options used by the browser to sign in with a passkey.",
"operationId": "startAuthentication",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAuthenticationOptionsDto"
}
}
},
"description": ""
}
},
"summary": "Start passkey authentication",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/registration/finish": {
"post": {
"description": "Verify the response from the authenticator and save the new passkey for the current user.",
"operationId": "finishRegistration",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyRegistrationFinishDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Finish passkey registration",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/registration/start": {
"post": {
"description": "Generate the options used by the browser to create a new passkey for the current user.",
"operationId": "startRegistration",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyRegistrationOptionsDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Start passkey registration",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/{id}": {
"delete": {
"description": "Delete a passkey by its ID. The current user must own this passkey.",
"operationId": "deletePasskey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete a passkey",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.delete",
"x-immich-state": "Alpha"
},
"get": {
"description": "Retrieve a passkey by its ID. The current user must own this passkey.",
"operationId": "getPasskey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve a passkey",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.read",
"x-immich-state": "Alpha"
},
"patch": {
"description": "Update the name of a passkey by its ID. The current user must own this passkey.",
"operationId": "updatePasskey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update a passkey",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.update",
"x-immich-state": "Alpha"
}
},
"/people": {
"delete": {
"description": "Bulk delete a list of people at once.",
@@ -18315,6 +18724,10 @@
"name": "Partners",
"description": "A partner is a link with another user that allows sharing of assets between two users."
},
{
"name": "Passkeys",
"description": "A passkey is a WebAuthn credential that can be used to login without a password."
},
{
"name": "People",
"description": "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."
@@ -18619,6 +19032,9 @@
"oauth": {
"$ref": "#/components/schemas/AdminConfigOAuthDto"
},
"passkey": {
"$ref": "#/components/schemas/AdminConfigPasskeyDto"
},
"passwordLogin": {
"$ref": "#/components/schemas/AdminConfigPasswordLoginDto"
},
@@ -18659,6 +19075,7 @@
"nightlyTasks",
"notifications",
"oauth",
"passkey",
"passwordLogin",
"reverseGeocoding",
"server",
@@ -19521,6 +19938,34 @@
],
"type": "object"
},
"AdminConfigPasskeyDto": {
"properties": {
"additionalDomains": {
"description": "Additional domains",
"items": {
"format": "uri",
"type": "string"
},
"type": "array"
},
"domain": {
"description": "Passkey domain",
"format": "uri",
"nullable": true,
"type": "string"
},
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"additionalDomains",
"domain",
"enabled"
],
"type": "object"
},
"AdminConfigPasswordLoginDto": {
"properties": {
"enabled": {
@@ -22790,6 +23235,7 @@
"AssetGenerateThumbnailsQueueAll",
"AssetGenerateThumbnails",
"AuditTableCleanup",
"AuthChallengeCleanup",
"DatabaseBackup",
"FacialRecognitionQueueAll",
"FacialRecognition",
@@ -24918,6 +25364,127 @@
],
"type": "object"
},
"PasskeyAuthenticationFinishDto": {
"properties": {
"response": {
"additionalProperties": {},
"description": "Response returned by @simplewebauthn/browser's `startAuthentication()`",
"properties": {
"id": {
"description": "Credential ID",
"format": "base64url",
"pattern": "^[A-Za-z0-9_-]*$",
"type": "string"
},
"response": {
"additionalProperties": {},
"properties": {
"userHandle": {
"description": "User handle",
"format": "base64url",
"pattern": "^[A-Za-z0-9_-]*$",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"id",
"response"
],
"type": "object"
}
},
"required": [
"response"
],
"type": "object"
},
"PasskeyAuthenticationOptionsDto": {
"additionalProperties": {},
"description": "Response returned by @simplewebauthn/server's `generateAuthenticationOptions`",
"type": "object"
},
"PasskeyRegistrationFinishDto": {
"properties": {
"name": {
"description": "Passkey name",
"type": "string"
},
"response": {
"description": "Response returned by @simplewebauthn/browser's `startAuthentication()`"
}
},
"required": [
"response"
],
"type": "object"
},
"PasskeyRegistrationOptionsDto": {
"additionalProperties": {},
"description": "Response returned by @simplewebauthn/server's `generateRegistrationOptions`",
"type": "object"
},
"PasskeyResponseDto": {
"properties": {
"createdAt": {
"description": "Creation date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"id": {
"description": "Passkey ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
"description": "Passkey name",
"nullable": true,
"type": "string"
},
"updatedAt": {
"description": "Last update date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"usedAt": {
"description": "Last used date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
}
},
"required": [
"createdAt",
"id",
"name",
"updatedAt",
"usedAt"
],
"type": "object"
},
"PasskeyUpdateDto": {
"properties": {
"name": {
"description": "Passkey name",
"minLength": 1,
"nullable": true,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"PeopleDeleteDto": {
"properties": {
"ids": {
@@ -25280,6 +25847,9 @@
"partner.read",
"partner.update",
"partner.delete",
"passkey.read",
"passkey.update",
"passkey.delete",
"person.create",
"person.read",
"person.update",
@@ -25963,6 +26533,9 @@
"oauth": {
"$ref": "#/components/schemas/PublicConfigOAuthDto"
},
"passkey": {
"$ref": "#/components/schemas/PublicConfigPasskeyDto"
},
"passwordLogin": {
"$ref": "#/components/schemas/PublicConfigPasswordLoginDto"
},
@@ -25974,6 +26547,7 @@
"ffmpeg",
"map",
"oauth",
"passkey",
"passwordLogin",
"server"
],
@@ -26048,6 +26622,18 @@
],
"type": "object"
},
"PublicConfigPasskeyDto": {
"properties": {
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"PublicConfigPasswordLoginDto": {
"properties": {
"enabled": {
@@ -33173,6 +33759,9 @@
"oauth": {
"$ref": "#/components/schemas/UserConfigOAuthDto"
},
"passkey": {
"$ref": "#/components/schemas/UserConfigPasskeyDto"
},
"passwordLogin": {
"$ref": "#/components/schemas/UserConfigPasswordLoginDto"
},
@@ -33196,6 +33785,7 @@
"map",
"notifications",
"oauth",
"passkey",
"passwordLogin",
"reverseGeocoding",
"server",
@@ -33416,6 +34006,18 @@
],
"type": "object"
},
"UserConfigPasskeyDto": {
"properties": {
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"UserConfigPasswordLoginDto": {
"properties": {
"enabled": {
+173
View File
@@ -357,6 +357,14 @@ export type AdminConfigOAuthDto = {
timeout: number;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
};
export type AdminConfigPasskeyDto = {
/** Additional domains */
additionalDomains: string[];
/** Passkey domain */
domain: string | null;
/** Enabled */
enabled: boolean;
};
export type AdminConfigPasswordLoginDto = {
/** Enabled */
enabled: boolean;
@@ -421,6 +429,7 @@ export type AdminConfigDto = {
nightlyTasks: AdminConfigNightlyTasksDto;
notifications: AdminConfigNotificationsDto;
oauth: AdminConfigOAuthDto;
passkey: AdminConfigPasskeyDto;
passwordLogin: AdminConfigPasswordLoginDto;
reverseGeocoding: AdminConfigReverseGeocodingDto;
server: AdminConfigServerDto;
@@ -1614,6 +1623,10 @@ export type UserConfigOAuthDto = {
/** Enabled */
enabled: boolean;
};
export type UserConfigPasskeyDto = {
/** Enabled */
enabled: boolean;
};
export type UserConfigPasswordLoginDto = {
/** Enabled */
enabled: boolean;
@@ -1647,6 +1660,7 @@ export type UserConfigDto = {
map: UserConfigMapDto;
notifications: UserConfigNotificationsDto;
oauth: UserConfigOAuthDto;
passkey: UserConfigPasskeyDto;
passwordLogin: UserConfigPasswordLoginDto;
reverseGeocoding: UserConfigReverseGeocodingDto;
server: UserConfigServerDto;
@@ -1997,6 +2011,47 @@ export type PartnerUpdateDto = {
/** Show partner assets in timeline */
inTimeline: boolean;
};
export type PasskeyResponseDto = {
/** Creation date */
createdAt: string;
/** Passkey ID */
id: string;
/** Passkey name */
name: string | null;
/** Last update date */
updatedAt: string;
/** Last used date */
usedAt: string | null;
};
export type PasskeyAuthenticationFinishDto = {
/** Response returned by @simplewebauthn/browser's `startAuthentication()` */
response: {
/** Credential ID */
id: string;
response: {
/** User handle */
userHandle?: string;
[key: string]: any;
};
[key: string]: any;
};
};
export type PasskeyAuthenticationOptionsDto = {
[key: string]: any;
};
export type PasskeyRegistrationFinishDto = {
/** Passkey name */
name?: string;
/** Response returned by @simplewebauthn/browser's `startAuthentication()` */
response: any;
};
export type PasskeyRegistrationOptionsDto = {
[key: string]: any;
};
export type PasskeyUpdateDto = {
/** Passkey name */
name: string | null;
};
export type PeopleDeleteDto = {
/** IDs to process */
ids: string[];
@@ -2198,6 +2253,10 @@ export type PublicConfigOAuthDto = {
/** Enabled */
enabled: boolean;
};
export type PublicConfigPasskeyDto = {
/** Enabled */
enabled: boolean;
};
export type PublicConfigPasswordLoginDto = {
/** Enabled */
enabled: boolean;
@@ -2210,6 +2269,7 @@ export type PublicConfigDto = {
ffmpeg: PublicConfigFFmpegDto;
map: PublicConfigMapDto;
oauth: PublicConfigOAuthDto;
passkey: PublicConfigPasskeyDto;
passwordLogin: PublicConfigPasswordLoginDto;
server: PublicConfigServerDto;
};
@@ -6126,6 +6186,115 @@ export function updatePartner({ id, partnerUpdateDto }: {
body: partnerUpdateDto
})));
}
/**
* Search passkeys
*/
export function searchPasskeys({ id }: {
id?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyResponseDto[];
}>(`/passkeys${QS.query(QS.explode({
id
}))}`, {
...opts
}));
}
/**
* Finish passkey authentication
*/
export function finishAuthentication({ passkeyAuthenticationFinishDto }: {
passkeyAuthenticationFinishDto: PasskeyAuthenticationFinishDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: LoginResponseDto;
}>("/passkeys/authentication/finish", oazapfts.json({
...opts,
method: "POST",
body: passkeyAuthenticationFinishDto
})));
}
/**
* Start passkey authentication
*/
export function startAuthentication(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyAuthenticationOptionsDto;
}>("/passkeys/authentication/start", {
...opts,
method: "POST"
}));
}
/**
* Finish passkey registration
*/
export function finishRegistration({ passkeyRegistrationFinishDto }: {
passkeyRegistrationFinishDto: PasskeyRegistrationFinishDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: PasskeyResponseDto;
}>("/passkeys/registration/finish", oazapfts.json({
...opts,
method: "POST",
body: passkeyRegistrationFinishDto
})));
}
/**
* Start passkey registration
*/
export function startRegistration(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyRegistrationOptionsDto;
}>("/passkeys/registration/start", {
...opts,
method: "POST"
}));
}
/**
* Delete a passkey
*/
export function deletePasskey({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/passkeys/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Retrieve a passkey
*/
export function getPasskey({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyResponseDto;
}>(`/passkeys/${encodeURIComponent(id)}`, {
...opts
}));
}
/**
* Update a passkey
*/
export function updatePasskey({ id, passkeyUpdateDto }: {
id: string;
passkeyUpdateDto: PasskeyUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyResponseDto;
}>(`/passkeys/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PATCH",
body: passkeyUpdateDto
})));
}
/**
* Delete people
*/
@@ -8221,6 +8390,9 @@ export enum Permission {
PartnerRead = "partner.read",
PartnerUpdate = "partner.update",
PartnerDelete = "partner.delete",
PasskeyRead = "passkey.read",
PasskeyUpdate = "passkey.update",
PasskeyDelete = "passkey.delete",
PersonCreate = "person.create",
PersonRead = "person.read",
PersonUpdate = "person.update",
@@ -8455,6 +8627,7 @@ export enum JobName {
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
AssetGenerateThumbnails = "AssetGenerateThumbnails",
AuditTableCleanup = "AuditTableCleanup",
AuthChallengeCleanup = "AuthChallengeCleanup",
DatabaseBackup = "DatabaseBackup",
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
FacialRecognition = "FacialRecognition",
+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>),
);
+1
View File
@@ -37,6 +37,7 @@
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@tanstack/svelte-query": "^6.3.2",
"@simplewebauthn/browser": "14.0.0",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.42.0",
"@zoom-image/svelte": "^0.3.0",
@@ -14,6 +14,7 @@
label?: string;
description?: string;
title?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
isEdited?: boolean;
@@ -39,6 +40,7 @@
label = '',
description = '',
title = '',
placeholder = '',
required = false,
disabled = false,
isEdited = false,
@@ -81,7 +83,10 @@
<div class="mb-4 w-full">
<div class="flex place-items-center gap-1">
<label class="min-h-6 text-sm font-medium text-primary" for={label}>{label}</label>
{#if label}
<label class="min-h-6 text-sm font-medium text-primary" for={label}>{label}</label>
{/if}
{#if required}
<div class="text-red-400">*</div>
{/if}
@@ -100,7 +105,7 @@
<p class="pb-2 text-sm immich-form-label" id="{label}-desc">
{description}
</p>
{:else}
{:else if descriptionSnippet}
<div class="pb-2">
{@render descriptionSnippet?.()}
</div>
@@ -145,6 +150,7 @@
onchange={handleChange}
{disabled}
{title}
{placeholder}
/>
{@render trailingSnippet?.()}
@@ -6,6 +6,7 @@ import type {
IntegrityReport,
JobCreateDto,
LoginResponseDto,
PasskeyResponseDto,
PersonResponseDto,
QueueResponseDto,
ReleaseEventV1,
@@ -34,6 +35,10 @@ export type Events = {
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
PasskeyCreate: [PasskeyResponseDto];
PasskeyUpdate: [PasskeyResponseDto];
PasskeyDelete: [PasskeyResponseDto];
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsUnarchive: [TimelineAsset[]];
@@ -0,0 +1,28 @@
<script lang="ts">
import { handleUpdatePasskey } from '$lib/services/passkey.service';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiShieldKeyOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
passkey: { id: string; name: string | null };
onClose: () => void;
};
let { passkey, onClose }: Props = $props();
let name = $state(passkey.name ?? '');
const onSubmit = async () => {
const success = await handleUpdatePasskey(passkey, { name });
if (success) {
onClose();
}
};
</script>
<FormModal title={$t('passkeys', { values: { count: 1 } })} icon={mdiShieldKeyOutline} {onClose} {onSubmit}>
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</FormModal>
+122
View File
@@ -0,0 +1,122 @@
import {
deletePasskey,
finishAuthentication,
finishRegistration,
startAuthentication,
startRegistration,
updatePasskey,
type PasskeyResponseDto,
type PasskeyUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import {
browserSupportsWebAuthn,
startAuthentication as startBrowserAuthentication,
startRegistration as startBrowserRegistration,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser';
import type { MessageFormatter } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import PasskeyUpdateModal from '$lib/modals/PasskeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const isPasskeySupported = () => browserSupportsWebAuthn();
export const isPasskeyCancelled = (error: unknown) => error instanceof Error && error.name === 'NotAllowedError';
export const getPasskeysActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('new_passkey'),
icon: mdiPlus,
onAction: async () => {
if (!isPasskeySupported()) {
toastManager.warning($t('passkeys_not_supported'));
return;
}
const passkey = await handleCreatePasskey();
if (passkey) {
await modalManager.show(PasskeyUpdateModal, { passkey });
}
},
};
return { Create };
};
export const getPasskeyActions = ($t: MessageFormatter, passkey: PasskeyResponseDto) => {
const Update: ActionItem = {
title: $t('edit_passkey'),
icon: mdiPencilOutline,
onAction: () => modalManager.show(PasskeyUpdateModal, { passkey }),
};
const Delete: ActionItem = {
title: $t('delete_passkey'),
icon: mdiTrashCanOutline,
onAction: () => handleDeletePasskey(passkey),
};
return { Update, Delete };
};
const handleCreatePasskey = async () => {
const $t = await getFormatter();
try {
const options = await startRegistration();
const response = await startBrowserRegistration({
optionsJSON: options as PublicKeyCredentialCreationOptionsJSON,
});
const passkey = await finishRegistration({ passkeyRegistrationFinishDto: { response } });
eventManager.emit('PasskeyCreate', passkey);
return passkey;
} catch (error) {
if (isPasskeyCancelled(error)) {
return;
}
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleLoginWithPasskey = async () => {
const options = await startAuthentication();
const response = await startBrowserAuthentication({ optionsJSON: options as PublicKeyCredentialRequestOptionsJSON });
return finishAuthentication({ passkeyAuthenticationFinishDto: { response } });
};
export const handleUpdatePasskey = async (passkey: { id: string }, dto: PasskeyUpdateDto) => {
const $t = await getFormatter();
try {
const response = await updatePasskey({ id: passkey.id, passkeyUpdateDto: { name: dto.name || null } });
eventManager.emit('PasskeyUpdate', response);
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_passkey_prompt') });
if (!confirmed) {
return;
}
try {
await deletePasskey({ id: passkey.id });
eventManager.emit('PasskeyDelete', passkey);
toastManager.primary(
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
@@ -17,6 +17,11 @@
<UserPageLayout title={data.meta.title} actions={[KeyboardShortcuts]}>
<Container size="medium" center>
<UserSettingsList keys={data.keys} sessions={data.sessions} />
<UserSettingsList
keys={data.keys}
sessions={data.sessions}
passkeys={data.passkeys}
passkeyEnabled={data.publicConfig.passkey.enabled}
/>
</Container>
</UserPageLayout>
+10 -4
View File
@@ -1,4 +1,4 @@
import { getApiKeys, getSessions } from '@immich/sdk';
import { getApiKeys, getPublicConfig, getSessions, searchPasskeys } from '@immich/sdk';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -6,13 +6,19 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const keys = await getApiKeys();
const sessions = await getSessions();
const $t = await getFormatter();
const publicConfig = await getPublicConfig();
const [keys, sessions, passkeys, $t] = await Promise.all([
getApiKeys(),
getSessions(),
publicConfig.passkey.enabled ? searchPasskeys({}) : Promise.resolve([]),
getFormatter(),
]);
return {
keys,
sessions,
passkeys,
publicConfig,
meta: {
title: $t('settings'),
},
@@ -0,0 +1,81 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { dateFormats } from '$lib/constants';
import { getPasskeyActions, getPasskeysActions } from '$lib/services/passkey.service';
import { locale } from '$lib/stores/preferences.store';
import { type PasskeyResponseDto } from '@immich/sdk';
import { Button, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = {
passkeys: PasskeyResponseDto[];
};
let { passkeys = $bindable() }: Props = $props();
const onPasskeyCreate = (passkey: PasskeyResponseDto) => {
passkeys = [passkey, ...passkeys];
};
const onPasskeyUpdate = (update: PasskeyResponseDto) => {
const index = passkeys.findIndex((passkey) => passkey.id === update.id);
if (index !== -1) {
passkeys[index] = update;
}
};
const onPasskeyDelete = ({ id }: PasskeyResponseDto) => {
passkeys = passkeys.filter((passkey) => passkey.id !== id);
};
const { Create } = $derived(getPasskeysActions($t));
</script>
<OnEvents {onPasskeyCreate} {onPasskeyUpdate} {onPasskeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2 sm:ms-8" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button
leadingIcon={Create.icon}
shape="round"
size="small"
onclick={(event: MouseEvent) => Create.onAction({ event, action: Create })}
>
{Create.title}
</Button>
</div>
{#if passkeys.length > 0}
<Table class="mt-4" striped spacing="small" size="small">
<TableHeader>
<TableHeading>{$t('name')}</TableHeading>
<TableHeading>{$t('created')}</TableHeading>
<TableHeading>{$t('last_used')}</TableHeading>
<TableHeading>{$t('action')}</TableHeading>
</TableHeader>
<TableBody>
{#each passkeys as passkey (passkey.id)}
{@const { Update, Delete } = getPasskeyActions($t, passkey)}
<TableRow>
<TableCell>{passkey.name ?? $t('passkeys', { values: { count: 1 } })}</TableCell>
<TableCell>{new Date(passkey.createdAt).toLocaleDateString($locale, dateFormats.settings)}</TableCell>
<TableCell>
{passkey.usedAt
? new Date(passkey.usedAt).toLocaleDateString($locale, dateFormats.settings)
: $t('never')}
</TableCell>
<TableCell class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1">
<TableButton action={Update} size="small" />
<TableButton action={Delete} size="small" />
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
</div>
</section>
@@ -9,7 +9,7 @@
import { OpenQueryParam, QueryParameter } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { oauth } from '$lib/utils';
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
import { type ApiKeyResponseDto, type PasskeyResponseDto, type SessionResponseDto } from '@immich/sdk';
import {
mdiAccountGroupOutline,
mdiAccountOutline,
@@ -23,6 +23,7 @@
mdiKeyOutline,
mdiLockSmart,
mdiServerOutline,
mdiShieldKeyOutline,
mdiTwoFactorAuthentication,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -34,14 +35,22 @@
import OauthSettings from './OauthSettings.svelte';
import SharingSettings from './SharingSettings.svelte';
import UserApiKeyList from './UserApiKeyList.svelte';
import UserPasskeyList from './UserPasskeyList.svelte';
import UserProfileSettings from './UserProfileSettings.svelte';
interface Props {
keys?: ApiKeyResponseDto[];
sessions?: SessionResponseDto[];
passkeys?: PasskeyResponseDto[];
passkeyEnabled?: boolean;
}
let { keys = $bindable([]), sessions = $bindable([]) }: Props = $props();
let {
keys = $bindable([]),
sessions = $bindable([]),
passkeys = $bindable([]),
passkeyEnabled = false,
}: Props = $props();
let oauthOpen =
oauth.isCallback(location) || $page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH;
@@ -130,6 +139,17 @@
</SettingAccordion>
{/if}
{#if passkeyEnabled}
<SettingAccordion
icon={mdiShieldKeyOutline}
key="passkeys"
title={$t('passkeys', { values: { count: passkeys.length } })}
subtitle={$t('manage_your_passkeys')}
>
<UserPasskeyList bind:passkeys />
</SettingAccordion>
{/if}
<SettingAccordion
icon={mdiFormTextboxPassword}
key="password"
@@ -11,8 +11,8 @@
import { getConfigQuery } from '$lib/queries/system-config.query';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
import { Button, Link, modalManager, Text, toastManager } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { Button, IconButton, Link, modalManager, Text, toastManager } from '@immich/ui';
import { mdiPlus, mdiRestart, mdiTrashCanOutline } from '@mdi/js';
import { cloneDeep } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -33,7 +33,8 @@
};
const onBeforeSave = async () => {
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
const allMethodsDisabled =
!configToEdit.oauth.enabled && !configToEdit.passkey.enabled && !configToEdit.passwordLogin.enabled;
if (allMethodsDisabled) {
const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
@@ -303,6 +304,67 @@
</div>
</SettingAccordion>
<SettingAccordion
key="passkey"
title={$t('admin.passkey_settings')}
subtitle={$t('admin.passkey_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.passkey_enable_description')}
{disabled}
bind:checked={configToEdit.passkey.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.passkey_domain')}
description={$t('admin.passkey_domain_description')}
placeholder="https://"
bind:value={
() => configToEdit.passkey.domain ?? '', (value) => (configToEdit.passkey.domain = value || null)
}
required={false}
disabled={disabled || !configToEdit.passkey.enabled}
isEdited={configToEdit.passkey.domain !== config.passkey.domain}
/>
<div class="flex flex-col gap-2">
{#each configToEdit.passkey.additionalDomains as _, i (i)}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={i === 0 ? $t('admin.passkey_additional_domains') : undefined}
description={i === 0 ? $t('admin.passkey_additional_domains_description') : undefined}
bind:value={configToEdit.passkey.additionalDomains[i]}
disabled={disabled || !configToEdit.passkey.enabled}
>
{#snippet trailingSnippet()}
<IconButton
aria-label=""
onclick={() => configToEdit.passkey.additionalDomains.splice(i, 1)}
icon={mdiTrashCanOutline}
color="danger"
/>
{/snippet}
</SettingInputField>
{/each}
</div>
<div class="flex justify-end">
<Button
class="mb-2"
size="small"
shape="round"
leadingIcon={mdiPlus}
onclick={() => void configToEdit.passkey.additionalDomains.push('')}
disabled={disabled || !configToEdit.passkey.enabled}>{$t('add_url')}</Button
>
</div>
</div>
</SettingAccordion>
<SettingAccordion
key="password"
title={$t('admin.password_settings')}
@@ -319,7 +381,7 @@
</div>
</SettingAccordion>
<SettingButtonsRow bind:configToEdit keys={['passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
<SettingButtonsRow bind:configToEdit keys={['passkey', 'passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
</div>
</form>
</div>
+78 -32
View File
@@ -4,10 +4,12 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { Route } from '$lib/route';
import { handleLoginWithPasskey, isPasskeyCancelled, isPasskeySupported } from '$lib/services/passkey.service';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { login, type LoginResponseDto } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
import { mdiShieldKeyOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -22,11 +24,15 @@
let email = $state('');
let password = $state('');
let oauthError = $state('');
let passkeyError = $state('');
let loading = $state(false);
let oauthLoading = $state(true);
let passkeyLoading = $state(false);
const passkeySupported = isPasskeySupported();
const serverConfig = $derived(serverConfigManager.value);
const publicConfig = $derived(data.publicConfig);
const passkeyEnabled = $derived(publicConfig.passkey.enabled && passkeySupported);
const onSuccess = async (user: LoginResponseDto) => {
await goto(data.continueUrl, { invalidateAll: true });
@@ -36,6 +42,28 @@
const onFirstLogin = () => goto(Route.changePassword());
const onOnboarding = () => goto(Route.onboarding());
const onLogin = async (user: LoginResponseDto) => {
if (user.isAdmin && !serverConfig.isOnboarded) {
await onOnboarding();
return;
}
// change the user password before we onboard them
if (!user.isAdmin && user.shouldChangePassword && publicConfig.passwordLogin.enabled) {
await onFirstLogin();
return;
}
// We want to onboard after the first login since their password will change
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user);
};
onMount(async () => {
if (!publicConfig.oauth.enabled) {
oauthLoading = false;
@@ -82,31 +110,24 @@
errorMessage = '';
loading = true;
const user = await login({ loginCredentialDto: { email, password } });
if (user.isAdmin && !serverConfig.isOnboarded) {
await onOnboarding();
return;
}
// change the user password before we onboard them
if (!user.isAdmin && user.shouldChangePassword) {
await onFirstLogin();
return;
}
// We want to onboard after the first login since their password will change
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user);
return;
await onLogin(user);
} catch (error) {
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
loading = false;
return;
}
};
const handlePasskeyLogin = async () => {
try {
passkeyError = '';
passkeyLoading = true;
const user = await handleLoginWithPasskey();
await onLogin(user);
} catch (error) {
if (!isPasskeyCancelled(error)) {
passkeyError = getServerErrorMessage(error) || $t('errors.something_went_wrong');
}
passkeyLoading = false;
}
};
@@ -126,6 +147,17 @@
};
</script>
{#snippet divider()}
<div class="my-4 inline-flex w-full items-center justify-center">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute inset-s-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 uppercase dark:bg-neutral-900 dark:text-white"
>
{$t('or')}
</span>
</div>
{/snippet}
<AuthPageLayout title={data.meta.title}>
<Stack gap={4}>
{#if publicConfig.server.loginPageMessage}
@@ -153,16 +185,30 @@
</form>
{/if}
{#if publicConfig.oauth.enabled}
{#if !oauthLoading && passkeyEnabled}
{#if publicConfig.passwordLogin.enabled}
<div class="my-4 inline-flex w-full items-center justify-center">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute inset-s-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 uppercase dark:bg-neutral-900 dark:text-white"
>
{$t('or')}
</span>
</div>
{@render divider()}
{/if}
{#if passkeyError}
<Alert color="danger" title={passkeyError} closable />
{/if}
<Button
shape="round"
leadingIcon={mdiShieldKeyOutline}
loading={passkeyLoading}
disabled={loading || passkeyLoading}
size="large"
fullWidth
color={publicConfig.passwordLogin.enabled ? 'secondary' : 'primary'}
onclick={handlePasskeyLogin}
>
{$t('login_with_passkey')}
</Button>
{/if}
{#if publicConfig.oauth.enabled}
{#if publicConfig.passwordLogin.enabled || passkeyEnabled}
{@render divider()}
{/if}
{#if oauthError}
<Alert color="danger" title={oauthError} closable />
@@ -180,7 +226,7 @@
</Button>
{/if}
{#if !publicConfig.passwordLogin.enabled && !publicConfig.oauth.enabled}
{#if !publicConfig.passwordLogin.enabled && !passkeyEnabled && !publicConfig.oauth.enabled}
<Alert color="warning" title={$t('login_has_been_disabled')} />
{/if}
</Stack>
+1
View File
@@ -17,6 +17,7 @@ const upstream = {
const proxy: Record<string, string | ProxyOptions> = {
'/api': upstream,
'/.well-known/immich': upstream,
'/.well-known/webauthn': upstream,
'/custom.css': upstream,
};