From fb3b880204a0eea9d074bce0bfe1a88e83e576e4 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Tue, 13 Jan 2026 18:35:42 +0100 Subject: [PATCH] 142 provide logic and apis to use more than one passkey with a staff member (#163) * Basic implementation * Syntax fixes * Implemented PRF for passkey administration. Updated docs, renamed confusing endpoints. * Update src/routes/api/auth/passkeys/[passkeyId]/crypto/+server.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removed unused param * Merge fixes, formatting fixes, database migrations --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- migrations/0008_complete_photon.sql | 1 + migrations/0009_bizarre_saracen.sql | 1 + migrations/meta/0009_snapshot.json | 838 ++++++++++++++++++ migrations/meta/_journal.json | 7 + src/app.d.ts | 1 + src/lib/server/auth/jwt-utils.ts | 21 +- src/lib/server/auth/session-service.ts | 11 +- src/lib/server/db/central-schema.ts | 2 + .../server/services/staff-crypto.service.ts | 7 +- src/routes/api/auth/login/+server.ts | 2 + .../api/auth/passkeys/[passkeyId]/+server.ts | 197 ++++ .../__tests__/delete-passkey.test.ts | 371 ++++++++ .../passkeys/[passkeyId]/crypto/+server.ts | 286 ++++++ .../crypto/__tests__/crypto.test.ts | 345 +++++++ src/routes/api/openapi.json/+server.ts | 2 + src/server-hooks/apiAuthHandle.ts | 3 +- 16 files changed, 2084 insertions(+), 11 deletions(-) create mode 100644 migrations/0008_complete_photon.sql create mode 100644 migrations/0009_bizarre_saracen.sql create mode 100644 migrations/meta/0009_snapshot.json create mode 100644 src/routes/api/auth/passkeys/[passkeyId]/+server.ts create mode 100644 src/routes/api/auth/passkeys/[passkeyId]/__tests__/delete-passkey.test.ts create mode 100644 src/routes/api/auth/passkeys/[passkeyId]/crypto/+server.ts create mode 100644 src/routes/api/auth/passkeys/[passkeyId]/crypto/__tests__/crypto.test.ts diff --git a/migrations/0008_complete_photon.sql b/migrations/0008_complete_photon.sql new file mode 100644 index 0000000..b02477a --- /dev/null +++ b/migrations/0008_complete_photon.sql @@ -0,0 +1 @@ +ALTER TABLE "user_session" ADD COLUMN "passkey_id" text; \ No newline at end of file diff --git a/migrations/0009_bizarre_saracen.sql b/migrations/0009_bizarre_saracen.sql new file mode 100644 index 0000000..b02477a --- /dev/null +++ b/migrations/0009_bizarre_saracen.sql @@ -0,0 +1 @@ +ALTER TABLE "user_session" ADD COLUMN "passkey_id" text; \ No newline at end of file diff --git a/migrations/meta/0009_snapshot.json b/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..56ada36 --- /dev/null +++ b/migrations/meta/0009_snapshot.json @@ -0,0 +1,838 @@ +{ + "id": "945e895d-77bd-4acb-a4d3-20f652924edf", + "prevId": "6f7d3653-5075-4baf-ad67-77afa7ae10ff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.challenge_throttle": { + "name": "challenge_throttle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reset_at": { + "name": "reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "defaultLanguage": { + "name": "defaultLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "logo": { + "name": "logo", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SETTINGS'" + }, + "links": { + "name": "links", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "confirmation_state": { + "name": "confirmation_state", + "type": "confirmation_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'INVITED'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "passphrase_hash": { + "name": "passphrase_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recovery_passphrase": { + "name": "recovery_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.confirmation_state": { + "name": "confirmation_state", + "schema": "public", + "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index a2729bd..1ee04c1 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1767362589999, "tag": "0008_wild_whirlwind", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1768324950524, + "tag": "0009_bizarre_saracen", + "breakpoints": true } ] } diff --git a/src/app.d.ts b/src/app.d.ts index 3424391..1fe91ae 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,6 +4,7 @@ import type { SelectUser } from "$lib/server/db/central-schema"; import type { Locale } from "$i18n/runtime"; interface SessionInfo { + passkeyId?: string; session: { sessionId: string; exp: number; diff --git a/src/lib/server/auth/jwt-utils.ts b/src/lib/server/auth/jwt-utils.ts index dd34742..ba9ba00 100644 --- a/src/lib/server/auth/jwt-utils.ts +++ b/src/lib/server/auth/jwt-utils.ts @@ -18,7 +18,11 @@ const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); const ACCESS_TOKEN_EXPIRES = "15m"; // 15 minutes const REFRESH_TOKEN_EXPIRES = "7d"; // 7 days -export async function generateAccessToken(user: SelectUser, sessionId: string): Promise { +export async function generateAccessToken( + user: SelectUser, + sessionId: string, + passkeyId?: string, +): Promise { const now = Math.floor(Date.now() / 1000); const payload: Omit = { @@ -28,6 +32,7 @@ export async function generateAccessToken(user: SelectUser, sessionId: string): role: user.role, tenantId: user.tenantId || undefined, sessionId, + passkeyId: passkeyId || undefined, }; const jwt = await new SignJWT(payload) @@ -59,7 +64,7 @@ export async function generateRefreshToken(userId: string, sessionId: string): P export async function decodeAccessToken( token: string, -): Promise<(JWTPayload & { userId: string; sessionId: string }) | null> { +): Promise<(JWTPayload & { userId: string; sessionId: string; passkeyId?: string }) | null> { try { const payload = await decodeJwt(token); @@ -70,6 +75,7 @@ export async function decodeAccessToken( role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", tenantId: payload.tenantId as string | undefined, sessionId: payload.sessionId as string, + passkeyId: payload.passkeyId as string | undefined, iat: payload.iat, exp: payload.exp, }; @@ -81,7 +87,7 @@ export async function decodeAccessToken( export async function verifyAccessToken( token: string, -): Promise<(JWTPayload & { userId: string; sessionId: string }) | null> { +): Promise<(JWTPayload & { userId: string; sessionId: string; passkeyId?: string }) | null> { try { const { payload } = await jwtVerify(token, JWT_SECRET); @@ -92,6 +98,7 @@ export async function verifyAccessToken( role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", tenantId: payload.tenantId as string | undefined, sessionId: payload.sessionId as string, + passkeyId: payload.passkeyId as string | undefined, iat: payload.iat, exp: payload.exp, }; @@ -126,9 +133,13 @@ export async function verifyRefreshToken( } } -export async function generateTokens(user: SelectUser, sessionId: string): Promise { +export async function generateTokens( + user: SelectUser, + sessionId: string, + passkeyId?: string, +): Promise { const [accessToken, refreshToken] = await Promise.all([ - generateAccessToken(user, sessionId), + generateAccessToken(user, sessionId, passkeyId), generateRefreshToken(user.id, sessionId), ]); diff --git a/src/lib/server/auth/session-service.ts b/src/lib/server/auth/session-service.ts index efec715..f45542d 100644 --- a/src/lib/server/auth/session-service.ts +++ b/src/lib/server/auth/session-service.ts @@ -41,6 +41,7 @@ export class SessionService { userId: string, ipAddress?: string, userAgent?: string, + passkeyId?: string, ): Promise { logger.info(`Creating session for user: ${userId}`); @@ -62,6 +63,7 @@ export class SessionService { sessionToken: "", // Will be updated with actual token accessToken: "", // Will be updated with actual token refreshToken: "", // Will be updated with actual token + passkeyId, // Store the passkey ID if WebAuthn was used ipAddress, userAgent, expiresAt: new Date(Date.now() + this.SESSION_DURATION), @@ -70,8 +72,8 @@ export class SessionService { const [createdSession] = await db.insert(userSession).values(sessionData).returning(); - // Generate tokens with the actual session ID - const tokens = await generateTokens(userData, createdSession.id); + // Generate tokens with the actual session ID and passkeyId + const tokens = await generateTokens(userData, createdSession.id, passkeyId); // Update session with actual tokens const [updatedSession] = await db @@ -86,7 +88,7 @@ export class SessionService { await db.update(user).set({ lastLoginAt: new Date() }).where(eq(user.id, userId)); - logger.info(`Session created successfully for user: ${userId}`); + logger.info(`Session created successfully for user: ${userId}`, { passkeyId }); return { sessionToken: updatedSession.sessionToken, @@ -102,7 +104,7 @@ export class SessionService { */ static async validateTokenWithDB( accessToken: string, - ): Promise<{ user: SelectUser; sessionId: string; exp: Date } | null> { + ): Promise<{ user: SelectUser; sessionId: string; exp: Date; passkeyId?: string } | null> { logger.debug("Validating access token with database"); try { @@ -151,6 +153,7 @@ export class SessionService { user: session.user, exp: session.user_session.expiresAt, sessionId: session.user_session.id, + passkeyId: session.user_session.passkeyId || tokenData.passkeyId, }; } catch (error) { logger.error("Error validating token with database:", { error: String(error) }); diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index c4073ab..cdfd844 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -169,6 +169,8 @@ export const userSession = pgTable( sessionToken: text("session_token").notNull().unique(), accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), + /** Passkey ID used for authentication (if WebAuthn was used) */ + passkeyId: text("passkey_id"), ipAddress: text("ip_address"), userAgent: text("user_agent"), createdAt: timestamp("created_at").defaultNow(), diff --git a/src/lib/server/services/staff-crypto.service.ts b/src/lib/server/services/staff-crypto.service.ts index 0b515d2..870b2ed 100644 --- a/src/lib/server/services/staff-crypto.service.ts +++ b/src/lib/server/services/staff-crypto.service.ts @@ -172,10 +172,12 @@ export class StaffCryptoService { /** * Get all staff public keys for a tenant + * Returns ALL public keys for all passkeys of each staff member + * This allows clients to encrypt appointment data that can be decrypted by any staff member's passkey */ async getStaffPublicKeys( tenantId: string, - ): Promise> { + ): Promise> { const log = logger.setContext("StaffCryptoService.getStaffPublicKeys"); try { @@ -184,6 +186,7 @@ export class StaffCryptoService { .select({ userId: staffCrypto.userId, publicKey: staffCrypto.publicKey, + passkeyId: staffCrypto.passkeyId, }) .from(staffCrypto) .where(eq(staffCrypto.isActive, true)); @@ -191,11 +194,13 @@ export class StaffCryptoService { const validStaffKeys = staffWithKeys.map((staff) => ({ userId: staff.userId, publicKey: staff.publicKey, + passkeyId: staff.passkeyId, })); log.info("Retrieved staff public keys", { tenantId, validKeys: validStaffKeys.length, + uniqueStaff: new Set(validStaffKeys.map((k) => k.userId)).size, }); return validStaffKeys; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 642043f..7c3566c 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -229,6 +229,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress, } // Handle WebAuthn authentication + let passkeyId: string | undefined; if (body.credential) { // Get the challenge from the session const challengeFromSession = cookies.get("webauthn-challenge"); @@ -269,6 +270,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress, user.id, ipAddress, userAgent || undefined, + passkeyId, ); // Set HTTP-only cookie for access token diff --git a/src/routes/api/auth/passkeys/[passkeyId]/+server.ts b/src/routes/api/auth/passkeys/[passkeyId]/+server.ts new file mode 100644 index 0000000..beba00b --- /dev/null +++ b/src/routes/api/auth/passkeys/[passkeyId]/+server.ts @@ -0,0 +1,197 @@ +import { json, type RequestEvent } from "@sveltejs/kit"; +import { UserService } from "$lib/server/services/user-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; +import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; +import { + NotFoundError, + ValidationError, + AuthorizationError, + BackendError, + InternalError, + logError, +} from "$lib/server/utils/errors"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import logger from "$lib/logger"; + +// Register OpenAPI documentation for DELETE +registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "DELETE", { + summary: "Delete a WebAuthn passkey from user account", + description: + "Allows authenticated users to delete one of their WebAuthn passkeys. Users cannot delete the passkey they are currently using for authentication.", + tags: ["Authentication"], + parameters: [ + { + name: "passkeyId", + in: "path", + required: true, + schema: { type: "string" }, + description: "The ID of the passkey to delete", + }, + ], + responses: { + "200": { + description: "Passkey deleted successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + deletedPasskeyId: { type: "string", description: "ID of the deleted passkey" }, + }, + required: ["message", "deletedPasskeyId"], + }, + example: { + message: "Passkey deleted successfully", + deletedPasskeyId: "credential_id_123", + }, + }, + }, + }, + "400": { + description: "Cannot delete the currently active passkey", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Cannot delete the passkey you are currently using" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Authentication required" }, + }, + }, + }, + "403": { + description: "Not authorized to delete this passkey", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "You can only delete your own passkeys" }, + }, + }, + }, + "404": { + description: "Passkey not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Passkey not found" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, +}); + +export async function DELETE({ params, locals }: RequestEvent) { + const log = logger.setContext("API.DeletePasskey"); + const { passkeyId } = params; + + try { + // Check authentication + if (!locals.user) { + throw new AuthorizationError("Authentication required"); + } + + if (!passkeyId) { + throw new ValidationError("Passkey ID is required"); + } + + const userId = locals.user.id; + + log.debug("Attempting to delete passkey", { + passkeyId, + userId, + }); + + // Get all passkeys for the user + const userPasskeys = await WebAuthnService.getUserPasskeys(userId); + + // Check if passkey exists and belongs to the user + const passkeyToDelete = userPasskeys.find((p) => p.id === passkeyId); + + if (!passkeyToDelete) { + throw new NotFoundError("Passkey not found or does not belong to you"); + } + + // Check if this is the last passkey + if (userPasskeys.length === 1) { + throw new ValidationError("Cannot delete your last passkey"); + } + + // Check if this is the passkey being used for the current session + // We need to get the session to check the passkeyId + const sessionPayload = locals.user; + if (sessionPayload?.passkeyId === passkeyId) { + throw new ValidationError("Cannot delete the passkey you are currently using"); + } + + // Delete the passkey from the central database + const deletedPasskey = await UserService.deletePasskey(passkeyId); + + if (!deletedPasskey) { + throw new NotFoundError("Failed to delete passkey"); + } + + log.info("Passkey deleted successfully", { + passkeyId, + userId, + deviceName: deletedPasskey.deviceName, + }); + + // If user has a tenant (STAFF or TENANT_ADMIN), also delete associated crypto data (CASCADE) + if (locals.user.tenantId) { + const tenantId = locals.user.tenantId; + + try { + const staffCryptoService = new StaffCryptoService(); + const deleted = await staffCryptoService.deleteStaffCryptoForPasskey( + tenantId, + userId, + passkeyId, + ); + + log.info("Staff crypto data cascaded deletion completed", { + passkeyId, + userId, + tenantId, + deleted, + }); + } catch (error) { + // Log but don't fail the request - the passkey is already deleted + log.warn("Failed to delete staff crypto data for passkey (cascade)", { + passkeyId, + userId, + tenantId, + error: String(error), + }); + } + } + + return json({ + message: "Passkey deleted successfully", + deletedPasskeyId: passkeyId, + }); + } catch (error) { + logError(log)("Failed to delete passkey", error, locals.user?.id, params.passkeyId); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +} diff --git a/src/routes/api/auth/passkeys/[passkeyId]/__tests__/delete-passkey.test.ts b/src/routes/api/auth/passkeys/[passkeyId]/__tests__/delete-passkey.test.ts new file mode 100644 index 0000000..2a0e647 --- /dev/null +++ b/src/routes/api/auth/passkeys/[passkeyId]/__tests__/delete-passkey.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect } from "vitest"; + +describe("Delete Passkey API", () => { + const mockUserId = "456e7890-e89b-12d3-a456-426614174001"; + const mockPasskeyId = "passkey_abc123def456"; + const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; + + describe("DELETE /api/auth/passkeys/[passkeyId]", () => { + it("should validate authentication requirement", () => { + // No authenticated user + const user = null; + + // Should require authentication + const isAuthenticated = user !== null; + expect(isAuthenticated).toBe(false); + }); + + it("should verify passkey exists and belongs to user", () => { + const userPasskeys = [ + { id: "passkey_1", userId: mockUserId, deviceName: "Device 1" }, + { id: "passkey_2", userId: mockUserId, deviceName: "Device 2" }, + { id: mockPasskeyId, userId: mockUserId, deviceName: "Current Device" }, + ]; + + // Should find owned passkey + const ownedPasskey = userPasskeys.find((p) => p.id === mockPasskeyId); + expect(ownedPasskey).toBeDefined(); + expect(ownedPasskey?.userId).toBe(mockUserId); + + // Should not find unowned passkey + const unownedPasskeyId = "passkey_not_owned"; + const unownedPasskey = userPasskeys.find((p) => p.id === unownedPasskeyId); + expect(unownedPasskey).toBeUndefined(); + }); + + it("should prevent deletion of last passkey", () => { + const userPasskeys = [{ id: mockPasskeyId, userId: mockUserId, deviceName: "Only Device" }]; + + // Should detect this is the last passkey + const isLastPasskey = userPasskeys.length === 1; + expect(isLastPasskey).toBe(true); + expect(userPasskeys.length).toBe(1); + }); + + it("should prevent deletion of currently used passkey", () => { + const currentSessionPasskeyId = mockPasskeyId; + const passkeyToDelete = mockPasskeyId; + + // Should detect attempted deletion of current session passkey + const isDeletingCurrentPasskey = currentSessionPasskeyId === passkeyToDelete; + expect(isDeletingCurrentPasskey).toBe(true); + }); + + it("should allow deletion when user has multiple passkeys", () => { + const userPasskeys = [ + { id: "passkey_1", userId: mockUserId }, + { id: "passkey_2", userId: mockUserId }, + { id: mockPasskeyId, userId: mockUserId }, + ]; + + // Should allow deletion when multiple passkeys exist + const canDelete = userPasskeys.length > 1; + expect(canDelete).toBe(true); + expect(userPasskeys.length).toBeGreaterThan(1); + }); + + it("should allow deletion when not deleting current session passkey", () => { + const currentSessionPasskeyId: string = "passkey_1"; + const passkeyToDelete = mockPasskeyId; + + // Should allow deletion of different passkey + const isDeletingDifferentPasskey = currentSessionPasskeyId !== passkeyToDelete; + expect(isDeletingDifferentPasskey).toBe(true); + }); + + it("should validate successful deletion response structure", () => { + const mockSuccessResponse = { + message: "Passkey deleted successfully", + deletedPasskeyId: mockPasskeyId, + }; + + expect(mockSuccessResponse.message).toBe("Passkey deleted successfully"); + expect(mockSuccessResponse.deletedPasskeyId).toBe(mockPasskeyId); + expect(typeof mockSuccessResponse.deletedPasskeyId).toBe("string"); + }); + + it("should cascade delete staff crypto data for tenant users", () => { + // User with tenant (STAFF or TENANT_ADMIN) + const userWithTenant = { + id: mockUserId, + tenantId: mockTenantId, + role: "STAFF" as const, + }; + + // Should trigger cascade deletion + const shouldCascadeDelete = userWithTenant.tenantId !== null; + expect(shouldCascadeDelete).toBe(true); + + // Simulate crypto data deletion + const cryptoEntries = [ + { passkeyId: "passkey_1", userId: mockUserId, tenantId: mockTenantId }, + { passkeyId: mockPasskeyId, userId: mockUserId, tenantId: mockTenantId }, + { passkeyId: "passkey_3", userId: mockUserId, tenantId: mockTenantId }, + ]; + + const remainingAfterDeletion = cryptoEntries.filter( + (entry) => entry.passkeyId !== mockPasskeyId, + ); + + expect(cryptoEntries.length).toBe(3); + expect(remainingAfterDeletion.length).toBe(2); + expect(remainingAfterDeletion.every((e) => e.passkeyId !== mockPasskeyId)).toBe(true); + }); + + it("should not cascade delete for global admin without tenant", () => { + // Global admin without tenant + const globalAdmin = { + id: mockUserId, + tenantId: null, + role: "GLOBAL_ADMIN" as const, + }; + + // Should not trigger cascade deletion + const shouldCascadeDelete = globalAdmin.tenantId !== null; + expect(shouldCascadeDelete).toBe(false); + }); + + it("should validate error responses for different scenarios", () => { + const errorScenarios = [ + { + status: 400, + error: "Cannot delete your last passkey", + scenario: "Last passkey deletion attempt", + }, + { + status: 400, + error: "Cannot delete the passkey you are currently using", + scenario: "Current passkey deletion attempt", + }, + { + status: 400, + error: "Passkey ID is required", + scenario: "Missing passkey ID", + }, + { + status: 401, + error: "Authentication required", + scenario: "Unauthenticated request", + }, + { + status: 404, + error: "Passkey not found or does not belong to you", + scenario: "Passkey doesn't exist or wrong owner", + }, + { + status: 404, + error: "Failed to delete passkey", + scenario: "Database deletion failed", + }, + ]; + + errorScenarios.forEach((scenario) => { + expect(scenario.status).toBeGreaterThanOrEqual(400); + expect(scenario.status).toBeLessThan(600); + expect(scenario.error).toBeTruthy(); + expect(scenario.scenario).toBeTruthy(); + }); + }); + + it("should validate passkeyId parameter extraction", () => { + // Simulate route params + const params = { passkeyId: mockPasskeyId }; + + expect(params.passkeyId).toBe(mockPasskeyId); + expect(typeof params.passkeyId).toBe("string"); + expect(params.passkeyId.length).toBeGreaterThan(0); + }); + + it("should handle cascade deletion errors gracefully", () => { + // Even if cascade deletion fails, the passkey should be deleted + const passkeyDeleted = true; + const cascadeDeletionFailed = true; + + // Main operation should succeed + expect(passkeyDeleted).toBe(true); + + // But cascade failure should be logged + if (cascadeDeletionFailed) { + const shouldLogWarning = true; + expect(shouldLogWarning).toBe(true); + } + }); + + it("should maintain referential integrity after deletion", () => { + // Before deletion + const userPasskeys = [ + { id: "passkey_1", userId: mockUserId }, + { id: mockPasskeyId, userId: mockUserId }, + { id: "passkey_3", userId: mockUserId }, + ]; + + const cryptoEntries = [ + { passkeyId: "passkey_1", publicKey: "key1" }, + { passkeyId: mockPasskeyId, publicKey: "key2" }, + { passkeyId: "passkey_3", publicKey: "key3" }, + ]; + + // After deletion + const remainingPasskeys = userPasskeys.filter((p) => p.id !== mockPasskeyId); + const remainingCryptoEntries = cryptoEntries.filter((c) => c.passkeyId !== mockPasskeyId); + + // Both should be reduced by 1 + expect(userPasskeys.length - remainingPasskeys.length).toBe(1); + expect(cryptoEntries.length - remainingCryptoEntries.length).toBe(1); + + // No orphaned crypto entries + const passkeyIds = remainingPasskeys.map((p) => p.id); + remainingCryptoEntries.forEach((crypto) => { + expect(passkeyIds).toContain(crypto.passkeyId); + }); + }); + + it("should verify ownership before any database operations", () => { + const userPasskeys = [ + { id: "passkey_1", userId: "different_user_id" }, + { id: "passkey_2", userId: "another_user_id" }, + ]; + + // Attempting to delete passkey that doesn't belong to user + const attemptedDeletion = mockPasskeyId; + const ownedPasskey = userPasskeys.find( + (p) => p.id === attemptedDeletion && p.userId === mockUserId, + ); + + // Should fail ownership check + expect(ownedPasskey).toBeUndefined(); + }); + + it("should handle concurrent deletion attempts", () => { + // Simulate race condition where passkey is deleted between check and deletion + const initialCheck = { passkeyExists: true }; + const deletionResult = { passkeyExists: false }; // Already deleted + + // Should handle gracefully + expect(initialCheck.passkeyExists).toBe(true); + expect(deletionResult.passkeyExists).toBe(false); + + // Should return appropriate error + if (!deletionResult.passkeyExists) { + const shouldThrowNotFound = true; + expect(shouldThrowNotFound).toBe(true); + } + }); + + it("should validate logging for security audit trail", () => { + const logEntries = { + attemptLog: { + action: "DELETE_PASSKEY_ATTEMPT", + passkeyId: mockPasskeyId, + userId: mockUserId, + }, + successLog: { + action: "DELETE_PASSKEY_SUCCESS", + passkeyId: mockPasskeyId, + userId: mockUserId, + cascadeDeleted: true, + }, + errorLog: { + action: "DELETE_PASSKEY_ERROR", + passkeyId: mockPasskeyId, + userId: mockUserId, + error: "Some error", + }, + }; + + expect(logEntries.attemptLog.action).toBeTruthy(); + expect(logEntries.successLog.action).toBeTruthy(); + expect(logEntries.errorLog.action).toBeTruthy(); + }); + + it("should support deletion by device name context", () => { + const userPasskeys = [ + { id: "passkey_1", userId: mockUserId, deviceName: "iPhone 13" }, + { id: "passkey_2", userId: mockUserId, deviceName: "MacBook Pro" }, + { id: mockPasskeyId, userId: mockUserId, deviceName: "iPad" }, + ]; + + // User wants to delete specific device + const targetDeviceName = "iPad"; + const passkeyToDelete = userPasskeys.find((p) => p.deviceName === targetDeviceName); + + expect(passkeyToDelete).toBeDefined(); + expect(passkeyToDelete?.id).toBe(mockPasskeyId); + expect(passkeyToDelete?.deviceName).toBe(targetDeviceName); + }); + + it("should validate transaction rollback on error", () => { + // Simulate transaction steps + const steps = { + passkeyDeleted: true, + cryptoDeleted: false, // Cascade failed + transactionCommitted: false, + }; + + // But since passkey deletion is main operation and cascade is best-effort + // The transaction should still commit if passkey deletion succeeds + const mainOperationSucceeded = steps.passkeyDeleted; + expect(mainOperationSucceeded).toBe(true); + + // Cascade failure should only log warning + if (!steps.cryptoDeleted) { + const shouldLogWarning = true; + expect(shouldLogWarning).toBe(true); + } + }); + }); + + describe("Multi-Passkey Support Validation", () => { + it("should allow users to manage multiple passkeys independently", () => { + const passkeys = [ + { id: "pk1", deviceName: "Phone", createdAt: new Date("2024-01-01") }, + { id: "pk2", deviceName: "Laptop", createdAt: new Date("2024-01-15") }, + { id: "pk3", deviceName: "Tablet", createdAt: new Date("2024-02-01") }, + ]; + + // Each passkey is independent + expect(passkeys.length).toBe(3); + + // Can delete any non-current passkey + const currentPasskeyId = "pk1"; + const deletablePasskeys = passkeys.filter((p) => p.id !== currentPasskeyId); + expect(deletablePasskeys.length).toBe(2); + }); + + it("should preserve crypto data for remaining passkeys after deletion", () => { + const cryptoData = [ + { passkeyId: "pk1", publicKey: "key1", privateKeyShare: "share1" }, + { passkeyId: "pk2", publicKey: "key2", privateKeyShare: "share2" }, + { passkeyId: "pk3", publicKey: "key3", privateKeyShare: "share3" }, + ]; + + // Delete pk2 + const deletedPasskeyId = "pk2"; + const remainingCrypto = cryptoData.filter((c) => c.passkeyId !== deletedPasskeyId); + + // Others should remain intact + expect(remainingCrypto.length).toBe(2); + expect(remainingCrypto.find((c) => c.passkeyId === "pk1")).toBeDefined(); + expect(remainingCrypto.find((c) => c.passkeyId === "pk3")).toBeDefined(); + expect(remainingCrypto.find((c) => c.passkeyId === "pk2")).toBeUndefined(); + }); + + it("should validate each passkey has unique PRF output", () => { + // Each passkey produces different PRF for same salt + const prfOutputs = [ + { passkeyId: "pk1", prfOutput: Buffer.alloc(32, 0x11) }, + { passkeyId: "pk2", prfOutput: Buffer.alloc(32, 0x22) }, + { passkeyId: "pk3", prfOutput: Buffer.alloc(32, 0x33) }, + ]; + + // All should be different + const pk1Output = prfOutputs[0].prfOutput.toString("hex"); + const pk2Output = prfOutputs[1].prfOutput.toString("hex"); + const pk3Output = prfOutputs[2].prfOutput.toString("hex"); + + expect(pk1Output).not.toBe(pk2Output); + expect(pk2Output).not.toBe(pk3Output); + expect(pk1Output).not.toBe(pk3Output); + }); + }); +}); diff --git a/src/routes/api/auth/passkeys/[passkeyId]/crypto/+server.ts b/src/routes/api/auth/passkeys/[passkeyId]/crypto/+server.ts new file mode 100644 index 0000000..eb5829c --- /dev/null +++ b/src/routes/api/auth/passkeys/[passkeyId]/crypto/+server.ts @@ -0,0 +1,286 @@ +import { json } from "@sveltejs/kit"; +import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; +import { + AuthorizationError, + NotFoundError, + ValidationError, + ConflictError, + BackendError, + InternalError, + logError, +} from "$lib/server/utils/errors"; +import { checkPermission } from "$lib/server/utils/permissions"; +import type { RequestEvent } from "@sveltejs/kit"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { z } from "zod"; +import logger from "$lib/logger"; +import { createHash } from "crypto"; + +// Register OpenAPI documentation +registerOpenAPIRoute("/auth/passkeys/{passkeyId}/crypto", "POST", { + summary: "Store cryptographic keypair for a new passkey", + description: + "When a staff member adds a new passkey, this endpoint stores the cryptographic keys for the new passkey. " + + "The client must use the PRF extension to derive the passkey-based key shard, then create the database shard via XOR with the private key. " + + "This maintains zero-knowledge security where the server never sees the complete private key.", + tags: ["Authentication", "Cryptography"], + parameters: [ + { + name: "passkeyId", + in: "path", + required: true, + schema: { type: "string" }, + description: "The ID of the new passkey to store cryptographic keys for", + }, + ], + requestBody: { + description: "Cryptographic key data derived using PRF extension", + content: { + "application/json": { + schema: { + type: "object", + properties: { + tenantId: { + type: "string", + format: "uuid", + description: "Tenant ID where the staff member belongs", + }, + publicKey: { + type: "string", + description: "Base64-encoded ML-KEM-768 public key", + }, + privateKeyShare: { + type: "string", + description: + "Base64-encoded database shard of the private key (XOR-split with PRF-derived shard)", + }, + prfOutput: { + type: "string", + description: + "Base64-encoded PRF output from WebAuthn for verification (derived using email as salt)", + }, + }, + required: ["tenantId", "publicKey", "privateKeyShare", "prfOutput"], + }, + }, + }, + }, + responses: { + "200": { + description: "Cryptographic keys successfully stored for the new passkey", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + passkeyId: { type: "string" }, + }, + }, + example: { + success: true, + message: "Cryptographic keys stored successfully", + passkeyId: "credential_abc123", + }, + }, + }, + }, + "400": { + description: "Invalid request data or PRF verification failed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Not authorized to store keys for this passkey", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Passkey not found or doesn't belong to the authenticated user", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "409": { + description: "Cryptographic keys already exist for this passkey", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +// Validation schema for request body +const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), +}); + +/** + * Verify PRF output format and length + * PRF extension produces 32 bytes of deterministic output + */ +function verifyPRFOutput(prfOutputBase64: string): boolean { + try { + // Decode from base64 + const prfBuffer = Buffer.from(prfOutputBase64, "base64"); + + // PRF output should be exactly 32 bytes + if (prfBuffer.length !== 32) { + logger.setContext("API.StorePasskeyCrypto").warn("Invalid PRF output length", { + expected: 32, + actual: prfBuffer.length, + }); + return false; + } + + // Verify it's not all zeros (invalid output) + const isAllZeros = prfBuffer.every((byte) => byte === 0); + if (isAllZeros) { + logger.setContext("API.StorePasskeyCrypto").warn("PRF output is all zeros (invalid)"); + return false; + } + + return true; + } catch (error) { + logger.setContext("API.StorePasskeyCrypto").error("Failed to verify PRF output", { + error: String(error), + }); + return false; + } +} + +/** + * Create a hash of the PRF output for future verification + * This allows us to verify that the same PRF output is used consistently + */ +function hashPRFOutput(prfOutputBase64: string): string { + const prfBuffer = Buffer.from(prfOutputBase64, "base64"); + return createHash("sha256").update(prfBuffer).digest("hex"); +} + +export async function POST({ request, params, locals }: RequestEvent) { + const log = logger.setContext("API.StorePasskeyCrypto"); + const { passkeyId } = params; + + try { + // 1. Check authentication + if (!locals.user) { + throw new AuthorizationError("Authentication required"); + } + + if (!passkeyId) { + throw new ValidationError("Passkey ID is required"); + } + + const userId = locals.user.id; + + // 2. Verify passkey ownership BEFORE parsing body (security: prevent data injection) + const userPasskeys = await WebAuthnService.getUserPasskeys(userId); + const targetPasskey = userPasskeys.find((p) => p.id === passkeyId); + + if (!targetPasskey) { + throw new NotFoundError("Passkey not found or does not belong to you"); + } + + // 3. Parse and validate request body + const body = await request.json(); + const validation = requestSchema.safeParse(body); + + if (!validation.success) { + throw new ValidationError( + "Invalid request data: " + validation.error.issues.map((e) => e.message).join(", "), + ); + } + + const { tenantId, publicKey, privateKeyShare, prfOutput } = validation.data; + + // 4. Check tenant access using standard permission check + checkPermission(locals, tenantId, false); + + log.debug("Storing crypto keys for passkey", { + passkeyId, + userId, + tenantId, + }); + + // 5. Verify PRF output + if (!verifyPRFOutput(prfOutput)) { + throw new ValidationError( + "Invalid PRF output: Must be 32 bytes of valid cryptographic data derived from the passkey", + ); + } + + const prfHash = hashPRFOutput(prfOutput); + log.debug("PRF output verified successfully", { + passkeyId, + prfHash: prfHash.substring(0, 16) + "...", // Log partial hash for debugging + }); + + // 6. Check for existing crypto data - prevent accidental overwrites + const staffCryptoService = new StaffCryptoService(); + const existingCrypto = await staffCryptoService.getStaffCryptoForPasskey( + tenantId, + userId, + passkeyId, + ); + + if (existingCrypto) { + throw new ConflictError( + "Crypto keys already exist for this passkey. Delete the passkey first if you need to re-register it.", + ); + } + + // 7. Store the crypto keys + await staffCryptoService.storeStaffKeypair( + tenantId, + userId, + passkeyId, + publicKey, + privateKeyShare, + ); + + log.info("Crypto keys stored successfully", { + passkeyId, + userId, + tenantId, + prfHash: prfHash.substring(0, 16) + "...", + }); + + return json({ + success: true, + message: "Crypto keys stored successfully", + passkeyId, + }); + } catch (error) { + logError(log)("Failed to store crypto keys", error, locals.user?.id, params.passkeyId); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +} diff --git a/src/routes/api/auth/passkeys/[passkeyId]/crypto/__tests__/crypto.test.ts b/src/routes/api/auth/passkeys/[passkeyId]/crypto/__tests__/crypto.test.ts new file mode 100644 index 0000000..ee78bb5 --- /dev/null +++ b/src/routes/api/auth/passkeys/[passkeyId]/crypto/__tests__/crypto.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; + +describe("Store Passkey Crypto Keys API", () => { + const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; + const mockUserId = "456e7890-e89b-12d3-a456-426614174001"; + const mockPasskeyId = "passkey_abc123def456"; + const mockEmail = "staff@example.com"; + + describe("POST /api/auth/passkeys/[passkeyId]/crypto", () => { + it("should validate request body with Zod schema", () => { + const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), + }); + + const validRequest = { + tenantId: mockTenantId, + publicKey: "SGVsbG8gV29ybGQ=", // Mock base64 + privateKeyShare: "VGVzdCBEYXRh", // Mock base64 + prfOutput: "UHJvb2ZPZk93bmVyc2hpcA==", // Mock base64 + }; + + const result = requestSchema.safeParse(validRequest); + expect(result.success).toBe(true); + }); + + it("should reject request without tenantId", () => { + const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), + }); + + const invalidRequest = { + publicKey: "SGVsbG8gV29ybGQ=", + privateKeyShare: "VGVzdCBEYXRh", + prfOutput: "UHJvb2ZPZk93bmVyc2hpcA==", + }; + + const result = requestSchema.safeParse(invalidRequest); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("string"); + } + }); + + it("should reject request with invalid UUID tenantId", () => { + const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), + }); + + const invalidRequest = { + tenantId: "not-a-uuid", + publicKey: "SGVsbG8gV29ybGQ=", + privateKeyShare: "VGVzdCBEYXRh", + prfOutput: "UHJvb2ZPZk93bmVyc2hpcA==", + }; + + const result = requestSchema.safeParse(invalidRequest); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("tenant ID is required"); + } + }); + + it("should reject request without publicKey", () => { + const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), + }); + + const invalidRequest = { + tenantId: mockTenantId, + privateKeyShare: "VGVzdCBEYXRh", + prfOutput: "UHJvb2ZPZk93bmVyc2hpcA==", + }; + + const result = requestSchema.safeParse(invalidRequest); + expect(result.success).toBe(false); + }); + + it("should reject request without privateKeyShare", () => { + const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), + }); + + const invalidRequest = { + tenantId: mockTenantId, + publicKey: "SGVsbG8gV29ybGQ=", + prfOutput: "UHJvb2ZPZk93bmVyc2hpcA==", + }; + + const result = requestSchema.safeParse(invalidRequest); + expect(result.success).toBe(false); + }); + + it("should reject request without PRF output", () => { + const requestSchema = z.object({ + tenantId: z.string().uuid("Valid tenant ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + prfOutput: z.string().min(1, "PRF output is required for zero-knowledge verification"), + }); + + const invalidRequest = { + tenantId: mockTenantId, + publicKey: "SGVsbG8gV29ybGQ=", + privateKeyShare: "VGVzdCBEYXRh", + }; + + const result = requestSchema.safeParse(invalidRequest); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("string"); + } + }); + + it("should verify passkey ownership validation", () => { + // Simulate user passkeys + const userPasskeys = [ + { id: "passkey_1", userId: mockUserId }, + { id: "passkey_2", userId: mockUserId }, + { id: mockPasskeyId, userId: mockUserId }, + ]; + + // Test finding owned passkey + const ownedPasskey = userPasskeys.find((p) => p.id === mockPasskeyId); + expect(ownedPasskey).toBeDefined(); + expect(ownedPasskey?.userId).toBe(mockUserId); + + // Test rejecting unowned passkey + const unownedPasskeyId = "passkey_not_owned"; + const unownedPasskey = userPasskeys.find((p) => p.id === unownedPasskeyId); + expect(unownedPasskey).toBeUndefined(); + }); + + it("should validate tenant access using checkPermission logic", () => { + // Mock user with tenant access + const user = { + id: mockUserId, + role: "STAFF" as const, + tenantId: mockTenantId, + }; + + // Should allow access if tenantId matches + const hasAccess = user.tenantId === mockTenantId; + expect(hasAccess).toBe(true); + + // Should deny access if tenantId doesn't match + const differentTenantId = "999e4567-e89b-12d3-a456-426614174999"; + const hasNoAccess = user.tenantId === differentTenantId; + expect(hasNoAccess).toBe(false); + }); + + it("should allow GLOBAL_ADMIN to access any tenant", () => { + const globalAdmin = { + id: mockUserId, + role: "GLOBAL_ADMIN" as const, + tenantId: null, + }; + + // Global admin should have access regardless of tenant + const canAccessAnyTenant = globalAdmin.role === "GLOBAL_ADMIN"; + expect(canAccessAnyTenant).toBe(true); + }); + + it("should detect duplicate crypto keys for same passkey", () => { + // Simulate existing crypto entry + const existingCrypto = { + userId: mockUserId, + passkeyId: mockPasskeyId, + publicKey: "existing_key", + privateKeyShare: "existing_share", + }; + + // Should detect conflict + const isDuplicate = existingCrypto.passkeyId === mockPasskeyId; + expect(isDuplicate).toBe(true); + }); + + it("should validate PRF output format", () => { + // PRF output should be base64 encoded + const mockPrfOutput = "UHJvb2ZPZk93bmVyc2hpcA=="; + const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; + + expect(mockPrfOutput).toMatch(base64Pattern); + + // Should decode to Buffer without errors + const decoded = Buffer.from(mockPrfOutput, "base64"); + expect(decoded).toBeInstanceOf(Buffer); + expect(decoded.length).toBeGreaterThan(0); + }); + + it("should validate PRF output is exactly 32 bytes", () => { + // Create a valid 32-byte PRF output + const validPrfOutput = Buffer.alloc(32, 0xaa); + const validPrfOutputBase64 = validPrfOutput.toString("base64"); + + const decoded = Buffer.from(validPrfOutputBase64, "base64"); + expect(decoded.length).toBe(32); + + // Test invalid lengths + const invalidPrfOutput = Buffer.alloc(16, 0xaa); // Too short + const invalidPrfOutputBase64 = invalidPrfOutput.toString("base64"); + const decodedInvalid = Buffer.from(invalidPrfOutputBase64, "base64"); + expect(decodedInvalid.length).not.toBe(32); + }); + + it("should reject PRF output with all zeros", () => { + // All-zero PRF output is invalid + const allZerosPrfOutput = Buffer.alloc(32, 0); + const isAllZeros = allZerosPrfOutput.every((byte) => byte === 0); + expect(isAllZeros).toBe(true); + + // Valid PRF output should not be all zeros + const validPrfOutput = Buffer.alloc(32, 0xaa); + const isNotAllZeros = !validPrfOutput.every((byte) => byte === 0); + expect(isNotAllZeros).toBe(true); + }); + + it("should validate PRF output is derived from email salt", () => { + // PRF salt format: "open-reception-prf:{email}" + const expectedSalt = `open-reception-prf:${mockEmail}`; + const encoder = new TextEncoder(); + const saltBytes = encoder.encode(expectedSalt); + + expect(saltBytes).toBeInstanceOf(Uint8Array); + expect(saltBytes.length).toBeGreaterThan(0); + expect(saltBytes.length).toBe(expectedSalt.length); + }); + + it("should validate successful response structure", () => { + const mockSuccessResponse = { + success: true, + message: "Crypto keys stored successfully", + passkeyId: mockPasskeyId, + }; + + expect(mockSuccessResponse.success).toBe(true); + expect(mockSuccessResponse.message).toBeTruthy(); + expect(mockSuccessResponse.passkeyId).toBe(mockPasskeyId); + }); + + it("should validate error responses for different scenarios", () => { + const errorScenarios = [ + { + status: 400, + error: "Invalid request data", + description: "Validation error", + }, + { + status: 403, + error: "Not authorized", + description: "Permission denied", + }, + { + status: 404, + error: "Passkey not found", + description: "Passkey doesn't exist", + }, + { + status: 409, + error: "Crypto keys already exist", + description: "Duplicate keys", + }, + ]; + + errorScenarios.forEach((scenario) => { + expect(scenario.status).toBeGreaterThanOrEqual(400); + expect(scenario.status).toBeLessThan(600); + expect(scenario.error).toBeTruthy(); + }); + }); + + it("should validate ML-KEM-768 key specifications", () => { + // ML-KEM-768 (formerly Kyber-768) key sizes + const mlKem768PublicKeySize = 1184; // bytes + const mlKem768PrivateKeySize = 2400; // bytes + + // Base64 encoding increases size by ~33% (4/3) + const expectedPublicKeyBase64Size = Math.ceil((mlKem768PublicKeySize * 4) / 3); + const expectedPrivateKeyBase64Size = Math.ceil((mlKem768PrivateKeySize * 4) / 3); + + expect(expectedPublicKeyBase64Size).toBeGreaterThan(1500); + expect(expectedPrivateKeyBase64Size).toBeGreaterThan(3100); + }); + + it("should validate authentication requirement", () => { + // No authenticated user + const user = null; + + // Should require authentication + const isAuthenticated = user !== null; + expect(isAuthenticated).toBe(false); + }); + + it("should validate cascading deletion on passkey removal", () => { + // Mock crypto entries + const cryptoEntries = [ + { passkeyId: "passkey_1", userId: mockUserId }, + { passkeyId: mockPasskeyId, userId: mockUserId }, + { passkeyId: "passkey_3", userId: mockUserId }, + ]; + + // Simulate deletion + const remainingEntries = cryptoEntries.filter((entry) => entry.passkeyId !== mockPasskeyId); + + expect(cryptoEntries.length).toBe(3); + expect(remainingEntries.length).toBe(2); + expect(remainingEntries.some((e) => e.passkeyId === mockPasskeyId)).toBe(false); + }); + + it("should validate zero-knowledge architecture principles", () => { + // Server should never see complete private key + const passkeyBasedShard = new Uint8Array([1, 2, 3, 4]); // PRF-derived (client-only) + const databaseShard = new Uint8Array([5, 6, 7, 8]); // Stored on server + const privateKey = new Uint8Array(4); // Complete key (client-only) + + // XOR reconstruction happens only on client + for (let i = 0; i < privateKey.length; i++) { + privateKey[i] = passkeyBasedShard[i] ^ databaseShard[i]; + } + + // Verify XOR properties + expect(privateKey[0]).toBe(1 ^ 5); + expect(privateKey[1]).toBe(2 ^ 6); + + // Server never has passkeyBasedShard + const serverHasCompleteKey = false; + expect(serverHasCompleteKey).toBe(false); + }); + }); +}); diff --git a/src/routes/api/openapi.json/+server.ts b/src/routes/api/openapi.json/+server.ts index 0551e95..4b0b631 100644 --- a/src/routes/api/openapi.json/+server.ts +++ b/src/routes/api/openapi.json/+server.ts @@ -10,6 +10,8 @@ import "../health/services/+server.js"; import "../auth/challenge/+server.js"; import "../auth/login/+server.js"; import "../auth/passkeys/+server.js"; +import "../auth/passkeys/[passkeyId]/+server.js"; +import "../auth/passkeys/[passkeyId]/crypto/+server.js"; import "../auth/confirm/+server.js"; import "../auth/register/[id]/+server.js"; import "../auth/resend-confirmation/+server.js"; diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index eeac0a2..2119038 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -25,6 +25,7 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { user: SelectUser; sessionId: string; exp: Date; + passkeyId?: string; } | null = null; if (accessToken) { // Verify access token with database session check @@ -37,7 +38,7 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { }); } - // Add sessionId to the user object for easy access + // Add sessionId and passkeyId to the user object for easy access event.locals.user = { session: { exp: sessionData.exp.valueOf(),