magically-sdk
Version:
Official SDK for Magically - Build mobile apps with AI
1,217 lines (1,050 loc) • 39.6 kB
text/typescript
import { AsyncStorage, WebBrowser, Platform } from './platform';
import { AuthState, User, TokenData, SDKConfig } from './types';
import { Logger } from './Logger';
import { APIClient } from './APIClient';
// SDK Version - should match package.json
const SDK_VERSION = '1.0.6';
export class MagicallyAuth {
private config: SDKConfig;
private logger: Logger;
private apiClient: APIClient;
private authState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
private listeners: ((state: AuthState) => void)[] = [];
private refreshTimer: NodeJS.Timeout | null = null;
private tokenKey = 'magically_tokens';
// Whitelist of allowed origins for postMessage
private allowedOrigins = [
'http://192.168.1.6:3000',
'http://localhost:3000',
'https://trymagically.com',
'https://trymagically.com',
'https://magically-aarida-reduxpress.vercel.app',
'https://a0fb-116-73-140-18.ngrok-free.app'
];
constructor(config: SDKConfig) {
this.config = config;
this.logger = new Logger(config.debug || false, 'MagicallyAuth');
this.apiClient = new APIClient(config, 'MagicallyAuth');
this.logger.info('Initializing MagicallyAuth', { projectId: config.projectId });
this.tokenKey = this.tokenKey + '_' + config.projectId;
this.initializeAuth();
}
/**
* Simple helper - is user authenticated?
*/
get isAuthenticated(): boolean {
return this.authState.isAuthenticated;
}
/**
* Initialize authentication - check for stored tokens
*/
private async initializeAuth(): Promise<void> {
this.logger.debug('Starting auth initialization');
this.setLoading(true);
try {
this.logger.debug('Checking for stored tokens');
const storedTokens = await this.getStoredTokens();
if (storedTokens) {
this.logger.debug('Found stored tokens, validating');
const user = await this.validateToken(storedTokens.accessToken);
if (user) {
this.logger.success('User authenticated from stored tokens', { userId: user.id, email: user.email });
this.setAuthState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
// Preserve existing token if any (from previous session)
token: this.authState.token
});
return;
} else {
this.logger.warn('Stored tokens are invalid, clearing');
await this.clearStoredTokens();
}
} else {
this.logger.debug('No stored tokens found');
}
} catch (error) {
this.logger.error('Auth initialization failed', error);
}
this.logger.debug('Auth initialization complete - user not authenticated');
this.setLoading(false);
}
/**
* Sign up with email and password
*/
async signUpWithEmail(params: {
email: string;
password: string;
name: string;
verificationCode?: string;
}): Promise<{ accessToken: string; user: User }> {
this.logger.info('Starting email sign up', { email: params.email });
this.setLoading(true);
this.setError(null);
try {
const data = await this.apiClient.request<any>(
`/api/project/${this.config.projectId}/auth/email`,
{
method: 'POST',
body: {
action: 'signup',
...params,
},
operation: 'email-signup',
}
);
// Store tokens
const tokens: TokenData = {
accessToken: data.accessToken,
refreshToken: data.refreshToken || '',
expiresIn: 3600,
tokenType: 'Bearer',
};
await this.storeTokens(tokens);
// Update auth state
const user = data.user;
user._id = user.id; // MongoDB compatibility
// Ensure user exists in project database
await this.ensureUserInDatabase(data.accessToken, user, 'email');
this.setAuthState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
token: data.accessToken // Store the access token
});
this.startBackgroundRefresh();
this.logger.success('Email sign up successful', { userId: user.id });
return { accessToken: data.accessToken, user };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Sign up failed';
this.logger.error('Email sign up failed', error);
this.setError(errorMessage);
throw new Error(errorMessage);
} finally {
this.setLoading(false);
}
}
/**
* Sign in with email and password
*/
async signInWithEmail(params: {
email: string;
password: string;
}): Promise<{ accessToken: string; user: User }> {
this.logger.info('Starting email sign in', { email: params.email });
this.setLoading(true);
this.setError(null);
try {
const data = await this.apiClient.request<any>(
`/api/project/${this.config.projectId}/auth/email`,
{
method: 'POST',
body: {
action: 'signin',
...params,
},
operation: 'email-signin',
}
);
// Store tokens
const tokens: TokenData = {
accessToken: data.accessToken,
refreshToken: data.refreshToken || '',
expiresIn: 3600,
tokenType: 'Bearer',
};
await this.storeTokens(tokens);
// Update auth state
const user = data.user;
user._id = user.id; // MongoDB compatibility
// Ensure user exists in project database
await this.ensureUserInDatabase(data.accessToken, user, 'email');
this.setAuthState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
token: data.accessToken // Store the access token
});
this.startBackgroundRefresh();
this.logger.success('Email sign in successful', { userId: user.id });
return { accessToken: data.accessToken, user };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Sign in failed';
this.logger.error('Email sign in failed', error);
this.setError(errorMessage);
throw new Error(errorMessage);
} finally {
this.setLoading(false);
}
}
/**
* Send email verification code
* @param email - User's email address
* @param type - Must be 'signup' for new users or 'password_reset' for existing users. Defaults to 'signup'
* @throws Error if type is invalid or API request fails
*/
async sendVerificationCode(email: string, type: 'signup' | 'password_reset' = 'signup'): Promise<void> {
// Validate type parameter for LLM guidance
if (type !== 'signup' && type !== 'password_reset') {
throw new Error(`Invalid type '${type}'. Must be 'signup' for new users or 'password_reset' for existing users`);
}
this.logger.info('Sending verification code', { email, type });
try {
await this.apiClient.request(
`/api/project/${this.config.projectId}/auth/send-verification`,
{
method: 'POST',
body: { email, type },
operation: 'send-verification',
}
);
this.logger.success('Verification code sent', { email });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to send code';
this.logger.error('Send verification failed', error);
throw new Error(errorMessage);
}
}
/**
* Verify email with code
*/
async verifyCode(email: string, code: string): Promise<void> {
this.logger.info('Verifying email code', { email });
try {
await this.apiClient.request(
`/api/project/${this.config.projectId}/auth/verify-code`,
{
method: 'POST',
body: { email, code },
operation: 'verify-code',
}
);
this.logger.success('Email verified', { email });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
this.logger.error('Email verification failed', error);
throw new Error(errorMessage);
}
}
/**
* Reset password with verification code
* Single call that handles verification and password update internally
*/
async resetPassword(params: {
email: string;
code: string;
newPassword: string;
}): Promise<void> {
this.logger.info('Resetting password', { email: params.email });
try {
await this.apiClient.request(
`/api/project/${this.config.projectId}/auth/reset-password`,
{
method: 'POST',
body: params,
operation: 'reset-password',
}
);
this.logger.success('Password reset successful', { email: params.email });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Reset failed';
this.logger.error('Password reset failed', error);
throw new Error(errorMessage);
}
}
/**
* Internal method that handles both Google and email authentication flows
* Contains the core authentication logic shared by both methods
*/
private async _performAuthenticationFlow(provider: 'google' | 'email' = 'google'): Promise<User> {
this.logger.info(`Starting ${provider} authentication`);
this.setLoading(true);
this.setError(null);
try {
let authData: { accessToken: string; user: User, refreshToken: string };
if (Platform.OS === 'web') {
// WEB ONLY: Use open-then-navigate pattern to prevent popup blocking
this.logger.debug('Web platform detected - using open-then-navigate pattern to prevent popup blocking');
// Step 1: Open popup IMMEDIATELY with loading page (maintains user gesture context)
const loadingUrl = `${this.getApiUrl()}/auth/loading?provider=${provider}`;
this.logger.debug('Opening popup with loading page', { loadingUrl });
const popupFeatures = 'width=500,height=650,left=100,top=100,toolbar=no,menubar=no';
const popupWindow = window.open(loadingUrl, 'magically_auth', popupFeatures);
if (!popupWindow) {
throw new Error('Unable to open authentication window. Please check your popup blocker settings and try again.');
}
try {
// Set up postMessage listener
const authPromise = this.setupPostMessageListener();
// Step 2: Get OAuth client info (popup already open)
this.logger.debug('Getting OAuth client info (popup already open)');
const clientInfo = await this.getOAuthClient();
this.logger.success('OAuth client info retrieved', { clientId: clientInfo.clientId });
// Step 3: Build OAuth URL
const authUrl = this.buildOAuthUrl(clientInfo.clientId, clientInfo.redirectUri, provider);
// Step 4: Navigate the already-open popup
if (!popupWindow.closed) {
this.logger.debug('Navigating popup to OAuth URL');
popupWindow.location.href = authUrl;
try {
popupWindow.focus();
} catch (e) {
// Ignore focus errors
}
} else {
throw new Error('Authentication window was closed');
}
// Wait for callback
this.logger.debug('Waiting for postMessage callback');
authData = await authPromise;
this.logger.success('Authentication callback received via postMessage', {
userId: authData.user.id,
email: authData.user.email
});
} catch (error) {
if (popupWindow && !popupWindow.closed) {
popupWindow.close();
}
throw error;
}
} else {
// NATIVE PLATFORMS: Use original flow (fetch config first, then open browser)
this.logger.debug('Native platform detected - using standard OAuth flow');
// Step 1: Get OAuth client info first
this.logger.debug('Step 1: Getting OAuth client info');
const clientInfo = await this.getOAuthClient();
this.logger.success('OAuth client info retrieved', { clientId: clientInfo.clientId });
// Step 2: Build OAuth URL
this.logger.debug('Step 2: Building OAuth URL');
const authUrl = this.buildOAuthUrl(clientInfo.clientId, clientInfo.redirectUri, provider);
this.logger.debug('Opening OAuth browser session', { authUrl });
// Step 3: Open browser with OAuth URL
this.logger.debug('Step 3: Using native WebBrowser flow');
this.logger.debug(`Listening for redirectUri: ${clientInfo.redirectUri}`);
const result = await WebBrowser.openAuthSessionAsync?.(authUrl, clientInfo.redirectUri) || { type: 'cancel' };
console.log('result.type', result)
if (result.type === 'cancel') {
this.logger.warn('OAuth browser session was cancelled by user');
throw new Error('Authentication was cancelled');
}
if (result.type !== 'success' || !result.url) {
this.logger.error('OAuth browser session failed', { result });
throw new Error('Authentication failed - no result URL');
}
// Parse the auth data from the result URL
this.logger.debug('OAuth browser session completed, parsing result URL', { url: result.url });
authData = this.parseAuthDataFromUrl(result.url) as any;
this.logger.success('Authentication data parsed from URL', {
userId: authData.user.id,
email: authData.user.email
});
}
// 4. Store tokens and set user state (hidden complexity)
this.logger.debug('Step 4: Storing tokens and setting user state');
const tokens: TokenData = {
accessToken: authData.accessToken,
refreshToken: authData.refreshToken || '', // Store refresh token if provided
expiresIn: 3600, // Default 1 hour
tokenType: 'Bearer'
};
await this.storeTokens(tokens);
// 5. Ensure user exists in project database
this.logger.debug('Step 5: Ensuring user exists in project database');
await this.ensureUserInDatabase(authData.accessToken, authData.user);
const user = authData.user;
// Adding for mongo backwards compatibility and LLM keeps using this pattern
user._id = user.id;
this.logger.success('User info received from callback', { userId: user.id, email: user.email });
// 6. Update internal state
this.setAuthState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
token: authData.accessToken // Store the access token
});
// 7. Start background token refresh
this.startBackgroundRefresh();
this.logger.success(`${provider} authentication completed successfully`, { userName: user.name });
return user;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
this.logger.error(`${provider} authentication failed`, error);
this.setError(errorMessage);
throw new Error(errorMessage);
} finally {
this.setLoading(false);
}
}
/**
* Sign in with Google OAuth
* Handles ALL complexity internally - just returns user or throws error
*/
async signInWithGoogle(): Promise<User> {
return this._performAuthenticationFlow('google');
}
/**
* Trigger authentication flow for specified provider
* Supports both Google OAuth and email/password authentication
*/
async triggerAuthenticationFlow(provider: 'google' | 'email'): Promise<User> {
return this._performAuthenticationFlow(provider);
}
/**
* Sign out user
* Handles ALL complexity internally - just clears everything
*/
async signOut(): Promise<void> {
this.logger.info('Starting sign out process');
try {
// Stop background refresh
this.stopBackgroundRefresh();
this.logger.debug('Clearing stored tokens');
await this.clearStoredTokens();
this.logger.debug('Tokens cleared successfully');
// Reset state to clean slate
this.setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
token: null
});
this.logger.success('Sign out completed successfully');
} catch (error) {
this.logger.error('Error during sign out, but continuing with state reset', error);
// Even if clearing fails, reset the state
this.setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
token: null
});
}
}
/**
* Get current user - simple getter
*/
get currentUser(): User | null {
return this.authState.user;
}
/**
* Get current user - LLM friendly method as LLM keeps looking for this for some reason
*/
getCurrentUser(): User | null {
return this.authState.user;
}
/**
* Parse user from JWT token (Supabase-like pattern for edge functions)
* Automatically sets the user in auth state for subsequent operations
* @param authHeaderOrToken - Either full "Bearer xxx" header or just the JWT token
* @returns User object or null if invalid/expired
*/
async getUser(authHeaderOrToken?: string | Request | null): Promise<{ user: User | null }> {
try {
let token: string | null = null;
// Handle different input types
if (!authHeaderOrToken) {
// Clear auth state if no token provided
this.setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
token: null
});
return { user: null };
}
if (typeof authHeaderOrToken === 'string') {
// If it's a Bearer token header
if (authHeaderOrToken.startsWith('Bearer ')) {
token = authHeaderOrToken.substring(7);
} else {
// Assume it's just the token
token = authHeaderOrToken;
}
} else if (authHeaderOrToken && typeof authHeaderOrToken === 'object' && 'headers' in authHeaderOrToken) {
// It's a Request object
const authHeader = authHeaderOrToken.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
}
}
if (!token) {
// Clear auth state if no valid token
this.setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
token: null
});
return { user: null };
}
// Skip API keys
if (token.startsWith('mg_')) {
this.logger.debug('API key detected in getUser, returning null');
// Don't set auth state for API keys
return { user: null };
}
// Decode and validate JWT
const payload = this.decodeJWT(token);
const now = Math.floor(Date.now() / 1000);
// Check expiration
if (payload.exp && payload.exp < now) {
this.logger.debug('Token expired in getUser');
// Clear auth state for expired token
this.setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
token: null
});
return { user: null };
}
// Parse user from JWT payload
const user: User = {
_id: payload.sub || payload.id,
id: payload.sub || payload.id,
email: payload.email,
name: payload.name,
firstName: payload.given_name,
lastName: payload.family_name,
};
// Store the token for future API calls (edge function scenario)
// This allows data operations to use the user's token instead of API key
if (typeof authHeaderOrToken === 'string' && !authHeaderOrToken.startsWith('Bearer ')) {
// We have the raw token, store it
const tokens: TokenData = {
accessToken: token,
refreshToken: '', // No refresh token in edge context
expiresIn: payload.exp ? payload.exp - now : 3600,
tokenType: 'Bearer'
};
await this.storeTokens(tokens);
}
// UPDATE AUTH STATE - Store the token in authState for edge environments
this.setAuthState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
token: token // Store the JWT token in auth state
});
this.logger.debug('User parsed from JWT and auth state updated', { userId: user.id, email: user.email });
return { user };
} catch (error) {
this.logger.error('Failed to parse user from token', error);
// Clear auth state on error
this.setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
token: null
});
return { user: null };
}
}
/**
* Get current auth state - for debugging/UI
*/
get state(): AuthState {
return { ...this.authState }; // Return copy to prevent mutation
}
/**
* Get a valid access token for API calls
* Automatically refreshes if needed
*/
async getValidToken(): Promise<string> {
if (!this.isAuthenticated) {
throw new Error('User is not authenticated');
}
return await this.ensureValidToken();
}
/**
* Subscribe to auth state changes
*/
onAuthStateChanged(callback: (state: AuthState) => void): () => void {
this.listeners.push(callback);
// Return unsubscribe function
return () => {
const index = this.listeners.indexOf(callback);
if (index > -1) {
this.listeners.splice(index, 1);
}
};
}
/**
* Refresh access token
*/
async refreshToken(): Promise<string> {
this.logger.debug('Starting token refresh');
const storedTokens = await this.getStoredTokens();
if (!storedTokens?.accessToken) {
this.logger.error('No access token available for refresh');
throw new Error('No access token available');
}
try {
let response: Response;
// If we have a refresh token, use the new method
if (storedTokens.refreshToken) {
this.logger.debug('Using refresh token for token refresh');
response = await fetch(`${this.getApiUrl()}/api/project/${this.config.projectId}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: storedTokens.refreshToken,
}),
});
} else {
// Fallback to JWT-only refresh for backward compatibility
this.logger.debug('Using JWT for token refresh (no refresh token available)');
response = await fetch(`${this.getApiUrl()}/api/project/${this.config.projectId}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${storedTokens.accessToken}`,
},
body: JSON.stringify({
projectId: this.config.projectId,
}),
});
}
if (!response.ok) {
this.logger.error('Token refresh failed', { status: response.status });
throw new Error('Token refresh failed');
}
const tokenData = await response.json();
const newTokens: TokenData = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || storedTokens.refreshToken,
expiresIn: tokenData.expires_in || 3600,
tokenType: tokenData.token_type || 'Bearer',
};
await this.storeTokens(newTokens);
this.logger.success('Token refreshed successfully');
return newTokens.accessToken;
} catch (error) {
this.logger.error('Token refresh failed, signing out user', error);
// If refresh fails, sign out user
await this.signOut();
throw error;
}
}
/**
* Check if token needs refresh and refresh if necessary
*/
async ensureValidToken(): Promise<string> {
// First check if we have a token in authState (edge environment)
if (this.authState.token) {
try {
// Validate the token from authState
const payload = this.decodeJWT(this.authState.token);
const now = Math.floor(Date.now() / 1000);
const expiresAt = payload.exp;
// Check if token is still valid (with 5 minute buffer)
const refreshThreshold = 5 * 60; // 5 minutes
if (expiresAt && (expiresAt - now) >= refreshThreshold) {
// Token from authState is valid
return this.authState.token;
}
// Token in authState is expired, clear it
this.authState.token = null;
} catch (error) {
// Token in authState is invalid, clear it
this.authState.token = null;
}
}
// Fall back to stored tokens (client environment)
const storedTokens = await this.getStoredTokens();
if (!storedTokens?.accessToken) {
throw new Error('No access token available');
}
try {
// Decode JWT to check expiration
const payload = this.decodeJWT(storedTokens.accessToken);
const now = Math.floor(Date.now() / 1000);
const expiresAt = payload.exp;
// Refresh if token expires in the next 5 minutes
const refreshThreshold = 5 * 60; // 5 minutes
if (expiresAt && (expiresAt - now) < refreshThreshold) {
this.logger.debug('Token expires soon, refreshing', {
expiresAt,
now,
timeLeft: expiresAt - now
});
return await this.refreshToken();
}
return storedTokens.accessToken;
} catch (error) {
this.logger.error('Token validation failed', error);
throw error;
}
}
// Private helper methods
private setAuthState(newState: Partial<AuthState>): void {
this.authState = { ...this.authState, ...newState };
this.notifyListeners();
}
private setLoading(isLoading: boolean): void {
this.setAuthState({ isLoading });
}
private setError(error: string | null): void {
this.setAuthState({ error });
}
private notifyListeners(): void {
this.listeners.forEach(callback => callback(this.authState));
}
private getApiUrl(): string {
return this.config.apiUrl || 'https://trymagically.com';
}
private async getRedirectUri(): Promise<string> {
// Get redirect URI from OAuth client info (server determines platform-specific URI)
const clientInfo = await this.getOAuthClient();
return clientInfo.redirectUri;
}
private async getClientId(): Promise<string> {
const clientInfo = await this.getOAuthClient();
return clientInfo.clientId;
}
private async getOAuthClient(): Promise<{ clientId: string; redirectUri: string }> {
// Determine platform for redirect URI
const platform = this.getPlatform();
const params = new URLSearchParams({ platform });
const url = `${this.getApiUrl()}/api/project/${this.config.projectId}/oauth-client?${params.toString()}`;
this.logger.debug('Fetching OAuth client info', { url, platform });
const response = await fetch(url);
if (!response.ok) {
this.logger.error('Failed to fetch OAuth client info', { status: response.status, statusText: response.statusText });
throw new Error('Failed to get OAuth client information');
}
const clientInfo = await response.json();
this.logger.debug('OAuth client info received', { clientId: clientInfo.clientId, redirectUri: clientInfo.redirectUri });
return clientInfo;
}
/**
* Determine platform for OAuth redirect URI
*/
private getPlatform(): string {
// @ts-ignore - Platform.OS can be 'web' in React Native Web
if (Platform.OS === 'web') {
return 'web';
}
// Check if running in Expo Go
// In Expo Go, the app bundle identifier contains 'host.exp.exponent'
if (typeof navigator !== 'undefined' && navigator.userAgent?.includes('Expo')) {
return 'expo-go';
}
// For React Native, check if we're in development mode (Expo Go)
if (typeof __DEV__ !== 'undefined' && __DEV__) {
return 'expo-go';
}
return 'native';
}
private buildOAuthUrl(clientId: string, redirectUri: string, provider?: string): string {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
state: this.generateState(),
});
// Add provider-specific parameters for backward compatibility
if (provider === 'google') {
params.set('auth_type', 'google'); // Existing parameter name
} else if (provider === 'email') {
params.set('provider', 'email'); // New parameter for email flow
}
return `${this.getApiUrl()}/api/oauth/authorize?${params.toString()}`;
}
private generateState(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
private extractCodeFromUrl(url: string): string {
const urlObj = new URL(url);
const code = urlObj.searchParams.get('code');
if (!code) {
throw new Error('No authorization code found in callback URL');
}
return code;
}
/**
* Parse authentication data from URL (supports both hash fragments and query params)
*/
private parseAuthDataFromUrl(url: string): { accessToken: string; refreshToken?: string; user: User } {
try {
this.logger.debug('Parsing auth data from URL', { url });
const urlObj = new URL(url);
// Try hash fragments first (more secure), then query params
let accessToken: string | null = null;
let refreshToken: string | null = null;
let userDataString: string | null = null;
let success: string | null = null;
if (urlObj.hash) {
// Parse hash fragments (format: #access_token=...&refresh_token=...&user_data=...)
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
accessToken = hashParams.get('access_token');
refreshToken = hashParams.get('refresh_token');
userDataString = hashParams.get('user_data');
success = hashParams.get('success');
this.logger.debug('Parsed from hash fragments', {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refreshToken,
hasUserData: !!userDataString
});
}
if (!accessToken) {
// Fallback to query params (format: ?access_token=...&refresh_token=...&user_data=...)
accessToken = urlObj.searchParams.get('access_token');
refreshToken = urlObj.searchParams.get('refresh_token');
userDataString = urlObj.searchParams.get('user_data');
success = urlObj.searchParams.get('success');
this.logger.debug('Parsed from query params', {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refreshToken,
hasUserData: !!userDataString
});
}
if (!success || success !== 'true') {
throw new Error('Authentication was not successful');
}
if (!accessToken) {
throw new Error('No access token found in callback URL');
}
if (!userDataString) {
throw new Error('No user data found in callback URL');
}
const userData = JSON.parse(userDataString);
this.logger.debug('Successfully parsed auth data from URL', {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refreshToken,
userId: userData.id,
email: userData.email
});
return {
accessToken,
refreshToken: refreshToken || undefined,
user: userData
};
} catch (error) {
this.logger.error('Failed to parse auth data from URL', { url, error });
throw new Error(`Failed to parse authentication data: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async getUserFromToken(accessToken: string): Promise<User> {
// Decode JWT to get user info (JWT contains user data from your existing implementation)
const payload = this.decodeJWT(accessToken);
return {
_id: payload.sub,
id: payload.sub,
email: payload.email,
name: payload.name,
firstName: payload.given_name,
lastName: payload.family_name,
};
}
private decodeJWT(token: string): any {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (error) {
throw new Error('Invalid JWT token');
}
}
private async validateToken(accessToken: string): Promise<User | null> {
try {
// Check if token is expired
const payload = this.decodeJWT(accessToken);
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
// Token expired, try to refresh
await this.refreshToken();
const newTokens = await this.getStoredTokens();
if (newTokens) {
return this.getUserFromToken(newTokens.accessToken);
}
return null;
}
return this.getUserFromToken(accessToken);
} catch (error) {
console.error('Token validation error:', error);
return null;
}
}
private async storeTokens(tokens: TokenData): Promise<void> {
try {
await AsyncStorage.setItem(this.tokenKey, JSON.stringify(tokens));
} catch (error) {
console.error('Error storing tokens:', error);
}
}
private async getStoredTokens(): Promise<TokenData | null> {
try {
const stored = await AsyncStorage.getItem(this.tokenKey);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error('Error getting stored tokens:', error);
return null;
}
}
private async clearStoredTokens(): Promise<void> {
try {
await AsyncStorage.removeItem(this.tokenKey);
} catch (error) {
console.error('Error clearing tokens:', error);
}
}
/**
* Set up postMessage listener for OAuth callback (web only)
*/
private setupPostMessageListener(): Promise<{ accessToken: string; user: User, refreshToken: string }> {
if (Platform.OS !== "web") {
this.logger.error('setupPostMessageListener called on non-web platform');
return Promise.reject(new Error('PostMessage listener only works on web platforms'));
}
return new Promise((resolve, reject) => {
this.logger.debug('Setting up postMessage listener', { allowedOrigins: this.allowedOrigins });
const handleMessage = (event: MessageEvent) => {
this.logger.debug('Received postMessage', {
origin: event.origin,
type: event.data?.type
});
// Validate origin
if (!this.allowedOrigins.includes(event.origin)) {
this.logger.warn('Ignoring postMessage from unauthorized origin', {
origin: event.origin,
allowedOrigins: this.allowedOrigins
});
return;
}
// Check message type
if (event.data?.type === 'magically-auth-callback') {
this.logger.success('Valid auth callback received', {
userId: event.data.user?.id,
email: event.data.user?.email
});
// Clean up listener
window.removeEventListener('message', handleMessage);
// Resolve with auth data
resolve({
accessToken: event.data.accessToken,
user: event.data.user,
refreshToken: event.data.refreshToken
});
}
};
// Set up listener
window.addEventListener('message', handleMessage);
// Set up timeout to prevent hanging
const timeout = setTimeout(() => {
this.logger.error('OAuth callback timeout - no postMessage received');
window.removeEventListener('message', handleMessage);
reject(new Error('Authentication timeout - no callback received'));
}, 60000); // 60 second timeout
// Clear timeout when resolved
const originalResolve = resolve;
resolve = (value) => {
clearTimeout(timeout);
originalResolve(value);
};
});
}
/**
* Start background token refresh
*/
private startBackgroundRefresh(): void {
this.logger.debug('Starting background token refresh');
// Clear any existing timer
this.stopBackgroundRefresh();
// Check token every 10 minutes
this.refreshTimer = setInterval(async () => {
try {
if (this.isAuthenticated) {
await this.ensureValidToken();
}
} catch (error) {
this.logger.error('Background token refresh failed', error);
// Don't sign out automatically on background refresh failure
// Let the next API call handle it
}
}, 10 * 60 * 1000); // 10 minutes
}
/**
* Stop background token refresh
*/
private stopBackgroundRefresh(): void {
if (this.refreshTimer) {
this.logger.debug('Stopping background token refresh');
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
/**
* Ensure user exists in project database
*/
private async ensureUserInDatabase(accessToken: string, user: User, provider: 'google' | 'email' = 'google'): Promise<void> {
try {
this.logger.debug('Creating/updating user in project database', { userId: user.id, provider });
// Detect platform
const platformName = Platform.OS === 'web' ? 'web' :
Platform.OS === 'ios' ? 'ios' :
Platform.OS === 'android' ? 'android' :
'react-native';
const response = await fetch(`${this.getApiUrl()}/api/project/${this.config.projectId}/data/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
provider: provider === 'email' ? 'credentials' : 'google',
origin: provider === 'email' ? 'email' : 'oauth',
emailVerified: true,
metadata: {
sdkVersion: SDK_VERSION,
platform: platformName,
loginTimestamp: new Date().toISOString(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'React Native',
},
}),
});
if (response.ok) {
this.logger.success('User created/updated in project database');
} else {
const errorText = await response.text();
this.logger.warn('Failed to create/update user in project database', {
status: response.status,
statusText: response.statusText,
error: errorText
});
// Log the error but don't throw - OAuth should still succeed
if (response.status === 409) {
this.logger.info('User already exists in project database (duplicate email)');
}
}
} catch (error) {
this.logger.error('Error ensuring user in database', error);
// Don't throw - this shouldn't break the auth flow
}
}
}