feat(auth): add WebAuthn/Passkey support across all apps

Implements passwordless authentication via passkeys using @simplewebauthn:

Backend (mana-core-auth):
- New passkeys table in auth schema (credentialId, publicKey, counter, etc.)
- PasskeyService with registration/authentication flows and challenge storage
- 7 new API endpoints (register, authenticate, list, delete, rename)
- createSessionAndTokens helper for non-password auth flows
- Security event types for passkey operations

Client (shared-auth):
- signInWithPasskey() and registerPasskey() with dynamic @simplewebauthn/browser imports
- isPasskeyAvailable() browser capability check
- Passkey management methods (list, delete, rename)

UI (shared-auth-ui):
- Passkey button on LoginPage with key icon, shown when browser supports WebAuthn
- Divider between passkey and email/password form

App integration:
- All 19 web app auth stores have isPasskeyAvailable() and signInWithPasskey()
- All 19 web app login pages pass passkeyAvailable and onSignInWithPasskey props
- rpID=mana.how in production enables cross-app passkey usage (SSO-compatible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 10:30:03 +01:00
parent 1095202ad9
commit 3091da914e
52 changed files with 1849 additions and 4 deletions

View file

@ -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-----"

View file

@ -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
*/

View file

@ -56,6 +56,8 @@
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -63,6 +63,8 @@
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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) {

View file

@ -47,6 +47,8 @@
primaryColor="#2563eb"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -54,6 +54,8 @@
primaryColor="#f59e0b"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -53,6 +53,8 @@
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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) {

View file

@ -48,6 +48,8 @@
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -32,6 +32,8 @@
primaryColor="#6366f1"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect="/dashboard"
registerPath="/register"

View file

@ -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
*/

View file

@ -32,6 +32,8 @@
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect="/decks"
registerPath="/register"

View file

@ -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) {

View file

@ -50,6 +50,8 @@
primaryColor="#f97316"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -52,6 +52,8 @@
primaryColor="#22C55E"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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) {

View file

@ -43,6 +43,8 @@
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -36,6 +36,8 @@
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect="/app/gallery"
registerPath="/auth/signup"

View file

@ -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) {

View file

@ -52,6 +52,8 @@
primaryColor="#22c55e"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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');

View file

@ -34,6 +34,8 @@
primaryColor="#06b6d4"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -40,6 +40,8 @@
primaryColor="#f97316"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={auth.isPasskeyAvailable()}
onSignInWithPasskey={() => auth.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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) {

View file

@ -58,6 +58,8 @@
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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) {

View file

@ -58,6 +58,8 @@
primaryColor="#10b981"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -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
*/

View file

@ -55,6 +55,8 @@
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -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
*/

View file

@ -53,6 +53,8 @@
primaryColor="#f59e0b"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
{goto}
successRedirect={redirectTo}
registerPath="/register"

View file

@ -84,6 +84,8 @@
version?: string;
/** Build timestamp (ISO string) to display next to version */
buildTime?: string;
onSignInWithPasskey?: () => Promise<AuthResult>;
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 @@
<p class="form-subtitle">{t.subtitle}</p>
</div>
{#if passkeyAvailable && onSignInWithPasskey}
<button
type="button"
onclick={handlePasskeySignIn}
disabled={loading || showSuccess}
class="passkey-button"
style:border-color={primaryColor}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z" />
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
</svg>
<span>Passkey</span>
</button>
<div class="divider">
<span>{t.orDivider}</span>
</div>
{/if}
{#if verificationEmailSent}
<div class="verified-banner" role="status" aria-live="polite">
<Check size={18} class="text-green-500 shrink-0" />
@ -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;

View file

@ -17,6 +17,7 @@
},
"dependencies": {
"@manacore/shared-types": "workspace:*",
"@simplewebauthn/browser": "^13.3.0",
"base64-js": "^1.5.1"
},
"devDependencies": {

View file

@ -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<AuthResult> {
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<AuthResult> {
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<any[]> {
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<AuthResult> {
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<AuthResult> {
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
*/

View file

@ -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;
}
/**

241
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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",

View file

@ -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
// =========================================================================

View file

@ -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 {}

View file

@ -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
*

View file

@ -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<string, ChallengeEntry>();
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<string>('database.url', '');
this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID', 'localhost');
const originsStr = this.configService.get<string>('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));
}
}

View file

@ -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<string[]>(), // ['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')

View file

@ -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',