diff --git a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts index 8310c2494..5e9b97ec8 100644 --- a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts @@ -122,6 +122,29 @@ export const authStore = { /** * Check if passkeys are available in this browser */ + + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte b/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte index e55516535..3036c6425 100644 --- a/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte @@ -58,6 +58,8 @@ onResendVerification={handleResendVerification} passkeyAvailable={authStore.isPasskeyAvailable()} onSignInWithPasskey={() => authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts index 27186ff81..6c9df46ea 100644 --- a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts @@ -122,6 +122,29 @@ export const authStore = { /** * Check if passkeys are available in this browser */ + + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte index 64aa778de..4b062c72c 100644 --- a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte @@ -65,6 +65,8 @@ onResendVerification={handleResendVerification} passkeyAvailable={authStore.isPasskeyAvailable()} onSignInWithPasskey={() => authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts b/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts index f0330d61e..c9af89ab0 100644 --- a/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts @@ -105,6 +105,28 @@ export const authStore = { } }, + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte b/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte index 13427ff98..0fc36c24f 100644 --- a/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte @@ -49,6 +49,8 @@ onResendVerification={handleResendVerification} passkeyAvailable={authStore.isPasskeyAvailable()} onSignInWithPasskey={() => authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts index 738cdbc29..7b426d977 100644 --- a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts @@ -122,6 +122,29 @@ export const authStore = { /** * Check if passkeys are available in this browser */ + + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte b/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte index d097d9ed5..6cab19920 100644 --- a/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte @@ -56,6 +56,8 @@ onResendVerification={handleResendVerification} passkeyAvailable={authStore.isPasskeyAvailable()} onSignInWithPasskey={() => authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index 8f7b80639..7a501c33a 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -122,6 +122,29 @@ export const authStore = { /** * Check if passkeys are available in this browser */ + + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte b/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte index 07ce2f8a6..6ad155be5 100644 --- a/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte @@ -55,6 +55,8 @@ onResendVerification={handleResendVerification} passkeyAvailable={authStore.isPasskeyAvailable()} onSignInWithPasskey={() => authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/context/apps/web/src/lib/stores/auth.svelte.ts b/apps/context/apps/web/src/lib/stores/auth.svelte.ts index 7478d546f..1e649a7ee 100644 --- a/apps/context/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/context/apps/web/src/lib/stores/auth.svelte.ts @@ -106,6 +106,28 @@ export const authStore = { } }, + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/context/apps/web/src/routes/(auth)/login/+page.svelte b/apps/context/apps/web/src/routes/(auth)/login/+page.svelte index 99897720d..2512d8b7e 100644 --- a/apps/context/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/context/apps/web/src/routes/(auth)/login/+page.svelte @@ -50,6 +50,8 @@ onResendVerification={handleResendVerification} passkeyAvailable={authStore.isPasskeyAvailable()} onSignInWithPasskey={() => authStore.signInWithPasskey()} + onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} + onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index 5f159f77f..f0c50b504 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -138,6 +138,46 @@ export const authStore = { } }, + async enableTwoFactor(password: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available' }; + return authService.enableTwoFactor(password); + }, + + async disableTwoFactor(password: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available' }; + return authService.disableTwoFactor(password); + }, + + async verifyTwoFactor(code: string, trustDevice?: boolean) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available' }; + const result = await authService.verifyTwoFactor(code, trustDevice); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async verifyBackupCode(code: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available' }; + const result = await authService.verifyBackupCode(code); + if (result.success) { + const userData = await authService.getUserFromToken(); + user = userData; + } + return result; + }, + + async generateBackupCodes(password: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available' }; + return authService.generateBackupCodes(password); + }, + async registerPasskey(friendlyName?: string) { const authService = getAuthService(); if (!authService) return { success: false, error: 'Auth not available' }; diff --git a/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte index 99d87441c..d351fe3df 100644 --- a/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte @@ -1,7 +1,7 @@ + +
+ {#if view === 'status'} +
+

{t.title}

+
+ + {enabled ? t.statusEnabled : t.statusDisabled} +
+
+ +
+ {#if enabled} + + + {:else} + + {/if} +
+ {:else if view === 'enable-password' || view === 'disable-password' || view === 'regenerate-password'} +
+

+ {#if view === 'disable-password'} + {t.disableConfirmTitle} + {:else if view === 'regenerate-password'} + {t.backupCodesTitle} + {:else} + {t.title} + {/if} +

+
+ + {#if view === 'disable-password'} +

{t.disableConfirmText}

+ {/if} + + {#if error} + + {/if} + +
{ + e.preventDefault(); + if (view === 'enable-password') handleEnable(); + else if (view === 'disable-password') handleDisable(); + else handleRegenerateBackupCodes(); + }} + > +
+ + +
+ +
+ + +
+
+ {:else if view === 'setup'} +
+

{t.setupTitle}

+
+ +
+

{t.setupStep1}

+ + {#if totpURI} +
+ QR Code for TOTP setup +
+ +
+

{t.manualEntryLabel}

+
+ {extractSecret(totpURI)} + +
+
+ {/if} +
+ + {#if backupCodes.length > 0} +
+

{t.setupStep2}

+

{t.backupCodesWarning}

+ +
+ {#each backupCodes as code} + {code} + {/each} +
+ + +
+ {/if} + +
+ +
+ {:else if view === 'backup-codes'} +
+

{t.backupCodesTitle}

+
+ +

{t.backupCodesWarning}

+ +
+ {#each backupCodes as code} + {code} + {/each} +
+ + + +
+ +
+ {/if} +
+ + diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index ffa5f6e2f..7c7c66a6b 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -9,6 +9,7 @@ export { default as AuthGateModal } from './components/AuthGateModal.svelte'; export { default as SessionExpiredBanner } from './components/SessionExpiredBanner.svelte'; export { default as AuthGate } from './components/AuthGate.svelte'; export { default as PasskeyManager } from './components/PasskeyManager.svelte'; +export { default as TwoFactorSetup } from './components/TwoFactorSetup.svelte'; // Utilities export { @@ -28,3 +29,4 @@ export type { AuthGateTranslations, } from './types'; export type { PasskeyManagerTranslations } from './components/PasskeyManager.svelte'; +export type { TwoFactorSetupTranslations } from './components/TwoFactorSetup.svelte'; diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte index 303d89fa8..574764bd5 100644 --- a/packages/shared-auth-ui/src/pages/LoginPage.svelte +++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte @@ -86,6 +86,8 @@ buildTime?: string; onSignInWithPasskey?: () => Promise; passkeyAvailable?: boolean; + onVerifyTwoFactor?: (code: string, trustDevice?: boolean) => Promise; + onVerifyBackupCode?: (code: string) => Promise; } let { @@ -110,6 +112,8 @@ buildTime = '', onSignInWithPasskey, passkeyAvailable = false, + onVerifyTwoFactor, + onVerifyBackupCode, }: Props = $props(); const t = $derived({ ...defaultTranslations, ...translations }); @@ -137,6 +141,9 @@ let showEmailNotVerified = $state(false); let resendingVerification = $state(false); let verificationEmailSent = $state(false); + let showTwoFactor = $state(false); + let twoFactorCode = $state(''); + let useBackupCode = $state(false); // Theme state - can be toggled manually, defaults to system preference let userThemePreference = $state<'light' | 'dark' | null>(null); @@ -229,6 +236,12 @@ const result = await onSignIn(email, password); loading = false; + // Check if 2FA is required + if ((result as any).twoFactorRedirect) { + showTwoFactor = true; + return; + } + if (result.success) { showSuccess = true; successAnnouncement = t.signInSuccess; @@ -258,6 +271,30 @@ } } + async function handleTwoFactorVerify() { + if (!twoFactorCode) return; + loading = true; + clearError(); + + const handler = useBackupCode ? onVerifyBackupCode : onVerifyTwoFactor; + if (!handler) { + loading = false; + return; + } + + const result = await handler(twoFactorCode); + loading = false; + + if (result.success) { + showSuccess = true; + successAnnouncement = t.signInSuccess; + setTimeout(() => goto(successRedirect), 600); + } else { + setError(result.error || t.signInFailed, 'general'); + twoFactorCode = ''; + } + } + async function handlePasskeySignIn() { if (!onSignInWithPasskey) return; loading = true; @@ -352,198 +389,290 @@
- {#if showVerifiedBanner} -
- -

{t.emailVerified}

- + {#if showTwoFactor} + +
+

Zwei-Faktor-Authentifizierung

+

+ {useBackupCode + ? 'Gib einen Backup-Code ein' + : 'Gib den Code aus deiner Authenticator-App ein'} +

- {/if} -
-

{t.title}

-

{t.subtitle}

-
+ {#if error} + + {/if} + +
{ + e.preventDefault(); + handleTwoFactorVerify(); + }} + > +
+ +
+ + +
- {#if passkeyAvailable && onSignInWithPasskey} -
- {t.orDivider} -
- {/if} - {#if verificationEmailSent} -
- -

{t.verificationEmailSent}

- -
- {/if} - - {#if error} - - {/if} - -
{ - e.preventDefault(); - handleLogin(); - }} - aria-busy={loading} - > - -
- - -
- - -
- -
- + + {:else} + {#if showVerifiedBanner} +
+ +

{t.emailVerified}

+ {/if} + +
+

{t.title}

+

{t.subtitle}

- -
- + {#if passkeyAvailable && onSignInWithPasskey} -
- - - - + Passkey + +
+ {t.orDivider} +
+ {/if} - + {#if verificationEmailSent} +
+ +

{t.verificationEmailSent}

+ +
+ {/if} + + {#if error} + + {/if} + +
{ + e.preventDefault(); + handleLogin(); + }} + aria-busy={loading} + > + +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + + +
+ + + {/if}
diff --git a/packages/shared-auth/src/core/authService.ts b/packages/shared-auth/src/core/authService.ts index baa8491bb..6ff58eb56 100644 --- a/packages/shared-auth/src/core/authService.ts +++ b/packages/shared-auth/src/core/authService.ts @@ -567,6 +567,186 @@ export function createAuthService(config: AuthServiceConfig) { } }, + /** + * Enable 2FA - returns TOTP URI for QR code and backup codes + */ + async enableTwoFactor( + password: string + ): Promise<{ success: boolean; totpURI?: string; backupCodes?: string[]; error?: string }> { + try { + const response = await fetch(`${baseUrl}/api/auth/two-factor/enable`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to enable 2FA' }; + } + + const data = await response.json(); + return { success: true, totpURI: data.totpURI, backupCodes: data.backupCodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to enable 2FA', + }; + } + }, + + /** + * Disable 2FA + */ + async disableTwoFactor(password: string): Promise { + try { + const response = await fetch(`${baseUrl}/api/auth/two-factor/disable`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to disable 2FA' }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to disable 2FA', + }; + } + }, + + /** + * Verify TOTP code during login (when 2FA is required) + */ + async verifyTwoFactor(code: string, trustDevice?: boolean): Promise { + try { + const storage = getStorageAdapter(); + + const response = await fetch(`${baseUrl}/api/auth/two-factor/verify-totp`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, trustDevice }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.message || 'Invalid code' }; + } + + // After 2FA verification, we need to get tokens + // The session cookie is now set by Better Auth + // Exchange session for JWT tokens via session-to-token + const tokenResponse = await fetch(`${baseUrl}/api/v1/auth/session-to-token`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); + + if (tokenResponse.ok) { + const tokenData = await tokenResponse.json(); + if (tokenData.accessToken && tokenData.refreshToken) { + await Promise.all([ + storage.setItem(storageKeys.APP_TOKEN, tokenData.accessToken), + storage.setItem(storageKeys.REFRESH_TOKEN, tokenData.refreshToken), + storage.setItem(storageKeys.USER_EMAIL, tokenData.user?.email || ''), + ]); + } + } + + trackAuth('login', { method: '2fa' }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Verification failed', + }; + } + }, + + /** + * Verify backup code during login + */ + async verifyBackupCode(code: string): Promise { + try { + const storage = getStorageAdapter(); + + const response = await fetch(`${baseUrl}/api/auth/two-factor/verify-backup-code`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.message || 'Invalid backup code' }; + } + + // Exchange session for JWT tokens + const tokenResponse = await fetch(`${baseUrl}/api/v1/auth/session-to-token`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); + + if (tokenResponse.ok) { + const tokenData = await tokenResponse.json(); + if (tokenData.accessToken && tokenData.refreshToken) { + await Promise.all([ + storage.setItem(storageKeys.APP_TOKEN, tokenData.accessToken), + storage.setItem(storageKeys.REFRESH_TOKEN, tokenData.refreshToken), + storage.setItem(storageKeys.USER_EMAIL, tokenData.user?.email || ''), + ]); + } + } + + trackAuth('login', { method: 'backup_code' }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Verification failed', + }; + } + }, + + /** + * Generate new backup codes (replaces existing ones) + */ + async generateBackupCodes( + password: string + ): Promise<{ success: boolean; backupCodes?: string[]; error?: string }> { + try { + const response = await fetch(`${baseUrl}/api/auth/two-factor/generate-backup-codes`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to generate backup codes' }; + } + + const data = await response.json(); + return { success: true, backupCodes: data.backupCodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to generate backup codes', + }; + } + }, + /** * Get the current app token */ diff --git a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts index 6176d0430..eb720dd29 100644 --- a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts +++ b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts @@ -13,7 +13,7 @@ * but our NestJS API uses `/api/v1/*` as the global prefix. */ -import { Controller, Get, Param, Query, Req, Res, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, All, Param, Query, Req, Res, HttpStatus } from '@nestjs/common'; import { Request, Response } from 'express'; import { ConfigService } from '@nestjs/config'; import { BetterAuthService } from './services/better-auth.service'; @@ -32,6 +32,77 @@ export class BetterAuthPassthroughController { this.logger = loggerService.setContext('BetterAuthPassthrough'); } + /** + * Forward requests to Better Auth's handler + * + * Converts Express request to Fetch Request and passes it to Better Auth. + * Copies response status, headers (including Set-Cookie), and body back. + */ + private async forwardToBetterAuth(req: Request, res: Response) { + const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; + const url = new URL(req.originalUrl, baseUrl); + + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value && typeof value === 'string') { + headers.set(key, value); + } else if (Array.isArray(value)) { + headers.set(key, value[0]); + } + } + + const fetchRequest = new Request(url.toString(), { + method: req.method, + headers, + body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined, + }); + + const handler = this.betterAuthService.getHandler(); + const response = await handler(fetchRequest); + + res.status(response.status); + + response.headers.forEach((value: string, key: string) => { + if (key.toLowerCase() === 'set-cookie') { + res.append(key, value); + } else { + res.setHeader(key, value); + } + }); + + const body = await response.text(); + try { + return res.json(JSON.parse(body)); + } catch { + return res.send(body); + } + } + + /** + * Two-Factor Authentication passthrough + * + * Forwards all /api/auth/two-factor/* requests to Better Auth's handler. + * The twoFactor plugin registers these routes: + * - POST /two-factor/enable + * - POST /two-factor/disable + * - POST /two-factor/verify-totp + * - POST /two-factor/verify-backup-code + * - POST /two-factor/get-totp-uri + * - POST /two-factor/generate-backup-codes + */ + @All('two-factor/*') + async twoFactorPassthrough(@Req() req: Request, @Res() res: Response) { + try { + return await this.forwardToBetterAuth(req, res); + } catch (error) { + this.logger.error( + 'Two-factor passthrough failed', + error instanceof Error ? error.stack : undefined + ); + return res.status(500).json({ error: 'Two-factor request failed' }); + } + } + /** * Handle SSO get-session request * diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts index deda4cb6b..5b04989f6 100644 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ b/services/mana-core-auth/src/auth/better-auth.config.ts @@ -19,6 +19,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { jwt } from 'better-auth/plugins/jwt'; import { organization } from 'better-auth/plugins/organization'; import { oidcProvider } from 'better-auth/plugins/oidc-provider'; +import { twoFactor } from 'better-auth/plugins/two-factor'; import { getDb } from '../db/connection'; import { organizations, members, invitations } from '../db/schema/organizations.schema'; import { @@ -31,6 +32,7 @@ import { oauthAccessTokens, oauthAuthorizationCodes, oauthConsents, + twoFactorAuth, } from '../db/schema/auth.schema'; import type { JWTPayloadContext } from './types/better-auth.types'; import { @@ -96,6 +98,9 @@ export function createBetterAuth(databaseUrl: string) { // JWT plugin table jwks: jwks, + // Two-Factor Authentication table + twoFactor: twoFactorAuth, + // OIDC Provider tables oauthApplication: oauthApplications, oauthAccessToken: oauthAccessTokens, @@ -403,6 +408,21 @@ export function createBetterAuth(databaseUrl: string) { }, ], }), + /** + * Two-Factor Authentication Plugin (TOTP) + * + * Provides TOTP-based 2FA with backup codes. + * Endpoints provided automatically by Better Auth passthrough: + * - POST /two-factor/enable (requires password) + * - POST /two-factor/disable (requires password) + * - POST /two-factor/verify-totp (during login) + * - POST /two-factor/verify-backup-code (during login) + * - POST /two-factor/get-totp-uri + * - POST /two-factor/generate-backup-codes + */ + twoFactor({ + issuer: 'ManaCore', + }), ], }); } diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index ed0fee8c9..4924ffcb4 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -478,6 +478,23 @@ export class BetterAuthService { } } + // Check if 2FA is required + if ( + result && + typeof result === 'object' && + 'twoFactorRedirect' in result && + (result as any).twoFactorRedirect + ) { + this.logger.debug('SignIn: 2FA required, returning redirect'); + return { + twoFactorRedirect: true, + user: null, + accessToken: '', + refreshToken: '', + expiresIn: 0, + } as any; + } + if (!hasUser(result)) { throw new UnauthorizedException('Invalid credentials'); } diff --git a/services/mana-core-auth/src/db/schema/auth.schema.ts b/services/mana-core-auth/src/db/schema/auth.schema.ts index 58ec0ded6..355f86f71 100644 --- a/services/mana-core-auth/src/db/schema/auth.schema.ts +++ b/services/mana-core-auth/src/db/schema/auth.schema.ts @@ -26,6 +26,7 @@ export const users = authSchema.table('users', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), // Custom fields (not required by Better Auth) role: userRoleEnum('role').default('user').notNull(), + twoFactorEnabled: boolean('two_factor_enabled').default(false), deletedAt: timestamp('deleted_at', { withTimezone: true }), }); @@ -103,7 +104,7 @@ export const twoFactorAuth = authSchema.table('two_factor_auth', { .references(() => users.id, { onDelete: 'cascade' }), secret: text('secret').notNull(), enabled: boolean('enabled').default(false).notNull(), - backupCodes: jsonb('backup_codes'), + backupCodes: text('backup_codes').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), enabledAt: timestamp('enabled_at', { withTimezone: true }), }); diff --git a/services/mana-core-auth/src/security/security-events.service.ts b/services/mana-core-auth/src/security/security-events.service.ts index 231a60f21..6cd11b610 100644 --- a/services/mana-core-auth/src/security/security-events.service.ts +++ b/services/mana-core-auth/src/security/security-events.service.ts @@ -49,6 +49,11 @@ export const SecurityEventType = { PASSKEY_LOGIN_FAILURE: 'passkey_login_failure', PASSKEY_DELETED: 'passkey_deleted', + // Two-Factor Authentication + TWO_FACTOR_ENABLED: 'two_factor_enabled', + TWO_FACTOR_DISABLED: 'two_factor_disabled', + TWO_FACTOR_VERIFIED: 'two_factor_verified', + // Organizations ORG_CREATED: 'org_created', ORG_DELETED: 'org_deleted',