/** * Supabase authentication client for CLI auth flows */ import { createClient, SupabaseClient as SupabaseJSClient, User, Session } from '@supabase/supabase-js'; import { AuthenticationError } from '../auth/types.js'; import { getLogger } from '../logger/index.js'; import { SupabaseSessionStorage } from '../auth/supabase-session-storage.js'; import { CredentialStore } from '../auth/credential-store.js'; export class SupabaseAuthClient { private client: SupabaseJSClient | null = null; private sessionStorage: SupabaseSessionStorage; private logger = getLogger('SupabaseAuthClient'); private credentialStore: CredentialStore; constructor() { this.credentialStore = CredentialStore.getInstance(); this.sessionStorage = new SupabaseSessionStorage(this.credentialStore); } /** * Get Supabase client with proper session management */ getClient(): SupabaseJSClient { if (!this.client) { // Get Supabase configuration from environment // Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*) const supabaseUrl = process.env.TM_SUPABASE_URL || process.env.TM_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.TM_SUPABASE_ANON_KEY || process.env.TM_PUBLIC_SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseAnonKey) { throw new AuthenticationError( 'Supabase configuration missing. Please set TM_SUPABASE_URL and TM_SUPABASE_ANON_KEY (runtime) or TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY (build-time) environment variables.', 'CONFIG_MISSING' ); } // Create client with custom storage adapter (similar to React Native AsyncStorage) this.client = createClient(supabaseUrl, supabaseAnonKey, { auth: { storage: this.sessionStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false } }); } return this.client; } /** * Initialize the client and restore session if available */ async initialize(): Promise { const client = this.getClient(); try { // Get the current session from storage const { data: { session }, error } = await client.auth.getSession(); if (error) { this.logger.warn('Failed to restore session:', error); return null; } if (session) { this.logger.info('Session restored successfully'); } return session; } catch (error) { this.logger.error('Error initializing session:', error); return null; } } /** * Sign in with PKCE flow (for CLI auth) */ async signInWithPKCE(): Promise<{ url: string; codeVerifier: string }> { const client = this.getClient(); try { // Generate PKCE challenge const { data, error } = await client.auth.signInWithOAuth({ provider: 'github', options: { redirectTo: process.env.TM_AUTH_CALLBACK_URL || 'http://localhost:3421/auth/callback', scopes: 'email' } }); if (error) { throw new AuthenticationError( `Failed to initiate PKCE flow: ${error.message}`, 'PKCE_INIT_FAILED' ); } if (!data?.url) { throw new AuthenticationError( 'No authorization URL returned', 'INVALID_RESPONSE' ); } // Extract code_verifier from the URL or generate it // Note: Supabase handles PKCE internally, we just need to handle the callback return { url: data.url, codeVerifier: '' // Supabase manages this internally }; } catch (error) { if (error instanceof AuthenticationError) { throw error; } throw new AuthenticationError( `Failed to start PKCE flow: ${(error as Error).message}`, 'PKCE_FAILED' ); } } /** * Exchange authorization code for session (PKCE flow) */ async exchangeCodeForSession(code: string): Promise { const client = this.getClient(); try { const { data, error } = await client.auth.exchangeCodeForSession(code); if (error) { throw new AuthenticationError( `Failed to exchange code: ${error.message}`, 'CODE_EXCHANGE_FAILED' ); } if (!data?.session) { throw new AuthenticationError( 'No session returned from code exchange', 'INVALID_RESPONSE' ); } this.logger.info('Successfully exchanged code for session'); return data.session; } catch (error) { if (error instanceof AuthenticationError) { throw error; } throw new AuthenticationError( `Code exchange failed: ${(error as Error).message}`, 'CODE_EXCHANGE_FAILED' ); } } /** * Get the current session */ async getSession(): Promise { const client = this.getClient(); try { const { data: { session }, error } = await client.auth.getSession(); if (error) { this.logger.warn('Failed to get session:', error); return null; } return session; } catch (error) { this.logger.error('Error getting session:', error); return null; } } /** * Refresh the current session */ async refreshSession(): Promise { const client = this.getClient(); try { this.logger.info('Refreshing session...'); // Supabase will automatically use the stored refresh token const { data: { session }, error } = await client.auth.refreshSession(); if (error) { this.logger.error('Failed to refresh session:', error); throw new AuthenticationError( `Failed to refresh session: ${error.message}`, 'REFRESH_FAILED' ); } if (session) { this.logger.info('Successfully refreshed session'); } return session; } catch (error) { if (error instanceof AuthenticationError) { throw error; } throw new AuthenticationError( `Failed to refresh session: ${(error as Error).message}`, 'REFRESH_FAILED' ); } } /** * Get current user from session */ async getUser(): Promise { const client = this.getClient(); try { const { data: { user }, error } = await client.auth.getUser(); if (error) { this.logger.warn('Failed to get user:', error); return null; } return user; } catch (error) { this.logger.error('Error getting user:', error); return null; } } /** * Sign out and clear session */ async signOut(): Promise { const client = this.getClient(); try { // Sign out with global scope to revoke all refresh tokens const { error } = await client.auth.signOut({ scope: 'global' }); if (error) { this.logger.warn('Failed to sign out:', error); } // Clear cached session data this.sessionStorage.clear(); } catch (error) { this.logger.error('Error during sign out:', error); } } /** * Set session from external auth (e.g., from server callback) */ async setSession(session: Session): Promise { const client = this.getClient(); try { const { error } = await client.auth.setSession({ access_token: session.access_token, refresh_token: session.refresh_token }); if (error) { throw new AuthenticationError( `Failed to set session: ${error.message}`, 'SESSION_SET_FAILED' ); } this.logger.info('Session set successfully'); } catch (error) { if (error instanceof AuthenticationError) { throw error; } throw new AuthenticationError( `Failed to set session: ${(error as Error).message}`, 'SESSION_SET_FAILED' ); } } }