mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 19:26:41 +02:00
Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()
This commit is contained in:
parent
725db638ea
commit
ef70a1af0b
198 changed files with 11113 additions and 3656 deletions
546
packages/shared-auth/src/core/authService.ts
Normal file
546
packages/shared-auth/src/core/authService.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import type {
|
||||
AuthServiceConfig,
|
||||
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';
|
||||
|
||||
/**
|
||||
* Default storage keys
|
||||
*/
|
||||
const DEFAULT_STORAGE_KEYS: StorageKeys = {
|
||||
APP_TOKEN: '@auth/appToken',
|
||||
REFRESH_TOKEN: '@auth/refreshToken',
|
||||
USER_EMAIL: '@auth/userEmail',
|
||||
};
|
||||
|
||||
/**
|
||||
* Default API endpoints
|
||||
*/
|
||||
const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
||||
signIn: '/auth/signin',
|
||||
signUp: '/auth/signup',
|
||||
signOut: '/auth/logout',
|
||||
refresh: '/auth/refresh',
|
||||
validate: '/auth/validate',
|
||||
forgotPassword: '/auth/forgot-password',
|
||||
googleSignIn: '/auth/google-signin',
|
||||
appleSignIn: '/auth/apple-signin',
|
||||
credits: '/auth/credits',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an authentication service with the given configuration
|
||||
*/
|
||||
export function createAuthService(config: AuthServiceConfig) {
|
||||
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, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return service.handleAuthError(response.status, errorData);
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
|
||||
await Promise.all([
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
||||
storage.setItem(storageKeys.USER_EMAIL, email),
|
||||
]);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during sign in',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const storage = getStorageAdapter();
|
||||
const deviceAdapter = getDeviceAdapter();
|
||||
const deviceInfo = await deviceAdapter.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
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 responseData = await response.json();
|
||||
|
||||
// Check if email verification is required
|
||||
if (responseData.confirmationRequired) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
if (appToken && refreshToken) {
|
||||
await Promise.all([
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
||||
storage.setItem(storageKeys.USER_EMAIL, email),
|
||||
]);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
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));
|
||||
}
|
||||
|
||||
await service.clearAuthStorage();
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<AuthResult> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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, deviceInfo }),
|
||||
});
|
||||
|
||||
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 { appToken, refreshToken } = await response.json();
|
||||
|
||||
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 };
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google
|
||||
*/
|
||||
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
||||
return service.signInWithSocial(idToken, endpoints.googleSignIn);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple
|
||||
*/
|
||||
async signInWithApple(identityToken: string): Promise<AuthResult> {
|
||||
return service.signInWithSocial(identityToken, endpoints.appleSignIn);
|
||||
},
|
||||
|
||||
/**
|
||||
* Internal: Sign in with social provider
|
||||
*/
|
||||
async signInWithSocial(token: string, endpoint: string): Promise<AuthResult> {
|
||||
try {
|
||||
const storage = getStorageAdapter();
|
||||
const deviceAdapter = getDeviceAdapter();
|
||||
const deviceInfo = await deviceAdapter.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return { success: false, error: errorData.message || 'Social sign in failed' };
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
// Extract email from response or token
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const userData = getUserFromToken(appToken);
|
||||
email = userData?.email;
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
const storagePromises = [
|
||||
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
||||
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
||||
];
|
||||
|
||||
if (email) {
|
||||
storagePromises.push(storage.setItem(storageKeys.USER_EMAIL, email));
|
||||
}
|
||||
|
||||
await Promise.all(storagePromises);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error with social sign in:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during social sign in',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.credits || 0,
|
||||
maxCreditLimit: data.max_credit_limit || 1000,
|
||||
userId: data.id || '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;
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the auth service instance
|
||||
*/
|
||||
export type AuthService = ReturnType<typeof createAuthService>;
|
||||
160
packages/shared-auth/src/core/jwtUtils.ts
Normal file
160
packages/shared-auth/src/core/jwtUtils.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import type { DecodedToken, UserData } from '../types';
|
||||
|
||||
/**
|
||||
* Decode a JWT token payload
|
||||
*/
|
||||
export function decodeToken(token: string): DecodedToken | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Add padding if needed
|
||||
const padding = base64.length % 4;
|
||||
const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64;
|
||||
|
||||
// Decode base64 - atob is available in browsers, Node.js 16+, and React Native
|
||||
const payload: DecodedToken = JSON.parse(atob(paddedBase64));
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Error decoding JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is valid locally (not expired)
|
||||
*/
|
||||
export function isTokenValidLocally(token: string, bufferSeconds: number = 10): boolean {
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bufferTime = bufferSeconds * 1000;
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
|
||||
return currentTime < expiryTime - bufferTime;
|
||||
} catch (error) {
|
||||
console.debug('Error validating token locally:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired
|
||||
*/
|
||||
export function isTokenExpired(token: string): boolean {
|
||||
return !isTokenValidLocally(token, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user data from a JWT token
|
||||
*/
|
||||
export function getUserFromToken(token: string, storedEmail?: string): UserData | null {
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get email from various sources
|
||||
let email = payload.email || '';
|
||||
if (!email && payload.user_metadata?.email) {
|
||||
email = payload.user_metadata.email;
|
||||
}
|
||||
if (!email && storedEmail) {
|
||||
email = storedEmail;
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: email || 'user@example.com',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error extracting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration time in milliseconds
|
||||
*/
|
||||
export function getTokenExpirationTime(token: string): number | null {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) {
|
||||
return null;
|
||||
}
|
||||
return payload.exp * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until token expiration in milliseconds
|
||||
*/
|
||||
export function getTimeUntilExpiration(token: string): number {
|
||||
const expirationTime = getTokenExpirationTime(token);
|
||||
if (!expirationTime) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, expirationTime - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is B2B based on JWT claims
|
||||
*/
|
||||
export function isB2BUser(token: string): boolean {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle different types for is_b2b
|
||||
return payload.is_b2b === true || payload.is_b2b === 'true' || payload.is_b2b === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get B2B information from JWT claims
|
||||
*/
|
||||
export function getB2BInfo(token: string): {
|
||||
disableRevenueCat: boolean;
|
||||
organizationId?: string;
|
||||
plan?: string;
|
||||
role?: string;
|
||||
} | null {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload?.app_settings?.b2b) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const b2bSettings = payload.app_settings.b2b;
|
||||
return {
|
||||
disableRevenueCat: !!b2bSettings.disableRevenueCat,
|
||||
organizationId: b2bSettings.organizationId,
|
||||
plan: b2bSettings.plan,
|
||||
role: b2bSettings.role,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if RevenueCat should be disabled for this token
|
||||
*/
|
||||
export function shouldDisableRevenueCat(token: string): boolean {
|
||||
const b2bInfo = getB2BInfo(token);
|
||||
return b2bInfo?.disableRevenueCat ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app settings from JWT claims
|
||||
*/
|
||||
export function getAppSettings(token: string): Record<string, unknown> | null {
|
||||
const payload = decodeToken(token);
|
||||
return payload?.app_settings || null;
|
||||
}
|
||||
464
packages/shared-auth/src/core/tokenManager.ts
Normal file
464
packages/shared-auth/src/core/tokenManager.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import type {
|
||||
TokenState,
|
||||
TokenStateObserver,
|
||||
QueuedRequest,
|
||||
InternalTokenRefreshResult,
|
||||
} from '../types';
|
||||
import { TokenState as TokenStateEnum } from '../types';
|
||||
import { isDeviceConnected, hasStableConnection } from '../adapters/network';
|
||||
import type { AuthService } from './authService';
|
||||
|
||||
/**
|
||||
* Configuration for the token manager
|
||||
*/
|
||||
export interface TokenManagerConfig {
|
||||
maxQueueSize?: number;
|
||||
queueTimeoutMs?: number;
|
||||
maxRefreshAttempts?: number;
|
||||
refreshCooldownMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a token manager instance
|
||||
*/
|
||||
export function createTokenManager(authService: AuthService, config?: TokenManagerConfig) {
|
||||
// Configuration
|
||||
const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
|
||||
const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
|
||||
const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
|
||||
const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
|
||||
|
||||
// State
|
||||
let state: TokenState = TokenStateEnum.IDLE;
|
||||
let refreshPromise: Promise<InternalTokenRefreshResult> | null = null;
|
||||
let requestQueue: QueuedRequest[] = [];
|
||||
const observers = new Set<TokenStateObserver>();
|
||||
let refreshAttempts = 0;
|
||||
let lastRefreshTime = 0;
|
||||
|
||||
// Internal functions
|
||||
function notifyObservers(newState: TokenState, token?: string): void {
|
||||
observers.forEach((observer) => {
|
||||
try {
|
||||
observer(newState, token);
|
||||
} catch (error) {
|
||||
console.debug('Error in token state observer:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setState(newState: TokenState, token?: string): void {
|
||||
if (state !== newState) {
|
||||
console.debug(`TokenManager: State transition ${state} -> ${newState}`);
|
||||
state = newState;
|
||||
notifyObservers(newState, token);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromQueue(requestId: string): void {
|
||||
const index = requestQueue.findIndex((item) => item.id === requestId);
|
||||
if (index !== -1) {
|
||||
requestQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const networkErrors = [
|
||||
'network', 'Network', 'fetch', 'connection', 'timeout',
|
||||
'Failed to fetch', 'NetworkError', 'TypeError', 'ERR_NETWORK',
|
||||
'ERR_INTERNET_DISCONNECTED', 'ECONNREFUSED', 'ENOTFOUND',
|
||||
'ETIMEDOUT', 'Unable to resolve host', 'Request failed',
|
||||
];
|
||||
|
||||
const authErrors = [
|
||||
'401', '403', 'Unauthorized', 'Forbidden', 'Invalid token',
|
||||
'Token expired', 'jwt expired', 'jwt malformed',
|
||||
];
|
||||
|
||||
const errorString = `${error.message} ${error.name}`.toLowerCase();
|
||||
|
||||
const isNetworkError = networkErrors.some((keyword) =>
|
||||
errorString.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const isAuthError = authErrors.some((keyword) =>
|
||||
errorString.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
return isNetworkError && !isAuthError;
|
||||
}
|
||||
|
||||
async function handleRefreshFailure(): Promise<void> {
|
||||
console.debug('TokenManager: Handling permanent refresh failure');
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
setState(TokenStateEnum.EXPIRED);
|
||||
} catch (error) {
|
||||
console.debug('Error in handleRefreshFailure:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function performTokenRefresh(): Promise<InternalTokenRefreshResult> {
|
||||
try {
|
||||
console.debug('TokenManager: Starting token refresh');
|
||||
|
||||
const isOnline = await isDeviceConnected();
|
||||
if (!isOnline) {
|
||||
console.debug('TokenManager: Device offline, skipping refresh');
|
||||
const currentToken = await authService.getAppToken();
|
||||
if (currentToken) {
|
||||
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
|
||||
}
|
||||
return { success: false, error: 'offline', shouldPreserveAuth: true };
|
||||
}
|
||||
|
||||
const isStable = await hasStableConnection();
|
||||
if (!isStable) {
|
||||
console.debug('TokenManager: Connection not stable yet, will retry');
|
||||
return { success: false, error: 'unstable_connection' };
|
||||
}
|
||||
|
||||
const refreshToken = await authService.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const refreshResult = await authService.refreshTokens(refreshToken);
|
||||
const { appToken } = refreshResult;
|
||||
|
||||
console.debug('TokenManager: Token refresh successful');
|
||||
return { success: true, token: appToken };
|
||||
} catch (error) {
|
||||
console.debug('TokenManager: Token refresh failed:', error);
|
||||
|
||||
const isRecoverable = isRecoverableError(error);
|
||||
if (!isRecoverable) {
|
||||
await handleRefreshFailure();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown refresh error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performTokenRefreshWithRetry(): Promise<InternalTokenRefreshResult> {
|
||||
const retryDelays = [0, 1000, 2000, 5000];
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
||||
try {
|
||||
if (retryDelays[attempt] > 0) {
|
||||
console.debug(
|
||||
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
|
||||
}
|
||||
|
||||
const result = await performTokenRefresh();
|
||||
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Non-retryable errors
|
||||
if (
|
||||
result.error === 'invalid_token' ||
|
||||
result.error === 'token_expired' ||
|
||||
result.error?.includes('Device ID has changed')
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.error === 'offline') {
|
||||
return { success: false, error: 'offline', shouldPreserveAuth: true };
|
||||
}
|
||||
|
||||
if (result.error === 'unstable_connection') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
lastError = new Error(result.error || 'Token refresh failed');
|
||||
|
||||
if (attempt === retryDelays.length - 1) break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const isRecoverable = isRecoverableError(error);
|
||||
|
||||
if (!isRecoverable || attempt === retryDelays.length - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
|
||||
};
|
||||
}
|
||||
|
||||
async function processQueuedRequests(token: string): Promise<void> {
|
||||
console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
|
||||
|
||||
const requests = [...requestQueue];
|
||||
requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
const response = await retryRequestWithToken(request.input, request.init, token);
|
||||
request.resolve(response);
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectQueuedRequests(error: string): Promise<void> {
|
||||
console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
|
||||
|
||||
const requests = [...requestQueue];
|
||||
requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRequestWithToken(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
token: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// Public API
|
||||
const manager = {
|
||||
/**
|
||||
* Subscribe to token state changes
|
||||
*/
|
||||
subscribe(observer: TokenStateObserver): () => void {
|
||||
observers.add(observer);
|
||||
return () => observers.delete(observer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current token state
|
||||
*/
|
||||
getState(): TokenState {
|
||||
return state;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a valid token, refreshing if necessary
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const currentToken = await authService.getAppToken();
|
||||
|
||||
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
||||
setState(TokenStateEnum.VALID, currentToken);
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
if (!currentToken) {
|
||||
console.debug('TokenManager: No token available, skipping refresh');
|
||||
setState(TokenStateEnum.EXPIRED);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOnline = await isDeviceConnected();
|
||||
if (!isOnline) {
|
||||
console.debug('TokenManager: Token expired while offline');
|
||||
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
const refreshResult = await manager.refreshToken();
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
return refreshResult.token;
|
||||
}
|
||||
|
||||
if (refreshResult.shouldPreserveAuth) {
|
||||
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle 401 response
|
||||
*/
|
||||
async handle401Response(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
if (state === TokenStateEnum.REFRESHING && refreshPromise) {
|
||||
return manager.queueRequest(input, init);
|
||||
}
|
||||
|
||||
const refreshResult = await manager.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
return retryRequestWithToken(input, init, refreshResult.token);
|
||||
}
|
||||
|
||||
throw new Error(refreshResult.error || 'Token refresh failed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue a request during token refresh
|
||||
*/
|
||||
async queueRequest(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (requestQueue.length >= MAX_QUEUE_SIZE) {
|
||||
reject(new Error('Request queue full'));
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItem: QueuedRequest = {
|
||||
id: Math.random().toString(36).substring(2, 11),
|
||||
input,
|
||||
init,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
requestQueue.push(queueItem);
|
||||
|
||||
setTimeout(() => {
|
||||
removeFromQueue(queueItem.id);
|
||||
reject(new Error('Queued request timeout'));
|
||||
}, QUEUE_TIMEOUT_MS);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the authentication token
|
||||
*/
|
||||
async refreshToken(): Promise<InternalTokenRefreshResult> {
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
|
||||
return { success: false, error: 'Refresh cooldown active' };
|
||||
}
|
||||
|
||||
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
|
||||
await handleRefreshFailure();
|
||||
return { success: false, error: 'Max refresh attempts reached' };
|
||||
}
|
||||
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
setState(TokenStateEnum.REFRESHING);
|
||||
lastRefreshTime = now;
|
||||
|
||||
refreshPromise = performTokenRefreshWithRetry();
|
||||
|
||||
try {
|
||||
const result = await refreshPromise;
|
||||
|
||||
if (result.success) {
|
||||
refreshAttempts = 0;
|
||||
setState(TokenStateEnum.VALID, result.token);
|
||||
await processQueuedRequests(result.token!);
|
||||
} else {
|
||||
refreshAttempts++;
|
||||
setState(TokenStateEnum.EXPIRED);
|
||||
await rejectQueuedRequests(result.error || 'Token refresh failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the token manager state
|
||||
*/
|
||||
reset(): void {
|
||||
state = TokenStateEnum.IDLE;
|
||||
refreshPromise = null;
|
||||
refreshAttempts = 0;
|
||||
lastRefreshTime = 0;
|
||||
|
||||
const requests = [...requestQueue];
|
||||
requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error('Token manager reset'));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear tokens and reset state
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
manager.reset();
|
||||
} catch (error) {
|
||||
console.debug('Error clearing tokens:', error);
|
||||
manager.reset();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get queue status for debugging
|
||||
*/
|
||||
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
|
||||
return {
|
||||
size: requestQueue.length,
|
||||
state,
|
||||
refreshAttempts,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Check initial token state
|
||||
*/
|
||||
async checkInitialState(): Promise<void> {
|
||||
try {
|
||||
const token = await authService.getAppToken();
|
||||
if (!token) {
|
||||
setState(TokenStateEnum.EXPIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
setState(TokenStateEnum.VALID, token);
|
||||
} else {
|
||||
setState(TokenStateEnum.EXPIRED);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error checking initial token state:', error);
|
||||
setState(TokenStateEnum.EXPIRED);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize
|
||||
manager.checkInitialState();
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the token manager instance
|
||||
*/
|
||||
export type TokenManager = ReturnType<typeof createTokenManager>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue