mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
1095202ad9
commit
3091da914e
52 changed files with 1849 additions and 4 deletions
|
|
@ -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-----"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@
|
|||
primaryColor="#0ea5e9"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@
|
|||
primaryColor="#0ea5e9"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@
|
|||
primaryColor="#2563eb"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@
|
|||
primaryColor="#f59e0b"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@
|
|||
primaryColor="#3b82f6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@
|
|||
primaryColor="#0ea5e9"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@
|
|||
primaryColor="#6366f1"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect="/dashboard"
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@
|
|||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect="/decks"
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@
|
|||
primaryColor="#f97316"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@
|
|||
primaryColor="#22C55E"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@
|
|||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@
|
|||
primaryColor="#3b82f6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect="/app/gallery"
|
||||
registerPath="/auth/signup"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@
|
|||
primaryColor="#22c55e"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@
|
|||
primaryColor="#06b6d4"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@
|
|||
primaryColor="#f97316"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={auth.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => auth.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@
|
|||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@
|
|||
primaryColor="#10b981"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@
|
|||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@
|
|||
primaryColor="#f59e0b"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"base64-js": "^1.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
241
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
333
services/mana-core-auth/src/auth/services/passkey.service.ts
Normal file
333
services/mana-core-auth/src/auth/services/passkey.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue