feat: passkeys (#32194)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev> (cherry picked from commit 6233a79bf7e4cec11e9ea1094908ff3b228fcbe0)
This commit is contained in:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,9 +34,8 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
).toJson(),
|
||||
'ffmpeg': {
|
||||
'realtime': {'enabled': false},
|
||||
},
|
||||
'ffmpeg': {'realtime': {'enabled': false}},
|
||||
'passkey': {'enabled': false},
|
||||
},
|
||||
'ServerConfigDto': {
|
||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
@@ -55,12 +54,14 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||
'ServerVersionResponseDto': {'featureLevel': 0},
|
||||
'UserConfigDto': {
|
||||
'notifications': {
|
||||
'smtp': {'enabled': false},
|
||||
},
|
||||
},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
'AdminConfigDto': {
|
||||
'passkey': {'enabled': false, 'domain': null, 'additionalDomains': const []},
|
||||
},
|
||||
'UserConfigDto': {
|
||||
'notifications': {'smtp': {'enabled': false}},
|
||||
'passkey': {'enabled': false},
|
||||
},
|
||||
};
|
||||
|
||||
// ignore: unused-code
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -60,7 +60,7 @@ export const reverseGeocodeMaxDistance = 25_000;
|
||||
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
|
||||
export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
||||
|
||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||
export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/custom.css', '/favicon.ico'];
|
||||
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
|
||||
@@ -175,6 +175,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.',
|
||||
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
|
||||
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
|
||||
[ApiTag.Passkeys]: 'A passkey is a WebAuthn credential that can be used to login without a password.',
|
||||
[ApiTag.People]:
|
||||
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
|
||||
[ApiTag.Plugins]:
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import request from 'supertest';
|
||||
import { AppController } from 'src/controllers/app.controller.js';
|
||||
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||
import { SystemConfigService } from 'src/services/system-config.service.js';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
|
||||
|
||||
describe(AppController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const passkeyService = mockBaseService(PasskeyService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AppController, [
|
||||
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
|
||||
{ provide: PasskeyService, useValue: passkeyService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.reset();
|
||||
passkeyService.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /.well-known/immich', () => {
|
||||
@@ -34,6 +38,13 @@ describe(AppController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /.well-known/webauthn', () => {
|
||||
it('should not be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/.well-known/webauthn');
|
||||
expect(ctx.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /custom.css', () => {
|
||||
it('should not be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/custom.css');
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -25,6 +25,7 @@ import { NotificationAdminController } from 'src/controllers/notification-admin.
|
||||
import { NotificationController } from 'src/controllers/notification.controller.js';
|
||||
import { OAuthController } from 'src/controllers/oauth.controller.js';
|
||||
import { PartnerController } from 'src/controllers/partner.controller.js';
|
||||
import { PasskeyController } from 'src/controllers/passkey.controller.js';
|
||||
import { PersonController } from 'src/controllers/person.controller.js';
|
||||
import { PluginController } from 'src/controllers/plugin.controller.js';
|
||||
import { QueueController } from 'src/controllers/queue.controller.js';
|
||||
@@ -73,6 +74,7 @@ export const controllers = [
|
||||
NotificationAdminController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PasskeyController,
|
||||
PersonController,
|
||||
PluginController,
|
||||
QueueController,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import request from 'supertest';
|
||||
import { PasskeyController } from 'src/controllers/passkey.controller.js';
|
||||
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||
import { factory } from 'test/small.factory.js';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
|
||||
|
||||
const registrationResponse = {
|
||||
id: 'Y3JlZGVudGlhbC1pZA',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: 'client-data',
|
||||
attestationObject: 'attestation-object',
|
||||
},
|
||||
};
|
||||
|
||||
const authenticationResponse = {
|
||||
id: 'Y3JlZGVudGlhbC1pZA',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: 'client-data',
|
||||
authenticatorData: 'authenticator-data',
|
||||
signature: 'signature',
|
||||
},
|
||||
};
|
||||
|
||||
describe(PasskeyController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(PasskeyService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(PasskeyController, [{ provide: PasskeyService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /passkeys/registration/start', () => {
|
||||
it('should respond with 200', async () => {
|
||||
service.startRegistration.mockResolvedValue({ challenge: 'challenge' } as any);
|
||||
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/registration/start');
|
||||
expect(status).toEqual(200);
|
||||
expect(headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /passkeys/registration/finish', () => {
|
||||
it('should require a response', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/passkeys/registration/finish')
|
||||
.send({ name: 'Passkey' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['response'], message: 'Invalid input: expected nonoptional, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond with 201', async () => {
|
||||
service.finishRegistration.mockResolvedValue({} as any);
|
||||
const { status, headers } = await request(ctx.getHttpServer())
|
||||
.post('/passkeys/registration/finish')
|
||||
.send({ name: 'Passkey', response: registrationResponse });
|
||||
expect(status).toEqual(201);
|
||||
expect(headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /passkeys/authentication/start', () => {
|
||||
it('should respond with 200', async () => {
|
||||
service.startAuthentication.mockResolvedValue({ challenge: 'challenge' } as any);
|
||||
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/authentication/start');
|
||||
expect(status).toEqual(200);
|
||||
expect(headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /passkeys/authentication/finish', () => {
|
||||
it('should require a response', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/passkeys/authentication/finish').send({});
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['response'], message: 'Invalid input: expected object, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond with 201 and set the auth cookies', async () => {
|
||||
service.finishAuthentication.mockResolvedValue({ accessToken: 'token' } as any);
|
||||
const { status, headers } = await request(ctx.getHttpServer())
|
||||
.post('/passkeys/authentication/finish')
|
||||
.send({ response: authenticationResponse });
|
||||
expect(status).toEqual(201);
|
||||
expect(headers['set-cookie']).toEqual([
|
||||
expect.stringContaining('immich_access_token=token; Max-Age=34560000; Path=/; Expires='),
|
||||
expect.stringContaining('immich_auth_type=passkey; Max-Age=34560000; Path=/; Expires='),
|
||||
expect.stringContaining('immich_is_authenticated=true; Max-Age=34560000; Path=/; Expires='),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /passkeys', () => {
|
||||
it('should reject an invalid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys').query({ id: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /passkeys/:id', () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys/123');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /passkeys/:id', () => {
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).patch(`/passkeys/${factory.uuid()}`).send({});
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['name'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an empty name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/passkeys/${factory.uuid()}`)
|
||||
.send({ name: '' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['name'], message: 'Too small: expected string to have >=1 characters' },
|
||||
]),
|
||||
);
|
||||
expect(service.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a null name', async () => {
|
||||
const auth = factory.auth();
|
||||
const id = factory.uuid();
|
||||
ctx.authenticate.mockResolvedValue(auth);
|
||||
service.update.mockResolvedValue({} as any);
|
||||
const { status } = await request(ctx.getHttpServer()).patch(`/passkeys/${id}`).send({ name: null });
|
||||
expect(status).toEqual(200);
|
||||
expect(service.update).toHaveBeenCalledWith(auth, id, { name: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /passkeys/:id', () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete('/passkeys/123');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { LoginDetails } from 'src/services/auth.service.js';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators.js';
|
||||
import { type AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
|
||||
import {
|
||||
PasskeyAuthenticationFinishDto,
|
||||
PasskeyAuthenticationOptionsDto,
|
||||
PasskeyRegistrationFinishDto,
|
||||
PasskeyRegistrationOptionsDto,
|
||||
PasskeyResponseDto,
|
||||
PasskeySearchDto,
|
||||
PasskeyUpdateDto,
|
||||
} from 'src/dtos/passkey.dto.js';
|
||||
import { ApiTag, AuthType, ImmichCookie, Permission } from 'src/enum.js';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard.js';
|
||||
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||
import { respondWithCookie } from 'src/utils/response.js';
|
||||
import { UUIDParamDto } from 'src/validation.js';
|
||||
|
||||
@ApiTags(ApiTag.Passkeys)
|
||||
@Controller('passkeys')
|
||||
export class PasskeyController {
|
||||
constructor(private service: PasskeyService) {}
|
||||
|
||||
@Post('registration/start')
|
||||
@Authenticated({ permission: false })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Start passkey registration',
|
||||
description: 'Generate the options used by the browser to create a new passkey for the current user.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
startRegistration(@Req() request: Request, @Auth() auth: AuthDto): Promise<PasskeyRegistrationOptionsDto> {
|
||||
return this.service.startRegistration(auth, request.headers);
|
||||
}
|
||||
|
||||
@Post('registration/finish')
|
||||
@Authenticated({ permission: false })
|
||||
@Endpoint({
|
||||
summary: 'Finish passkey registration',
|
||||
description: 'Verify the response from the authenticator and save the new passkey for the current user.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
finishRegistration(
|
||||
@Req() request: Request,
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: PasskeyRegistrationFinishDto,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<PasskeyResponseDto> {
|
||||
return this.service.finishRegistration(auth, dto, request.headers, loginDetails);
|
||||
}
|
||||
|
||||
@Post('authentication/start')
|
||||
@Authenticated({ public: true })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Start passkey authentication',
|
||||
description: 'Generate the options used by the browser to sign in with a passkey.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
startAuthentication(@Req() request: Request): Promise<PasskeyAuthenticationOptionsDto> {
|
||||
return this.service.startAuthentication(request.headers);
|
||||
}
|
||||
|
||||
@Post('authentication/finish')
|
||||
@Authenticated({ public: true })
|
||||
@Endpoint({
|
||||
summary: 'Finish passkey authentication',
|
||||
description: 'Verify the response from the authenticator and receive a session token.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
async finishAuthentication(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body() dto: PasskeyAuthenticationFinishDto,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const body = await this.service.finishAuthentication(dto, request.headers, loginDetails);
|
||||
return respondWithCookie(res, body, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [
|
||||
{ key: ImmichCookie.AccessToken, value: body.accessToken },
|
||||
{ key: ImmichCookie.AuthType, value: AuthType.Passkey },
|
||||
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.PasskeyRead })
|
||||
@Endpoint({
|
||||
summary: 'Search passkeys',
|
||||
description: 'Search the passkeys of the current user.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
searchPasskeys(@Auth() auth: AuthDto, @Query() dto: PasskeySearchDto): Promise<PasskeyResponseDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.PasskeyRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a passkey',
|
||||
description: 'Retrieve a passkey by its ID. The current user must own this passkey.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
getPasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PasskeyResponseDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Authenticated({ permission: Permission.PasskeyUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Update a passkey',
|
||||
description: 'Update the name of a passkey by its ID. The current user must own this passkey.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
updatePasskey(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: PasskeyUpdateDto,
|
||||
): Promise<PasskeyResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ permission: Permission.PasskeyDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete a passkey',
|
||||
description: 'Delete a passkey by its ID. The current user must own this passkey.',
|
||||
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||
})
|
||||
deletePasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -17,6 +17,7 @@ const PUBLIC_PROPERTIES = [
|
||||
'oauth.autoLaunch',
|
||||
'oauth.buttonText',
|
||||
'oauth.enabled',
|
||||
'passkey.enabled',
|
||||
'passwordLogin.enabled',
|
||||
'server.loginPageMessage',
|
||||
];
|
||||
@@ -75,3 +76,45 @@ describe('config visibility', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const parsePasskey = (passkey: Record<string, unknown>) => AdminConfigDto.schema.safeParse({ ...defaults, passkey });
|
||||
const passkeyMessages = (passkey: Record<string, unknown>) =>
|
||||
parsePasskey(passkey).error?.issues.map((issue) => issue.message);
|
||||
|
||||
describe('passkey config', () => {
|
||||
it('should allow enabling without a domain', () => {
|
||||
expect(parsePasskey({ enabled: true, domain: null, additionalDomains: [] }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject domains that are not https', () => {
|
||||
expect(passkeyMessages({ enabled: true, domain: 'ftp://immich.example', additionalDomains: [] })).toEqual([
|
||||
'Invalid URL',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reject domains with a path', () => {
|
||||
expect(
|
||||
passkeyMessages({ enabled: true, domain: null, additionalDomains: ['https://immich.tailnet.example/photos'] }),
|
||||
).toEqual(['Additional domain must not include a path, query, or fragment']);
|
||||
});
|
||||
|
||||
it('should reject duplicate additional domains', () => {
|
||||
expect(
|
||||
passkeyMessages({
|
||||
enabled: true,
|
||||
domain: null,
|
||||
additionalDomains: ['https://immich.tailnet.example', 'https://immich.tailnet.example/'],
|
||||
}),
|
||||
).toEqual(['Duplicate domain']);
|
||||
});
|
||||
|
||||
it('should reject the passkey domain as an additional domain', () => {
|
||||
expect(
|
||||
passkeyMessages({
|
||||
enabled: true,
|
||||
domain: 'https://immich.example',
|
||||
additionalDomains: ['https://immich.example/'],
|
||||
}),
|
||||
).toEqual(['The passkey domain is always allowed and must not be listed as an additional domain']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,18 @@ const cronExpressionSchema = z
|
||||
})
|
||||
.describe('Cron expression');
|
||||
|
||||
const httpsDomain = (label: string) =>
|
||||
z
|
||||
.url({ protocol: /^https$/, hostname: z.regexes.domain })
|
||||
.normalize()
|
||||
.refine((url) => {
|
||||
try {
|
||||
return new URL(url).href === `${new URL(url).origin}/`;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}, `${label} must not include a path, query, or fragment`);
|
||||
|
||||
const emptyOrUrl = (error: string) =>
|
||||
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
|
||||
|
||||
@@ -322,6 +334,29 @@ const AdminConfigSchemaWithVisibility = z
|
||||
return value;
|
||||
})
|
||||
.meta({ id: 'AdminConfigOAuthDto' }),
|
||||
passkey: z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled').meta({ visibility: Public }),
|
||||
domain: httpsDomain('Passkey domain').nullable().describe('Passkey domain'),
|
||||
additionalDomains: z
|
||||
.array(httpsDomain('Additional domain'))
|
||||
.refine(
|
||||
(domains) => domains.length === new Set(domains.map((domain) => new URL(domain).origin)).size,
|
||||
'Duplicate domain',
|
||||
)
|
||||
.describe('Additional domains'),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const origin = value.domain && new URL(value.domain).origin;
|
||||
if (origin && value.additionalDomains.some((domain) => new URL(domain).origin === origin)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'The passkey domain is always allowed and must not be listed as an additional domain',
|
||||
input: value.additionalDomains,
|
||||
});
|
||||
}
|
||||
})
|
||||
.meta({ id: 'AdminConfigPasskeyDto' }),
|
||||
passwordLogin: z
|
||||
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
|
||||
.meta({ id: 'AdminConfigPasswordLoginDto' }),
|
||||
@@ -469,7 +504,12 @@ const applyVisibilityRecursive = (
|
||||
|
||||
let visible: z.ZodType | undefined;
|
||||
if (Object.keys(shape).length > 0) {
|
||||
visible = z.object(shape).meta({
|
||||
const isComplete = Object.keys(shape).length === Object.keys(object.shape).length;
|
||||
const rebuilt =
|
||||
schema instanceof z.ZodPipe && isComplete
|
||||
? z.object(shape).pipe(schema.def.out as z.ZodTransform<unknown, Record<string, unknown>>)
|
||||
: z.object(shape);
|
||||
visible = rebuilt.meta({
|
||||
...(id && { id: `${visibility}${id.slice(Admin.length)}` }),
|
||||
...((override ?? description) && { description: override ?? description }),
|
||||
});
|
||||
@@ -498,7 +538,7 @@ const stripVisibilityMetadata = <T extends z.ZodType>(schema: T): T => {
|
||||
return schema;
|
||||
};
|
||||
|
||||
const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType<SystemConfig>;
|
||||
const AdminConfigSchema = AdminConfigSchemaWithVisibility;
|
||||
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||
|
||||
@@ -682,6 +722,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
timeout: 30_000,
|
||||
allowInsecureRequests: false,
|
||||
},
|
||||
passkey: {
|
||||
enabled: true,
|
||||
domain: null,
|
||||
additionalDomains: [],
|
||||
},
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -320,6 +320,15 @@ where
|
||||
"partner"."sharedById" in ($1)
|
||||
and "partner"."sharedWithId" = $2
|
||||
|
||||
-- AccessRepository.passkey.checkOwnerAccess
|
||||
select
|
||||
"passkey"."id"
|
||||
from
|
||||
"passkey"
|
||||
where
|
||||
"passkey"."id" in ($1)
|
||||
and "passkey"."userId" = $2
|
||||
|
||||
-- AccessRepository.session.checkOwnerAccess
|
||||
select
|
||||
"session"."id"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AuthChallengeRepository.consume
|
||||
delete from "auth_challenge"
|
||||
where
|
||||
"challenge" = $1
|
||||
and "type" = $2
|
||||
and "expiresAt" > $3
|
||||
returning
|
||||
"id",
|
||||
"userId"
|
||||
|
||||
-- AuthChallengeRepository.deleteExpired
|
||||
delete from "auth_challenge"
|
||||
where
|
||||
"expiresAt" <= $1
|
||||
@@ -0,0 +1,46 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- PasskeyRepository.delete
|
||||
delete from "passkey"
|
||||
where
|
||||
"id" = $1::uuid
|
||||
|
||||
-- PasskeyRepository.getByCredentialId
|
||||
select
|
||||
*
|
||||
from
|
||||
"passkey"
|
||||
where
|
||||
"credentialId" = $1
|
||||
|
||||
-- PasskeyRepository.get
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"userId",
|
||||
"credentialId",
|
||||
"transports",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"usedAt"
|
||||
from
|
||||
"passkey"
|
||||
where
|
||||
"id" = $1::uuid
|
||||
|
||||
-- PasskeyRepository.search
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"userId",
|
||||
"credentialId",
|
||||
"transports",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"usedAt"
|
||||
from
|
||||
"passkey"
|
||||
where
|
||||
"userId" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
@@ -528,6 +528,26 @@ class ClusterGroupRequestAccess {
|
||||
}
|
||||
}
|
||||
|
||||
class PasskeyAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, passkeyIds: Set<string>) {
|
||||
if (passkeyIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return this.db
|
||||
.selectFrom('passkey')
|
||||
.select('passkey.id')
|
||||
.where('passkey.id', 'in', [...passkeyIds])
|
||||
.where('passkey.userId', '=', userId)
|
||||
.execute()
|
||||
.then((passkeys) => new Set(passkeys.map((passkey) => passkey.id)));
|
||||
}
|
||||
}
|
||||
|
||||
class PersonAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@@ -673,6 +693,7 @@ export class AccessRepository {
|
||||
clusterGroupRequest: ClusterGroupRequestAccess;
|
||||
person: PersonAccess;
|
||||
partner: PartnerAccess;
|
||||
passkey: PasskeyAccess;
|
||||
session: SessionAccess;
|
||||
stack: StackAccess;
|
||||
tag: TagAccess;
|
||||
@@ -692,6 +713,7 @@ export class AccessRepository {
|
||||
this.clusterGroupRequest = new ClusterGroupRequestAccess(db);
|
||||
this.person = new PersonAccess(db);
|
||||
this.partner = new PartnerAccess(db);
|
||||
this.passkey = new PasskeyAccess(db);
|
||||
this.session = new SessionAccess(db);
|
||||
this.stack = new StackAccess(db);
|
||||
this.tag = new TagAccess(db);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import type { Insertable, Kysely } from 'kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators.js';
|
||||
import { AuthChallengeType } from 'src/enum.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthChallengeRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
create(dto: Insertable<AuthChallengeTable>) {
|
||||
return this.db.insertInto('auth_challenge').values(dto).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ type: AuthChallengeType.PasskeyRegistration, challenge: DummyValue.STRING }] })
|
||||
consume({ type, challenge }: { type: AuthChallengeType; challenge: string }) {
|
||||
return this.db
|
||||
.deleteFrom('auth_challenge')
|
||||
.where('challenge', '=', challenge)
|
||||
.where('type', '=', type)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.returning(['id', 'userId'])
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
async deleteExpired() {
|
||||
const result = await this.db.deleteFrom('auth_challenge').where('expiresAt', '<=', new Date()).executeTakeFirst();
|
||||
return Number(result.numDeletedRows);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
|
||||
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository.js';
|
||||
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
||||
import { CronRepository } from 'src/repositories/cron.repository.js';
|
||||
@@ -31,6 +32,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
|
||||
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository.js';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository.js';
|
||||
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
|
||||
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
|
||||
import { PersonRepository } from 'src/repositories/person.repository.js';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository.js';
|
||||
@@ -52,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository.js';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository.js';
|
||||
import { ViewRepository } from 'src/repositories/view-repository.js';
|
||||
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
|
||||
|
||||
@@ -66,6 +69,7 @@ export const repositories = [
|
||||
AssetEditRepository,
|
||||
AssetFileRepository,
|
||||
AssetJobRepository,
|
||||
AuthChallengeRepository,
|
||||
ConfigRepository,
|
||||
CronRepository,
|
||||
CryptoRepository,
|
||||
@@ -89,6 +93,7 @@ export const repositories = [
|
||||
OcrRepository,
|
||||
ClusterGroupRepository,
|
||||
PartnerRepository,
|
||||
PasskeyRepository,
|
||||
PersonRepository,
|
||||
PersonUserRepository,
|
||||
PluginRepository,
|
||||
@@ -110,6 +115,7 @@ export const repositories = [
|
||||
ViewRepository,
|
||||
VersionHistoryRepository,
|
||||
VideoStreamRepository,
|
||||
WebAuthnRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import type { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { columns } from 'src/database.js';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
|
||||
import { asUuid } from 'src/utils/database.js';
|
||||
|
||||
export type PasskeySearchOptions = {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PasskeyRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
create(dto: Insertable<PasskeyTable>) {
|
||||
return this.db.insertInto('passkey').values(dto).returning(columns.passkey).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
update(id: string, dto: Updateable<PasskeyTable>) {
|
||||
return this.db
|
||||
.updateTable('passkey')
|
||||
.set(dto)
|
||||
.where('id', '=', asUuid(id))
|
||||
.returning(columns.passkey)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async delete(id: string) {
|
||||
await this.db.deleteFrom('passkey').where('id', '=', asUuid(id)).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByCredentialId(credentialId: string) {
|
||||
return this.db.selectFrom('passkey').selectAll().where('credentialId', '=', credentialId).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
get(id: string) {
|
||||
return this.db.selectFrom('passkey').select(columns.passkey).where('id', '=', asUuid(id)).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID }] })
|
||||
search(options: PasskeySearchOptions) {
|
||||
return this.db
|
||||
.selectFrom('passkey')
|
||||
.select(columns.passkey)
|
||||
.$if(!!options.id, (qb) => qb.where('id', '=', asUuid(options.id!)))
|
||||
.$if(!!options.userId, (qb) => qb.where('userId', '=', options.userId!))
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
type GenerateAuthenticationOptionsOpts,
|
||||
type GenerateRegistrationOptionsOpts,
|
||||
type VerifyAuthenticationResponseOpts,
|
||||
type VerifyRegistrationResponseOpts,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
@Injectable()
|
||||
export class WebAuthnRepository {
|
||||
generateRegistrationOptions(options: GenerateRegistrationOptionsOpts) {
|
||||
return generateRegistrationOptions(options);
|
||||
}
|
||||
|
||||
verifyRegistrationResponse(options: VerifyRegistrationResponseOpts) {
|
||||
return verifyRegistrationResponse(options);
|
||||
}
|
||||
|
||||
generateAuthenticationOptions(options: GenerateAuthenticationOptionsOpts) {
|
||||
return generateAuthenticationOptions(options);
|
||||
}
|
||||
|
||||
verifyAuthenticationResponse(options: VerifyAuthenticationResponseOpts) {
|
||||
return verifyAuthenticationResponse(options);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js';
|
||||
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table.js';
|
||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table.js';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table.js';
|
||||
import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
|
||||
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table.js';
|
||||
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
|
||||
@@ -66,6 +67,7 @@ import { NotificationTable } from 'src/schema/tables/notification.table.js';
|
||||
import { OcrSearchTable } from 'src/schema/tables/ocr-search.table.js';
|
||||
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table.js';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table.js';
|
||||
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
|
||||
import { PersonAuditTable } from 'src/schema/tables/person-audit.table.js';
|
||||
import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table.js';
|
||||
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
|
||||
@@ -121,6 +123,7 @@ export class ImmichDatabase {
|
||||
AssetOcrTable,
|
||||
AssetOcrAuditTable,
|
||||
AssetTable,
|
||||
AuthChallengeTable,
|
||||
AssetFileTable,
|
||||
AssetExifTable,
|
||||
ClusterGroupTable,
|
||||
@@ -139,6 +142,7 @@ export class ImmichDatabase {
|
||||
OcrSearchTable,
|
||||
PartnerAuditTable,
|
||||
PartnerTable,
|
||||
PasskeyTable,
|
||||
PersonTable,
|
||||
PersonAuditTable,
|
||||
PersonUserTable,
|
||||
@@ -223,6 +227,7 @@ export interface DB {
|
||||
|
||||
asset: AssetTable;
|
||||
asset_audit: AssetAuditTable;
|
||||
auth_challenge: AuthChallengeTable;
|
||||
asset_edit: AssetEditTable;
|
||||
asset_edit_audit: AssetEditAuditTable;
|
||||
asset_exif: AssetExifTable;
|
||||
@@ -263,6 +268,8 @@ export interface DB {
|
||||
partner: PartnerTable;
|
||||
partner_audit: PartnerAuditTable;
|
||||
|
||||
passkey: PasskeyTable;
|
||||
|
||||
person: PersonTable;
|
||||
person_audit: PersonAuditTable;
|
||||
person_user: PersonUserTable;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE TABLE "passkey" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" character varying,
|
||||
"credentialId" character varying NOT NULL,
|
||||
"publicKey" bytea NOT NULL,
|
||||
"counter" bigint NOT NULL DEFAULT 0,
|
||||
"transports" character varying[] NOT NULL,
|
||||
"backedUp" boolean NOT NULL DEFAULT false,
|
||||
"deviceType" character varying NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"usedAt" timestamp with time zone,
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "passkey_credentialId_uq" UNIQUE ("credentialId"),
|
||||
CONSTRAINT "passkey_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "passkey_userId_idx" ON "passkey" ("userId");`.execute(db);
|
||||
await sql`CREATE TABLE "auth_challenge" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"type" character varying NOT NULL,
|
||||
"challenge" character varying NOT NULL,
|
||||
"userId" uuid,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"expiresAt" timestamp with time zone NOT NULL,
|
||||
CONSTRAINT "auth_challenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "auth_challenge_challenge_uq" UNIQUE ("challenge"),
|
||||
CONSTRAINT "auth_challenge_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "auth_challenge_userId_idx" ON "auth_challenge" ("userId");`.execute(db);
|
||||
await sql`CREATE INDEX "passkey_updateId_idx" ON "passkey" ("updateId");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "passkey_updatedAt"
|
||||
BEFORE UPDATE ON "passkey"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_passkey_updatedAt', '{"type":"trigger","name":"passkey_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"passkey_updatedAt\\"\\n BEFORE UPDATE ON \\"passkey\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TRIGGER "passkey_updatedAt" ON "passkey";`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_passkey_updatedAt';`.execute(db);
|
||||
await sql`DROP TABLE "passkey";`.execute(db);
|
||||
await sql`DROP TABLE "auth_challenge";`.execute(db);
|
||||
}
|
||||
@@ -99,3 +99,4 @@
|
||||
1790616293884-PersonSharing
|
||||
1790693088454-AddPersonUserTableSharedBySharedWithConstraint
|
||||
1792213725597-AddAssetFaceAssetState
|
||||
1792511451454-AddPasskeyTable
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
type Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
type Timestamp,
|
||||
} from '@immich/sql-tools';
|
||||
import { AuthChallengeType } from 'src/enum.js';
|
||||
import { UserTable } from 'src/schema/tables/user.table.js';
|
||||
|
||||
@Table('auth_challenge')
|
||||
export class AuthChallengeTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'character varying' })
|
||||
type!: AuthChallengeType;
|
||||
|
||||
@Column({ unique: true })
|
||||
challenge!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
|
||||
userId!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@Column({ type: 'timestamp with time zone' })
|
||||
expiresAt!: Timestamp;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
type Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { ColumnType } from 'kysely';
|
||||
import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js';
|
||||
import { UserTable } from 'src/schema/tables/user.table.js';
|
||||
|
||||
@Table('passkey')
|
||||
@UpdatedAtTrigger('passkey_updatedAt')
|
||||
export class PasskeyTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Column({ unique: true })
|
||||
credentialId!: string;
|
||||
|
||||
@Column({ type: 'bytea' })
|
||||
publicKey!: Buffer;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
counter!: Generated<ColumnType<number>>;
|
||||
|
||||
@Column({ array: true, type: 'character varying' })
|
||||
transports!: string[];
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
backedUp!: Generated<boolean>;
|
||||
|
||||
@Column()
|
||||
deviceType!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
||||
userId!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
usedAt!: Timestamp | null;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
PinCodeSetupDto,
|
||||
SessionUnlockDto,
|
||||
SignUpDto,
|
||||
mapLoginResponse,
|
||||
} from 'src/dtos/auth.dto.js';
|
||||
import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js';
|
||||
@@ -612,28 +611,6 @@ export class AuthService extends BaseService {
|
||||
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
||||
}
|
||||
|
||||
private async createLoginResponse(
|
||||
user: UserAdmin,
|
||||
loginDetails: LoginDetails,
|
||||
oauthSid?: string,
|
||||
oauthBearerToken?: string,
|
||||
) {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
await this.sessionRepository.create({
|
||||
token: hashed,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
appVersion: loginDetails.appVersion,
|
||||
userId: user.id,
|
||||
oauthSid: oauthSid ?? null,
|
||||
oauthBearerToken: oauthBearerToken ?? null,
|
||||
});
|
||||
|
||||
return mapLoginResponse(user, token);
|
||||
}
|
||||
|
||||
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T | undefined {
|
||||
const raw = profile[options.key as keyof OAuthProfile];
|
||||
const value = options.parse ? options.parse(raw) : raw;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { VerifiedAuthenticationResponse, VerifiedRegistrationResponse } from '@simplewebauthn/server';
|
||||
import { InsertResult } from 'kysely';
|
||||
import { AuthChallengeType, JobStatus } from 'src/enum.js';
|
||||
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||
import { AuthFactory } from 'test/factories/auth.factory.js';
|
||||
import { PasskeyFactory } from 'test/factories/passkey.factory.js';
|
||||
import { SessionFactory } from 'test/factories/session.factory.js';
|
||||
import { UserFactory } from 'test/factories/user.factory.js';
|
||||
import { newUuid } from 'test/small.factory.js';
|
||||
import { ServiceMocks, newTestService } from 'test/utils.js';
|
||||
|
||||
const loginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
const headers = { origin: 'https://immich.example' };
|
||||
|
||||
const registrationOptions = {
|
||||
rp: { id: 'immich.example', name: 'Immich' },
|
||||
user: { id: 'user-handle', name: 'user@immich.cloud', displayName: 'User' },
|
||||
challenge: 'challenge',
|
||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' as const }],
|
||||
};
|
||||
|
||||
const authenticationOptions = {
|
||||
challenge: 'challenge',
|
||||
rpId: 'immich.example',
|
||||
};
|
||||
|
||||
const registrationResponse = {
|
||||
id: 'credential-id',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key' as const,
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: 'client-data',
|
||||
attestationObject: 'attestation-object',
|
||||
transports: ['internal'],
|
||||
},
|
||||
};
|
||||
|
||||
const registrationResult: VerifiedRegistrationResponse = {
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
fmt: 'none',
|
||||
aaguid: '00000000-0000-0000-0000-000000000000',
|
||||
credential: {
|
||||
id: 'credential-id',
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
counter: 0,
|
||||
transports: ['internal'],
|
||||
},
|
||||
credentialType: 'public-key',
|
||||
attestationObject: new Uint8Array(),
|
||||
userVerified: true,
|
||||
credentialDeviceType: 'multiDevice',
|
||||
credentialBackedUp: true,
|
||||
origin: 'https://immich.example',
|
||||
},
|
||||
};
|
||||
|
||||
const authenticationResult: VerifiedAuthenticationResponse = {
|
||||
verified: true,
|
||||
authenticationInfo: {
|
||||
credentialID: 'credential-id',
|
||||
newCounter: 5,
|
||||
userVerified: true,
|
||||
credentialDeviceType: 'multiDevice',
|
||||
credentialBackedUp: true,
|
||||
origin: 'https://immich.example',
|
||||
rpID: 'immich.example',
|
||||
},
|
||||
};
|
||||
|
||||
const newAuthenticationResponse = (userId?: string) => ({
|
||||
id: 'credential-id',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key' as const,
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: 'client-data',
|
||||
authenticatorData: 'authenticator-data',
|
||||
signature: 'signature',
|
||||
userHandle: userId ? Buffer.from(userId).toString('base64url') : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
describe(PasskeyService.name, () => {
|
||||
let sut: PasskeyService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(PasskeyService));
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
passkey: { enabled: true, domain: 'https://immich.example', additionalDomains: [] },
|
||||
});
|
||||
mocks.authChallenge.create.mockResolvedValue(new InsertResult(void 0, 1n));
|
||||
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: null });
|
||||
});
|
||||
|
||||
describe('handleCleanup', () => {
|
||||
it('should delete expired challenges', async () => {
|
||||
mocks.authChallenge.deleteExpired.mockResolvedValue(3);
|
||||
|
||||
await expect(sut.handleCleanup()).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.authChallenge.deleteExpired).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startRegistration', () => {
|
||||
it('should require a session', async () => {
|
||||
await expect(sut.startRegistration(AuthFactory.create(), headers)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if passkeys are disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
|
||||
|
||||
await expect(sut.startRegistration(AuthFactory.from().session().build(), headers)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use the android origins when the origin header is missing', async () => {
|
||||
mocks.passkey.search.mockResolvedValue([]);
|
||||
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
|
||||
|
||||
await expect(sut.startRegistration(AuthFactory.from().session().build(), {})).resolves.toEqual(
|
||||
registrationOptions,
|
||||
);
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rpID: 'my.immich.app' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the origin hostname when no relying party ID is configured', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: true, domain: null, additionalDomains: [] } });
|
||||
mocks.authChallenge.create.mockResolvedValue(new InsertResult(void 0, 1n));
|
||||
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: null });
|
||||
mocks.passkey.search.mockResolvedValue([]);
|
||||
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
|
||||
|
||||
await expect(
|
||||
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://photos.immich.example' }),
|
||||
).resolves.toEqual(registrationOptions);
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rpID: 'photos.immich.example' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid origin header', async () => {
|
||||
await expect(
|
||||
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'not a url' }),
|
||||
).rejects.toThrow('Origin header is invalid');
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an origin that is not trusted', async () => {
|
||||
await expect(
|
||||
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://evil.example' }),
|
||||
).rejects.toThrow('Origin is not trusted');
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a subdomain of the relying party that is not listed', async () => {
|
||||
await expect(
|
||||
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://photos.immich.example' }),
|
||||
).rejects.toThrow('Origin is not trusted');
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept the relying party on any port', async () => {
|
||||
mocks.passkey.search.mockResolvedValue([]);
|
||||
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
|
||||
|
||||
await expect(
|
||||
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://immich.example:2283' }),
|
||||
).resolves.toEqual(registrationOptions);
|
||||
});
|
||||
|
||||
it('should accept a trusted origin', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
passkey: {
|
||||
enabled: true,
|
||||
domain: 'https://immich.example',
|
||||
additionalDomains: ['https://immich.tailnet.example/'],
|
||||
},
|
||||
});
|
||||
mocks.passkey.search.mockResolvedValue([]);
|
||||
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
|
||||
|
||||
await expect(
|
||||
sut.startRegistration(AuthFactory.from().session().build(), { origin: 'https://immich.tailnet.example' }),
|
||||
).resolves.toEqual(registrationOptions);
|
||||
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rpID: 'immich.example' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate registration options', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.passkey.search.mockResolvedValue([passkey]);
|
||||
mocks.webAuthn.generateRegistrationOptions.mockResolvedValue(registrationOptions);
|
||||
|
||||
await expect(sut.startRegistration(auth, headers)).resolves.toEqual(registrationOptions);
|
||||
|
||||
expect(mocks.passkey.search).toHaveBeenCalledWith({ userId: auth.user.id });
|
||||
expect(mocks.authChallenge.create).toHaveBeenCalledWith({
|
||||
type: AuthChallengeType.PasskeyRegistration,
|
||||
challenge: registrationOptions.challenge,
|
||||
userId: auth.user.id,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(mocks.webAuthn.generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rpID: 'immich.example',
|
||||
userName: auth.user.email,
|
||||
userDisplayName: auth.user.name,
|
||||
excludeCredentials: [{ id: passkey.credentialId, transports: passkey.transports }],
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishRegistration', () => {
|
||||
it('should require a session', async () => {
|
||||
await expect(
|
||||
sut.finishRegistration(AuthFactory.create(), { response: registrationResponse }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.webAuthn.verifyRegistrationResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if passkeys are disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
|
||||
|
||||
await expect(
|
||||
sut.finishRegistration(
|
||||
AuthFactory.from().session().build(),
|
||||
{ response: registrationResponse },
|
||||
headers,
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject an invalid response', async () => {
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockRejectedValue(new Error('Invalid signature'));
|
||||
|
||||
await expect(
|
||||
sut.finishRegistration(
|
||||
AuthFactory.from().session().build(),
|
||||
{ response: registrationResponse },
|
||||
headers,
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.passkey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an unverified response', async () => {
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue({ verified: false });
|
||||
|
||||
await expect(
|
||||
sut.finishRegistration(
|
||||
AuthFactory.from().session().build(),
|
||||
{ response: registrationResponse },
|
||||
headers,
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.passkey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a credential that is already registered', async () => {
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.finishRegistration(
|
||||
AuthFactory.from().session().build(),
|
||||
{ response: registrationResponse },
|
||||
headers,
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.passkey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should verify against the android app origins when the origin header is missing', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(passkey);
|
||||
|
||||
await expect(sut.finishRegistration(auth, { response: registrationResponse }, {}, loginDetails)).resolves.toEqual(
|
||||
expect.objectContaining({ id: passkey.id }),
|
||||
);
|
||||
|
||||
expect(mocks.webAuthn.verifyRegistrationResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedOrigin: [
|
||||
'android:apk-key-hash:hsXEVd-vSYWSOo81rbMdDJ4LlX1_lMLSr2okOKqWACA',
|
||||
'android:apk-key-hash:WiLBg0dUBfVJxOufsmwuk6PvnFdmFQp684yNP-UVzNY',
|
||||
],
|
||||
expectedRPID: 'my.immich.app',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should consume a challenge issued to the user', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
|
||||
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: auth.user.id });
|
||||
|
||||
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
|
||||
|
||||
const { expectedChallenge } = mocks.webAuthn.verifyRegistrationResponse.mock.calls[0][0];
|
||||
await expect((expectedChallenge as (challenge: string) => Promise<boolean>)('challenge')).resolves.toBe(true);
|
||||
expect(mocks.authChallenge.consume).toHaveBeenCalledWith({
|
||||
type: AuthChallengeType.PasskeyRegistration,
|
||||
challenge: 'challenge',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a challenge issued to another user', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
|
||||
mocks.authChallenge.consume.mockResolvedValue({ id: newUuid(), userId: newUuid() });
|
||||
|
||||
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
|
||||
|
||||
const { expectedChallenge } = mocks.webAuthn.verifyRegistrationResponse.mock.calls[0][0];
|
||||
await expect((expectedChallenge as (challenge: string) => Promise<boolean>)('challenge')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an unknown challenge', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
|
||||
mocks.authChallenge.consume.mockResolvedValue(void 0);
|
||||
|
||||
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
|
||||
|
||||
const { expectedChallenge } = mocks.webAuthn.verifyRegistrationResponse.mock.calls[0][0];
|
||||
await expect((expectedChallenge as (challenge: string) => Promise<boolean>)('challenge')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should create a passkey', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id, name: 'My passkey' });
|
||||
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(passkey);
|
||||
|
||||
await expect(
|
||||
sut.finishRegistration(auth, { name: 'My passkey', response: registrationResponse }, headers, loginDetails),
|
||||
).resolves.toEqual({
|
||||
id: passkey.id,
|
||||
name: passkey.name,
|
||||
createdAt: passkey.createdAt,
|
||||
updatedAt: passkey.updatedAt,
|
||||
usedAt: passkey.usedAt,
|
||||
});
|
||||
|
||||
expect(mocks.webAuthn.verifyRegistrationResponse).toHaveBeenCalledWith({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: expect.any(Function),
|
||||
expectedOrigin: 'https://immich.example',
|
||||
expectedRPID: 'immich.example',
|
||||
});
|
||||
expect(mocks.passkey.create).toHaveBeenCalledWith({
|
||||
userId: auth.user.id,
|
||||
name: 'My passkey',
|
||||
credentialId: 'credential-id',
|
||||
publicKey: Buffer.from([1, 2, 3]),
|
||||
counter: 0,
|
||||
transports: ['internal'],
|
||||
backedUp: true,
|
||||
deviceType: 'multiDevice',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a default name', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
|
||||
|
||||
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
|
||||
|
||||
expect(mocks.passkey.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Passkey' }));
|
||||
});
|
||||
|
||||
it('should name the passkey after a known authenticator', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue({
|
||||
...registrationResult,
|
||||
registrationInfo: { ...registrationResult.registrationInfo, aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd' },
|
||||
});
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
|
||||
|
||||
await sut.finishRegistration(auth, { response: registrationResponse }, headers, loginDetails);
|
||||
|
||||
expect(mocks.passkey.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Apple Passwords' }));
|
||||
});
|
||||
|
||||
it('should name the passkey after the device for an unknown authenticator', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
|
||||
mocks.webAuthn.verifyRegistrationResponse.mockResolvedValue(registrationResult);
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
mocks.passkey.create.mockResolvedValue(PasskeyFactory.create({ userId: auth.user.id }));
|
||||
|
||||
await sut.finishRegistration(auth, { response: registrationResponse }, headers, {
|
||||
...loginDetails,
|
||||
deviceType: 'Chrome',
|
||||
deviceOS: 'Linux',
|
||||
});
|
||||
|
||||
expect(mocks.passkey.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Chrome on Linux' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('startAuthentication', () => {
|
||||
it('should throw an error if passkeys are disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
|
||||
|
||||
await expect(sut.startAuthentication(headers)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should generate authentication options', async () => {
|
||||
mocks.webAuthn.generateAuthenticationOptions.mockResolvedValue(authenticationOptions);
|
||||
|
||||
await expect(sut.startAuthentication(headers)).resolves.toEqual(authenticationOptions);
|
||||
|
||||
expect(mocks.authChallenge.create).toHaveBeenCalledWith({
|
||||
type: AuthChallengeType.PasskeyAuthentication,
|
||||
challenge: authenticationOptions.challenge,
|
||||
userId: null,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(mocks.webAuthn.generateAuthenticationOptions).toHaveBeenCalledWith({
|
||||
rpID: 'immich.example',
|
||||
userVerification: 'required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishAuthentication', () => {
|
||||
it('should throw an error if passkeys are disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject an unknown credential', async () => {
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(void 0);
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.webAuthn.verifyAuthenticationResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a user handle that does not match the credential', async () => {
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse(newUuid()) }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.webAuthn.verifyAuthenticationResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invalid response', async () => {
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
|
||||
mocks.webAuthn.verifyAuthenticationResponse.mockRejectedValue(new Error('Invalid signature'));
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.session.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an unverified response', async () => {
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
|
||||
mocks.webAuthn.verifyAuthenticationResponse.mockResolvedValue({ ...authenticationResult, verified: false });
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.session.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a passkey of a deleted user', async () => {
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(PasskeyFactory.create());
|
||||
mocks.webAuthn.verifyAuthenticationResponse.mockResolvedValue(authenticationResult);
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse() }, headers, loginDetails),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.session.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log the user in', async () => {
|
||||
const user = UserFactory.create();
|
||||
const passkey = PasskeyFactory.create({ userId: user.id, counter: 4 });
|
||||
|
||||
mocks.passkey.getByCredentialId.mockResolvedValue(passkey);
|
||||
mocks.webAuthn.verifyAuthenticationResponse.mockResolvedValue(authenticationResult);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.passkey.update.mockResolvedValue(passkey);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create({ userId: user.id }));
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: newAuthenticationResponse(user.id) }, headers, loginDetails),
|
||||
).resolves.toEqual({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
name: user.name,
|
||||
profileImagePath: user.profileImagePath,
|
||||
isAdmin: user.isAdmin,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: user.shouldChangePassword,
|
||||
});
|
||||
|
||||
expect(mocks.webAuthn.verifyAuthenticationResponse).toHaveBeenCalledWith({
|
||||
response: newAuthenticationResponse(user.id),
|
||||
expectedChallenge: expect.any(Function),
|
||||
expectedOrigin: 'https://immich.example',
|
||||
expectedRPID: 'immich.example',
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: new Uint8Array(passkey.publicKey),
|
||||
counter: 4,
|
||||
transports: passkey.transports,
|
||||
},
|
||||
});
|
||||
expect(mocks.passkey.update).toHaveBeenCalledWith(passkey.id, { counter: 5, usedAt: expect.any(Date) });
|
||||
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ userId: user.id }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWellKnown', () => {
|
||||
it('should throw an error if passkeys are disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ passkey: { enabled: false } });
|
||||
|
||||
await expect(sut.getWellKnown()).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should return the relying party and trusted origins', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
passkey: {
|
||||
enabled: true,
|
||||
domain: 'https://immich.example/',
|
||||
additionalDomains: ['https://immich.tailnet.example/', 'https://photos.immich.example'],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sut.getWellKnown()).resolves.toEqual({
|
||||
origins: ['https://immich.example', 'https://immich.tailnet.example', 'https://photos.immich.example'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should search the passkeys of the current user', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.passkey.search.mockResolvedValue([passkey]);
|
||||
|
||||
await expect(sut.search(auth, { id: passkey.id })).resolves.toEqual([
|
||||
{
|
||||
id: passkey.id,
|
||||
name: passkey.name,
|
||||
createdAt: passkey.createdAt,
|
||||
updatedAt: passkey.updatedAt,
|
||||
usedAt: passkey.usedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mocks.passkey.search).toHaveBeenCalledWith({ id: passkey.id, userId: auth.user.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.get(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.passkey.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get a passkey', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.access.passkey.checkOwnerAccess.mockResolvedValue(new Set([passkey.id]));
|
||||
mocks.passkey.get.mockResolvedValue(passkey);
|
||||
|
||||
await expect(sut.get(auth, passkey.id)).resolves.toEqual(expect.objectContaining({ id: passkey.id }));
|
||||
|
||||
expect(mocks.access.passkey.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([passkey.id]));
|
||||
expect(mocks.passkey.get).toHaveBeenCalledWith(passkey.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.update(AuthFactory.create(), newUuid(), { name: 'New name' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a passkey', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.access.passkey.checkOwnerAccess.mockResolvedValue(new Set([passkey.id]));
|
||||
mocks.passkey.update.mockResolvedValue({ ...passkey, name: 'New name' });
|
||||
|
||||
await expect(sut.update(auth, passkey.id, { name: 'New name' })).resolves.toEqual(
|
||||
expect.objectContaining({ id: passkey.id, name: 'New name' }),
|
||||
);
|
||||
|
||||
expect(mocks.passkey.update).toHaveBeenCalledWith(passkey.id, { name: 'New name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.passkey.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete a passkey', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const passkey = PasskeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.access.passkey.checkOwnerAccess.mockResolvedValue(new Set([passkey.id]));
|
||||
mocks.passkey.delete.mockResolvedValue();
|
||||
|
||||
await sut.delete(auth, passkey.id);
|
||||
|
||||
expect(mocks.passkey.delete).toHaveBeenCalledWith(passkey.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import aaguids from 'aaguids';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
RegistrationResponseJSON,
|
||||
VerifiedAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { LoginDetails } from 'src/services/auth.service.js';
|
||||
import { OnJob } from 'src/decorators.js';
|
||||
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
|
||||
import { SystemConfig } from 'src/dtos/config.dto.js';
|
||||
import {
|
||||
PasskeyAuthenticationFinishDto,
|
||||
PasskeyAuthenticationOptionsDto,
|
||||
PasskeyRegistrationFinishDto,
|
||||
PasskeyRegistrationOptionsDto,
|
||||
PasskeyResponseDto,
|
||||
PasskeySearchDto,
|
||||
PasskeyUpdateDto,
|
||||
mapPasskey,
|
||||
} from 'src/dtos/passkey.dto.js';
|
||||
import { AuthChallengeType, JobName, JobStatus, Permission, QueueName } from 'src/enum.js';
|
||||
import { BaseService } from 'src/services/base.service.js';
|
||||
import { findOrFail } from 'src/utils/misc.js';
|
||||
|
||||
const RP_NAME = 'Immich';
|
||||
|
||||
const CHALLENGE_TTL = Duration.fromObject({ minutes: 5 });
|
||||
|
||||
const MOBILE_RP_ID = 'my.immich.app';
|
||||
|
||||
// keep in sync with https://my.immich.app/.well-known/assetlinks.json
|
||||
const ANDROID_CERTIFICATE_FINGERPRINTS = [
|
||||
'86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20',
|
||||
'5A:22:C1:83:47:54:05:F5:49:C4:EB:9F:B2:6C:2E:93:A3:EF:9C:57:66:15:0A:7A:F3:8C:8D:3F:E5:15:CC:D6',
|
||||
];
|
||||
|
||||
const ANDROID_ORIGINS = ANDROID_CERTIFICATE_FINGERPRINTS.map(
|
||||
(fingerprint) => `android:apk-key-hash:${Buffer.from(fingerprint.replaceAll(':', ''), 'hex').toString('base64url')}`,
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class PasskeyService extends BaseService {
|
||||
async startRegistration(auth: AuthDto, headers: IncomingHttpHeaders): Promise<PasskeyRegistrationOptionsDto> {
|
||||
this.requireSession(auth);
|
||||
const config = await this.requireEnabled();
|
||||
|
||||
const { rpId } = this.getRelyingParty(config, headers);
|
||||
const passkeys = await this.passkeyRepository.search({ userId: auth.user.id });
|
||||
|
||||
const options = await this.webAuthnRepository.generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(auth.user.id),
|
||||
userName: auth.user.email,
|
||||
userDisplayName: auth.user.name,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: passkeys.map(({ credentialId, transports }) => ({ id: credentialId, transports })),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||
});
|
||||
|
||||
await this.authChallengeRepository.create({
|
||||
type: AuthChallengeType.PasskeyRegistration,
|
||||
challenge: options.challenge,
|
||||
userId: auth.user.id,
|
||||
expiresAt: DateTime.now().plus(CHALLENGE_TTL).toJSDate(),
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async finishRegistration(
|
||||
auth: AuthDto,
|
||||
dto: PasskeyRegistrationFinishDto,
|
||||
headers: IncomingHttpHeaders,
|
||||
loginDetails: LoginDetails,
|
||||
): Promise<PasskeyResponseDto> {
|
||||
this.requireSession(auth);
|
||||
const config = await this.requireEnabled();
|
||||
|
||||
const { rpId, origin } = this.getRelyingParty(config, headers);
|
||||
|
||||
let result: VerifiedRegistrationResponse;
|
||||
try {
|
||||
result = await this.webAuthnRepository.verifyRegistrationResponse({
|
||||
response: dto.response as RegistrationResponseJSON,
|
||||
expectedChallenge: async (challenge) => {
|
||||
const record = await this.authChallengeRepository.consume({
|
||||
type: AuthChallengeType.PasskeyRegistration,
|
||||
challenge,
|
||||
});
|
||||
return record?.userId === auth.user.id;
|
||||
},
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Passkey registration failed for user ${auth.user.id}: ${error.message}`);
|
||||
throw new BadRequestException('Passkey registration failed');
|
||||
}
|
||||
|
||||
if (!result.verified) {
|
||||
throw new BadRequestException('Passkey registration failed');
|
||||
}
|
||||
|
||||
const { aaguid, credential, credentialBackedUp, credentialDeviceType } = result.registrationInfo;
|
||||
|
||||
const duplicate = await this.passkeyRepository.getByCredentialId(credential.id);
|
||||
if (duplicate) {
|
||||
throw new BadRequestException('Passkey is already registered');
|
||||
}
|
||||
const passkey = await this.passkeyRepository.create({
|
||||
userId: auth.user.id,
|
||||
name:
|
||||
dto.name ||
|
||||
(aaguids as unknown as Record<string, string>)[aaguid] ||
|
||||
[loginDetails.deviceType, loginDetails.deviceOS].filter(Boolean).join(' on ') ||
|
||||
'Passkey',
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: credential.transports ?? [],
|
||||
backedUp: credentialBackedUp,
|
||||
deviceType: credentialDeviceType,
|
||||
});
|
||||
|
||||
return mapPasskey(passkey);
|
||||
}
|
||||
|
||||
async startAuthentication(headers: IncomingHttpHeaders): Promise<PasskeyAuthenticationOptionsDto> {
|
||||
const config = await this.requireEnabled();
|
||||
|
||||
const { rpId } = this.getRelyingParty(config, headers);
|
||||
|
||||
const options = await this.webAuthnRepository.generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
userVerification: 'required',
|
||||
});
|
||||
|
||||
await this.authChallengeRepository.create({
|
||||
type: AuthChallengeType.PasskeyAuthentication,
|
||||
challenge: options.challenge,
|
||||
userId: null,
|
||||
expiresAt: DateTime.now().plus(CHALLENGE_TTL).toJSDate(),
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async finishAuthentication(
|
||||
dto: PasskeyAuthenticationFinishDto,
|
||||
headers: IncomingHttpHeaders,
|
||||
loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const config = await this.requireEnabled();
|
||||
|
||||
const { rpId, origin } = this.getRelyingParty(config, headers);
|
||||
|
||||
const passkey = await this.passkeyRepository.getByCredentialId(dto.response.id);
|
||||
const userHandle = dto.response.response.userHandle;
|
||||
const userId = userHandle ? Buffer.from(userHandle, 'base64url').toString() : undefined;
|
||||
if (!passkey || (userId && userId !== passkey.userId)) {
|
||||
this.logger.warn(`Failed passkey login attempt from ip address ${loginDetails.clientIp}`);
|
||||
throw new UnauthorizedException('Invalid passkey');
|
||||
}
|
||||
|
||||
let result: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
result = await this.webAuthnRepository.verifyAuthenticationResponse({
|
||||
response: dto.response as unknown as AuthenticationResponseJSON,
|
||||
expectedChallenge: async (challenge) => {
|
||||
const record = await this.authChallengeRepository.consume({
|
||||
type: AuthChallengeType.PasskeyAuthentication,
|
||||
challenge,
|
||||
});
|
||||
return !!record;
|
||||
},
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: new Uint8Array(passkey.publicKey),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed passkey login attempt from ip address ${loginDetails.clientIp}: ${error.message}`);
|
||||
throw new UnauthorizedException('Invalid passkey');
|
||||
}
|
||||
|
||||
if (!result.verified) {
|
||||
throw new UnauthorizedException('Invalid passkey');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.get(passkey.userId, {});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid passkey');
|
||||
}
|
||||
|
||||
await this.passkeyRepository.update(passkey.id, {
|
||||
counter: result.authenticationInfo.newCounter,
|
||||
usedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.createLoginResponse(user, loginDetails);
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: PasskeySearchDto): Promise<PasskeyResponseDto[]> {
|
||||
const passkeys = await this.passkeyRepository.search({ id: dto.id, userId: auth.user.id });
|
||||
return passkeys.map((passkey) => mapPasskey(passkey));
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<PasskeyResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PasskeyRead, ids: [id] });
|
||||
const passkey = await findOrFail(() => this.passkeyRepository.get(id), 'Passkey');
|
||||
return mapPasskey(passkey);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PasskeyUpdateDto): Promise<PasskeyResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PasskeyUpdate, ids: [id] });
|
||||
const passkey = await this.passkeyRepository.update(id, { name: dto.name });
|
||||
return mapPasskey(passkey);
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.PasskeyDelete, ids: [id] });
|
||||
await this.passkeyRepository.delete(id);
|
||||
}
|
||||
|
||||
private requireSession(auth: AuthDto) {
|
||||
if (!auth.session) {
|
||||
throw new BadRequestException('This endpoint can only be used with a session token');
|
||||
}
|
||||
}
|
||||
|
||||
async getWellKnown(): Promise<{ origins: string[] }> {
|
||||
const { passkey } = await this.getConfig({ withCache: true });
|
||||
if (!passkey.enabled || !passkey.domain) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return {
|
||||
origins: [passkey.domain, ...passkey.additionalDomains].map((value) => new URL(value).origin),
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AuthChallengeCleanup, queue: QueueName.BackgroundTask })
|
||||
async handleCleanup(): Promise<JobStatus> {
|
||||
const count = await this.authChallengeRepository.deleteExpired();
|
||||
if (count > 0) {
|
||||
this.logger.log(`Deleted ${count} expired auth challenges`);
|
||||
}
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private async requireEnabled() {
|
||||
const { passkey } = await this.getConfig({ withCache: false });
|
||||
if (!passkey.enabled) {
|
||||
throw new BadRequestException('Passkeys are disabled');
|
||||
}
|
||||
|
||||
return passkey;
|
||||
}
|
||||
|
||||
private getRelyingParty(
|
||||
config: SystemConfig['passkey'],
|
||||
headers: IncomingHttpHeaders,
|
||||
): { origin: string | string[]; rpId: string } {
|
||||
const origin = headers.origin;
|
||||
// no origin implies a mobile app
|
||||
if (!origin) {
|
||||
return { origin: ANDROID_ORIGINS, rpId: MOBILE_RP_ID };
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(origin).hostname;
|
||||
} catch {
|
||||
throw new BadRequestException('Origin header is invalid');
|
||||
}
|
||||
|
||||
if (!config.domain) {
|
||||
return { origin, rpId: hostname };
|
||||
}
|
||||
|
||||
const rpId = new URL(config.domain).hostname;
|
||||
if (hostname !== rpId && config.additionalDomains.every((value) => new URL(value).origin !== origin)) {
|
||||
throw new BadRequestException('Origin is not trusted');
|
||||
}
|
||||
|
||||
return { origin, rpId };
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ describe(QueueService.name, () => {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuthChallengeCleanup },
|
||||
{ name: JobName.HlsSessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.MemoryGenerate },
|
||||
|
||||
@@ -275,6 +275,7 @@ export class QueueService extends BaseService {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuthChallengeCleanup },
|
||||
{ name: JobName.HlsSessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
);
|
||||
|
||||
@@ -177,6 +177,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
roleClaim: 'immich_role',
|
||||
},
|
||||
passkey: {
|
||||
enabled: true,
|
||||
domain: null,
|
||||
additionalDomains: [],
|
||||
},
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -411,6 +411,7 @@ export type JobItem =
|
||||
|
||||
// Cleanup
|
||||
| { name: JobName.SessionCleanup; data?: IBaseJob }
|
||||
| { name: JobName.AuthChallengeCleanup; data?: IBaseJob }
|
||||
| { name: JobName.HlsSessionCleanup; data?: IBaseJob }
|
||||
|
||||
// Tags
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
|
||||
import { PasskeyLike } from 'test/factories/types.js';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory.js';
|
||||
|
||||
export class PasskeyFactory {
|
||||
private constructor(private value: Selectable<PasskeyTable>) {}
|
||||
|
||||
static create(dto: PasskeyLike = {}) {
|
||||
return PasskeyFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: PasskeyLike = {}) {
|
||||
return new PasskeyFactory({
|
||||
id: newUuid(),
|
||||
name: 'Passkey',
|
||||
credentialId: 'credential-id',
|
||||
publicKey: Buffer.from('public-key'),
|
||||
counter: 0,
|
||||
transports: ['internal'],
|
||||
backedUp: false,
|
||||
deviceType: 'singleDevice',
|
||||
userId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
usedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { AssetTable } from 'src/schema/tables/asset.table.js';
|
||||
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table.js';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table.js';
|
||||
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
|
||||
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
|
||||
import { PersonTable } from 'src/schema/tables/person.table.js';
|
||||
import { SessionTable } from 'src/schema/tables/session.table.js';
|
||||
@@ -45,5 +46,6 @@ export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||
export type PasskeyLike = Partial<Selectable<PasskeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
export type OAuthProfileLike = Partial<OAuthProfile>;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
|
||||
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository.js';
|
||||
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
||||
import { CronRepository } from 'src/repositories/cron.repository.js';
|
||||
@@ -50,6 +51,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository.js';
|
||||
import { NotificationRepository } from 'src/repositories/notification.repository.js';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository.js';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository.js';
|
||||
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
|
||||
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
|
||||
import { PersonRepository } from 'src/repositories/person.repository.js';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository.js';
|
||||
@@ -66,6 +68,7 @@ import { TagRepository } from 'src/repositories/tag.repository.js';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository.js';
|
||||
import { UserRepository } from 'src/repositories/user.repository.js';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
|
||||
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
@@ -77,6 +80,7 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table.js';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table.js';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table.js';
|
||||
import { PasskeyTable } from 'src/schema/tables/passkey.table.js';
|
||||
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
|
||||
import { PersonTable } from 'src/schema/tables/person.table.js';
|
||||
import { SessionTable } from 'src/schema/tables/session.table.js';
|
||||
@@ -177,6 +181,12 @@ export class MediumTestContext<S extends ClassConstructor<typeof BaseService> =
|
||||
return { user, result };
|
||||
}
|
||||
|
||||
async newPasskey(dto: Partial<Insertable<PasskeyTable>> & { userId: string }) {
|
||||
const passkey = mediumFactory.passkeyInsert(dto);
|
||||
const result = await this.get(PasskeyRepository).create(passkey);
|
||||
return { passkey, result };
|
||||
}
|
||||
|
||||
async newPartner(dto: { sharedById: string; sharedWithId: string; inTimeline?: boolean }) {
|
||||
const partner = { inTimeline: true, ...dto };
|
||||
const result = await this.get(PartnerRepository).create(partner);
|
||||
@@ -494,7 +504,9 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
|
||||
case LibraryRepository:
|
||||
case NotificationRepository:
|
||||
case OcrRepository:
|
||||
case AuthChallengeRepository:
|
||||
case PartnerRepository:
|
||||
case PasskeyRepository:
|
||||
case PersonRepository:
|
||||
case PersonUserRepository:
|
||||
case SearchRepository:
|
||||
@@ -512,7 +524,8 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
|
||||
}
|
||||
|
||||
case ConfigRepository:
|
||||
case CryptoRepository: {
|
||||
case CryptoRepository:
|
||||
case WebAuthnRepository: {
|
||||
return new key() as InstanceType<T>;
|
||||
}
|
||||
|
||||
@@ -557,6 +570,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
case AlbumRepository:
|
||||
case AssetRepository:
|
||||
case AssetJobRepository:
|
||||
case AuthChallengeRepository:
|
||||
case ConfigRepository:
|
||||
case CryptoRepository:
|
||||
case LibraryRepository:
|
||||
@@ -565,6 +579,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
case NotificationRepository:
|
||||
case OcrRepository:
|
||||
case PartnerRepository:
|
||||
case PasskeyRepository:
|
||||
case PersonRepository:
|
||||
case SessionRepository:
|
||||
case SyncRepository:
|
||||
@@ -779,6 +794,30 @@ const sessionInsert = ({
|
||||
};
|
||||
};
|
||||
|
||||
const passkeyInsert = ({
|
||||
id = newUuid(),
|
||||
userId,
|
||||
...passkey
|
||||
}: Partial<Insertable<PasskeyTable>> & { userId: string }) => {
|
||||
const defaults: Insertable<PasskeyTable> = {
|
||||
id,
|
||||
userId,
|
||||
name: 'Passkey',
|
||||
credentialId: `credential-${id}`,
|
||||
publicKey: Buffer.from('public-key'),
|
||||
counter: 0,
|
||||
transports: ['internal'],
|
||||
backedUp: false,
|
||||
deviceType: 'singleDevice',
|
||||
};
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...passkey,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
const userInsert = (user: Partial<Insertable<UserTable>> & { clusterGroupId: string }) => {
|
||||
const id = user.id || newUuid();
|
||||
|
||||
@@ -899,6 +938,7 @@ export const mediumFactory = {
|
||||
faceInsert,
|
||||
personInsert,
|
||||
personUserInsert,
|
||||
passkeyInsert,
|
||||
sessionInsert,
|
||||
syncStream,
|
||||
userInsert,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AuthChallengeType, SystemMetadataKey } from 'src/enum.js';
|
||||
import { AccessRepository } from 'src/repositories/access.repository.js';
|
||||
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository.js';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository.js';
|
||||
import { EventRepository } from 'src/repositories/event.repository.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
|
||||
import { SessionRepository } from 'src/repositories/session.repository.js';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository.js';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository.js';
|
||||
import { UserRepository } from 'src/repositories/user.repository.js';
|
||||
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||
import { mediumFactory, newMediumService } from 'test/medium.factory.js';
|
||||
import { factory } from 'test/small.factory.js';
|
||||
import { getKyselyDB } from 'test/utils.js';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const origin = 'https://immich.example';
|
||||
const headers = { origin };
|
||||
|
||||
const registrationResponse = {
|
||||
id: 'credential-id',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: 'client-data',
|
||||
attestationObject: 'attestation-object',
|
||||
},
|
||||
};
|
||||
|
||||
const authenticationResponse = {
|
||||
id: 'credential-id',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
clientDataJSON: 'client-data',
|
||||
authenticatorData: 'authenticator-data',
|
||||
signature: 'signature',
|
||||
},
|
||||
};
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(PasskeyService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [
|
||||
AccessRepository,
|
||||
AuthChallengeRepository,
|
||||
ClusterGroupRepository,
|
||||
ConfigRepository,
|
||||
CryptoRepository,
|
||||
DatabaseRepository,
|
||||
PasskeyRepository,
|
||||
SessionRepository,
|
||||
SystemMetadataRepository,
|
||||
UserRepository,
|
||||
WebAuthnRepository,
|
||||
],
|
||||
mock: [LoggingRepository, StorageRepository, EventRepository, TelemetryRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
const { ctx } = setup();
|
||||
await ctx.get(SystemMetadataRepository).set(SystemMetadataKey.SystemConfig, {
|
||||
passkey: { enabled: true, domain: 'https://immich.example', additionalDomains: [] },
|
||||
});
|
||||
});
|
||||
|
||||
describe(PasskeyService.name, () => {
|
||||
describe('startRegistration', () => {
|
||||
it('should generate registration options', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await expect(sut.startRegistration(factory.auth({ user, session: {} }), headers)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
challenge: expect.any(String),
|
||||
rp: { id: 'immich.example', name: 'Immich' },
|
||||
user: expect.objectContaining({ name: user.email, displayName: user.name }),
|
||||
excludeCredentials: [],
|
||||
authenticatorSelection: expect.objectContaining({ residentKey: 'required', userVerification: 'required' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should store a single-use challenge for the user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const { challenge } = await sut.startRegistration(factory.auth({ user, session: {} }), headers);
|
||||
const repository = ctx.get(AuthChallengeRepository);
|
||||
|
||||
await expect(
|
||||
repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(repository.consume({ type: AuthChallengeType.PasskeyRegistration, challenge })).resolves.toEqual({
|
||||
id: expect.any(String),
|
||||
userId: user.id,
|
||||
});
|
||||
await expect(
|
||||
repository.consume({ type: AuthChallengeType.PasskeyRegistration, challenge }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude the existing passkeys of the user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id });
|
||||
|
||||
await expect(sut.startRegistration(factory.auth({ user, session: {} }), headers)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
excludeCredentials: [{ id: passkey.credentialId, type: 'public-key', transports: passkey.transports }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishRegistration', () => {
|
||||
it('should reject an invalid response', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await expect(
|
||||
sut.finishRegistration(
|
||||
factory.auth({ user, session: {} }),
|
||||
{ response: registrationResponse },
|
||||
headers,
|
||||
mediumFactory.loginDetails(),
|
||||
),
|
||||
).rejects.toThrow('Passkey registration failed');
|
||||
|
||||
await expect(ctx.get(PasskeyRepository).search({ userId: user.id })).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startAuthentication', () => {
|
||||
it('should store a single-use challenge without a user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { challenge } = await sut.startAuthentication(headers);
|
||||
const repository = ctx.get(AuthChallengeRepository);
|
||||
|
||||
await expect(repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge })).resolves.toEqual({
|
||||
id: expect.any(String),
|
||||
userId: null,
|
||||
});
|
||||
await expect(
|
||||
repository.consume({ type: AuthChallengeType.PasskeyAuthentication, challenge }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
it('should generate authentication options', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.startAuthentication(headers)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
challenge: expect.any(String),
|
||||
rpId: 'immich.example',
|
||||
userVerification: 'required',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishAuthentication', () => {
|
||||
it('should reject an unknown credential', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: authenticationResponse }, headers, mediumFactory.loginDetails()),
|
||||
).rejects.toThrow('Invalid passkey');
|
||||
});
|
||||
|
||||
it('should reject an invalid response for a known credential', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id, credentialId: authenticationResponse.id });
|
||||
|
||||
await expect(
|
||||
sut.finishAuthentication({ response: authenticationResponse }, headers, mediumFactory.loginDetails()),
|
||||
).rejects.toThrow('Invalid passkey');
|
||||
|
||||
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
|
||||
expect.objectContaining({ usedAt: null }),
|
||||
);
|
||||
await expect(ctx.get(SessionRepository).getByUserId(user.id)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return an empty list', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await expect(sut.search(factory.auth({ user }), {})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should only return the passkeys of the current user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id });
|
||||
await ctx.newPasskey({ userId: otherUser.id });
|
||||
|
||||
await expect(sut.search(factory.auth({ user }), {})).resolves.toEqual([
|
||||
expect.objectContaining({ id: passkey.id, name: passkey.name, usedAt: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter by id', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id });
|
||||
await ctx.newPasskey({ userId: user.id });
|
||||
|
||||
await expect(sut.search(factory.auth({ user }), { id: passkey.id })).resolves.toEqual([
|
||||
expect.objectContaining({ id: passkey.id }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should reject the passkey of another user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
|
||||
|
||||
await expect(sut.get(factory.auth({ user }), passkey.id)).rejects.toThrow('Not found or no passkey.read access');
|
||||
});
|
||||
|
||||
it('should get a passkey', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id });
|
||||
|
||||
await expect(sut.get(factory.auth({ user }), passkey.id)).resolves.toEqual({
|
||||
id: passkey.id,
|
||||
name: passkey.name,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
usedAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should reject the passkey of another user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
|
||||
|
||||
await expect(sut.update(factory.auth({ user }), passkey.id, { name: 'Renamed' })).rejects.toThrow(
|
||||
'Not found or no passkey.update access',
|
||||
);
|
||||
});
|
||||
|
||||
it('should rename a passkey', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id });
|
||||
|
||||
await expect(sut.update(factory.auth({ user }), passkey.id, { name: 'Renamed' })).resolves.toEqual(
|
||||
expect.objectContaining({ id: passkey.id, name: 'Renamed' }),
|
||||
);
|
||||
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'Renamed' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should reject the passkey of another user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: otherUser.id });
|
||||
|
||||
await expect(sut.delete(factory.auth({ user }), passkey.id)).rejects.toThrow(
|
||||
'Not found or no passkey.delete access',
|
||||
);
|
||||
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toEqual(
|
||||
expect.objectContaining({ id: passkey.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a passkey', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { passkey } = await ctx.newPasskey({ userId: user.id });
|
||||
|
||||
await sut.delete(factory.auth({ user }), passkey.id);
|
||||
|
||||
await expect(ctx.get(PasskeyRepository).get(passkey.id)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
||||
checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
passkey: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
session: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js';
|
||||
import { AssetFileRepository } from 'src/repositories/asset-file.repository.js';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository.js';
|
||||
import { AuthChallengeRepository } from 'src/repositories/auth-challenge.repository.js';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
||||
import { CronRepository } from 'src/repositories/cron.repository.js';
|
||||
@@ -52,6 +53,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
|
||||
import { OAuthRepository } from 'src/repositories/oauth.repository.js';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository.js';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository.js';
|
||||
import { PasskeyRepository } from 'src/repositories/passkey.repository.js';
|
||||
import { PersonUserRepository } from 'src/repositories/person-user.repository.js';
|
||||
import { PersonRepository } from 'src/repositories/person.repository.js';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository.js';
|
||||
@@ -73,6 +75,7 @@ import { UserRepository } from 'src/repositories/user.repository.js';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository.js';
|
||||
import { ViewRepository } from 'src/repositories/view-repository.js';
|
||||
import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
@@ -246,6 +249,7 @@ export type ServiceOverrides = {
|
||||
assetEdit: AssetEditRepository;
|
||||
assetFile: AssetFileRepository;
|
||||
assetJob: AssetJobRepository;
|
||||
authChallenge: AuthChallengeRepository;
|
||||
clusterGroup: ClusterGroupRepository;
|
||||
config: ConfigRepository;
|
||||
cron: CronRepository;
|
||||
@@ -269,6 +273,7 @@ export type ServiceOverrides = {
|
||||
ocr: OcrRepository;
|
||||
oauth: OAuthRepository;
|
||||
partner: PartnerRepository;
|
||||
passkey: PasskeyRepository;
|
||||
person: PersonRepository;
|
||||
personUser: PersonUserRepository;
|
||||
plugin: PluginRepository;
|
||||
@@ -290,6 +295,7 @@ export type ServiceOverrides = {
|
||||
versionHistory: VersionHistoryRepository;
|
||||
videoStream: VideoStreamRepository;
|
||||
view: ViewRepository;
|
||||
webAuthn: WebAuthnRepository;
|
||||
websocket: WebsocketRepository;
|
||||
workflow: WorkflowRepository;
|
||||
};
|
||||
@@ -333,6 +339,7 @@ export const getMocks = () => {
|
||||
assetEdit: automock(AssetEditRepository),
|
||||
assetFile: automock(AssetFileRepository),
|
||||
assetJob: automock(AssetJobRepository),
|
||||
authChallenge: automock(AuthChallengeRepository),
|
||||
clusterGroup: automock(ClusterGroupRepository),
|
||||
app: automock(AppRepository, { strict: false }),
|
||||
config: newConfigRepositoryMock(),
|
||||
@@ -356,6 +363,7 @@ export const getMocks = () => {
|
||||
ocr: automock(OcrRepository, { strict: false }),
|
||||
oauth: automock(OAuthRepository, { args: [loggerMock] }),
|
||||
partner: automock(PartnerRepository, { strict: false }),
|
||||
passkey: automock(PasskeyRepository),
|
||||
person: automock(PersonRepository, { strict: false }),
|
||||
personUser: automock(PersonUserRepository, { strict: false }),
|
||||
plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }),
|
||||
@@ -380,6 +388,7 @@ export const getMocks = () => {
|
||||
versionHistory: automock(VersionHistoryRepository),
|
||||
videoStream: automock(VideoStreamRepository, { strict: false }),
|
||||
view: automock(ViewRepository),
|
||||
webAuthn: automock(WebAuthnRepository),
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
|
||||
workflow: automock(WorkflowRepository, { strict: true }),
|
||||
@@ -409,6 +418,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
|
||||
overrides.assetFile || (mocks.assetFile as As<AssetFileRepository>),
|
||||
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
|
||||
overrides.authChallenge || (mocks.authChallenge as As<AuthChallengeRepository>),
|
||||
overrides.clusterGroup || (mocks.clusterGroup as As<ClusterGroupRepository>),
|
||||
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),
|
||||
overrides.cron || (mocks.cron as As<CronRepository>),
|
||||
@@ -431,6 +441,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
|
||||
overrides.ocr || (mocks.ocr as As<OcrRepository>),
|
||||
overrides.partner || (mocks.partner as As<PartnerRepository>),
|
||||
overrides.passkey || (mocks.passkey as As<PasskeyRepository>),
|
||||
overrides.person || (mocks.person as As<PersonRepository>),
|
||||
overrides.personUser || (mocks.personUser as As<PersonUserRepository>),
|
||||
overrides.plugin || (mocks.plugin as As<PluginRepository>),
|
||||
@@ -452,6 +463,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
|
||||
overrides.videoStream || (mocks.videoStream as As<VideoStreamRepository>),
|
||||
overrides.view || (mocks.view as As<ViewRepository>),
|
||||
overrides.webAuthn || (mocks.webAuthn as As<WebAuthnRepository>),
|
||||
overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
|
||||
overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.14.0",
|
||||
"@tanstack/svelte-query": "^6.3.2",
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@zoom-image/core": "^0.42.0",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
label?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
isEdited?: boolean;
|
||||
@@ -39,6 +40,7 @@
|
||||
label = '',
|
||||
description = '',
|
||||
title = '',
|
||||
placeholder = '',
|
||||
required = false,
|
||||
disabled = false,
|
||||
isEdited = false,
|
||||
@@ -81,7 +83,10 @@
|
||||
|
||||
<div class="mb-4 w-full">
|
||||
<div class="flex place-items-center gap-1">
|
||||
<label class="min-h-6 text-sm font-medium text-primary" for={label}>{label}</label>
|
||||
{#if label}
|
||||
<label class="min-h-6 text-sm font-medium text-primary" for={label}>{label}</label>
|
||||
{/if}
|
||||
|
||||
{#if required}
|
||||
<div class="text-red-400">*</div>
|
||||
{/if}
|
||||
@@ -100,7 +105,7 @@
|
||||
<p class="pb-2 text-sm immich-form-label" id="{label}-desc">
|
||||
{description}
|
||||
</p>
|
||||
{:else}
|
||||
{:else if descriptionSnippet}
|
||||
<div class="pb-2">
|
||||
{@render descriptionSnippet?.()}
|
||||
</div>
|
||||
@@ -145,6 +150,7 @@
|
||||
onchange={handleChange}
|
||||
{disabled}
|
||||
{title}
|
||||
{placeholder}
|
||||
/>
|
||||
|
||||
{@render trailingSnippet?.()}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IntegrityReport,
|
||||
JobCreateDto,
|
||||
LoginResponseDto,
|
||||
PasskeyResponseDto,
|
||||
PersonResponseDto,
|
||||
QueueResponseDto,
|
||||
ReleaseEventV1,
|
||||
@@ -34,6 +35,10 @@ export type Events = {
|
||||
ApiKeyUpdate: [ApiKeyResponseDto];
|
||||
ApiKeyDelete: [ApiKeyResponseDto];
|
||||
|
||||
PasskeyCreate: [PasskeyResponseDto];
|
||||
PasskeyUpdate: [PasskeyResponseDto];
|
||||
PasskeyDelete: [PasskeyResponseDto];
|
||||
|
||||
AssetUpdate: [AssetResponseDto];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsUnarchive: [TimelineAsset[]];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { handleUpdatePasskey } from '$lib/services/passkey.service';
|
||||
import { Field, FormModal, Input } from '@immich/ui';
|
||||
import { mdiShieldKeyOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
passkey: { id: string; name: string | null };
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { passkey, onClose }: Props = $props();
|
||||
|
||||
let name = $state(passkey.name ?? '');
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdatePasskey(passkey, { name });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('passkeys', { values: { count: 1 } })} icon={mdiShieldKeyOutline} {onClose} {onSubmit}>
|
||||
<Field label={$t('name')}>
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
</FormModal>
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
deletePasskey,
|
||||
finishAuthentication,
|
||||
finishRegistration,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
updatePasskey,
|
||||
type PasskeyResponseDto,
|
||||
type PasskeyUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startAuthentication as startBrowserAuthentication,
|
||||
startRegistration as startBrowserRegistration,
|
||||
type PublicKeyCredentialCreationOptionsJSON,
|
||||
type PublicKeyCredentialRequestOptionsJSON,
|
||||
} from '@simplewebauthn/browser';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import PasskeyUpdateModal from '$lib/modals/PasskeyUpdateModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
export const isPasskeySupported = () => browserSupportsWebAuthn();
|
||||
|
||||
export const isPasskeyCancelled = (error: unknown) => error instanceof Error && error.name === 'NotAllowedError';
|
||||
|
||||
export const getPasskeysActions = ($t: MessageFormatter) => {
|
||||
const Create: ActionItem = {
|
||||
title: $t('new_passkey'),
|
||||
icon: mdiPlus,
|
||||
onAction: async () => {
|
||||
if (!isPasskeySupported()) {
|
||||
toastManager.warning($t('passkeys_not_supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const passkey = await handleCreatePasskey();
|
||||
if (passkey) {
|
||||
await modalManager.show(PasskeyUpdateModal, { passkey });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { Create };
|
||||
};
|
||||
|
||||
export const getPasskeyActions = ($t: MessageFormatter, passkey: PasskeyResponseDto) => {
|
||||
const Update: ActionItem = {
|
||||
title: $t('edit_passkey'),
|
||||
icon: mdiPencilOutline,
|
||||
onAction: () => modalManager.show(PasskeyUpdateModal, { passkey }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete_passkey'),
|
||||
icon: mdiTrashCanOutline,
|
||||
onAction: () => handleDeletePasskey(passkey),
|
||||
};
|
||||
|
||||
return { Update, Delete };
|
||||
};
|
||||
|
||||
const handleCreatePasskey = async () => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const options = await startRegistration();
|
||||
const response = await startBrowserRegistration({
|
||||
optionsJSON: options as PublicKeyCredentialCreationOptionsJSON,
|
||||
});
|
||||
const passkey = await finishRegistration({ passkeyRegistrationFinishDto: { response } });
|
||||
eventManager.emit('PasskeyCreate', passkey);
|
||||
return passkey;
|
||||
} catch (error) {
|
||||
if (isPasskeyCancelled(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleLoginWithPasskey = async () => {
|
||||
const options = await startAuthentication();
|
||||
const response = await startBrowserAuthentication({ optionsJSON: options as PublicKeyCredentialRequestOptionsJSON });
|
||||
return finishAuthentication({ passkeyAuthenticationFinishDto: { response } });
|
||||
};
|
||||
|
||||
export const handleUpdatePasskey = async (passkey: { id: string }, dto: PasskeyUpdateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await updatePasskey({ id: passkey.id, passkeyUpdateDto: { name: dto.name || null } });
|
||||
eventManager.emit('PasskeyUpdate', response);
|
||||
toastManager.primary();
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({ prompt: $t('delete_passkey_prompt') });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deletePasskey({ id: passkey.id });
|
||||
eventManager.emit('PasskeyDelete', passkey);
|
||||
toastManager.primary(
|
||||
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,11 @@
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[KeyboardShortcuts]}>
|
||||
<Container size="medium" center>
|
||||
<UserSettingsList keys={data.keys} sessions={data.sessions} />
|
||||
<UserSettingsList
|
||||
keys={data.keys}
|
||||
sessions={data.sessions}
|
||||
passkeys={data.passkeys}
|
||||
passkeyEnabled={data.publicConfig.passkey.enabled}
|
||||
/>
|
||||
</Container>
|
||||
</UserPageLayout>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getApiKeys, getSessions } from '@immich/sdk';
|
||||
import { getApiKeys, getPublicConfig, getSessions, searchPasskeys } from '@immich/sdk';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
@@ -6,13 +6,19 @@ import type { PageLoad } from './$types';
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
|
||||
const keys = await getApiKeys();
|
||||
const sessions = await getSessions();
|
||||
const $t = await getFormatter();
|
||||
const publicConfig = await getPublicConfig();
|
||||
const [keys, sessions, passkeys, $t] = await Promise.all([
|
||||
getApiKeys(),
|
||||
getSessions(),
|
||||
publicConfig.passkey.enabled ? searchPasskeys({}) : Promise.resolve([]),
|
||||
getFormatter(),
|
||||
]);
|
||||
|
||||
return {
|
||||
keys,
|
||||
sessions,
|
||||
passkeys,
|
||||
publicConfig,
|
||||
meta: {
|
||||
title: $t('settings'),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import TableButton from '$lib/components/TableButton.svelte';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { getPasskeyActions, getPasskeysActions } from '$lib/services/passkey.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { type PasskeyResponseDto } from '@immich/sdk';
|
||||
import { Button, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
passkeys: PasskeyResponseDto[];
|
||||
};
|
||||
|
||||
let { passkeys = $bindable() }: Props = $props();
|
||||
|
||||
const onPasskeyCreate = (passkey: PasskeyResponseDto) => {
|
||||
passkeys = [passkey, ...passkeys];
|
||||
};
|
||||
|
||||
const onPasskeyUpdate = (update: PasskeyResponseDto) => {
|
||||
const index = passkeys.findIndex((passkey) => passkey.id === update.id);
|
||||
if (index !== -1) {
|
||||
passkeys[index] = update;
|
||||
}
|
||||
};
|
||||
|
||||
const onPasskeyDelete = ({ id }: PasskeyResponseDto) => {
|
||||
passkeys = passkeys.filter((passkey) => passkey.id !== id);
|
||||
};
|
||||
|
||||
const { Create } = $derived(getPasskeysActions($t));
|
||||
</script>
|
||||
|
||||
<OnEvents {onPasskeyCreate} {onPasskeyUpdate} {onPasskeyDelete} />
|
||||
|
||||
<section class="my-4">
|
||||
<div class="flex flex-col gap-2 sm:ms-8" in:fade={{ duration: 500 }}>
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button
|
||||
leadingIcon={Create.icon}
|
||||
shape="round"
|
||||
size="small"
|
||||
onclick={(event: MouseEvent) => Create.onAction({ event, action: Create })}
|
||||
>
|
||||
{Create.title}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if passkeys.length > 0}
|
||||
<Table class="mt-4" striped spacing="small" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading>{$t('name')}</TableHeading>
|
||||
<TableHeading>{$t('created')}</TableHeading>
|
||||
<TableHeading>{$t('last_used')}</TableHeading>
|
||||
<TableHeading>{$t('action')}</TableHeading>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{#each passkeys as passkey (passkey.id)}
|
||||
{@const { Update, Delete } = getPasskeyActions($t, passkey)}
|
||||
<TableRow>
|
||||
<TableCell>{passkey.name ?? $t('passkeys', { values: { count: 1 } })}</TableCell>
|
||||
<TableCell>{new Date(passkey.createdAt).toLocaleDateString($locale, dateFormats.settings)}</TableCell>
|
||||
<TableCell>
|
||||
{passkey.usedAt
|
||||
? new Date(passkey.usedAt).toLocaleDateString($locale, dateFormats.settings)
|
||||
: $t('never')}
|
||||
</TableCell>
|
||||
<TableCell class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TableButton action={Update} size="small" />
|
||||
<TableButton action={Delete} size="small" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -9,7 +9,7 @@
|
||||
import { OpenQueryParam, QueryParameter } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||
import { type ApiKeyResponseDto, type PasskeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiAccountOutline,
|
||||
@@ -23,6 +23,7 @@
|
||||
mdiKeyOutline,
|
||||
mdiLockSmart,
|
||||
mdiServerOutline,
|
||||
mdiShieldKeyOutline,
|
||||
mdiTwoFactorAuthentication,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -34,14 +35,22 @@
|
||||
import OauthSettings from './OauthSettings.svelte';
|
||||
import SharingSettings from './SharingSettings.svelte';
|
||||
import UserApiKeyList from './UserApiKeyList.svelte';
|
||||
import UserPasskeyList from './UserPasskeyList.svelte';
|
||||
import UserProfileSettings from './UserProfileSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
keys?: ApiKeyResponseDto[];
|
||||
sessions?: SessionResponseDto[];
|
||||
passkeys?: PasskeyResponseDto[];
|
||||
passkeyEnabled?: boolean;
|
||||
}
|
||||
|
||||
let { keys = $bindable([]), sessions = $bindable([]) }: Props = $props();
|
||||
let {
|
||||
keys = $bindable([]),
|
||||
sessions = $bindable([]),
|
||||
passkeys = $bindable([]),
|
||||
passkeyEnabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
let oauthOpen =
|
||||
oauth.isCallback(location) || $page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH;
|
||||
@@ -130,6 +139,17 @@
|
||||
</SettingAccordion>
|
||||
{/if}
|
||||
|
||||
{#if passkeyEnabled}
|
||||
<SettingAccordion
|
||||
icon={mdiShieldKeyOutline}
|
||||
key="passkeys"
|
||||
title={$t('passkeys', { values: { count: passkeys.length } })}
|
||||
subtitle={$t('manage_your_passkeys')}
|
||||
>
|
||||
<UserPasskeyList bind:passkeys />
|
||||
</SettingAccordion>
|
||||
{/if}
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiFormTextboxPassword}
|
||||
key="password"
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
import { getConfigQuery } from '$lib/queries/system-config.query';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk';
|
||||
import { Button, Link, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { mdiRestart } from '@mdi/js';
|
||||
import { Button, IconButton, Link, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { mdiPlus, mdiRestart, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -33,7 +33,8 @@
|
||||
};
|
||||
|
||||
const onBeforeSave = async () => {
|
||||
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
|
||||
const allMethodsDisabled =
|
||||
!configToEdit.oauth.enabled && !configToEdit.passkey.enabled && !configToEdit.passwordLogin.enabled;
|
||||
|
||||
if (allMethodsDisabled) {
|
||||
const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
|
||||
@@ -303,6 +304,67 @@
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="passkey"
|
||||
title={$t('admin.passkey_settings')}
|
||||
subtitle={$t('admin.passkey_settings_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.passkey_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.passkey.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.passkey_domain')}
|
||||
description={$t('admin.passkey_domain_description')}
|
||||
placeholder="https://"
|
||||
bind:value={
|
||||
() => configToEdit.passkey.domain ?? '', (value) => (configToEdit.passkey.domain = value || null)
|
||||
}
|
||||
required={false}
|
||||
disabled={disabled || !configToEdit.passkey.enabled}
|
||||
isEdited={configToEdit.passkey.domain !== config.passkey.domain}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each configToEdit.passkey.additionalDomains as _, i (i)}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={i === 0 ? $t('admin.passkey_additional_domains') : undefined}
|
||||
description={i === 0 ? $t('admin.passkey_additional_domains_description') : undefined}
|
||||
bind:value={configToEdit.passkey.additionalDomains[i]}
|
||||
disabled={disabled || !configToEdit.passkey.enabled}
|
||||
>
|
||||
{#snippet trailingSnippet()}
|
||||
<IconButton
|
||||
aria-label=""
|
||||
onclick={() => configToEdit.passkey.additionalDomains.splice(i, 1)}
|
||||
icon={mdiTrashCanOutline}
|
||||
color="danger"
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
class="mb-2"
|
||||
size="small"
|
||||
shape="round"
|
||||
leadingIcon={mdiPlus}
|
||||
onclick={() => void configToEdit.passkey.additionalDomains.push('')}
|
||||
disabled={disabled || !configToEdit.passkey.enabled}>{$t('add_url')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="password"
|
||||
title={$t('admin.password_settings')}
|
||||
@@ -319,7 +381,7 @@
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
|
||||
<SettingButtonsRow bind:configToEdit keys={['passkey', 'passwordLogin', 'oauth']} {onBeforeSave} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleLoginWithPasskey, isPasskeyCancelled, isPasskeySupported } from '$lib/services/passkey.service';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
||||
import { login, type LoginResponseDto } from '@immich/sdk';
|
||||
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
|
||||
import { mdiShieldKeyOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -22,11 +24,15 @@
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let oauthError = $state('');
|
||||
let passkeyError = $state('');
|
||||
let loading = $state(false);
|
||||
let oauthLoading = $state(true);
|
||||
let passkeyLoading = $state(false);
|
||||
const passkeySupported = isPasskeySupported();
|
||||
|
||||
const serverConfig = $derived(serverConfigManager.value);
|
||||
const publicConfig = $derived(data.publicConfig);
|
||||
const passkeyEnabled = $derived(publicConfig.passkey.enabled && passkeySupported);
|
||||
|
||||
const onSuccess = async (user: LoginResponseDto) => {
|
||||
await goto(data.continueUrl, { invalidateAll: true });
|
||||
@@ -36,6 +42,28 @@
|
||||
const onFirstLogin = () => goto(Route.changePassword());
|
||||
const onOnboarding = () => goto(Route.onboarding());
|
||||
|
||||
const onLogin = async (user: LoginResponseDto) => {
|
||||
if (user.isAdmin && !serverConfig.isOnboarded) {
|
||||
await onOnboarding();
|
||||
return;
|
||||
}
|
||||
|
||||
// change the user password before we onboard them
|
||||
if (!user.isAdmin && user.shouldChangePassword && publicConfig.passwordLogin.enabled) {
|
||||
await onFirstLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// We want to onboard after the first login since their password will change
|
||||
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
|
||||
if (!user.isOnboarded) {
|
||||
await onOnboarding();
|
||||
return;
|
||||
}
|
||||
|
||||
await onSuccess(user);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!publicConfig.oauth.enabled) {
|
||||
oauthLoading = false;
|
||||
@@ -82,31 +110,24 @@
|
||||
errorMessage = '';
|
||||
loading = true;
|
||||
const user = await login({ loginCredentialDto: { email, password } });
|
||||
|
||||
if (user.isAdmin && !serverConfig.isOnboarded) {
|
||||
await onOnboarding();
|
||||
return;
|
||||
}
|
||||
|
||||
// change the user password before we onboard them
|
||||
if (!user.isAdmin && user.shouldChangePassword) {
|
||||
await onFirstLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// We want to onboard after the first login since their password will change
|
||||
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
|
||||
if (!user.isOnboarded) {
|
||||
await onOnboarding();
|
||||
return;
|
||||
}
|
||||
|
||||
await onSuccess(user);
|
||||
return;
|
||||
await onLogin(user);
|
||||
} catch (error) {
|
||||
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
passkeyError = '';
|
||||
passkeyLoading = true;
|
||||
const user = await handleLoginWithPasskey();
|
||||
await onLogin(user);
|
||||
} catch (error) {
|
||||
if (!isPasskeyCancelled(error)) {
|
||||
passkeyError = getServerErrorMessage(error) || $t('errors.something_went_wrong');
|
||||
}
|
||||
passkeyLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,6 +147,17 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet divider()}
|
||||
<div class="my-4 inline-flex w-full items-center justify-center">
|
||||
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
|
||||
<span
|
||||
class="absolute inset-s-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 uppercase dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
{$t('or')}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<AuthPageLayout title={data.meta.title}>
|
||||
<Stack gap={4}>
|
||||
{#if publicConfig.server.loginPageMessage}
|
||||
@@ -153,16 +185,30 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if publicConfig.oauth.enabled}
|
||||
{#if !oauthLoading && passkeyEnabled}
|
||||
{#if publicConfig.passwordLogin.enabled}
|
||||
<div class="my-4 inline-flex w-full items-center justify-center">
|
||||
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
|
||||
<span
|
||||
class="absolute inset-s-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 uppercase dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
{$t('or')}
|
||||
</span>
|
||||
</div>
|
||||
{@render divider()}
|
||||
{/if}
|
||||
{#if passkeyError}
|
||||
<Alert color="danger" title={passkeyError} closable />
|
||||
{/if}
|
||||
<Button
|
||||
shape="round"
|
||||
leadingIcon={mdiShieldKeyOutline}
|
||||
loading={passkeyLoading}
|
||||
disabled={loading || passkeyLoading}
|
||||
size="large"
|
||||
fullWidth
|
||||
color={publicConfig.passwordLogin.enabled ? 'secondary' : 'primary'}
|
||||
onclick={handlePasskeyLogin}
|
||||
>
|
||||
{$t('login_with_passkey')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if publicConfig.oauth.enabled}
|
||||
{#if publicConfig.passwordLogin.enabled || passkeyEnabled}
|
||||
{@render divider()}
|
||||
{/if}
|
||||
{#if oauthError}
|
||||
<Alert color="danger" title={oauthError} closable />
|
||||
@@ -180,7 +226,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !publicConfig.passwordLogin.enabled && !publicConfig.oauth.enabled}
|
||||
{#if !publicConfig.passwordLogin.enabled && !passkeyEnabled && !publicConfig.oauth.enabled}
|
||||
<Alert color="warning" title={$t('login_has_been_disabled')} />
|
||||
{/if}
|
||||
</Stack>
|
||||
|
||||
@@ -17,6 +17,7 @@ const upstream = {
|
||||
const proxy: Record<string, string | ProxyOptions> = {
|
||||
'/api': upstream,
|
||||
'/.well-known/immich': upstream,
|
||||
'/.well-known/webauthn': upstream,
|
||||
'/custom.css': upstream,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user