feat: implement tm list remote (#1185)
This commit is contained in:
@@ -1,19 +1,32 @@
|
||||
/**
|
||||
* Supabase client for authentication
|
||||
* Supabase authentication client for CLI auth flows
|
||||
*/
|
||||
|
||||
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
|
||||
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';
|
||||
import { CredentialStore } from '../auth/credential-store';
|
||||
|
||||
export class SupabaseAuthClient {
|
||||
private client: SupabaseClient | null = null;
|
||||
private client: SupabaseJSClient | null = null;
|
||||
private sessionStorage: SupabaseSessionStorage;
|
||||
private logger = getLogger('SupabaseAuthClient');
|
||||
|
||||
constructor() {
|
||||
const credentialStore = new CredentialStore();
|
||||
this.sessionStorage = new SupabaseSessionStorage(credentialStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Supabase client
|
||||
* Get Supabase client with proper session management
|
||||
*/
|
||||
private getClient(): SupabaseClient {
|
||||
getClient(): SupabaseJSClient {
|
||||
if (!this.client) {
|
||||
// Get Supabase configuration from environment - using TM_PUBLIC prefix
|
||||
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL;
|
||||
@@ -26,10 +39,12 @@ export class SupabaseAuthClient {
|
||||
);
|
||||
}
|
||||
|
||||
// Create client with custom storage adapter (similar to React Native AsyncStorage)
|
||||
this.client = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: this.sessionStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: false, // We handle persistence ourselves
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false
|
||||
}
|
||||
});
|
||||
@@ -39,40 +54,159 @@ export class SupabaseAuthClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: Code exchange is now handled server-side
|
||||
* The server returns tokens directly to avoid PKCE issues
|
||||
* This method is kept for potential future use
|
||||
* Initialize the client and restore session if available
|
||||
*/
|
||||
async exchangeCodeForSession(_code: string): Promise<{
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
}> {
|
||||
throw new AuthenticationError(
|
||||
'Code exchange is handled server-side. CLI receives tokens directly.',
|
||||
'NOT_SUPPORTED'
|
||||
);
|
||||
async initialize(): Promise<Session | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token
|
||||
* Sign in with PKCE flow (for CLI auth)
|
||||
*/
|
||||
async refreshSession(refreshToken: string): Promise<{
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string;
|
||||
}> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
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<Session> {
|
||||
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<Session | null> {
|
||||
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<Session | null> {
|
||||
const client = this.getClient();
|
||||
|
||||
try {
|
||||
this.logger.info('Refreshing session...');
|
||||
|
||||
// Set the session with refresh token
|
||||
const { data, error } = await client.auth.refreshSession({
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
// 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);
|
||||
@@ -82,22 +216,11 @@ export class SupabaseAuthClient {
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
throw new AuthenticationError(
|
||||
'No session data returned',
|
||||
'INVALID_RESPONSE'
|
||||
);
|
||||
if (session) {
|
||||
this.logger.info('Successfully refreshed session');
|
||||
}
|
||||
|
||||
this.logger.info('Successfully refreshed session');
|
||||
|
||||
return {
|
||||
token: data.session.access_token,
|
||||
refreshToken: data.session.refresh_token,
|
||||
expiresAt: data.session.expires_at
|
||||
? new Date(data.session.expires_at * 1000).toISOString()
|
||||
: undefined
|
||||
};
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
@@ -111,21 +234,23 @@ export class SupabaseAuthClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details from token
|
||||
* Get current user from session
|
||||
*/
|
||||
async getUser(token: string): Promise<User | null> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
async getUser(): Promise<User | null> {
|
||||
const client = this.getClient();
|
||||
|
||||
// Get user with the token
|
||||
const { data, error } = await client.auth.getUser(token);
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
error
|
||||
} = await client.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
this.logger.warn('Failed to get user:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.user;
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting user:', error);
|
||||
return null;
|
||||
@@ -133,22 +258,55 @@ export class SupabaseAuthClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out (revoke tokens)
|
||||
* Note: This requires the user to be authenticated with the current session.
|
||||
* For remote token revocation, a server-side admin API with service_role key would be needed.
|
||||
* Sign out and clear session
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
const client = this.getClient();
|
||||
|
||||
// Sign out the current session with global scope to revoke all refresh tokens
|
||||
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<void> {
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user