diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 5c34acdd9..28b588a59 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -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 }, diff --git a/e2e/src/specs/web/auth.e2e-spec.ts b/e2e/src/specs/web/auth.e2e-spec.ts index 633bad05f..37ee638a7 100644 --- a/e2e/src/specs/web/auth.e2e-spec.ts +++ b/e2e/src/specs/web/auth.e2e-spec.ts @@ -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'); diff --git a/i18n/en.json b/i18n/en.json index dde028d0e..b70f998e9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 1b41736dc..dd03525a3 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -34,9 +34,8 @@ final Map> 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> 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 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3baa5b8fb..a32926e84 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3747578af..2d06a14d9 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -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", diff --git a/server/package.json b/server/package.json index 46895dd49..cfe6cd1a0 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/constants.ts b/server/src/constants.ts index efa5885c3..13f64db34 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -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 = { '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]: diff --git a/server/src/controllers/app.controller.spec.ts b/server/src/controllers/app.controller.spec.ts index 5307c28dc..6eb1ca0f3 100644 --- a/server/src/controllers/app.controller.spec.ts +++ b/server/src/controllers/app.controller.spec.ts @@ -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'); diff --git a/server/src/controllers/app.controller.ts b/server/src/controllers/app.controller.ts index e4739fb42..064da932b 100644 --- a/server/src/controllers/app.controller.ts +++ b/server/src/controllers/app.controller.ts @@ -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 }) diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 3cf594ce7..b34848026 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -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, diff --git a/server/src/controllers/passkey.controller.spec.ts b/server/src/controllers/passkey.controller.spec.ts new file mode 100644 index 000000000..bb6cc0027 --- /dev/null +++ b/server/src/controllers/passkey.controller.spec.ts @@ -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' }])); + }); + }); +}); diff --git a/server/src/controllers/passkey.controller.ts b/server/src/controllers/passkey.controller.ts new file mode 100644 index 000000000..2a2cc770b --- /dev/null +++ b/server/src/controllers/passkey.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.service.delete(auth, id); + } +} diff --git a/server/src/database.ts b/server/src/database.ts index 3f262c4f6..609d84c42 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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', diff --git a/server/src/dtos/config.dto.spec.ts b/server/src/dtos/config.dto.spec.ts index 1b5c4ccfe..9d2d63d55 100644 --- a/server/src/dtos/config.dto.spec.ts +++ b/server/src/dtos/config.dto.spec.ts @@ -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) => AdminConfigDto.schema.safeParse({ ...defaults, passkey }); +const passkeyMessages = (passkey: Record) => + 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']); + }); +}); diff --git a/server/src/dtos/config.dto.ts b/server/src/dtos/config.dto.ts index ae060cfda..c123358f4 100644 --- a/server/src/dtos/config.dto.ts +++ b/server/src/dtos/config.dto.ts @@ -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>) + : z.object(shape); + visible = rebuilt.meta({ ...(id && { id: `${visibility}${id.slice(Admin.length)}` }), ...((override ?? description) && { description: override ?? description }), }); @@ -498,7 +538,7 @@ const stripVisibilityMetadata = (schema: T): T => { return schema; }; -const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType; +const AdminConfigSchema = AdminConfigSchemaWithVisibility; const UserConfigSchema = applyVisibility(User)! as z.ZodType>; const PublicConfigSchema = applyVisibility(Public)! as z.ZodType>; @@ -682,6 +722,11 @@ export const defaults = Object.freeze({ timeout: 30_000, allowInsecureRequests: false, }, + passkey: { + enabled: true, + domain: null, + additionalDomains: [], + }, passwordLogin: { enabled: true, }, diff --git a/server/src/dtos/passkey.dto.ts b/server/src/dtos/passkey.dto.ts new file mode 100644 index 000000000..919d408d8 --- /dev/null +++ b/server/src/dtos/passkey.dto.ts @@ -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, +}); diff --git a/server/src/enum.ts b/server/src/enum.ts index 843339d22..5208eac40 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 681860117..cf837ddab 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -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" diff --git a/server/src/queries/auth.challenge.repository.sql b/server/src/queries/auth.challenge.repository.sql new file mode 100644 index 000000000..beed7eb44 --- /dev/null +++ b/server/src/queries/auth.challenge.repository.sql @@ -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 diff --git a/server/src/queries/passkey.repository.sql b/server/src/queries/passkey.repository.sql new file mode 100644 index 000000000..58cbe8551 --- /dev/null +++ b/server/src/queries/passkey.repository.sql @@ -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 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 99520ec9a..812a3422a 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -528,6 +528,26 @@ class ClusterGroupRequestAccess { } } +class PasskeyAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, passkeyIds: Set) { + if (passkeyIds.size === 0) { + return new Set(); + } + + 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) {} @@ -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); diff --git a/server/src/repositories/auth-challenge.repository.ts b/server/src/repositories/auth-challenge.repository.ts new file mode 100644 index 000000000..db8a58481 --- /dev/null +++ b/server/src/repositories/auth-challenge.repository.ts @@ -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) {} + + create(dto: Insertable) { + 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); + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index f2c6d1adf..303852db6 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -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, ]; diff --git a/server/src/repositories/passkey.repository.ts b/server/src/repositories/passkey.repository.ts new file mode 100644 index 000000000..53eb99757 --- /dev/null +++ b/server/src/repositories/passkey.repository.ts @@ -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) {} + + create(dto: Insertable) { + return this.db.insertInto('passkey').values(dto).returning(columns.passkey).executeTakeFirstOrThrow(); + } + + update(id: string, dto: Updateable) { + 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(); + } +} diff --git a/server/src/repositories/webauthn.repository.ts b/server/src/repositories/webauthn.repository.ts new file mode 100644 index 000000000..72c60293a --- /dev/null +++ b/server/src/repositories/webauthn.repository.ts @@ -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); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 9ee8c00b1..0bd0a197a 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -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; diff --git a/server/src/schema/migrations/1792511451454-AddPasskeyTable.ts b/server/src/schema/migrations/1792511451454-AddPasskeyTable.ts new file mode 100644 index 000000000..ec28bba5d --- /dev/null +++ b/server/src/schema/migrations/1792511451454-AddPasskeyTable.ts @@ -0,0 +1,48 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + 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); +} diff --git a/server/src/schema/migrations/ORDER b/server/src/schema/migrations/ORDER index 2619cbe44..29bc7eaf7 100644 --- a/server/src/schema/migrations/ORDER +++ b/server/src/schema/migrations/ORDER @@ -99,3 +99,4 @@ 1790616293884-PersonSharing 1790693088454-AddPersonUserTableSharedBySharedWithConstraint 1792213725597-AddAssetFaceAssetState +1792511451454-AddPasskeyTable diff --git a/server/src/schema/tables/auth-challenge.table.ts b/server/src/schema/tables/auth-challenge.table.ts new file mode 100644 index 000000000..251f48378 --- /dev/null +++ b/server/src/schema/tables/auth-challenge.table.ts @@ -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; + + @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; + + @Column({ type: 'timestamp with time zone' }) + expiresAt!: Timestamp; +} diff --git a/server/src/schema/tables/passkey.table.ts b/server/src/schema/tables/passkey.table.ts new file mode 100644 index 000000000..97d7d3a08 --- /dev/null +++ b/server/src/schema/tables/passkey.table.ts @@ -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; + + @Column({ nullable: true }) + name!: string | null; + + @Column({ unique: true }) + credentialId!: string; + + @Column({ type: 'bytea' }) + publicKey!: Buffer; + + @Column({ type: 'bigint', default: 0 }) + counter!: Generated>; + + @Column({ array: true, type: 'character varying' }) + transports!: string[]; + + @Column({ type: 'boolean', default: false }) + backedUp!: Generated; + + @Column() + deviceType!: string; + + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @Column({ type: 'timestamp with time zone', nullable: true }) + usedAt!: Timestamp | null; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index e5909e9a9..cd88499a8 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -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(profile: OAuthProfile, options: ClaimOptions): T | undefined { const raw = profile[options.key as keyof OAuthProfile]; const value = options.parse ? options.parse(raw) : raw; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 2ff5dcc40..70d10854c 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -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); + } } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b8ad33be0..a491c6722 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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, diff --git a/server/src/services/passkey.service.spec.ts b/server/src/services/passkey.service.spec.ts new file mode 100644 index 000000000..37bb23af2 --- /dev/null +++ b/server/src/services/passkey.service.spec.ts @@ -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)('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)('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)('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); + }); + }); +}); diff --git a/server/src/services/passkey.service.ts b/server/src/services/passkey.service.ts new file mode 100644 index 000000000..3499c85e6 --- /dev/null +++ b/server/src/services/passkey.service.ts @@ -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 { + 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 { + 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)[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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 2d7b420cd..0f9549b4a 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -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 }, diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index 425b1a1fd..118a30221 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -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 }, ); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 6735f8215..f4b72831b 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -177,6 +177,11 @@ const updatedConfig = Object.freeze({ storageQuotaClaim: 'immich_quota', roleClaim: 'immich_role', }, + passkey: { + enabled: true, + domain: null, + additionalDomains: [], + }, passwordLogin: { enabled: true, }, diff --git a/server/src/types.ts b/server/src/types.ts index 31973a055..e338a7fea 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -411,6 +411,7 @@ export type JobItem = // Cleanup | { name: JobName.SessionCleanup; data?: IBaseJob } + | { name: JobName.AuthChallengeCleanup; data?: IBaseJob } | { name: JobName.HlsSessionCleanup; data?: IBaseJob } // Tags diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 0666f2d67..f54c90461 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -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: diff --git a/server/test/factories/passkey.factory.ts b/server/test/factories/passkey.factory.ts new file mode 100644 index 000000000..79fac3b8e --- /dev/null +++ b/server/test/factories/passkey.factory.ts @@ -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) {} + + 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 }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 6b262209c..c48a5e710 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -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>; export type PartnerLike = Partial>; export type ActivityLike = Partial>; export type ApiKeyLike = Partial>; +export type PasskeyLike = Partial>; export type SessionLike = Partial>; export type OAuthProfileLike = Partial; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index d78e9a0f2..d66d40c49 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -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 = return { user, result }; } + async newPasskey(dto: Partial> & { 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 = (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 = (key: T, db: Kysely } case ConfigRepository: - case CryptoRepository: { + case CryptoRepository: + case WebAuthnRepository: { return new key() as InstanceType; } @@ -557,6 +570,7 @@ const newMockRepository = (key: ClassConstructor) => { case AlbumRepository: case AssetRepository: case AssetJobRepository: + case AuthChallengeRepository: case ConfigRepository: case CryptoRepository: case LibraryRepository: @@ -565,6 +579,7 @@ const newMockRepository = (key: ClassConstructor) => { 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> & { userId: string }) => { + const defaults: Insertable = { + 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> & { clusterGroupId: string }) => { const id = user.id || newUuid(); @@ -899,6 +938,7 @@ export const mediumFactory = { faceInsert, personInsert, personUserInsert, + passkeyInsert, sessionInsert, syncStream, userInsert, diff --git a/server/test/medium/specs/services/passkey.service.spec.ts b/server/test/medium/specs/services/passkey.service.spec.ts new file mode 100644 index 000000000..28762bcd7 --- /dev/null +++ b/server/test/medium/specs/services/passkey.service.spec.ts @@ -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; + +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) => { + 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(); + }); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 3c3e3ea59..230098eac 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -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()), }, diff --git a/server/test/utils.ts b/server/test/utils.ts index 9d2f35b08..84bb45831 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -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 = ( overrides.assetEdit || (mocks.assetEdit as As), overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), + overrides.authChallenge || (mocks.authChallenge as As), overrides.clusterGroup || (mocks.clusterGroup as As), overrides.config || (mocks.config as As as ConfigRepository), overrides.cron || (mocks.cron as As), @@ -431,6 +441,7 @@ export const newTestService = ( overrides.oauth || (mocks.oauth as As), overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), + overrides.passkey || (mocks.passkey as As), overrides.person || (mocks.person as As), overrides.personUser || (mocks.personUser as As), overrides.plugin || (mocks.plugin as As), @@ -452,6 +463,7 @@ export const newTestService = ( overrides.versionHistory || (mocks.versionHistory as As), overrides.videoStream || (mocks.videoStream as As), overrides.view || (mocks.view as As), + overrides.webAuthn || (mocks.webAuthn as As), overrides.websocket || (mocks.websocket as As), overrides.workflow || (mocks.workflow as As), ); diff --git a/web/package.json b/web/package.json index 261966dd5..51a2d670a 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/components/shared-components/settings/SettingInputField.svelte b/web/src/lib/components/shared-components/settings/SettingInputField.svelte index 40cd96f73..6d0f54bd8 100644 --- a/web/src/lib/components/shared-components/settings/SettingInputField.svelte +++ b/web/src/lib/components/shared-components/settings/SettingInputField.svelte @@ -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 @@
- + {#if label} + + {/if} + {#if required}
*
{/if} @@ -100,7 +105,7 @@

{description}

- {:else} + {:else if descriptionSnippet}
{@render descriptionSnippet?.()}
@@ -145,6 +150,7 @@ onchange={handleChange} {disabled} {title} + {placeholder} /> {@render trailingSnippet?.()} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 8e0ac5adc..eb25741b8 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -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[]]; diff --git a/web/src/lib/modals/PasskeyUpdateModal.svelte b/web/src/lib/modals/PasskeyUpdateModal.svelte new file mode 100644 index 000000000..05c531c7d --- /dev/null +++ b/web/src/lib/modals/PasskeyUpdateModal.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/web/src/lib/services/passkey.service.ts b/web/src/lib/services/passkey.service.ts new file mode 100644 index 000000000..69885d86b --- /dev/null +++ b/web/src/lib/services/passkey.service.ts @@ -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')); + } +}; diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index 9501e459f..b6248434a 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -17,6 +17,11 @@ - + diff --git a/web/src/routes/(user)/user-settings/+page.ts b/web/src/routes/(user)/user-settings/+page.ts index d93a69404..54dee8608 100644 --- a/web/src/routes/(user)/user-settings/+page.ts +++ b/web/src/routes/(user)/user-settings/+page.ts @@ -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'), }, diff --git a/web/src/routes/(user)/user-settings/UserPasskeyList.svelte b/web/src/routes/(user)/user-settings/UserPasskeyList.svelte new file mode 100644 index 000000000..22021c433 --- /dev/null +++ b/web/src/routes/(user)/user-settings/UserPasskeyList.svelte @@ -0,0 +1,81 @@ + + + + +
+
+
+ +
+ + {#if passkeys.length > 0} + + + {$t('name')} + {$t('created')} + {$t('last_used')} + {$t('action')} + + + + {#each passkeys as passkey (passkey.id)} + {@const { Update, Delete } = getPasskeyActions($t, passkey)} + + {passkey.name ?? $t('passkeys', { values: { count: 1 } })} + {new Date(passkey.createdAt).toLocaleDateString($locale, dateFormats.settings)} + + {passkey.usedAt + ? new Date(passkey.usedAt).toLocaleDateString($locale, dateFormats.settings) + : $t('never')} + + + + + + + {/each} + +
+ {/if} +
+
diff --git a/web/src/routes/(user)/user-settings/UserSettingsList.svelte b/web/src/routes/(user)/user-settings/UserSettingsList.svelte index 890bb51de..ad412b512 100644 --- a/web/src/routes/(user)/user-settings/UserSettingsList.svelte +++ b/web/src/routes/(user)/user-settings/UserSettingsList.svelte @@ -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 @@ {/if} +{#if passkeyEnabled} + + + +{/if} + { - 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 @@
+ +
+ + +
+ + configToEdit.passkey.domain ?? '', (value) => (configToEdit.passkey.domain = value || null) + } + required={false} + disabled={disabled || !configToEdit.passkey.enabled} + isEdited={configToEdit.passkey.domain !== config.passkey.domain} + /> + +
+ {#each configToEdit.passkey.additionalDomains as _, i (i)} + + {#snippet trailingSnippet()} + configToEdit.passkey.additionalDomains.splice(i, 1)} + icon={mdiTrashCanOutline} + color="danger" + /> + {/snippet} + + {/each} +
+ +
+ +
+
+
+ - +
diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index b505ef1f9..699799050 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -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 @@ }; +{#snippet divider()} +
+
+ + {$t('or')} + +
+{/snippet} + {#if publicConfig.server.loginPageMessage} @@ -153,16 +185,30 @@ {/if} - {#if publicConfig.oauth.enabled} + {#if !oauthLoading && passkeyEnabled} {#if publicConfig.passwordLogin.enabled} -
-
- - {$t('or')} - -
+ {@render divider()} + {/if} + {#if passkeyError} + + {/if} + + {/if} + + {#if publicConfig.oauth.enabled} + {#if publicConfig.passwordLogin.enabled || passkeyEnabled} + {@render divider()} {/if} {#if oauthError} @@ -180,7 +226,7 @@ {/if} - {#if !publicConfig.passwordLogin.enabled && !publicConfig.oauth.enabled} + {#if !publicConfig.passwordLogin.enabled && !passkeyEnabled && !publicConfig.oauth.enabled} {/if}
diff --git a/web/vite.config.ts b/web/vite.config.ts index 07e7efaf2..d965592cc 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -17,6 +17,7 @@ const upstream = { const proxy: Record = { '/api': upstream, '/.well-known/immich': upstream, + '/.well-known/webauthn': upstream, '/custom.css': upstream, };