managarten/packages/shared-auth/src/core/authService.ts
Till JS b1af506b99 fix(auth): surface email-not-verified error and detect needsVerification on signup
- mana-auth login route: catch Better Auth's email verification error and
  return 403 EMAIL_NOT_VERIFIED instead of 401 Invalid credentials
- shared-auth signUp: detect emailVerified:false in register response and
  return needsVerification:true so the UI shows the verification prompt
- shared-auth-ui LoginPage: map INVALID_CREDENTIALS error code to friendly message
- shared-i18n: add invalidCredentials translation (de/en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:52:47 +02:00

1180 lines
34 KiB
TypeScript

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<string, string | number | boolean>): 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<AuthResult> {
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<AuthResult> {
try {
const body: Record<string, string> = { 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' };
}
const data = await response.json();
// If emailVerified is false, the user needs to verify their email before login
const needsVerification = data?.user?.emailVerified === false;
trackAuth('signup', { method: 'email' });
return { success: true, needsVerification };
} 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<void> {
try {
const storage = getStorageAdapter();
const refreshToken = await storage.getItem<string>(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<AuthResult> {
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<AuthResult> {
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<AuthResult> {
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<TokenRefreshResult> {
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<string>(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<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',
};
}
},
/**
* 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<AuthResult> {
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<AuthResult> {
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<AuthResult> {
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<AuthResult> {
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<AuthResult> {
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<any[]> {
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<any[]> {
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<AuthResult> {
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<string | null> {
try {
const storage = getStorageAdapter();
return await storage.getItem<string>(storageKeys.APP_TOKEN);
} catch (error) {
console.error('Error getting app token:', error);
return null;
}
},
/**
* Get the current refresh token
*/
async getRefreshToken(): Promise<string | null> {
try {
const storage = getStorageAdapter();
return await storage.getItem<string>(storageKeys.REFRESH_TOKEN);
} catch (error) {
console.debug('Error getting refresh token:', error);
return null;
}
},
/**
* Update stored tokens
*/
async updateTokens(appToken: string, refreshToken: string): Promise<void> {
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<string>(storageKeys.USER_EMAIL);
const userData = getUserFromToken(appToken, storedEmail || undefined);
if (userData && onTokenRefreshCallback) {
onTokenRefreshCallback(userData);
}
},
/**
* Get user from current token
*/
async getUserFromToken(): Promise<UserData | null> {
const storage = getStorageAdapter();
const appToken = await storage.getItem<string>(storageKeys.APP_TOKEN);
if (!appToken) return null;
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
return getUserFromToken(appToken, storedEmail || undefined);
},
/**
* Clear all authentication data
*/
async clearAuthStorage(): Promise<void> {
const storage = getStorageAdapter();
await Promise.all(Object.values(storageKeys).map((key) => storage.removeItem(key)));
},
/**
* Check if user is authenticated
*/
async isAuthenticated(): Promise<boolean> {
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<CreditBalance | null> {
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<boolean> {
const appToken = await service.getAppToken();
if (!appToken) return false;
return checkB2BUser(appToken);
},
/**
* Get B2B information
*/
async getB2BInfo(): Promise<B2BInfo | null> {
const appToken = await service.getAppToken();
if (!appToken) return null;
return getB2BInfoFromToken(appToken);
},
/**
* Check if RevenueCat should be disabled
*/
async shouldDisableRevenueCat(): Promise<boolean> {
const appToken = await service.getAppToken();
if (!appToken) return false;
return checkRevenueCat(appToken);
},
/**
* Get app settings from token
*/
async getAppSettings(): Promise<Record<string, unknown> | 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<string, unknown>): 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<AuthResult> {
try {
const storage = getStorageAdapter();
// Check if we already have valid tokens - skip SSO if so
const existingToken = await storage.getItem<string>(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;