import type { AuthServiceConfig, AuthServiceInterface, AuthEndpoints, AuthResult, TokenRefreshResult, UserData, StorageKeys, CreditBalance, B2BInfo, } from '../types'; import { getStorageAdapter } from '../adapters/storage'; import { getDeviceAdapter } from '../adapters/device'; import { decodeToken, isTokenValidLocally, getUserFromToken, getB2BInfo as getB2BInfoFromToken, shouldDisableRevenueCat as checkRevenueCat, isB2BUser as checkB2BUser, getAppSettings as getAppSettingsFromToken, } from './jwtUtils'; /** * Inline analytics helper - tracks auth events via Umami if available. * No-ops silently in environments without Umami (mobile, SSR, dev). */ function trackAuth(event: string, data?: Record): void { if (typeof window !== 'undefined' && (window as any).umami?.track) { try { (window as any).umami.track(event, data); } catch { // Silently ignore tracking errors } } } /** * Default storage keys */ const DEFAULT_STORAGE_KEYS: StorageKeys = { APP_TOKEN: '@auth/appToken', REFRESH_TOKEN: '@auth/refreshToken', USER_EMAIL: '@auth/userEmail', }; /** * Default API endpoints - Updated for Mana Core Auth */ const DEFAULT_ENDPOINTS: AuthEndpoints = { signIn: '/api/v1/auth/login', signUp: '/api/v1/auth/register', signOut: '/api/v1/auth/logout', refresh: '/api/v1/auth/refresh', validate: '/api/v1/auth/validate', forgotPassword: '/api/v1/auth/forgot-password', resetPassword: '/api/v1/auth/reset-password', resendVerification: '/api/v1/auth/resend-verification', 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', }; /** * Create an authentication service with the given configuration */ export function createAuthService(config: AuthServiceConfig): AuthServiceInterface { const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash const storageKeys: StorageKeys = { ...DEFAULT_STORAGE_KEYS, ...config.storageKeys }; const endpoints: AuthEndpoints = { ...DEFAULT_ENDPOINTS, ...config.endpoints }; // Callback for token refresh events let onTokenRefreshCallback: ((userData: UserData) => void) | null = null; const service = { /** * Sign in with email and password */ async signIn(email: string, password: string): Promise { try { const storage = getStorageAdapter(); const deviceAdapter = getDeviceAdapter(); const deviceInfo = await deviceAdapter.getDeviceInfo(); const response = await fetch(`${baseUrl}${endpoints.signIn}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, deviceId: deviceInfo?.deviceId, deviceName: deviceInfo?.deviceName, }), }); if (!response.ok) { const errorData = await response.json(); return service.handleAuthError(response.status, errorData); } const data = await response.json(); const appToken = data.accessToken; // Mana Core Auth uses '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, email), ]); // Also sign in via Better Auth native endpoint to set session cookie // This enables cross-subdomain SSO (cookie shared across *.mana.how) try { await fetch(`${baseUrl}/api/auth/sign-in/email`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); } catch { // SSO cookie is nice-to-have, don't fail login if this fails } trackAuth('login', { method: 'email' }); return { success: true }; } catch (error) { console.error('Error signing in:', error); trackAuth('login_failed', { method: 'email' }); return { success: false, error: error instanceof Error ? error.message : 'Unknown error during sign in', }; } }, /** * Sign up with email and password * @param email User email * @param password User password * @param sourceAppUrl Optional URL of the app where the user is registering */ async signUp(email: string, password: string, sourceAppUrl?: string): Promise { try { const body: Record = { email, password }; if (sourceAppUrl) { body.sourceAppUrl = sourceAppUrl; } const response = await fetch(`${baseUrl}${endpoints.signUp}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { const errorData = await response.json(); if (response.status === 409) { return { success: false, error: 'Email already in use' }; } else if (response.status === 400) { return { success: false, error: errorData.message || 'Invalid email or password' }; } return { success: false, error: errorData.message || 'Sign up failed' }; } // Consume response to avoid unhandled promise await response.json(); // Mana Core Auth returns user data immediately on registration // User needs to sign in separately to get tokens trackAuth('signup', { method: 'email' }); return { success: true, needsVerification: false }; } catch (error) { console.error('Error signing up:', error); trackAuth('signup_failed', { method: 'email' }); return { success: false, error: error instanceof Error ? error.message : 'Unknown error during sign up', }; } }, /** * Sign out the current user */ async signOut(): Promise { try { const storage = getStorageAdapter(); const refreshToken = await storage.getItem(storageKeys.REFRESH_TOKEN); if (refreshToken) { await fetch(`${baseUrl}${endpoints.signOut}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }).catch((err) => console.error('Error logging out on server:', err)); } trackAuth('logout'); await service.clearAuthStorage(); } catch (error) { console.error('Error signing out:', error); } }, /** * Send password reset email * @param email - User's email address * @param redirectTo - Optional URL to redirect after password reset (current app origin) */ async forgotPassword(email: string, redirectTo?: string): Promise { try { const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, redirectTo }), }); if (!response.ok) { const errorData = await response.json(); if (errorData.message?.includes('rate limit')) { return { success: false, error: 'Too many attempts. Please wait before trying again.' }; } return { success: false, error: errorData.message || 'Password reset failed' }; } trackAuth('password_reset_requested'); return { success: true }; } catch (error) { console.error('Error sending password reset email:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error during password reset', }; } }, /** * Reset password with token */ async resetPassword(token: string, newPassword: string): Promise { try { const response = await fetch(`${baseUrl}${endpoints.resetPassword}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, newPassword }), }); if (!response.ok) { const errorData = await response.json(); if (errorData.message?.includes('expired')) { return { success: false, error: 'Reset link has expired. Please request a new one.' }; } if (errorData.message?.includes('invalid')) { return { success: false, error: 'Invalid reset link. Please request a new one.' }; } return { success: false, error: errorData.message || 'Password reset failed' }; } return { success: true }; } catch (error) { console.error('Error resetting password:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error during password reset', }; } }, /** * Resend verification email * @param email - User's email address * @param sourceAppUrl - Optional URL to redirect after verification (current app origin) */ async resendVerificationEmail(email: string, sourceAppUrl?: string): Promise { try { const response = await fetch(`${baseUrl}${endpoints.resendVerification}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, sourceAppUrl }), }); if (!response.ok) { const errorData = await response.json(); return { success: false, error: errorData.message || 'Failed to resend verification email', }; } return { success: true }; } catch (error) { console.error('Error resending verification email:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } }, /** * Refresh the authentication tokens */ async refreshTokens(currentRefreshToken: string): Promise { const storage = getStorageAdapter(); const deviceAdapter = getDeviceAdapter(); // Check for device ID mismatch const storedDeviceId = await deviceAdapter.getStoredDeviceId(); const deviceInfo = await deviceAdapter.getDeviceInfo(); if (storedDeviceId && deviceInfo.deviceId !== storedDeviceId) { throw new Error('Device ID has changed. Please sign in again.'); } const response = await fetch(`${baseUrl}${endpoints.refresh}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: currentRefreshToken }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); if (response.status === 401 && errorData.message === 'Invalid refresh token') { throw new Error('Session expired. Please sign in again.'); } throw new Error(errorData.message || 'Failed to refresh tokens'); } const data = await response.json(); const appToken = data.accessToken; // Mana Core Auth uses 'accessToken' const refreshToken = data.refreshToken; if (!appToken || !refreshToken) { throw new Error('Invalid response from token refresh - missing tokens'); } // Store new tokens await storage.setItem(storageKeys.APP_TOKEN, appToken); await storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken); // Extract user data from new token const storedEmail = await storage.getItem(storageKeys.USER_EMAIL); const userData = getUserFromToken(appToken, storedEmail || undefined); // Notify callback if registered if (userData && onTokenRefreshCallback) { onTokenRefreshCallback(userData); } return { appToken, refreshToken, userData }; }, /** * Check if WebAuthn/Passkeys are supported in this browser */ isPasskeyAvailable(): boolean { if (typeof window === 'undefined') return false; return !!window.PublicKeyCredential; }, /** * Register a new passkey for the current user */ async registerPasskey(friendlyName?: string): Promise { try { const { startRegistration } = await import('@simplewebauthn/browser'); const appToken = await service.getAppToken(); if (!appToken) return { success: false, error: 'Not authenticated' }; // Step 1: Get registration options from server const optionsRes = await fetch(`${baseUrl}${endpoints.passkeyRegisterOptions}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}`, }, }); if (!optionsRes.ok) { const err = await optionsRes.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to get registration options' }; } const { options, challengeId } = await optionsRes.json(); // Step 2: Create credential via browser WebAuthn API const credential = await startRegistration({ optionsJSON: options }); // Step 3: Send credential to server for verification const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyRegisterVerify}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}`, }, body: JSON.stringify({ challengeId, credential, friendlyName }), }); if (!verifyRes.ok) { const err = await verifyRes.json().catch(() => ({})); return { success: false, error: err.message || 'Passkey registration failed' }; } trackAuth('passkey_registered'); return { success: true }; } catch (error) { // User cancelled or WebAuthn error if (error instanceof Error && error.name === 'NotAllowedError') { return { success: false, error: 'Passkey registration was cancelled' }; } console.error('Passkey registration error:', error); return { success: false, error: error instanceof Error ? error.message : 'Passkey registration failed', }; } }, /** * Sign in with a passkey */ async signInWithPasskey(): Promise { try { const { startAuthentication } = await import('@simplewebauthn/browser'); const storage = getStorageAdapter(); // Step 1: Get authentication options from server const optionsRes = await fetch(`${baseUrl}${endpoints.passkeyAuthOptions}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!optionsRes.ok) { const err = await optionsRes.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to get authentication options' }; } const { options, challengeId } = await optionsRes.json(); // Step 2: Authenticate via browser WebAuthn API const credential = await startAuthentication({ optionsJSON: options }); // Step 3: Send credential to server for verification const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyAuthVerify}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengeId, credential }), }); if (!verifyRes.ok) { const err = await verifyRes.json().catch(() => ({})); return { success: false, error: err.message || 'Passkey authentication failed' }; } const data = await verifyRes.json(); const appToken = data.accessToken; const refreshToken = data.refreshToken; await Promise.all([ storage.setItem(storageKeys.APP_TOKEN, appToken), storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken), storage.setItem(storageKeys.USER_EMAIL, data.user?.email || ''), ]); trackAuth('login', { method: 'passkey' }); return { success: true }; } catch (error) { if (error instanceof Error && error.name === 'NotAllowedError') { return { success: false, error: 'Passkey authentication was cancelled' }; } console.error('Passkey authentication error:', error); trackAuth('login_failed', { method: 'passkey' }); return { success: false, error: error instanceof Error ? error.message : 'Passkey authentication failed', }; } }, /** * List user's registered passkeys */ async listPasskeys(): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return []; const res = await fetch(`${baseUrl}${endpoints.passkeyList}`, { headers: { Authorization: `Bearer ${appToken}` }, }); if (!res.ok) return []; return await res.json(); } catch { return []; } }, /** * Delete a passkey */ async deletePasskey(passkeyId: string): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return { success: false, error: 'Not authenticated' }; const res = await fetch(`${baseUrl}${endpoints.passkeyList}/${passkeyId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${appToken}` }, }); if (!res.ok) { const err = await res.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to delete passkey' }; } return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to delete passkey', }; } }, /** * Rename a passkey */ async renamePasskey(passkeyId: string, friendlyName: string): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return { success: false, error: 'Not authenticated' }; const res = await fetch(`${baseUrl}${endpoints.passkeyList}/${passkeyId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}`, }, body: JSON.stringify({ friendlyName }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to rename passkey' }; } return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to rename passkey', }; } }, /** * 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', }; } }, /** * Change password */ async changePassword(currentPassword: string, newPassword: string): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return { success: false, error: 'Not authenticated' }; const response = await fetch(`${baseUrl}/api/v1/auth/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}`, }, body: JSON.stringify({ currentPassword, newPassword }), }); if (!response.ok) { const err = await response.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to change password' }; } return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to change password', }; } }, /** * Send magic link for passwordless login */ async sendMagicLink(email: string): Promise { try { const response = await fetch(`${baseUrl}/api/auth/magic-link/send-magic-link`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (!response.ok) { const err = await response.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to send magic link' }; } trackAuth('magic_link_sent'); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to send magic link', }; } }, /** * Get security events (audit log) */ async getSecurityEvents(limit = 50): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return []; const res = await fetch(`${baseUrl}/api/v1/auth/security-events?limit=${limit}`, { headers: { Authorization: `Bearer ${appToken}` }, }); if (!res.ok) return []; return await res.json(); } catch { return []; } }, /** * List active sessions */ async listSessions(): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return []; const res = await fetch(`${baseUrl}/api/v1/auth/sessions`, { headers: { Authorization: `Bearer ${appToken}` }, }); if (!res.ok) return []; return await res.json(); } catch { return []; } }, /** * Revoke a session */ async revokeSession(sessionId: string): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return { success: false, error: 'Not authenticated' }; const res = await fetch(`${baseUrl}/api/v1/auth/sessions/${sessionId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${appToken}` }, }); if (!res.ok) { const err = await res.json().catch(() => ({})); return { success: false, error: err.message || 'Failed to revoke session' }; } return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to revoke session', }; } }, /** * Get the current app token */ async getAppToken(): Promise { try { const storage = getStorageAdapter(); return await storage.getItem(storageKeys.APP_TOKEN); } catch (error) { console.error('Error getting app token:', error); return null; } }, /** * Get the current refresh token */ async getRefreshToken(): Promise { try { const storage = getStorageAdapter(); return await storage.getItem(storageKeys.REFRESH_TOKEN); } catch (error) { console.debug('Error getting refresh token:', error); return null; } }, /** * Update stored tokens */ async updateTokens(appToken: string, refreshToken: string): Promise { const storage = getStorageAdapter(); await Promise.all([ storage.setItem(storageKeys.APP_TOKEN, appToken), storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken), ]); // Notify callback const storedEmail = await storage.getItem(storageKeys.USER_EMAIL); const userData = getUserFromToken(appToken, storedEmail || undefined); if (userData && onTokenRefreshCallback) { onTokenRefreshCallback(userData); } }, /** * Get user from current token */ async getUserFromToken(): Promise { const storage = getStorageAdapter(); const appToken = await storage.getItem(storageKeys.APP_TOKEN); if (!appToken) return null; const storedEmail = await storage.getItem(storageKeys.USER_EMAIL); return getUserFromToken(appToken, storedEmail || undefined); }, /** * Clear all authentication data */ async clearAuthStorage(): Promise { const storage = getStorageAdapter(); await Promise.all(Object.values(storageKeys).map((key) => storage.removeItem(key))); }, /** * Check if user is authenticated */ async isAuthenticated(): Promise { const appToken = await service.getAppToken(); if (!appToken) return false; return isTokenValidLocally(appToken); }, /** * Check if token is valid locally */ isTokenValidLocally(token: string): boolean { return isTokenValidLocally(token); }, /** * Decode token */ decodeToken(token: string) { return decodeToken(token); }, /** * Get user credits */ async getUserCredits(): Promise { try { const appToken = await service.getAppToken(); if (!appToken) return null; const response = await fetch(`${baseUrl}${endpoints.credits}`, { method: 'GET', headers: { Authorization: `Bearer ${appToken}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error('Failed to fetch user credits'); } const data = await response.json(); return { credits: (data.balance || 0) + (data.freeCreditsRemaining || 0), maxCreditLimit: data.maxCreditLimit || 1000, userId: data.userId || 'unknown', }; } catch (error) { console.error('Error fetching user credits:', error); return null; } }, /** * Check if user is B2B */ async isB2BUser(): Promise { const appToken = await service.getAppToken(); if (!appToken) return false; return checkB2BUser(appToken); }, /** * Get B2B information */ async getB2BInfo(): Promise { const appToken = await service.getAppToken(); if (!appToken) return null; return getB2BInfoFromToken(appToken); }, /** * Check if RevenueCat should be disabled */ async shouldDisableRevenueCat(): Promise { const appToken = await service.getAppToken(); if (!appToken) return false; return checkRevenueCat(appToken); }, /** * Get app settings from token */ async getAppSettings(): Promise | null> { const appToken = await service.getAppToken(); if (!appToken) return null; return getAppSettingsFromToken(appToken); }, /** * Set callback for token refresh events */ set onTokenRefresh(callback: ((userData: UserData) => void) | null) { onTokenRefreshCallback = callback; }, /** * Get callback for token refresh events */ get onTokenRefresh(): ((userData: UserData) => void) | null { return onTokenRefreshCallback; }, /** * Handle authentication errors */ handleAuthError(status: number, errorData: Record): AuthResult { if (status === 401) { const isFirebaseUserNeedsReset = String(errorData.message).includes('Firebase user detected') || String(errorData.message).includes('password reset required') || errorData.code === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED'; if (isFirebaseUserNeedsReset) { return { success: false, error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED' }; } const isEmailNotConfirmed = String(errorData.message).includes('Email not confirmed') || String(errorData.message).includes('Email not verified') || errorData.code === 'EMAIL_NOT_VERIFIED'; if (isEmailNotConfirmed) { return { success: false, error: 'EMAIL_NOT_VERIFIED' }; } return { success: false, error: 'INVALID_CREDENTIALS' }; } else if (status === 403) { return { success: false, error: 'EMAIL_NOT_VERIFIED' }; } return { success: false, error: String(errorData.message) || 'Authentication failed' }; }, /** * Get the base URL */ getBaseUrl(): string { return baseUrl; }, /** * Get storage keys */ getStorageKeys(): StorageKeys { return storageKeys; }, /** * Try to authenticate using SSO session cookie * * This enables cross-domain SSO: If the user is logged in on another app * (e.g., calendar.mana.how), they will automatically be logged in here * via the shared session cookie on .mana.how * * @returns AuthResult with success=true if SSO succeeded */ async trySSO(): Promise { try { const storage = getStorageAdapter(); // Check if we already have valid tokens - skip SSO if so const existingToken = await storage.getItem(storageKeys.APP_TOKEN); if (existingToken && isTokenValidLocally(existingToken)) { return { success: true }; } // Try to get session from cookie (credentials: 'include' sends cookies) const response = await fetch(`${baseUrl}${endpoints.getSession}`, { method: 'GET', credentials: 'include', // Send cookies cross-origin headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { // No valid session cookie - user needs to login manually return { success: false, error: 'No SSO session found' }; } const data = await response.json(); // Better Auth returns session with user info if (!data.session || !data.user) { return { success: false, error: 'Invalid session response' }; } // Now get tokens by signing in with the session // We need to exchange the 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) { // Fallback: Session exists but no token endpoint // Store session info for display, but user may need to re-authenticate for API calls console.warn('SSO: Session found but token exchange not available'); return { success: false, error: 'Token exchange not available' }; } const tokenData = await tokenResponse.json(); const appToken = tokenData.accessToken; const refreshToken = tokenData.refreshToken; if (!appToken || !refreshToken) { return { success: false, error: 'Invalid token response' }; } // Store the tokens await Promise.all([ storage.setItem(storageKeys.APP_TOKEN, appToken), storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken), storage.setItem(storageKeys.USER_EMAIL, data.user.email || ''), ]); console.log('SSO: Successfully authenticated via session cookie'); trackAuth('login', { method: 'sso' }); return { success: true }; } catch (error) { // SSO failed - this is expected if user hasn't logged in anywhere console.debug('SSO check failed:', error); return { success: false, error: error instanceof Error ? error.message : 'SSO check failed', }; } }, }; return service; } /** * Type for the auth service instance. * Uses the explicit interface instead of ReturnType<> to avoid TS inference truncation. */ export type AuthService = AuthServiceInterface;