diff --git a/.env.development b/.env.development index a273535f4..861d83038 100644 --- a/.env.development +++ b/.env.development @@ -26,6 +26,10 @@ MANA_CORE_AUTH_URL=http://localhost:3001 # Service key for bot-to-auth communication (Matrix-SSO-Link) MANA_CORE_SERVICE_KEY=dev-service-key-for-bot-sso-2024 +# WebAuthn / Passkeys (localhost for dev, mana.how for production) +WEBAUTHN_RP_ID=localhost +WEBAUTHN_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:3001 + # JWT Keys (shared across apps for token verification) JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGRsOXROB4lprw\n9oXaOIt+cwHe3UxBOoiWiUXcpFuXwb+kBWn/LyjeCIOXtefOwE0S10JEodK+6foe\naqGHanq86qAmmkb4a8sjj5LAxXkHL35sJo8HaYcx5NkJQLxQSRHpTfdfxsKsKwxa\n4R4uqrvToqdo6tl/VMsGDPS8L7KzaiKaSdGugvlVtXWgV1soeXSUPyPwpyAXQg7h\nY4CkTSkJAplrs77RLdj8u6jbHKR3F7QkwiU1JocjhM1GP/suKiqXRu8omLFnu45C\ns09SNSRsOpNY5csrKA4PZ2LCks9VHH7HafFvB+BbRw4+Ssr6myOysAztqi3bZMRW\nLTakWpBbAgMBAAECggEAF5zi0IzaghHxhtkyYfrSRgSynX9+WYBRNu2ch8/SZqAj\neghOXMkZgAPEjtiSMDGqRsr4ReMoYtB2Qea8sOX8kwC1gj4Po1Mhtez0cwexclUf\nebLH3X/y9/1YiZJk5YImOMIuaoC/ELDvFOhIEhJcMbKREbIc+oiMcH6HgN0vViVh\nJptgHTnqnGHNARkEpf+xnxqJJxEgrEMz50b4fApKpoZsWXNnZ3Atc/i2ziGew5z4\npnGJxs9TWSukBZaQvl9iluBBvqmPkCOId+L7CmB44bNURpqQOm8gxEgLcdn06y5j\nIKee3Z4H6OTseFvSIYYqBqCyyyZWHICBZXUCDQKUbQKBgQDnFe+O+pQc5looLFiF\nxuYsfDtJqvoMgQ0BaVAo6wVpPe6w+1NA6ZxghcM0+8zyc70jZvdMXINhdsfWD5Gi\nJ/NEDI8EXJJKMfnFQ7F1Ad5NyTnnn/TsLda4GIGQznPRS6uxUP4ljFtxmU9G8Diz\nUQ47XsLjwzzbTedMTSYoQ46kdwKBgQDbp0dIq047o4A72/BBttKdZbgQmjFmqCXF\n8YRUquIDXh/CJ4OQwOIaOvk2398Rg53c3MsV+XCJaMmWYqnJ4BdITLsqeGKsczoS\nI0DMehDr++aOoX/f29r1c+7J/fV5jtAEUcwIEOR1vyAM+WdiWnnTvdpMPVUDsgaT\ntuH0E8WgPQKBgQCCINci87Z+Q7VXVAmRY7zwJhEY3eArNGzHc6+BKz+D0S1dmll6\nf1LhA9I2PuldSpGiovP1m08cjk/gGipPXyHdGxlaQmravyPA0urWUfQGZ59k8K1y\nZim4x4wGqEuN+4e2tT44lL5VzRhYgSPcznMuOaGTsrjNYiQy0mr/V3O25wKBgHvV\nryaVDaIp553XvXgO7ma2djNF+xv5KHKUWxqwzINBiX4YcOAnHlHTdbUuOcDSByoB\ngK1+16dgYGZccYTSxc2JFOw4usimndKj9WBSYT/p4G4BNuqqNKO1HKbceoxxq20E\nAJd7jpGjkxo9cb/Nammp22yoF0niEDsvG+xTSVOxAoGBAMfxHYCMdPc625upCbqG\nkPSJJGYREKGad80OtXilYXLvBPzV65q32k2YZGjaicPKRAzj72KO4nfIu9SY6bfO\nBvXCtIcvllZQuxyd3Cd8MirujJodKwThLTMd4bAYYMXGz1/W6R6pzunZs5KEpgEr\nczy9Gk9WNp0t8vfzyZZ9aago\n-----END PRIVATE KEY-----" JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxkbDl0TgeJaa8PaF2jiL\nfnMB3t1MQTqIlolF3KRbl8G/pAVp/y8o3giDl7XnzsBNEtdCRKHSvun6Hmqhh2p6\nvOqgJppG+GvLI4+SwMV5By9+bCaPB2mHMeTZCUC8UEkR6U33X8bCrCsMWuEeLqq7\n06KnaOrZf1TLBgz0vC+ys2oimknRroL5VbV1oFdbKHl0lD8j8KcgF0IO4WOApE0p\nCQKZa7O+0S3Y/Luo2xykdxe0JMIlNSaHI4TNRj/7Lioql0bvKJixZ7uOQrNPUjUk\nbDqTWOXLKygOD2diwpLPVRx+x2nxbwfgW0c+Ssr6myOysAztqi3bZMRWLTakWpBb\nwIDAQAB\n-----END PUBLIC KEY-----" diff --git a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts index 327881b8b..8310c2494 100644 --- a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts @@ -118,6 +118,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte b/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte index dd053734c..e55516535 100644 --- a/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte @@ -56,6 +56,8 @@ primaryColor="#0ea5e9" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts index 10574ae72..27186ff81 100644 --- a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts @@ -118,6 +118,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte index 014b76bf6..64aa778de 100644 --- a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte @@ -63,6 +63,8 @@ primaryColor="#0ea5e9" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts b/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts index 5816eee68..ebd5df713 100644 --- a/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts @@ -105,6 +105,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte b/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte index aa9def267..13427ff98 100644 --- a/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte @@ -47,6 +47,8 @@ primaryColor="#2563eb" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts index 9f489afdb..738cdbc29 100644 --- a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts @@ -118,6 +118,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte b/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte index 0c7708aef..d097d9ed5 100644 --- a/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte @@ -54,6 +54,8 @@ primaryColor="#f59e0b" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index fd1775857..8f7b80639 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -118,6 +118,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte b/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte index 04709eb29..07ce2f8a6 100644 --- a/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte @@ -53,6 +53,8 @@ primaryColor="#3b82f6" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/context/apps/web/src/lib/stores/auth.svelte.ts b/apps/context/apps/web/src/lib/stores/auth.svelte.ts index 4280d9896..7478d546f 100644 --- a/apps/context/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/context/apps/web/src/lib/stores/auth.svelte.ts @@ -106,6 +106,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/context/apps/web/src/routes/(auth)/login/+page.svelte b/apps/context/apps/web/src/routes/(auth)/login/+page.svelte index a1407c3fc..99897720d 100644 --- a/apps/context/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/context/apps/web/src/routes/(auth)/login/+page.svelte @@ -48,6 +48,8 @@ primaryColor="#0ea5e9" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index 8e6ab7365..89b112403 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -101,6 +101,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte b/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte index 99f52c093..161bc803a 100644 --- a/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte @@ -32,6 +32,8 @@ primaryColor="#6366f1" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect="/dashboard" registerPath="/register" diff --git a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts b/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts index 5f1471d3a..1722c56f5 100644 --- a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts @@ -93,6 +93,20 @@ export const authStore = { return true; }, + isPasskeyAvailable(): boolean { + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const result = await authService.signInWithPasskey(); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = toManaUser(userData); + } + return result; + }, + + /** /** * Sign in with email and password */ diff --git a/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte b/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte index 0d969c437..a12cd68ac 100644 --- a/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte @@ -32,6 +32,8 @@ primaryColor="#8b5cf6" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect="/decks" registerPath="/register" diff --git a/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts b/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts index 791254643..731e70549 100644 --- a/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts @@ -107,6 +107,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte b/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte index 36655d1fc..1d4aa2fbe 100644 --- a/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte @@ -50,6 +50,8 @@ primaryColor="#f97316" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts index deee6cf72..1e5af1606 100644 --- a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts @@ -116,6 +116,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte b/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte index 28650cb79..9526325dd 100644 --- a/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte @@ -52,6 +52,8 @@ primaryColor="#22C55E" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/photos/apps/web/src/lib/stores/auth.svelte.ts b/apps/photos/apps/web/src/lib/stores/auth.svelte.ts index c44f17943..1e9a37f1c 100644 --- a/apps/photos/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/auth.svelte.ts @@ -109,6 +109,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte b/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte index eae543453..a55fd1c9d 100644 --- a/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte @@ -43,6 +43,8 @@ primaryColor="#8b5cf6" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts index 72ef847f1..fe1ef950d 100644 --- a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts @@ -116,6 +116,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/picture/apps/web/src/routes/auth/login/+page.svelte b/apps/picture/apps/web/src/routes/auth/login/+page.svelte index 486313c5d..b49883252 100644 --- a/apps/picture/apps/web/src/routes/auth/login/+page.svelte +++ b/apps/picture/apps/web/src/routes/auth/login/+page.svelte @@ -36,6 +36,8 @@ primaryColor="#3b82f6" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect="/app/gallery" registerPath="/auth/signup" diff --git a/apps/planta/apps/web/src/lib/stores/auth.svelte.ts b/apps/planta/apps/web/src/lib/stores/auth.svelte.ts index d736fa8af..598d1f053 100644 --- a/apps/planta/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/planta/apps/web/src/lib/stores/auth.svelte.ts @@ -115,6 +115,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte b/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte index 8891fc956..45cf4f1e8 100644 --- a/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte @@ -52,6 +52,8 @@ primaryColor="#22c55e" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/playground/apps/web/src/lib/stores/auth.svelte.ts b/apps/playground/apps/web/src/lib/stores/auth.svelte.ts index d6efeb1d2..eb279191b 100644 --- a/apps/playground/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/playground/apps/web/src/lib/stores/auth.svelte.ts @@ -84,6 +84,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) throw new Error('Auth not initialized'); diff --git a/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte b/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte index 2ed380544..ebf39666f 100644 --- a/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte @@ -34,6 +34,8 @@ primaryColor="#06b6d4" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts index 47500e369..bff0415bb 100644 --- a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts @@ -115,6 +115,43 @@ export const auth = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte b/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte index fdead3efa..f37ccd7ee 100644 --- a/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte @@ -40,6 +40,8 @@ primaryColor="#f97316" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={auth.isPasskeyAvailable()} + onSignInWithPasskey={() => auth.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts index bb2d8a9e3..b72178136 100644 --- a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts @@ -113,6 +113,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte index 61534ba6a..3885d5e35 100644 --- a/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte @@ -58,6 +58,8 @@ primaryColor="#8b5cf6" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts index d312b0af5..b07d98d4b 100644 --- a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts @@ -117,6 +117,35 @@ export const authStore = { } }, + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + async signIn(email: string, password: string) { const authService = getAuthService(); if (!authService) { diff --git a/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte b/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte index dc1ec65b5..d3be2c7f1 100644 --- a/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte @@ -58,6 +58,8 @@ primaryColor="#10b981" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/storage/apps/web/src/lib/stores/auth.svelte.ts b/apps/storage/apps/web/src/lib/stores/auth.svelte.ts index 117bd3e03..da3b37124 100644 --- a/apps/storage/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/storage/apps/web/src/lib/stores/auth.svelte.ts @@ -114,6 +114,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts index 371c33ac7..fbfa6f1da 100644 --- a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts @@ -129,6 +129,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte b/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte index a1e19cdf5..77181ae00 100644 --- a/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte @@ -55,6 +55,8 @@ primaryColor="#8b5cf6" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts index d12538694..68ec766ca 100644 --- a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts @@ -118,6 +118,43 @@ export const authStore = { } }, + /** + /** + * Check if passkeys are available in this browser + */ + isPasskeyAvailable(): boolean { + const authService = getAuthService(); + if (!authService) return false; + return authService.isPasskeyAvailable(); + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signInWithPasskey(); + + if (!result.success) { + return { success: false, error: result.error || 'Passkey authentication failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + /** * Sign in with email and password */ diff --git a/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte b/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte index 6530a6382..6d18f9a63 100644 --- a/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte @@ -53,6 +53,8 @@ primaryColor="#f59e0b" onSignIn={handleSignIn} onResendVerification={handleResendVerification} + passkeyAvailable={authStore.isPasskeyAvailable()} + onSignInWithPasskey={() => authStore.signInWithPasskey()} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte index 1cfc1677f..303d89fa8 100644 --- a/packages/shared-auth-ui/src/pages/LoginPage.svelte +++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte @@ -84,6 +84,8 @@ version?: string; /** Build timestamp (ISO string) to display next to version */ buildTime?: string; + onSignInWithPasskey?: () => Promise; + passkeyAvailable?: boolean; } let { @@ -106,6 +108,8 @@ initialPassword = '', version = '', buildTime = '', + onSignInWithPasskey, + passkeyAvailable = false, }: Props = $props(); const t = $derived({ ...defaultTranslations, ...translations }); @@ -254,6 +258,25 @@ } } + async function handlePasskeySignIn() { + if (!onSignInWithPasskey) return; + loading = true; + clearError(); + + const result = await onSignInWithPasskey(); + loading = false; + + if (result.success) { + showSuccess = true; + successAnnouncement = t.signInSuccess; + setTimeout(() => goto(successRedirect), 600); + } else if (result.error === 'Passkey authentication was cancelled') { + // User cancelled - don't show error + } else { + setError(result.error || t.signInFailed, 'general'); + } + } + function skipToForm() { if (emailInput) emailInput.focus(); } @@ -349,6 +372,34 @@

{t.subtitle}

+ {#if passkeyAvailable && onSignInWithPasskey} + +
+ {t.orDivider} +
+ {/if} + {#if verificationEmailSent}
@@ -927,6 +978,61 @@ cursor: not-allowed; } + .passkey-button { + width: 100%; + height: 3.5rem; + border: 2px solid; + border-radius: 0.75rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + cursor: pointer; + transition: opacity 0.2s; + color: rgba(255, 255, 255, 0.9); + background: transparent; + margin-bottom: 0; + } + + .light .passkey-button { + color: rgba(0, 0, 0, 0.9); + } + + .passkey-button:hover:not(:disabled) { + opacity: 0.85; + } + + .passkey-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .divider { + display: flex; + align-items: center; + gap: 1rem; + margin: 1.25rem 0; + } + + .divider::before, + .divider::after { + content: ''; + flex: 1; + height: 1px; + background: currentColor; + opacity: 0.2; + } + + .divider span { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + } + + .light .divider span { + color: rgba(0, 0, 0, 0.5); + } + .register-link { text-align: center; font-size: 0.875rem; diff --git a/packages/shared-auth/package.json b/packages/shared-auth/package.json index ab2e5980b..3d21b59c2 100644 --- a/packages/shared-auth/package.json +++ b/packages/shared-auth/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@manacore/shared-types": "workspace:*", + "@simplewebauthn/browser": "^13.3.0", "base64-js": "^1.5.1" }, "devDependencies": { diff --git a/packages/shared-auth/src/core/authService.ts b/packages/shared-auth/src/core/authService.ts index 504286ea6..baa8491bb 100644 --- a/packages/shared-auth/src/core/authService.ts +++ b/packages/shared-auth/src/core/authService.ts @@ -58,6 +58,11 @@ const DEFAULT_ENDPOINTS: AuthEndpoints = { credits: '/api/v1/credits/balance', // Better Auth native endpoints for SSO getSession: '/api/auth/get-session', + passkeyRegisterOptions: '/api/v1/auth/passkeys/register/options', + passkeyRegisterVerify: '/api/v1/auth/passkeys/register/verify', + passkeyAuthOptions: '/api/v1/auth/passkeys/authenticate/options', + passkeyAuthVerify: '/api/v1/auth/passkeys/authenticate/verify', + passkeyList: '/api/v1/auth/passkeys', }; /** @@ -358,6 +363,210 @@ export function createAuthService(config: AuthServiceConfig) { return { appToken, refreshToken, userData }; }, + /** + * Check if WebAuthn/Passkeys are supported in this browser + */ + isPasskeyAvailable(): boolean { + if (typeof window === 'undefined') return false; + return !!window.PublicKeyCredential; + }, + + /** + * Register a new passkey for the current user + */ + async registerPasskey(friendlyName?: string): Promise { + try { + const { startRegistration } = await import('@simplewebauthn/browser'); + const appToken = await service.getAppToken(); + if (!appToken) return { success: false, error: 'Not authenticated' }; + + // Step 1: Get registration options from server + const optionsRes = await fetch(`${baseUrl}${endpoints.passkeyRegisterOptions}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${appToken}`, + }, + }); + + if (!optionsRes.ok) { + const err = await optionsRes.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to get registration options' }; + } + + const { options, challengeId } = await optionsRes.json(); + + // Step 2: Create credential via browser WebAuthn API + const credential = await startRegistration({ optionsJSON: options }); + + // Step 3: Send credential to server for verification + const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyRegisterVerify}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${appToken}`, + }, + body: JSON.stringify({ challengeId, credential, friendlyName }), + }); + + if (!verifyRes.ok) { + const err = await verifyRes.json().catch(() => ({})); + return { success: false, error: err.message || 'Passkey registration failed' }; + } + + trackAuth('passkey_registered'); + return { success: true }; + } catch (error) { + // User cancelled or WebAuthn error + if (error instanceof Error && error.name === 'NotAllowedError') { + return { success: false, error: 'Passkey registration was cancelled' }; + } + console.error('Passkey registration error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Passkey registration failed', + }; + } + }, + + /** + * Sign in with a passkey + */ + async signInWithPasskey(): Promise { + try { + const { startAuthentication } = await import('@simplewebauthn/browser'); + const storage = getStorageAdapter(); + + // Step 1: Get authentication options from server + const optionsRes = await fetch(`${baseUrl}${endpoints.passkeyAuthOptions}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!optionsRes.ok) { + const err = await optionsRes.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to get authentication options' }; + } + + const { options, challengeId } = await optionsRes.json(); + + // Step 2: Authenticate via browser WebAuthn API + const credential = await startAuthentication({ optionsJSON: options }); + + // Step 3: Send credential to server for verification + const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyAuthVerify}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challengeId, credential }), + }); + + if (!verifyRes.ok) { + const err = await verifyRes.json().catch(() => ({})); + return { success: false, error: err.message || 'Passkey authentication failed' }; + } + + const data = await verifyRes.json(); + const appToken = data.accessToken; + const refreshToken = data.refreshToken; + + await Promise.all([ + storage.setItem(storageKeys.APP_TOKEN, appToken), + storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken), + storage.setItem(storageKeys.USER_EMAIL, data.user?.email || ''), + ]); + + trackAuth('login', { method: 'passkey' }); + return { success: true }; + } catch (error) { + if (error instanceof Error && error.name === 'NotAllowedError') { + return { success: false, error: 'Passkey authentication was cancelled' }; + } + console.error('Passkey authentication error:', error); + trackAuth('login_failed', { method: 'passkey' }); + return { + success: false, + error: error instanceof Error ? error.message : 'Passkey authentication failed', + }; + } + }, + + /** + * List user's registered passkeys + */ + async listPasskeys(): Promise { + try { + const appToken = await service.getAppToken(); + if (!appToken) return []; + + const res = await fetch(`${baseUrl}${endpoints.passkeyList}`, { + headers: { Authorization: `Bearer ${appToken}` }, + }); + + if (!res.ok) return []; + return await res.json(); + } catch { + return []; + } + }, + + /** + * Delete a passkey + */ + async deletePasskey(passkeyId: string): Promise { + try { + const appToken = await service.getAppToken(); + if (!appToken) return { success: false, error: 'Not authenticated' }; + + const res = await fetch(`${baseUrl}${endpoints.passkeyList}/${passkeyId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${appToken}` }, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to delete passkey' }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete passkey', + }; + } + }, + + /** + * Rename a passkey + */ + async renamePasskey(passkeyId: string, friendlyName: string): Promise { + try { + const appToken = await service.getAppToken(); + if (!appToken) return { success: false, error: 'Not authenticated' }; + + const res = await fetch(`${baseUrl}${endpoints.passkeyList}/${passkeyId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${appToken}`, + }, + body: JSON.stringify({ friendlyName }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to rename passkey' }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename passkey', + }; + } + }, + /** * Get the current app token */ diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts index 52d7ab0a1..4cd551546 100644 --- a/packages/shared-auth/src/types/index.ts +++ b/packages/shared-auth/src/types/index.ts @@ -133,6 +133,11 @@ export interface AuthEndpoints { credits: string; /** Better Auth native endpoint for SSO session check */ getSession: string; + passkeyRegisterOptions: string; + passkeyRegisterVerify: string; + passkeyAuthOptions: string; + passkeyAuthVerify: string; + passkeyList: string; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81c593713..e3c3edfe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -978,6 +978,18 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking + '@manacore/shared-feedback-service': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-service + '@manacore/shared-feedback-ui': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-ui + '@manacore/shared-help-types': + specifier: workspace:* + version: link:../../../../packages/shared-help-types + '@manacore/shared-help-ui': + specifier: workspace:* + version: link:../../../../packages/shared-help-ui '@manacore/shared-i18n': specifier: workspace:* version: link:../../../../packages/shared-i18n @@ -2941,6 +2953,18 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking + '@manacore/shared-feedback-service': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-service + '@manacore/shared-feedback-ui': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-ui + '@manacore/shared-help-types': + specifier: workspace:* + version: link:../../../../packages/shared-help-types + '@manacore/shared-help-ui': + specifier: workspace:* + version: link:../../../../packages/shared-help-ui '@manacore/shared-i18n': specifier: workspace:* version: link:../../../../packages/shared-i18n @@ -4609,6 +4633,12 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking + '@manacore/shared-feedback-service': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-service + '@manacore/shared-feedback-ui': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -5126,6 +5156,12 @@ importers: '@manacore/shared-error-tracking': specifier: workspace:* version: link:../../../../packages/shared-error-tracking + '@manacore/shared-feedback-service': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-service + '@manacore/shared-feedback-ui': + specifier: workspace:* + version: link:../../../../packages/shared-feedback-ui '@manacore/shared-help-types': specifier: workspace:* version: link:../../../../packages/shared-help-types @@ -6696,6 +6732,9 @@ importers: '@manacore/shared-types': specifier: workspace:* version: link:../shared-types + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 base64-js: specifier: ^1.5.1 version: 1.5.1 @@ -7576,6 +7615,9 @@ importers: '@nestjs/throttler': specifier: ^6.2.1 version: 6.4.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2) + '@simplewebauthn/server': + specifier: ^13.3.0 + version: 13.3.0 '@types/multer': specifier: ^2.0.0 version: 2.0.0 @@ -12099,6 +12141,9 @@ packages: '@hapi/topo@6.0.2': resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -12796,6 +12841,9 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@ljharb/through@2.3.14': resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==} engines: {node: '>= 0.4'} @@ -13546,6 +13594,43 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -14699,6 +14784,13 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -16862,6 +16954,10 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -24201,6 +24297,13 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true @@ -26148,6 +26251,9 @@ packages: resolution: {integrity: sha512-ngZCuhQvNClm5YHbuKN7EmRhOpu1XmsJ2+d56rpeiW9ZvXIxtDWyOf8TEojEgrgZVca9XJglVFNHYtyjQSmYOA==} engines: {node: '>=10'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -26185,6 +26291,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -32845,6 +32955,8 @@ snapshots: dependencies: '@hapi/hoek': 11.0.7 + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -33820,6 +33932,8 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': optional: true + '@levischuck/tiny-cbor@0.2.11': {} + '@ljharb/through@2.3.14': dependencies: call-bind: 1.0.8 @@ -34916,6 +35030,102 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@petamoriken/float16@3.9.3': {} '@pixi/colord@2.9.6': {} @@ -38223,6 +38433,19 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/x509': 1.14.3 + '@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.34.41': {} @@ -41836,6 +42059,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assert-plus@1.0.0: {} assertion-error@1.1.0: {} @@ -53966,6 +54195,12 @@ snapshots: pure-rand@7.0.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qrcode-terminal@0.11.0: {} qrcode@1.5.4: @@ -57937,6 +58172,8 @@ snapshots: - encoding - supports-color + tslib@1.14.1: {} + tslib@2.8.1: {} tsm@2.3.0: @@ -57985,6 +58222,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json index ac96f3be7..77042cfda 100644 --- a/services/mana-core-auth/package.json +++ b/services/mana-core-auth/package.json @@ -36,6 +36,7 @@ "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^8.1.0", "@nestjs/throttler": "^6.2.1", + "@simplewebauthn/server": "^13.3.0", "@types/multer": "^2.0.0", "axios": "^1.7.2", "bcryptjs": "^2.4.3", diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts index ed9ddad37..95e73a294 100644 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ b/services/mana-core-auth/src/auth/auth.controller.ts @@ -19,6 +19,7 @@ import type { Request, Response } from 'express'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { BetterAuthService } from './services/better-auth.service'; +import { PasskeyService } from './services/passkey.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @@ -76,7 +77,8 @@ export class AuthController { constructor( private readonly betterAuthService: BetterAuthService, private readonly securityEvents: SecurityEventsService, - private readonly accountLockout: AccountLockoutService + private readonly accountLockout: AccountLockoutService, + private readonly passkeyService: PasskeyService ) {} // ========================================================================= @@ -816,6 +818,159 @@ export class AuthController { } } + // ========================================================================= + // Passkey (WebAuthn) Endpoints + // ========================================================================= + + /** + * Generate passkey registration options + * + * Returns WebAuthn registration options for the authenticated user. + * The user must be logged in to register a passkey. + */ + @Post('passkeys/register/options') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Generate passkey registration options' }) + async passkeyRegisterOptions(@CurrentUser() user: CurrentUserData) { + return this.passkeyService.generateRegistrationOptions(user.userId); + } + + /** + * Verify and store passkey registration + * + * Verifies the WebAuthn registration response and stores the passkey. + */ + @Post('passkeys/register/verify') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Verify and store passkey registration' }) + async passkeyRegisterVerify( + @CurrentUser() user: CurrentUserData, + @Body() body: { challengeId: string; credential: any; friendlyName?: string }, + @Req() req: Request + ) { + const result = await this.passkeyService.verifyRegistration( + body.challengeId, + body.credential, + body.friendlyName + ); + await this.securityEvents.logEvent({ + userId: user.userId, + eventType: SecurityEventType.PASSKEY_REGISTERED, + ipAddress: req.ip, + userAgent: req.headers['user-agent'] as string, + metadata: { passkeyId: result.id }, + }); + return result; + } + + /** + * Generate passkey authentication options + * + * Returns WebAuthn authentication options. No auth required. + */ + @Post('passkeys/authenticate/options') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Generate passkey authentication options' }) + async passkeyAuthOptions() { + return this.passkeyService.generateAuthenticationOptions(); + } + + /** + * Verify passkey authentication and return JWT tokens + * + * Verifies the WebAuthn authentication response and returns + * JWT access and refresh tokens (same format as login). + */ + @Post('passkeys/authenticate/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify passkey authentication and return JWT tokens' }) + async passkeyAuthVerify( + @Body() body: { challengeId: string; credential: any }, + @Req() req: Request + ) { + const { user, passkeyId } = await this.passkeyService.verifyAuthentication( + body.challengeId, + body.credential + ); + + // Generate session + JWT tokens (same pattern as signIn) + const tokenResult = await this.betterAuthService.createSessionAndTokens(user, { + ipAddress: req.ip, + userAgent: req.headers['user-agent'] as string, + }); + + await this.securityEvents.logEvent({ + userId: user.id, + eventType: SecurityEventType.PASSKEY_LOGIN_SUCCESS, + ipAddress: req.ip, + userAgent: req.headers['user-agent'] as string, + metadata: { passkeyId }, + }); + + return tokenResult; + } + + /** + * List user's passkeys + * + * Returns all passkeys registered by the authenticated user. + */ + @Get('passkeys') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List user passkeys' }) + async listPasskeys(@CurrentUser() user: CurrentUserData) { + return this.passkeyService.listPasskeys(user.userId); + } + + /** + * Delete a passkey + * + * Removes a passkey from the user's account. + */ + @Delete('passkeys/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a passkey' }) + async deletePasskey( + @CurrentUser() user: CurrentUserData, + @Param('id') passkeyId: string, + @Req() req: Request + ) { + await this.passkeyService.deletePasskey(user.userId, passkeyId); + await this.securityEvents.logEvent({ + userId: user.userId, + eventType: SecurityEventType.PASSKEY_DELETED, + ipAddress: req.ip, + userAgent: req.headers['user-agent'] as string, + metadata: { passkeyId }, + }); + } + + /** + * Rename a passkey + * + * Updates the friendly name of a passkey. + */ + @Patch('passkeys/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Rename a passkey' }) + async renamePasskey( + @CurrentUser() user: CurrentUserData, + @Param('id') passkeyId: string, + @Body() body: { friendlyName: string } + ) { + await this.passkeyService.renamePasskey(user.userId, passkeyId, body.friendlyName); + return { success: true }; + } + // ========================================================================= // Helper Methods // ========================================================================= diff --git a/services/mana-core-auth/src/auth/auth.module.ts b/services/mana-core-auth/src/auth/auth.module.ts index 487199784..94d09b23c 100644 --- a/services/mana-core-auth/src/auth/auth.module.ts +++ b/services/mana-core-auth/src/auth/auth.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { BetterAuthPassthroughController } from './better-auth-passthrough.controller'; import { OidcController } from './oidc.controller'; @@ -6,10 +7,11 @@ import { OidcLoginController } from './oidc-login.controller'; import { MatrixSessionController } from './matrix-session.controller'; import { BetterAuthService } from './services/better-auth.service'; import { MatrixSessionService } from './services/matrix-session.service'; +import { PasskeyService } from './services/passkey.service'; import { SecurityModule } from '../security'; @Module({ - imports: [SecurityModule], + imports: [SecurityModule, ConfigModule], controllers: [ AuthController, BetterAuthPassthroughController, @@ -17,7 +19,7 @@ import { SecurityModule } from '../security'; OidcLoginController, MatrixSessionController, ], - providers: [BetterAuthService, MatrixSessionService], - exports: [BetterAuthService, MatrixSessionService], + providers: [BetterAuthService, MatrixSessionService, PasskeyService], + exports: [BetterAuthService, MatrixSessionService, PasskeyService], }) export class AuthModule {} diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index daa11d1f4..ed0fee8c9 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -603,6 +603,73 @@ export class BetterAuthService { } } + /** + * Create a session and generate JWT tokens for a user + * Used by passkey authentication and other non-password flows + */ + async createSessionAndTokens( + user: { id: string; email: string; name: string; role?: string }, + meta?: { ipAddress?: string; userAgent?: string; deviceId?: string; deviceName?: string } + ) { + const db = getDb(this.databaseUrl); + const { sessions } = await import('../../db/schema'); + const { nanoid } = await import('nanoid'); + + const sessionId = nanoid(); + const sessionToken = nanoid(64); + const refreshToken = nanoid(64); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + // Create session in DB + await db.insert(sessions).values({ + id: sessionId, + token: sessionToken, + userId: user.id, + expiresAt, + refreshToken, + refreshTokenExpiresAt, + ipAddress: meta?.ipAddress || null, + userAgent: meta?.userAgent || null, + deviceId: meta?.deviceId || null, + deviceName: meta?.deviceName || null, + lastActivityAt: new Date(), + }); + + // Generate JWT access token + let accessToken = ''; + try { + const api = this.auth.api as any; + const jwtResult = await api.signJWT({ + body: { + payload: { + sub: user.id, + email: user.email, + role: user.role || 'user', + sid: sessionId, + }, + }, + }); + accessToken = jwtResult?.token || ''; + if (!accessToken) throw new Error('signJWT returned empty token'); + } catch (jwtError) { + this.logger.warn('signJWT failed for passkey auth, using session token as fallback'); + accessToken = sessionToken; + } + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role || 'user', + }, + accessToken, + refreshToken, + expiresIn: 15 * 60, // 15 minutes + }; + } + /** * Sign out user * diff --git a/services/mana-core-auth/src/auth/services/passkey.service.ts b/services/mana-core-auth/src/auth/services/passkey.service.ts new file mode 100644 index 000000000..2a9d7beb7 --- /dev/null +++ b/services/mana-core-auth/src/auth/services/passkey.service.ts @@ -0,0 +1,333 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server'; +import type { + RegistrationResponseJSON, + AuthenticationResponseJSON, + AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import { getDb } from '../../db/connection'; +import { passkeys, users } from '../../db/schema'; +import { eq, and } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { LoggerService } from '../../common/logger'; + +interface ChallengeEntry { + challenge: string; + userId?: string; // Only set for registration + expiresAt: number; +} + +@Injectable() +export class PasskeyService { + private readonly logger: LoggerService; + private readonly challenges = new Map(); + private readonly rpID: string; + private readonly rpName = 'ManaCore'; + private readonly expectedOrigins: string[]; + private readonly databaseUrl: string; + + constructor( + private readonly configService: ConfigService, + loggerService: LoggerService + ) { + this.logger = loggerService.setContext('PasskeyService'); + this.databaseUrl = this.configService.get('database.url', ''); + this.rpID = this.configService.get('WEBAUTHN_RP_ID', 'localhost'); + + const originsStr = this.configService.get('WEBAUTHN_ORIGINS', ''); + this.expectedOrigins = originsStr + ? originsStr.split(',').map((o) => o.trim()) + : ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3001']; + + // Clean up expired challenges every 5 minutes + setInterval(() => this.cleanupChallenges(), 5 * 60 * 1000); + } + + private getDb() { + return getDb(this.databaseUrl); + } + + private cleanupChallenges() { + const now = Date.now(); + for (const [key, entry] of this.challenges) { + if (entry.expiresAt < now) { + this.challenges.delete(key); + } + } + } + + private storeChallenge(challengeId: string, challenge: string, userId?: string) { + this.challenges.set(challengeId, { + challenge, + userId, + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes + }); + } + + private getAndDeleteChallenge(challengeId: string): ChallengeEntry | null { + const entry = this.challenges.get(challengeId); + if (!entry) return null; + this.challenges.delete(challengeId); + if (entry.expiresAt < Date.now()) return null; + return entry; + } + + /** + * Generate registration options for a logged-in user + */ + async generateRegistrationOptions(userId: string) { + const db = this.getDb(); + + // Get user + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + if (!user) throw new NotFoundException('User not found'); + + // Get existing passkeys to exclude + const existingPasskeys = await db.select().from(passkeys).where(eq(passkeys.userId, userId)); + + const excludeCredentials = existingPasskeys.map((pk) => ({ + id: pk.credentialId, + transports: (pk.transports as AuthenticatorTransportFuture[]) || [], + })); + + const options = await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userName: user.email, + userDisplayName: user.name || user.email, + attestationType: 'none', + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + + // Store challenge + const challengeId = nanoid(); + this.storeChallenge(challengeId, options.challenge, userId); + + return { options, challengeId }; + } + + /** + * Verify registration response and store the new passkey + */ + async verifyRegistration( + challengeId: string, + credential: RegistrationResponseJSON, + friendlyName?: string + ) { + const entry = this.getAndDeleteChallenge(challengeId); + if (!entry || !entry.userId) { + throw new BadRequestException('Invalid or expired challenge'); + } + + const verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge: entry.challenge, + expectedOrigin: this.expectedOrigins, + expectedRPID: this.rpID, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw new BadRequestException('Passkey verification failed'); + } + + const { + credential: cred, + credentialDeviceType, + credentialBackedUp, + } = verification.registrationInfo; + + const db = this.getDb(); + + // Check for duplicate + const [existing] = await db + .select() + .from(passkeys) + .where(eq(passkeys.credentialId, cred.id)) + .limit(1); + + if (existing) { + throw new ConflictException('This passkey is already registered'); + } + + const id = nanoid(); + const [newPasskey] = await db + .insert(passkeys) + .values({ + id, + userId: entry.userId, + credentialId: cred.id, + publicKey: Buffer.from(cred.publicKey).toString('base64url'), + counter: cred.counter, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + transports: cred.transports || [], + friendlyName: friendlyName || null, + }) + .returning(); + + this.logger.log(`Passkey registered for user ${entry.userId}: ${id}`); + + return { + id: newPasskey.id, + credentialId: newPasskey.credentialId, + deviceType: newPasskey.deviceType, + friendlyName: newPasskey.friendlyName, + createdAt: newPasskey.createdAt, + }; + } + + /** + * Generate authentication options (public - no auth required) + */ + async generateAuthenticationOptions() { + // Use discoverable credentials (resident keys) - no allowCredentials needed + // The browser will show all available passkeys for this rpID + const options = await generateAuthenticationOptions({ + rpID: this.rpID, + userVerification: 'preferred', + }); + + const challengeId = nanoid(); + this.storeChallenge(challengeId, options.challenge); + + return { options, challengeId }; + } + + /** + * Verify authentication response and return the user + */ + async verifyAuthentication(challengeId: string, credential: AuthenticationResponseJSON) { + const entry = this.getAndDeleteChallenge(challengeId); + if (!entry) { + throw new BadRequestException('Invalid or expired challenge'); + } + + const db = this.getDb(); + + // Find the passkey by credential ID + const [passkey] = await db + .select() + .from(passkeys) + .where(eq(passkeys.credentialId, credential.id)) + .limit(1); + + if (!passkey) { + throw new BadRequestException('Passkey not found'); + } + + const verification = await verifyAuthenticationResponse({ + response: credential, + expectedChallenge: entry.challenge, + expectedOrigin: this.expectedOrigins, + expectedRPID: this.rpID, + credential: { + id: passkey.credentialId, + publicKey: Buffer.from(passkey.publicKey, 'base64url'), + counter: passkey.counter, + transports: (passkey.transports as AuthenticatorTransportFuture[]) || [], + }, + }); + + if (!verification.verified) { + throw new BadRequestException('Passkey authentication failed'); + } + + // Update counter and lastUsedAt + await db + .update(passkeys) + .set({ + counter: verification.authenticationInfo.newCounter, + lastUsedAt: new Date(), + }) + .where(eq(passkeys.id, passkey.id)); + + // Get user + const [user] = await db.select().from(users).where(eq(users.id, passkey.userId)).limit(1); + + if (!user) { + throw new BadRequestException('User not found'); + } + + if (user.deletedAt) { + throw new BadRequestException('Account has been deleted'); + } + + return { user, passkeyId: passkey.id }; + } + + /** + * List all passkeys for a user + */ + async listPasskeys(userId: string) { + const db = this.getDb(); + const userPasskeys = await db + .select({ + id: passkeys.id, + credentialId: passkeys.credentialId, + deviceType: passkeys.deviceType, + backedUp: passkeys.backedUp, + friendlyName: passkeys.friendlyName, + lastUsedAt: passkeys.lastUsedAt, + createdAt: passkeys.createdAt, + }) + .from(passkeys) + .where(eq(passkeys.userId, userId)); + + return userPasskeys; + } + + /** + * Delete a passkey + */ + async deletePasskey(userId: string, passkeyId: string) { + const db = this.getDb(); + + const [passkey] = await db + .select() + .from(passkeys) + .where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId))) + .limit(1); + + if (!passkey) { + throw new NotFoundException('Passkey not found'); + } + + await db.delete(passkeys).where(eq(passkeys.id, passkeyId)); + + this.logger.log(`Passkey deleted: ${passkeyId} for user ${userId}`); + } + + /** + * Rename a passkey + */ + async renamePasskey(userId: string, passkeyId: string, friendlyName: string) { + const db = this.getDb(); + + const [passkey] = await db + .select() + .from(passkeys) + .where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId))) + .limit(1); + + if (!passkey) { + throw new NotFoundException('Passkey not found'); + } + + await db.update(passkeys).set({ friendlyName }).where(eq(passkeys.id, passkeyId)); + } +} diff --git a/services/mana-core-auth/src/db/schema/auth.schema.ts b/services/mana-core-auth/src/db/schema/auth.schema.ts index d1c68f3b8..58ec0ded6 100644 --- a/services/mana-core-auth/src/db/schema/auth.schema.ts +++ b/services/mana-core-auth/src/db/schema/auth.schema.ts @@ -7,6 +7,7 @@ import { jsonb, pgEnum, index, + integer, } from 'drizzle-orm/pg-core'; export const authSchema = pgSchema('auth'); @@ -207,6 +208,29 @@ export const matrixUserLinks = authSchema.table( }) ); +// Passkeys table (WebAuthn credentials) +export const passkeys = authSchema.table( + 'passkeys', + { + id: text('id').primaryKey(), // nanoid + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + credentialId: text('credential_id').unique().notNull(), // base64url-encoded + publicKey: text('public_key').notNull(), // base64url-encoded COSE public key + counter: integer('counter').default(0).notNull(), // signature counter + deviceType: text('device_type').notNull(), // 'singleDevice' | 'multiDevice' + backedUp: boolean('backed_up').default(false).notNull(), + transports: jsonb('transports').$type(), // ['internal', 'hybrid', etc.] + friendlyName: text('friendly_name'), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdIdx: index('passkeys_user_id_idx').on(table.userId), + }) +); + // User settings table (synced across all apps) export const userSettings = authSchema.table('user_settings', { userId: text('user_id') diff --git a/services/mana-core-auth/src/security/security-events.service.ts b/services/mana-core-auth/src/security/security-events.service.ts index 341faac75..231a60f21 100644 --- a/services/mana-core-auth/src/security/security-events.service.ts +++ b/services/mana-core-auth/src/security/security-events.service.ts @@ -43,6 +43,12 @@ export const SecurityEventType = { API_KEY_VALIDATED: 'api_key_validated', API_KEY_VALIDATION_FAILED: 'api_key_validation_failed', + // Passkeys + PASSKEY_REGISTERED: 'passkey_registered', + PASSKEY_LOGIN_SUCCESS: 'passkey_login_success', + PASSKEY_LOGIN_FAILURE: 'passkey_login_failure', + PASSKEY_DELETED: 'passkey_deleted', + // Organizations ORG_CREATED: 'org_created', ORG_DELETED: 'org_deleted',