mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: implement MFA in taskmaster (#1438)
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
* Shared types and interfaces for bridge functions
|
||||
*/
|
||||
|
||||
import type { TmCore } from '@tm/core';
|
||||
|
||||
/**
|
||||
* Log levels used by bridge report functions
|
||||
*/
|
||||
@@ -40,7 +42,7 @@ export interface StorageCheckResult {
|
||||
/** Whether API storage is being used */
|
||||
isApiStorage: boolean;
|
||||
/** TmCore instance if initialization succeeded */
|
||||
tmCore?: import('@tm/core').TmCore;
|
||||
tmCore?: TmCore;
|
||||
/** Error message if initialization failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ export class AuthDomain {
|
||||
return this.authManager.authenticateWithCode(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA code and complete authentication
|
||||
* Call this after authenticateWithCode() throws MFA_REQUIRED error
|
||||
*
|
||||
* @param factorId - MFA factor ID from the MFA_REQUIRED error
|
||||
* @param code - The TOTP code from the user's authenticator app
|
||||
*/
|
||||
async verifyMFA(factorId: string, code: string): Promise<AuthCredentials> {
|
||||
return this.authManager.verifyMFA(factorId, code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,8 @@ export type {
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData,
|
||||
UserContext
|
||||
UserContext,
|
||||
MFAVerificationResult
|
||||
} from './types.js';
|
||||
|
||||
export { AuthenticationError } from './types.js';
|
||||
|
||||
@@ -1,52 +1,35 @@
|
||||
/**
|
||||
* Tests for AuthManager singleton behavior
|
||||
*
|
||||
* Mocking strategy (per @tm/core guidelines):
|
||||
* - Mock external I/O: SupabaseAuthClient (API), SessionManager (filesystem), OAuthService (OAuth APIs)
|
||||
* - Mock side effects: logger (acceptable for unit tests)
|
||||
* - Mock internal services: ContextStore (TODO: evaluate if real instance can be used)
|
||||
*
|
||||
* Note: Mocking 5 dependencies is a code smell suggesting AuthManager may have too many responsibilities.
|
||||
* Consider refactoring to reduce coupling in the future.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the logger to verify warnings (must be hoisted before SUT import)
|
||||
const mockLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../logger/index.js', () => ({
|
||||
getLogger: () => mockLogger
|
||||
vi.mock('../../../common/logger/index.js', () => ({
|
||||
getLogger: () => ({
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
// Spy on CredentialStore constructor to verify config propagation
|
||||
const CredentialStoreSpy = vi.fn();
|
||||
vi.mock('./credential-store.js', () => {
|
||||
return {
|
||||
CredentialStore: class {
|
||||
static getInstance(config?: any) {
|
||||
return new (this as any)(config);
|
||||
}
|
||||
static resetInstance() {
|
||||
// Mock reset instance method
|
||||
}
|
||||
constructor(config: any) {
|
||||
CredentialStoreSpy(config);
|
||||
}
|
||||
getCredentials(_options?: any) {
|
||||
return null;
|
||||
}
|
||||
saveCredentials() {}
|
||||
clearCredentials() {}
|
||||
hasCredentials() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock OAuthService to avoid side effects
|
||||
vi.mock('./oauth-service.js', () => {
|
||||
// Spy on OAuthService constructor to verify config propagation
|
||||
const OAuthServiceSpy = vi.fn();
|
||||
vi.mock('../services/oauth-service.js', () => {
|
||||
return {
|
||||
OAuthService: class {
|
||||
constructor() {}
|
||||
constructor(_contextStore: any, _supabaseClient: any, config?: any) {
|
||||
OAuthServiceSpy(config);
|
||||
}
|
||||
authenticate() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
@@ -57,8 +40,38 @@ vi.mock('./oauth-service.js', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ContextStore
|
||||
vi.mock('../services/context-store.js', () => {
|
||||
return {
|
||||
ContextStore: class {
|
||||
static getInstance() {
|
||||
return new (this as any)();
|
||||
}
|
||||
static resetInstance() {}
|
||||
getUserContext() {
|
||||
return null;
|
||||
}
|
||||
getContext() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SessionManager
|
||||
vi.mock('../services/session-manager.js', () => {
|
||||
return {
|
||||
SessionManager: class {
|
||||
constructor() {}
|
||||
async getAuthCredentials() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SupabaseAuthClient to avoid side effects
|
||||
vi.mock('../clients/supabase-client.js', () => {
|
||||
vi.mock('../../integration/clients/supabase-client.js', () => {
|
||||
return {
|
||||
SupabaseAuthClient: class {
|
||||
constructor() {}
|
||||
@@ -74,13 +87,14 @@ vi.mock('../clients/supabase-client.js', () => {
|
||||
|
||||
// Import SUT after mocks
|
||||
import { AuthManager } from './auth-manager.js';
|
||||
import { AuthenticationError } from '../types.js';
|
||||
|
||||
describe('AuthManager Singleton', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton before each test
|
||||
AuthManager.resetInstance();
|
||||
vi.clearAllMocks();
|
||||
CredentialStoreSpy.mockClear();
|
||||
OAuthServiceSpy.mockClear();
|
||||
});
|
||||
|
||||
it('should return the same instance on multiple calls', () => {
|
||||
@@ -100,44 +114,42 @@ describe('AuthManager Singleton', () => {
|
||||
const instance = AuthManager.getInstance(config);
|
||||
expect(instance).toBeDefined();
|
||||
|
||||
// Assert that CredentialStore was constructed with the provided config
|
||||
expect(CredentialStoreSpy).toHaveBeenCalledTimes(1);
|
||||
expect(CredentialStoreSpy).toHaveBeenCalledWith(config);
|
||||
// Assert that OAuthService was constructed with the provided config
|
||||
expect(OAuthServiceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(OAuthServiceSpy).toHaveBeenCalledWith(config);
|
||||
|
||||
// Verify the config is passed to internal components through observable behavior
|
||||
// getCredentials would look in the configured file path
|
||||
const credentials = await instance.getCredentials();
|
||||
expect(credentials).toBeNull(); // File doesn't exist, but config was propagated correctly
|
||||
// getAuthCredentials would use the configured session
|
||||
const credentials = await instance.getAuthCredentials();
|
||||
expect(credentials).toBeNull(); // No session, but config was propagated correctly
|
||||
});
|
||||
|
||||
it('should warn when config is provided after initialization', () => {
|
||||
// Clear previous calls
|
||||
mockLogger.warn.mockClear();
|
||||
|
||||
// First call with config
|
||||
AuthManager.getInstance({ baseUrl: 'https://first.auth.com' });
|
||||
|
||||
// Second call with different config
|
||||
// Reset the spy to track only the second call
|
||||
OAuthServiceSpy.mockClear();
|
||||
|
||||
// Second call with different config (should trigger warning)
|
||||
AuthManager.getInstance({ baseUrl: 'https://second.auth.com' });
|
||||
|
||||
// Verify warning was logged
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/config.*after initialization.*ignored/i)
|
||||
);
|
||||
// Verify OAuthService was not constructed again (singleton behavior)
|
||||
expect(OAuthServiceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not warn when no config is provided after initialization', () => {
|
||||
// Clear previous calls
|
||||
mockLogger.warn.mockClear();
|
||||
|
||||
it('should not call OAuthService again when no config is provided after initialization', () => {
|
||||
// First call with config
|
||||
AuthManager.getInstance({ configDir: '/test/config' });
|
||||
|
||||
// Reset the spy
|
||||
OAuthServiceSpy.mockClear();
|
||||
|
||||
// Second call without config
|
||||
AuthManager.getInstance();
|
||||
|
||||
// Verify no warning was logged
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
// Verify OAuthService was not constructed again
|
||||
expect(OAuthServiceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow resetting the instance', () => {
|
||||
@@ -153,3 +165,187 @@ describe('AuthManager Singleton', () => {
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthManager - MFA Retry Logic', () => {
|
||||
beforeEach(() => {
|
||||
AuthManager.resetInstance();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('verifyMFAWithRetry', () => {
|
||||
it('should succeed on first attempt with valid code', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
let callCount = 0;
|
||||
|
||||
// Mock code provider
|
||||
const codeProvider = vi.fn(async () => {
|
||||
callCount++;
|
||||
return '123456';
|
||||
});
|
||||
|
||||
// Mock successful verification
|
||||
vi.spyOn(authManager, 'verifyMFA').mockResolvedValue({
|
||||
token: 'test-token',
|
||||
userId: 'test-user',
|
||||
email: 'test@example.com',
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const result = await authManager.verifyMFAWithRetry(
|
||||
'factor-123',
|
||||
codeProvider,
|
||||
3
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.attemptsUsed).toBe(1);
|
||||
expect(result.credentials).toBeDefined();
|
||||
expect(result.credentials?.token).toBe('test-token');
|
||||
expect(codeProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry on INVALID_MFA_CODE and succeed on second attempt', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
let attemptCount = 0;
|
||||
|
||||
// Mock code provider
|
||||
const codeProvider = vi.fn(async () => {
|
||||
attemptCount++;
|
||||
return `code-${attemptCount}`;
|
||||
});
|
||||
|
||||
// Mock verification: fail once, then succeed
|
||||
const verifyMFASpy = vi
|
||||
.spyOn(authManager, 'verifyMFA')
|
||||
.mockRejectedValueOnce(
|
||||
new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE')
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
token: 'test-token',
|
||||
userId: 'test-user',
|
||||
email: 'test@example.com',
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const result = await authManager.verifyMFAWithRetry(
|
||||
'factor-123',
|
||||
codeProvider,
|
||||
3
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.attemptsUsed).toBe(2);
|
||||
expect(result.credentials).toBeDefined();
|
||||
expect(codeProvider).toHaveBeenCalledTimes(2);
|
||||
expect(verifyMFASpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fail after max attempts with INVALID_MFA_CODE', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const codeProvider = vi.fn(async () => '000000');
|
||||
|
||||
// Mock verification to always fail
|
||||
vi.spyOn(authManager, 'verifyMFA').mockRejectedValue(
|
||||
new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE')
|
||||
);
|
||||
|
||||
const result = await authManager.verifyMFAWithRetry(
|
||||
'factor-123',
|
||||
codeProvider,
|
||||
3
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.attemptsUsed).toBe(3);
|
||||
expect(result.credentials).toBeUndefined();
|
||||
expect(result.errorCode).toBe('INVALID_MFA_CODE');
|
||||
expect(codeProvider).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should throw immediately on non-INVALID_MFA_CODE errors', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const codeProvider = vi.fn(async () => '123456');
|
||||
|
||||
// Mock verification to throw different error
|
||||
const networkError = new AuthenticationError(
|
||||
'Network error',
|
||||
'NETWORK_ERROR'
|
||||
);
|
||||
vi.spyOn(authManager, 'verifyMFA').mockRejectedValue(networkError);
|
||||
|
||||
await expect(
|
||||
authManager.verifyMFAWithRetry('factor-123', codeProvider, 3)
|
||||
).rejects.toThrow('Network error');
|
||||
|
||||
// Should not retry on non-INVALID_MFA_CODE errors
|
||||
expect(codeProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should respect custom maxAttempts parameter', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const codeProvider = vi.fn(async () => '000000');
|
||||
|
||||
// Mock verification to always fail
|
||||
vi.spyOn(authManager, 'verifyMFA').mockRejectedValue(
|
||||
new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE')
|
||||
);
|
||||
|
||||
const result = await authManager.verifyMFAWithRetry(
|
||||
'factor-123',
|
||||
codeProvider,
|
||||
5 // Custom max attempts
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.attemptsUsed).toBe(5);
|
||||
expect(codeProvider).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should use default maxAttempts of 3', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const codeProvider = vi.fn(async () => '000000');
|
||||
|
||||
vi.spyOn(authManager, 'verifyMFA').mockRejectedValue(
|
||||
new AuthenticationError('Invalid MFA code', 'INVALID_MFA_CODE')
|
||||
);
|
||||
|
||||
// Don't pass maxAttempts - should default to 3
|
||||
const result = await authManager.verifyMFAWithRetry(
|
||||
'factor-123',
|
||||
codeProvider
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.attemptsUsed).toBe(3);
|
||||
expect(codeProvider).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should throw TypeError on invalid maxAttempts (0 or negative)', async () => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const codeProvider = vi.fn(async () => '123456');
|
||||
|
||||
// Test with 0
|
||||
await expect(
|
||||
authManager.verifyMFAWithRetry('factor-123', codeProvider, 0)
|
||||
).rejects.toThrow(TypeError);
|
||||
|
||||
await expect(
|
||||
authManager.verifyMFAWithRetry('factor-123', codeProvider, 0)
|
||||
).rejects.toThrow('Invalid maxAttempts value: 0. Must be at least 1.');
|
||||
|
||||
// Test with negative
|
||||
await expect(
|
||||
authManager.verifyMFAWithRetry('factor-123', codeProvider, -1)
|
||||
).rejects.toThrow(TypeError);
|
||||
|
||||
await expect(
|
||||
authManager.verifyMFAWithRetry('factor-123', codeProvider, -1)
|
||||
).rejects.toThrow('Invalid maxAttempts value: -1. Must be at least 1.');
|
||||
|
||||
// Verify code provider was never called
|
||||
expect(codeProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Authentication manager for Task Master CLI
|
||||
* Lightweight coordinator that delegates to focused services
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
@@ -19,91 +17,45 @@ import {
|
||||
OrganizationService,
|
||||
type RemoteTask
|
||||
} from '../services/organization.service.js';
|
||||
import { SessionManager } from '../services/session-manager.js';
|
||||
import {
|
||||
AuthConfig,
|
||||
AuthCredentials,
|
||||
type AuthConfig,
|
||||
type AuthCredentials,
|
||||
AuthenticationError,
|
||||
OAuthFlowOptions,
|
||||
UserContext,
|
||||
UserContextWithBrief
|
||||
type MFAVerificationResult,
|
||||
type OAuthFlowOptions,
|
||||
type UserContext,
|
||||
type UserContextWithBrief
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Authentication manager class
|
||||
* Authentication manager class - coordinates auth services
|
||||
*/
|
||||
export class AuthManager {
|
||||
private static instance: AuthManager | null = null;
|
||||
private static readonly staticLogger = getLogger('AuthManager');
|
||||
private contextStore: ContextStore;
|
||||
private oauthService: OAuthService;
|
||||
private sessionManager: SessionManager;
|
||||
public supabaseClient: SupabaseAuthClient;
|
||||
private organizationService?: OrganizationService;
|
||||
private readonly logger = getLogger('AuthManager');
|
||||
private readonly LEGACY_AUTH_FILE = path.join(
|
||||
os.homedir(),
|
||||
'.taskmaster',
|
||||
'auth.json'
|
||||
);
|
||||
|
||||
private constructor(config?: Partial<AuthConfig>) {
|
||||
this.contextStore = ContextStore.getInstance();
|
||||
this.supabaseClient = new SupabaseAuthClient();
|
||||
|
||||
// Initialize session manager (handles session lifecycle)
|
||||
this.sessionManager = new SessionManager(
|
||||
this.supabaseClient,
|
||||
this.contextStore
|
||||
);
|
||||
|
||||
// Pass the supabase client to OAuthService so they share the same instance
|
||||
this.oauthService = new OAuthService(
|
||||
this.contextStore,
|
||||
this.supabaseClient,
|
||||
config
|
||||
);
|
||||
|
||||
// Initialize Supabase client with session restoration
|
||||
// Fire-and-forget with catch handler to prevent unhandled rejections
|
||||
this.initializeSupabaseSession().catch(() => {
|
||||
// Errors are already logged in initializeSupabaseSession
|
||||
});
|
||||
|
||||
// Migrate legacy auth.json if it exists
|
||||
// Fire-and-forget with catch handler
|
||||
this.migrateLegacyAuth().catch(() => {
|
||||
// Errors are already logged in migrateLegacyAuth
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Supabase session from stored credentials
|
||||
*/
|
||||
private async initializeSupabaseSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabaseClient.initialize();
|
||||
} catch (error) {
|
||||
// Log but don't throw - session might not exist yet
|
||||
this.logger.debug('No existing session to restore');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy auth.json to Supabase session
|
||||
* Called once during AuthManager initialization
|
||||
*/
|
||||
private async migrateLegacyAuth(): Promise<void> {
|
||||
if (!fs.existsSync(this.LEGACY_AUTH_FILE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If we have a valid Supabase session, delete legacy file
|
||||
const hasSession = await this.hasValidSession();
|
||||
if (hasSession) {
|
||||
fs.unlinkSync(this.LEGACY_AUTH_FILE);
|
||||
this.logger.info('Migrated to Supabase auth, removed legacy auth.json');
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, user needs to re-authenticate
|
||||
this.logger.warn('Legacy auth.json found but no valid Supabase session.');
|
||||
this.logger.warn('Please run: task-master auth login');
|
||||
} catch (error) {
|
||||
this.logger.debug('Error during legacy auth migration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,8 +86,7 @@ export class AuthManager {
|
||||
* @returns Access token or null if not authenticated
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
const session = await this.supabaseClient.getSession();
|
||||
return session?.access_token || null;
|
||||
return this.sessionManager.getAccessToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,24 +95,7 @@ export class AuthManager {
|
||||
* @returns AuthCredentials object or null if not authenticated
|
||||
*/
|
||||
async getAuthCredentials(): Promise<AuthCredentials | null> {
|
||||
const session = await this.supabaseClient.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
const user = session.user;
|
||||
const context = this.contextStore.getUserContext();
|
||||
|
||||
return {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context || undefined
|
||||
};
|
||||
return this.sessionManager.getAuthCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,61 +113,91 @@ export class AuthManager {
|
||||
* where browser-based auth is not practical
|
||||
*/
|
||||
async authenticateWithCode(token: string): Promise<AuthCredentials> {
|
||||
try {
|
||||
this.logger.info('Authenticating with one-time token...');
|
||||
return this.sessionManager.authenticateWithCode(token);
|
||||
}
|
||||
|
||||
// Verify the token and get session from Supabase
|
||||
const session = await this.supabaseClient.verifyOneTimeCode(token);
|
||||
/**
|
||||
* Verify MFA code and complete authentication
|
||||
* Call this after authenticateWithCode() throws MFA_REQUIRED error
|
||||
*/
|
||||
async verifyMFA(factorId: string, code: string): Promise<AuthCredentials> {
|
||||
return this.sessionManager.verifyMFA(factorId, code);
|
||||
}
|
||||
|
||||
if (!session || !session.access_token) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to obtain access token from token',
|
||||
'NO_TOKEN'
|
||||
);
|
||||
}
|
||||
|
||||
// Get user information
|
||||
const user = await this.supabaseClient.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to get user information',
|
||||
'INVALID_RESPONSE'
|
||||
);
|
||||
}
|
||||
|
||||
// Store user context
|
||||
this.contextStore.saveContext({
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
// Build credentials response
|
||||
const context = this.contextStore.getUserContext();
|
||||
const credentials: AuthCredentials = {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context || undefined
|
||||
};
|
||||
|
||||
this.logger.info('Successfully authenticated with token');
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError(
|
||||
`Token authentication failed: ${(error as Error).message}`,
|
||||
'CODE_AUTH_FAILED'
|
||||
/**
|
||||
* Verify MFA code with automatic retry logic
|
||||
* Handles retry attempts for invalid MFA codes up to maxAttempts
|
||||
*
|
||||
* @param factorId - MFA factor ID from the MFA_REQUIRED error
|
||||
* @param codeProvider - Function that prompts for and returns the MFA code
|
||||
* @param maxAttempts - Maximum number of verification attempts (default: 3)
|
||||
* @returns Result object with success status, attempts used, and credentials if successful
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await authManager.verifyMFAWithRetry(
|
||||
* factorId,
|
||||
* async () => await promptUserForMFACode(),
|
||||
* 3
|
||||
* );
|
||||
*
|
||||
* if (result.success) {
|
||||
* console.log('MFA verified!', result.credentials);
|
||||
* } else {
|
||||
* console.error(`Failed after ${result.attemptsUsed} attempts`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async verifyMFAWithRetry(
|
||||
factorId: string,
|
||||
codeProvider: () => Promise<string>,
|
||||
maxAttempts = 3
|
||||
): Promise<MFAVerificationResult> {
|
||||
// Guard against invalid maxAttempts values
|
||||
if (maxAttempts < 1) {
|
||||
throw new TypeError(
|
||||
`Invalid maxAttempts value: ${maxAttempts}. Must be at least 1.`
|
||||
);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const code = await codeProvider();
|
||||
const credentials = await this.verifyMFA(factorId, code);
|
||||
return {
|
||||
success: true,
|
||||
attemptsUsed: attempt,
|
||||
credentials
|
||||
};
|
||||
} catch (error) {
|
||||
// Only retry on invalid MFA code errors
|
||||
if (
|
||||
error instanceof AuthenticationError &&
|
||||
error.code === 'INVALID_MFA_CODE'
|
||||
) {
|
||||
// If we've exhausted attempts, return failure
|
||||
if (attempt >= maxAttempts) {
|
||||
return {
|
||||
success: false,
|
||||
attemptsUsed: attempt,
|
||||
errorCode: 'INVALID_MFA_CODE'
|
||||
};
|
||||
}
|
||||
// Otherwise continue to next attempt
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, fail immediately
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here due to loop logic, but TypeScript needs it
|
||||
return {
|
||||
success: false,
|
||||
attemptsUsed: maxAttempts,
|
||||
errorCode: 'MFA_VERIFICATION_FAILED'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,75 +213,14 @@ export class AuthManager {
|
||||
* This method is mainly for explicit refresh requests.
|
||||
*/
|
||||
async refreshToken(): Promise<AuthCredentials> {
|
||||
try {
|
||||
// Use Supabase's built-in session refresh
|
||||
const session = await this.supabaseClient.refreshSession();
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to refresh session',
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Sync user info to context store
|
||||
this.contextStore.saveContext({
|
||||
userId: session.user.id,
|
||||
email: session.user.email
|
||||
});
|
||||
|
||||
// Build credentials response
|
||||
const context = this.contextStore.getContext();
|
||||
const credentials: AuthCredentials = {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context?.selectedContext
|
||||
};
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError(
|
||||
`Token refresh failed: ${(error as Error).message}`,
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
return this.sessionManager.refreshToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear credentials
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
// First try to sign out from Supabase to revoke tokens
|
||||
await this.supabaseClient.signOut();
|
||||
} catch (error) {
|
||||
// Log but don't throw - we still want to clear local credentials
|
||||
this.logger.warn('Failed to sign out from Supabase:', error);
|
||||
}
|
||||
|
||||
// Clear app context
|
||||
this.contextStore.clearContext();
|
||||
// Session is cleared by supabaseClient.signOut()
|
||||
|
||||
// Clear legacy auth.json if it exists
|
||||
try {
|
||||
if (fs.existsSync(this.LEGACY_AUTH_FILE)) {
|
||||
fs.unlinkSync(this.LEGACY_AUTH_FILE);
|
||||
this.logger.debug('Cleared legacy auth.json');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors clearing legacy file
|
||||
this.logger.debug('No legacy credentials to clear');
|
||||
}
|
||||
return this.sessionManager.logout();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,26 +228,21 @@ export class AuthManager {
|
||||
* @returns true if a valid session exists
|
||||
*/
|
||||
async hasValidSession(): Promise<boolean> {
|
||||
try {
|
||||
const session = await this.supabaseClient.getSession();
|
||||
return session !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return this.sessionManager.hasValidSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Supabase session
|
||||
*/
|
||||
async getSession() {
|
||||
return this.supabaseClient.getSession();
|
||||
return this.sessionManager.getSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored user context (userId, email)
|
||||
*/
|
||||
getStoredContext() {
|
||||
return this.contextStore.getContext();
|
||||
return this.sessionManager.getStoredContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,8 +280,8 @@ export class AuthManager {
|
||||
*/
|
||||
private async getOrganizationService(): Promise<OrganizationService> {
|
||||
if (!this.organizationService) {
|
||||
// Check if we have a valid Supabase session
|
||||
const session = await this.supabaseClient.getSession();
|
||||
// Check if we have a valid Supabase session via SessionManager
|
||||
const session = await this.sessionManager.getSession();
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Tests for SessionManager
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Mock the logger
|
||||
const mockLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../../../common/logger/index.js', () => ({
|
||||
getLogger: () => mockLogger
|
||||
}));
|
||||
|
||||
// Mock fs with default implementations
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => false),
|
||||
unlinkSync: vi.fn()
|
||||
},
|
||||
existsSync: vi.fn(() => false),
|
||||
unlinkSync: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock SupabaseAuthClient
|
||||
const mockSupabaseClient = {
|
||||
initialize: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
verifyOneTimeCode: vi.fn(),
|
||||
checkMFARequired: vi.fn(),
|
||||
verifyMFA: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../../integration/clients/supabase-client.js', () => ({
|
||||
SupabaseAuthClient: vi.fn(() => mockSupabaseClient)
|
||||
}));
|
||||
|
||||
// Mock ContextStore
|
||||
const mockContextStore = {
|
||||
getUserContext: vi.fn(),
|
||||
getContext: vi.fn(),
|
||||
saveContext: vi.fn(),
|
||||
clearContext: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('./context-store.js', () => ({
|
||||
ContextStore: {
|
||||
getInstance: () => mockContextStore
|
||||
}
|
||||
}));
|
||||
|
||||
import { SessionManager } from './session-manager.js';
|
||||
import { AuthenticationError } from '../types.js';
|
||||
|
||||
describe('SessionManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize session on construction', async () => {
|
||||
mockSupabaseClient.initialize.mockResolvedValue(undefined);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
// Wait for initialization to complete
|
||||
await sessionManager.waitForInitialization();
|
||||
|
||||
expect(mockSupabaseClient.initialize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle initialization errors gracefully', async () => {
|
||||
mockSupabaseClient.initialize.mockRejectedValue(new Error('No session'));
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
sessionManager.waitForInitialization()
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'No existing session to restore'
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent race conditions by waiting for initialization', async () => {
|
||||
let initResolved = false;
|
||||
mockSupabaseClient.initialize.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
initResolved = true;
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
// Call method that should wait for initialization
|
||||
const sessionPromise = sessionManager.getSession();
|
||||
|
||||
// Verify init hasn't completed yet
|
||||
expect(initResolved).toBe(false);
|
||||
|
||||
await sessionPromise;
|
||||
|
||||
// Now it should have completed
|
||||
expect(initResolved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy Migration', () => {
|
||||
const LEGACY_AUTH_FILE = path.join(
|
||||
os.homedir(),
|
||||
'.taskmaster',
|
||||
'auth.json'
|
||||
);
|
||||
|
||||
it('should delete legacy file if valid session exists', async () => {
|
||||
// Setup all mocks before creating SessionManager
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
|
||||
|
||||
// Mock initialization to succeed
|
||||
mockSupabaseClient.initialize.mockResolvedValue(undefined);
|
||||
|
||||
// First call in migrateLegacyAuth, second call checks session
|
||||
mockSupabaseClient.getSession
|
||||
.mockResolvedValueOnce({
|
||||
access_token: 'valid-token',
|
||||
user: { id: 'user-1', email: 'test@example.com' }
|
||||
})
|
||||
.mockResolvedValue({
|
||||
access_token: 'valid-token',
|
||||
user: { id: 'user-1', email: 'test@example.com' }
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
await sessionManager.waitForInitialization();
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(LEGACY_AUTH_FILE);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Migrated to Supabase auth')
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn if legacy file exists but no session', async () => {
|
||||
// Setup all mocks before creating SessionManager
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
// Mock initialization to succeed
|
||||
mockSupabaseClient.initialize.mockResolvedValue(undefined);
|
||||
|
||||
// No session available
|
||||
mockSupabaseClient.getSession.mockResolvedValue(null);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
await sessionManager.waitForInitialization();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Legacy auth.json found')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session State', () => {
|
||||
it('should return true for valid session', async () => {
|
||||
mockSupabaseClient.getSession.mockResolvedValue({
|
||||
access_token: 'valid-token'
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const hasSession = await sessionManager.hasValidSession();
|
||||
|
||||
expect(hasSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no session exists', async () => {
|
||||
mockSupabaseClient.getSession.mockResolvedValue(null);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const hasSession = await sessionManager.hasValidSession();
|
||||
|
||||
expect(hasSession).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on session check error', async () => {
|
||||
mockSupabaseClient.getSession.mockRejectedValue(new Error('Failed'));
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const hasSession = await sessionManager.hasValidSession();
|
||||
|
||||
expect(hasSession).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Operations', () => {
|
||||
it('should get access token from session', async () => {
|
||||
mockSupabaseClient.getSession.mockResolvedValue({
|
||||
access_token: 'test-token'
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const token = await sessionManager.getAccessToken();
|
||||
|
||||
expect(token).toBe('test-token');
|
||||
});
|
||||
|
||||
it('should return null when no session', async () => {
|
||||
mockSupabaseClient.getSession.mockResolvedValue(null);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const token = await sessionManager.getAccessToken();
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('should build auth credentials from session', async () => {
|
||||
const mockSession = {
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_at: 1234567890,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
mockSupabaseClient.getSession.mockResolvedValue(mockSession);
|
||||
mockContextStore.getUserContext.mockReturnValue({
|
||||
briefId: 'brief-1',
|
||||
briefName: 'Test Brief'
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const credentials = await sessionManager.getAuthCredentials();
|
||||
|
||||
expect(credentials).toMatchObject({
|
||||
token: 'test-token',
|
||||
refreshToken: 'refresh-token',
|
||||
userId: 'user-1',
|
||||
email: 'test@example.com',
|
||||
tokenType: 'standard'
|
||||
});
|
||||
expect(credentials?.selectedContext).toEqual({
|
||||
briefId: 'brief-1',
|
||||
briefName: 'Test Brief'
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh token and sync context', async () => {
|
||||
const mockSession = {
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_at: 9999999999,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
mockSupabaseClient.refreshSession.mockResolvedValue(mockSession);
|
||||
mockContextStore.getContext.mockReturnValue({
|
||||
selectedContext: { briefId: 'brief-1' }
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const credentials = await sessionManager.refreshToken();
|
||||
|
||||
expect(mockContextStore.saveContext).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
expect(credentials.token).toBe('new-token');
|
||||
});
|
||||
|
||||
it('should throw on refresh failure', async () => {
|
||||
mockSupabaseClient.refreshSession.mockResolvedValue(null);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
await expect(sessionManager.refreshToken()).rejects.toThrow(
|
||||
AuthenticationError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication with Code', () => {
|
||||
it('should authenticate with one-time code', async () => {
|
||||
const mockSession = {
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_at: 1234567890
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com'
|
||||
};
|
||||
|
||||
mockSupabaseClient.verifyOneTimeCode.mockResolvedValue(mockSession);
|
||||
mockSupabaseClient.getUser.mockResolvedValue(mockUser);
|
||||
mockSupabaseClient.checkMFARequired.mockResolvedValue({
|
||||
required: false
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const credentials =
|
||||
await sessionManager.authenticateWithCode('test-code');
|
||||
|
||||
expect(credentials.token).toBe('test-token');
|
||||
expect(credentials.userId).toBe('user-1');
|
||||
expect(mockContextStore.saveContext).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw MFA_REQUIRED when MFA needed', async () => {
|
||||
const mockSession = {
|
||||
access_token: 'test-token',
|
||||
refresh_token: 'refresh-token'
|
||||
};
|
||||
|
||||
mockSupabaseClient.verifyOneTimeCode.mockResolvedValue(mockSession);
|
||||
mockSupabaseClient.getUser.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
mockSupabaseClient.checkMFARequired.mockResolvedValue({
|
||||
required: true,
|
||||
factorId: 'factor-123',
|
||||
factorType: 'totp'
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
try {
|
||||
await sessionManager.authenticateWithCode('test-code');
|
||||
expect.fail('Should have thrown MFA_REQUIRED error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect((error as AuthenticationError).code).toBe('MFA_REQUIRED');
|
||||
expect((error as AuthenticationError).mfaChallenge).toEqual({
|
||||
factorId: 'factor-123',
|
||||
factorType: 'totp'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA Verification', () => {
|
||||
it('should verify MFA and return credentials', async () => {
|
||||
const mockSession = {
|
||||
access_token: 'mfa-token',
|
||||
refresh_token: 'mfa-refresh-token',
|
||||
expires_at: 1234567890
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com'
|
||||
};
|
||||
|
||||
mockSupabaseClient.verifyMFA.mockResolvedValue(mockSession);
|
||||
mockSupabaseClient.getUser.mockResolvedValue(mockUser);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
const credentials = await sessionManager.verifyMFA(
|
||||
'factor-123',
|
||||
'123456'
|
||||
);
|
||||
|
||||
expect(credentials.token).toBe('mfa-token');
|
||||
expect(mockContextStore.saveContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw on MFA verification failure', async () => {
|
||||
mockSupabaseClient.verifyMFA.mockResolvedValue(null);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
await expect(
|
||||
sessionManager.verifyMFA('factor-123', '123456')
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logout', () => {
|
||||
it('should sign out and clear all credentials', async () => {
|
||||
// Setup all mocks before creating SessionManager
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false); // No legacy file during init
|
||||
mockSupabaseClient.initialize.mockResolvedValue(undefined);
|
||||
mockSupabaseClient.signOut.mockResolvedValue(undefined);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
// Now mock existsSync to return true for logout test
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
|
||||
|
||||
await sessionManager.logout();
|
||||
|
||||
expect(mockSupabaseClient.signOut).toHaveBeenCalled();
|
||||
expect(mockContextStore.clearContext).toHaveBeenCalled();
|
||||
expect(fs.unlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear local state even if Supabase signout fails', async () => {
|
||||
mockSupabaseClient.signOut.mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
mockSupabaseClient as any,
|
||||
mockContextStore as any
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(sessionManager.logout()).resolves.toBeUndefined();
|
||||
|
||||
expect(mockContextStore.clearContext).toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to sign out'),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
390
packages/tm-core/src/modules/auth/services/session-manager.ts
Normal file
390
packages/tm-core/src/modules/auth/services/session-manager.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Session Manager
|
||||
* Handles session initialization, authentication, and lifecycle management
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
|
||||
import { ContextStore } from './context-store.js';
|
||||
import type { AuthCredentials } from '../types.js';
|
||||
import { AuthenticationError } from '../types.js';
|
||||
|
||||
/**
|
||||
* SessionManager - Focused service for session and token management
|
||||
*/
|
||||
export class SessionManager {
|
||||
private readonly logger = getLogger('SessionManager');
|
||||
private readonly LEGACY_AUTH_FILE = path.join(
|
||||
os.homedir(),
|
||||
'.taskmaster',
|
||||
'auth.json'
|
||||
);
|
||||
private initializationPromise: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private supabaseClient: SupabaseAuthClient,
|
||||
private contextStore: ContextStore
|
||||
) {
|
||||
// Initialize session with proper promise tracking to prevent race conditions
|
||||
this.initializationPromise = this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session - called once during construction
|
||||
* Ensures all async initialization completes before session operations
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.initializeSupabaseSession();
|
||||
await this.migrateLegacyAuth();
|
||||
} catch (error) {
|
||||
// Log but don't throw - initialization errors are handled gracefully
|
||||
this.logger.debug('Session initialization completed with warnings');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for initialization to complete
|
||||
* Call this before any operation that depends on session state
|
||||
*/
|
||||
async waitForInitialization(): Promise<void> {
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Supabase session from stored credentials
|
||||
*/
|
||||
private async initializeSupabaseSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabaseClient.initialize();
|
||||
} catch (error) {
|
||||
// Log but don't throw - session might not exist yet
|
||||
this.logger.debug('No existing session to restore');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy auth.json to Supabase session
|
||||
* Called once during SessionManager initialization
|
||||
*/
|
||||
private async migrateLegacyAuth(): Promise<void> {
|
||||
if (!fs.existsSync(this.LEGACY_AUTH_FILE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we have a valid Supabase session (don't use hasValidSession to avoid circular wait)
|
||||
const session = await this.supabaseClient.getSession();
|
||||
if (session) {
|
||||
fs.unlinkSync(this.LEGACY_AUTH_FILE);
|
||||
this.logger.info('Migrated to Supabase auth, removed legacy auth.json');
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, user needs to re-authenticate
|
||||
this.logger.warn('Legacy auth.json found but no valid Supabase session.');
|
||||
this.logger.warn('Please run: task-master auth login');
|
||||
} catch (error) {
|
||||
this.logger.debug('Error during legacy auth migration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Session State ==========
|
||||
|
||||
/**
|
||||
* Check if valid Supabase session exists
|
||||
* @returns true if a valid session exists
|
||||
*/
|
||||
async hasValidSession(): Promise<boolean> {
|
||||
await this.waitForInitialization();
|
||||
try {
|
||||
const session = await this.supabaseClient.getSession();
|
||||
return session !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Supabase session
|
||||
*/
|
||||
async getSession() {
|
||||
await this.waitForInitialization();
|
||||
return this.supabaseClient.getSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored user context (userId, email)
|
||||
*/
|
||||
getStoredContext() {
|
||||
return this.contextStore.getContext();
|
||||
}
|
||||
|
||||
// ========== Token Operations ==========
|
||||
|
||||
/**
|
||||
* Get access token from current Supabase session
|
||||
* @returns Access token or null if not authenticated
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
await this.waitForInitialization();
|
||||
const session = await this.supabaseClient.getSession();
|
||||
return session?.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication credentials from Supabase session
|
||||
* Modern replacement for legacy getCredentials()
|
||||
* @returns AuthCredentials object or null if not authenticated
|
||||
*/
|
||||
async getAuthCredentials(): Promise<AuthCredentials | null> {
|
||||
await this.waitForInitialization();
|
||||
const session = await this.supabaseClient.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
const user = session.user;
|
||||
const context = this.contextStore.getUserContext();
|
||||
|
||||
return {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token using Supabase session
|
||||
* Note: Supabase handles token refresh automatically via the session storage adapter.
|
||||
* This method is mainly for explicit refresh requests.
|
||||
*/
|
||||
async refreshToken(): Promise<AuthCredentials> {
|
||||
await this.waitForInitialization();
|
||||
try {
|
||||
// Use Supabase's built-in session refresh
|
||||
const session = await this.supabaseClient.refreshSession();
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to refresh session',
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Sync user info to context store
|
||||
this.contextStore.saveContext({
|
||||
userId: session.user.id,
|
||||
email: session.user.email
|
||||
});
|
||||
|
||||
// Build credentials response
|
||||
const context = this.contextStore.getContext();
|
||||
const credentials: AuthCredentials = {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context?.selectedContext
|
||||
};
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError(
|
||||
`Token refresh failed: ${(error as Error).message}`,
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Authentication ==========
|
||||
|
||||
/**
|
||||
* Authenticate using a one-time token
|
||||
* This is useful for CLI authentication in SSH/remote environments
|
||||
* where browser-based auth is not practical
|
||||
*/
|
||||
async authenticateWithCode(token: string): Promise<AuthCredentials> {
|
||||
await this.waitForInitialization();
|
||||
try {
|
||||
this.logger.info('Authenticating with one-time token...');
|
||||
|
||||
// Verify the token and get session from Supabase
|
||||
const session = await this.supabaseClient.verifyOneTimeCode(token);
|
||||
|
||||
if (!session || !session.access_token) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to obtain access token from token',
|
||||
'NO_TOKEN'
|
||||
);
|
||||
}
|
||||
|
||||
// Get user information
|
||||
const user = await this.supabaseClient.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to get user information',
|
||||
'INVALID_RESPONSE'
|
||||
);
|
||||
}
|
||||
|
||||
// Check if MFA is required for this user
|
||||
const mfaCheck = await this.supabaseClient.checkMFARequired();
|
||||
|
||||
if (mfaCheck.required && mfaCheck.factorId && mfaCheck.factorType) {
|
||||
// MFA is required - throw an error with the MFA challenge information
|
||||
throw new AuthenticationError(
|
||||
'MFA verification required. Please provide your authentication code.',
|
||||
'MFA_REQUIRED',
|
||||
undefined,
|
||||
{
|
||||
factorId: mfaCheck.factorId,
|
||||
factorType: mfaCheck.factorType
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Store user context
|
||||
this.contextStore.saveContext({
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
// Build credentials response
|
||||
const context = this.contextStore.getUserContext();
|
||||
const credentials: AuthCredentials = {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context || undefined
|
||||
};
|
||||
|
||||
this.logger.info('Successfully authenticated with token');
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError(
|
||||
`Token authentication failed: ${(error as Error).message}`,
|
||||
'CODE_AUTH_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA code and complete authentication
|
||||
* Call this after authenticateWithCode() throws MFA_REQUIRED error
|
||||
*/
|
||||
async verifyMFA(factorId: string, code: string): Promise<AuthCredentials> {
|
||||
await this.waitForInitialization();
|
||||
try {
|
||||
this.logger.info('Verifying MFA code...');
|
||||
|
||||
// Verify MFA code and get upgraded session
|
||||
const session = await this.supabaseClient.verifyMFA(factorId, code);
|
||||
|
||||
if (!session || !session.access_token) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to obtain access token after MFA verification',
|
||||
'NO_TOKEN'
|
||||
);
|
||||
}
|
||||
|
||||
// Get user information
|
||||
const user = await this.supabaseClient.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to get user information',
|
||||
'INVALID_RESPONSE'
|
||||
);
|
||||
}
|
||||
|
||||
// Store user context
|
||||
this.contextStore.saveContext({
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
// Build credentials response
|
||||
const context = this.contextStore.getUserContext();
|
||||
const credentials: AuthCredentials = {
|
||||
token: session.access_token,
|
||||
refreshToken: session.refresh_token,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
expiresAt: session.expires_at
|
||||
? new Date(session.expires_at * 1000).toISOString()
|
||||
: undefined,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString(),
|
||||
selectedContext: context || undefined
|
||||
};
|
||||
|
||||
this.logger.info('Successfully verified MFA and authenticated');
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError(
|
||||
`MFA verification failed: ${(error as Error).message}`,
|
||||
'MFA_VERIFICATION_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Session Lifecycle ==========
|
||||
|
||||
/**
|
||||
* Logout and clear credentials
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
await this.waitForInitialization();
|
||||
try {
|
||||
// First try to sign out from Supabase to revoke tokens
|
||||
await this.supabaseClient.signOut();
|
||||
} catch (error) {
|
||||
// Log but don't throw - we still want to clear local credentials
|
||||
this.logger.warn('Failed to sign out from Supabase:', error);
|
||||
}
|
||||
|
||||
// Clear app context
|
||||
this.contextStore.clearContext();
|
||||
// Session is cleared by supabaseClient.signOut()
|
||||
|
||||
// Clear legacy auth.json if it exists
|
||||
try {
|
||||
if (fs.existsSync(this.LEGACY_AUTH_FILE)) {
|
||||
fs.unlinkSync(this.LEGACY_AUTH_FILE);
|
||||
this.logger.debug('Cleared legacy auth.json');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors clearing legacy file
|
||||
this.logger.debug('No legacy credentials to clear');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,30 @@ export interface CliData {
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA challenge information
|
||||
*/
|
||||
export interface MFAChallenge {
|
||||
/** ID of the MFA factor that needs verification */
|
||||
factorId: string;
|
||||
/** Type of MFA factor (e.g., 'totp') */
|
||||
factorType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of MFA verification with retry
|
||||
*/
|
||||
export interface MFAVerificationResult {
|
||||
/** Whether verification was successful */
|
||||
success: boolean;
|
||||
/** Number of attempts used */
|
||||
attemptsUsed: number;
|
||||
/** Credentials if successful */
|
||||
credentials?: AuthCredentials;
|
||||
/** Error code if failed */
|
||||
errorCode?: AuthErrorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error codes
|
||||
*/
|
||||
@@ -90,19 +114,27 @@ export type AuthErrorCode =
|
||||
| 'CODE_EXCHANGE_FAILED'
|
||||
| 'SESSION_SET_FAILED'
|
||||
| 'CODE_AUTH_FAILED'
|
||||
| 'INVALID_CODE';
|
||||
| 'INVALID_CODE'
|
||||
| 'MFA_REQUIRED'
|
||||
| 'MFA_VERIFICATION_FAILED'
|
||||
| 'INVALID_MFA_CODE';
|
||||
|
||||
/**
|
||||
* Authentication error class
|
||||
*/
|
||||
export class AuthenticationError extends Error {
|
||||
/** Optional MFA challenge information when MFA is required */
|
||||
public mfaChallenge?: MFAChallenge;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public code: AuthErrorCode,
|
||||
public cause?: unknown
|
||||
public cause?: unknown,
|
||||
mfaChallenge?: MFAChallenge
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.mfaChallenge = mfaChallenge;
|
||||
if (cause && cause instanceof Error) {
|
||||
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Tests for SupabaseAuthClient
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
|
||||
// Mock logger
|
||||
const mockLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../../../common/logger/index.js', () => ({
|
||||
getLogger: () => mockLogger
|
||||
}));
|
||||
|
||||
// Mock SupabaseSessionStorage
|
||||
vi.mock('../../auth/services/supabase-session-storage.js', () => ({
|
||||
SupabaseSessionStorage: class {
|
||||
clear() {}
|
||||
getItem() {
|
||||
return null;
|
||||
}
|
||||
setItem() {}
|
||||
removeItem() {}
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after mocking (synchronous imports)
|
||||
import { SupabaseAuthClient } from './supabase-client.js';
|
||||
import { AuthenticationError } from '../../auth/types.js';
|
||||
|
||||
describe('SupabaseAuthClient', () => {
|
||||
let authClient: InstanceType<typeof SupabaseAuthClient>;
|
||||
let mockSupabaseClient: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set required environment variables
|
||||
process.env.TM_SUPABASE_URL = 'https://test.supabase.co';
|
||||
process.env.TM_SUPABASE_ANON_KEY = 'test-anon-key';
|
||||
|
||||
authClient = new SupabaseAuthClient();
|
||||
|
||||
// Create mock Supabase client
|
||||
mockSupabaseClient = {
|
||||
auth: {
|
||||
getSession: vi.fn(),
|
||||
setSession: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
signInWithOAuth: vi.fn(),
|
||||
exchangeCodeForSession: vi.fn(),
|
||||
verifyOtp: vi.fn(),
|
||||
mfa: {
|
||||
challenge: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
getAuthenticatorAssuranceLevel: vi.fn(),
|
||||
listFactors: vi.fn()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('verifyMFA', () => {
|
||||
it('should verify MFA and refresh session to upgrade to AAL2', async () => {
|
||||
// Mock the challenge response
|
||||
const mockChallenge = {
|
||||
data: { id: 'challenge-123' },
|
||||
error: null
|
||||
};
|
||||
|
||||
// Mock the MFA verification response
|
||||
const mockVerifyResponse = {
|
||||
data: {
|
||||
access_token: 'temp-token',
|
||||
refresh_token: 'temp-refresh'
|
||||
},
|
||||
error: null
|
||||
};
|
||||
|
||||
// Mock the refreshed session with AAL2
|
||||
const mockSession: Session = {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const mockRefreshSessionResponse = {
|
||||
data: { session: mockSession },
|
||||
error: null
|
||||
};
|
||||
|
||||
// Setup mocks
|
||||
mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge);
|
||||
mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse);
|
||||
mockSupabaseClient.auth.refreshSession.mockResolvedValue(
|
||||
mockRefreshSessionResponse
|
||||
);
|
||||
|
||||
// Override getClient to return our mock
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
// Execute
|
||||
const result = await authClient.verifyMFA('factor-123', '123456');
|
||||
|
||||
// Verify refreshSession was called after MFA verification
|
||||
expect(mockSupabaseClient.auth.refreshSession).toHaveBeenCalled();
|
||||
|
||||
// Verify the returned session
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError when MFA challenge fails', async () => {
|
||||
const mockChallenge = {
|
||||
data: null,
|
||||
error: { message: 'Challenge failed' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const error = await authClient
|
||||
.verifyMFA('factor-123', '123456')
|
||||
.catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect(error.code).toBe('MFA_VERIFICATION_FAILED');
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError when MFA verification fails', async () => {
|
||||
const mockChallenge = {
|
||||
data: { id: 'challenge-123' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockVerifyResponse = {
|
||||
data: null,
|
||||
error: { message: 'Invalid code' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge);
|
||||
mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const error = await authClient
|
||||
.verifyMFA('factor-123', '123456')
|
||||
.catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect(error.code).toBe('INVALID_MFA_CODE');
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError when refreshSession fails', async () => {
|
||||
const mockChallenge = {
|
||||
data: { id: 'challenge-123' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockVerifyResponse = {
|
||||
data: {
|
||||
access_token: 'temp-token',
|
||||
refresh_token: 'temp-refresh'
|
||||
},
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockRefreshSessionResponse = {
|
||||
data: { session: null },
|
||||
error: { message: 'Refresh failed' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge);
|
||||
mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse);
|
||||
mockSupabaseClient.auth.refreshSession.mockResolvedValue(
|
||||
mockRefreshSessionResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const error = await authClient
|
||||
.verifyMFA('factor-123', '123456')
|
||||
.catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect(error.code).toBe('REFRESH_FAILED');
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError when refreshSession returns no session', async () => {
|
||||
const mockChallenge = {
|
||||
data: { id: 'challenge-123' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockVerifyResponse = {
|
||||
data: {
|
||||
access_token: 'temp-token',
|
||||
refresh_token: 'temp-refresh'
|
||||
},
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockRefreshSessionResponse = {
|
||||
data: { session: null },
|
||||
error: null
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge);
|
||||
mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse);
|
||||
mockSupabaseClient.auth.refreshSession.mockResolvedValue(
|
||||
mockRefreshSessionResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const error = await authClient
|
||||
.verifyMFA('factor-123', '123456')
|
||||
.catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect(error.message).toBe(
|
||||
'Failed to refresh session after MFA: No session returned'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError when MFA verification returns no data', async () => {
|
||||
const mockChallenge = {
|
||||
data: { id: 'challenge-123' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockVerifyResponse = {
|
||||
data: null,
|
||||
error: null
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.mfa.challenge.mockResolvedValue(mockChallenge);
|
||||
mockSupabaseClient.auth.mfa.verify.mockResolvedValue(mockVerifyResponse);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const error = await authClient
|
||||
.verifyMFA('factor-123', '123456')
|
||||
.catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect(error.code).toBe('INVALID_RESPONSE');
|
||||
expect(error.message).toBe('No data returned from MFA verification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkMFARequired', () => {
|
||||
it('should return required: true when user has verified MFA factors but is at AAL1', async () => {
|
||||
const mockSession: Session = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const mockGetSessionResponse = {
|
||||
data: { session: mockSession },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockAALResponse = {
|
||||
data: { currentLevel: 'aal1', nextLevel: 'aal2' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockFactorsResponse = {
|
||||
data: {
|
||||
totp: [
|
||||
{
|
||||
id: 'factor-123',
|
||||
status: 'verified',
|
||||
factor_type: 'totp'
|
||||
}
|
||||
]
|
||||
},
|
||||
error: null
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.getSession.mockResolvedValue(
|
||||
mockGetSessionResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue(
|
||||
mockAALResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.listFactors.mockResolvedValue(
|
||||
mockFactorsResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const result = await authClient.checkMFARequired();
|
||||
|
||||
expect(result).toEqual({
|
||||
required: true,
|
||||
factorId: 'factor-123',
|
||||
factorType: 'totp'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return required: false when session is already at AAL2', async () => {
|
||||
const mockSession: Session = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const mockGetSessionResponse = {
|
||||
data: { session: mockSession },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockAALResponse = {
|
||||
data: { currentLevel: 'aal2' },
|
||||
error: null
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.getSession.mockResolvedValue(
|
||||
mockGetSessionResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue(
|
||||
mockAALResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const result = await authClient.checkMFARequired();
|
||||
|
||||
expect(result).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('should return required: false when user has no verified MFA factors', async () => {
|
||||
const mockSession: Session = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const mockGetSessionResponse = {
|
||||
data: { session: mockSession },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockAALResponse = {
|
||||
data: { currentLevel: 'aal1' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockFactorsResponse = {
|
||||
data: { totp: [] },
|
||||
error: null
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.getSession.mockResolvedValue(
|
||||
mockGetSessionResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue(
|
||||
mockAALResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.listFactors.mockResolvedValue(
|
||||
mockFactorsResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const result = await authClient.checkMFARequired();
|
||||
|
||||
expect(result).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('should return required: false when getSession fails', async () => {
|
||||
const mockGetSessionResponse = {
|
||||
data: { session: null },
|
||||
error: { message: 'Session error' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.getSession.mockResolvedValue(
|
||||
mockGetSessionResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const result = await authClient.checkMFARequired();
|
||||
|
||||
expect(result).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('should return required: false when getAuthenticatorAssuranceLevel fails', async () => {
|
||||
const mockSession: Session = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const mockGetSessionResponse = {
|
||||
data: { session: mockSession },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockAALResponse = {
|
||||
data: null,
|
||||
error: { message: 'AAL error' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.getSession.mockResolvedValue(
|
||||
mockGetSessionResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue(
|
||||
mockAALResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const result = await authClient.checkMFARequired();
|
||||
|
||||
expect(result).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('should return required: false when listFactors fails', async () => {
|
||||
const mockSession: Session = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600,
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'authenticated',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const mockGetSessionResponse = {
|
||||
data: { session: mockSession },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockAALResponse = {
|
||||
data: { currentLevel: 'aal1', nextLevel: 'aal2' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const mockFactorsResponse = {
|
||||
data: null,
|
||||
error: { message: 'Factors error' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.getSession.mockResolvedValue(
|
||||
mockGetSessionResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue(
|
||||
mockAALResponse
|
||||
);
|
||||
mockSupabaseClient.auth.mfa.listFactors.mockResolvedValue(
|
||||
mockFactorsResponse
|
||||
);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
const result = await authClient.checkMFARequired();
|
||||
|
||||
expect(result).toEqual({ required: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('signOut', () => {
|
||||
it('should sign out with local scope', async () => {
|
||||
const mockSignOutResponse = {
|
||||
error: null
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.signOut.mockResolvedValue(mockSignOutResponse);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
await authClient.signOut();
|
||||
|
||||
expect(mockSupabaseClient.auth.signOut).toHaveBeenCalledWith({
|
||||
scope: 'local'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle signOut errors gracefully', async () => {
|
||||
const mockSignOutResponse = {
|
||||
error: { message: 'Sign out failed' }
|
||||
};
|
||||
|
||||
mockSupabaseClient.auth.signOut.mockResolvedValue(mockSignOutResponse);
|
||||
(authClient as any).client = mockSupabaseClient;
|
||||
|
||||
// signOut should not throw errors - it handles them gracefully
|
||||
await expect(authClient.signOut()).resolves.not.toThrow();
|
||||
|
||||
// Verify signOut was still called
|
||||
expect(mockSupabaseClient.auth.signOut).toHaveBeenCalledWith({
|
||||
scope: 'local'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Session,
|
||||
SupabaseClient as SupabaseJSClient,
|
||||
User,
|
||||
type Session,
|
||||
type SupabaseClient as SupabaseJSClient,
|
||||
type User,
|
||||
createClient
|
||||
} from '@supabase/supabase-js';
|
||||
import { getLogger } from '../../../common/logger/index.js';
|
||||
@@ -266,8 +266,8 @@ export class SupabaseAuthClient {
|
||||
const client = this.getClient();
|
||||
|
||||
try {
|
||||
// Sign out with global scope to revoke all refresh tokens
|
||||
const { error } = await client.auth.signOut({ scope: 'global' });
|
||||
// Sign out with local scope to clear only this device's session
|
||||
const { error } = await client.auth.signOut({ scope: 'local' });
|
||||
|
||||
if (error) {
|
||||
this.logger.warn('Failed to sign out:', error);
|
||||
@@ -357,4 +357,151 @@ export class SupabaseAuthClient {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MFA is required for the current session
|
||||
* @returns Object with required=true and factor details if MFA is required,
|
||||
* or required=false if session is already at AAL2 or no MFA is configured
|
||||
*/
|
||||
async checkMFARequired(): Promise<{
|
||||
required: boolean;
|
||||
factorId?: string;
|
||||
factorType?: string;
|
||||
}> {
|
||||
const client = this.getClient();
|
||||
|
||||
try {
|
||||
// Get the current session
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError
|
||||
} = await client.auth.getSession();
|
||||
|
||||
if (sessionError || !session) {
|
||||
this.logger.warn('No session available to check MFA');
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
// Check the current Authentication Assurance Level (AAL)
|
||||
// AAL1 = basic authentication (password/oauth)
|
||||
// AAL2 = MFA verified
|
||||
const { data: aalData, error: aalError } =
|
||||
await client.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
|
||||
if (aalError) {
|
||||
this.logger.warn('Failed to get AAL:', aalError);
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
// If already at AAL2, MFA is not required
|
||||
if (aalData?.currentLevel === 'aal2') {
|
||||
this.logger.info('Session already at AAL2, MFA not required');
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
// Get MFA factors for this user
|
||||
const { data: factors, error: factorsError } =
|
||||
await client.auth.mfa.listFactors();
|
||||
|
||||
if (factorsError) {
|
||||
this.logger.warn('Failed to list MFA factors:', factorsError);
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
// Check if user has any verified MFA factors
|
||||
const verifiedFactors = factors?.totp?.filter(
|
||||
(factor) => factor.status === 'verified'
|
||||
);
|
||||
|
||||
if (!verifiedFactors || verifiedFactors.length === 0) {
|
||||
this.logger.info('No verified MFA factors found');
|
||||
return { required: false };
|
||||
}
|
||||
|
||||
// MFA is required - user has MFA enabled but session is only at AAL1
|
||||
const factor = verifiedFactors[0]; // Use the first verified factor
|
||||
this.logger.info('MFA verification required', {
|
||||
factorId: factor.id,
|
||||
factorType: factor.factor_type
|
||||
});
|
||||
|
||||
return {
|
||||
required: true,
|
||||
factorId: factor.id,
|
||||
factorType: factor.factor_type
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Error checking MFA requirement:', error);
|
||||
return { required: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA code and upgrade session to AAL2
|
||||
*/
|
||||
async verifyMFA(factorId: string, code: string): Promise<Session> {
|
||||
const client = this.getClient();
|
||||
|
||||
try {
|
||||
this.logger.info('Verifying MFA code...');
|
||||
|
||||
// Create MFA challenge
|
||||
const { data: challengeData, error: challengeError } =
|
||||
await client.auth.mfa.challenge({ factorId });
|
||||
|
||||
if (challengeError || !challengeData) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to create MFA challenge: ${challengeError?.message || 'Unknown error'}`,
|
||||
'MFA_VERIFICATION_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the TOTP code
|
||||
const { data, error } = await client.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId: challengeData.id,
|
||||
code
|
||||
});
|
||||
|
||||
if (error) {
|
||||
this.logger.error('MFA verification failed:', error);
|
||||
throw new AuthenticationError(
|
||||
`Invalid MFA code: ${error.message}`,
|
||||
'INVALID_MFA_CODE'
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new AuthenticationError(
|
||||
'No data returned from MFA verification',
|
||||
'INVALID_RESPONSE'
|
||||
);
|
||||
}
|
||||
|
||||
// After successful MFA verification, refresh the session to get the upgraded AAL2 session
|
||||
const {
|
||||
data: { session },
|
||||
error: refreshError
|
||||
} = await client.auth.refreshSession();
|
||||
|
||||
if (refreshError || !session) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to refresh session after MFA: ${refreshError?.message || 'No session returned'}`,
|
||||
'REFRESH_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info('Successfully verified MFA, session upgraded to AAL2');
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AuthenticationError(
|
||||
`MFA verification failed: ${(error as Error).message}`,
|
||||
'MFA_VERIFICATION_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user