feat: implement MFA in taskmaster (#1438)

This commit is contained in:
Ralph Khreish
2025-11-24 20:51:47 +01:00
committed by GitHub
parent 783398ecdf
commit af36d171c7
12 changed files with 2148 additions and 286 deletions

View File

@@ -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;
}

View File

@@ -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
*/

View File

@@ -17,7 +17,8 @@ export type {
OAuthFlowOptions,
AuthConfig,
CliData,
UserContext
UserContext,
MFAVerificationResult
} from './types.js';
export { AuthenticationError } from './types.js';

View File

@@ -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();
});
});
});

View File

@@ -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');

View File

@@ -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()
);
});
});
});

View 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');
}
}
}

View File

@@ -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}`;
}

View File

@@ -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'
});
});
});
});

View File

@@ -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'
);
}
}
}