Merge pull request #1354 from eyaltoledano/next Release 0.30.2

This commit is contained in:
Ralph Khreish
2025-10-28 17:18:40 +01:00
committed by GitHub
18 changed files with 831 additions and 1528 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Improve session persistence reliability

View File

@@ -62,8 +62,22 @@ export class AuthCommand extends Command {
private addLoginCommand(): void {
this.command('login')
.description('Authenticate with tryhamster.com')
.action(async () => {
await this.executeLogin();
.argument(
'[token]',
'Authentication token (optional, for SSH/remote environments)'
)
.option('-y, --yes', 'Skip interactive prompts')
.addHelpText(
'after',
`
Examples:
$ tm auth login # Browser-based OAuth flow (interactive)
$ tm auth login <token> # Token-based authentication
$ tm auth login <token> -y # Non-interactive token auth (for scripts)
`
)
.action(async (token?: string, options?: { yes?: boolean }) => {
await this.executeLogin(token, options?.yes);
});
}
@@ -103,9 +117,11 @@ export class AuthCommand extends Command {
/**
* Execute login command
*/
private async executeLogin(): Promise<void> {
private async executeLogin(token?: string, yes?: boolean): Promise<void> {
try {
const result = await this.performInteractiveAuth();
const result = token
? await this.performTokenAuth(token, yes)
: await this.performInteractiveAuth(yes);
this.setLastResult(result);
if (!result.success) {
@@ -143,7 +159,7 @@ export class AuthCommand extends Command {
*/
private async executeStatus(): Promise<void> {
try {
const result = this.displayStatus();
const result = await this.displayStatus();
this.setLastResult(result);
} catch (error: any) {
displayError(error);
@@ -169,21 +185,28 @@ export class AuthCommand extends Command {
/**
* Display authentication status
*/
private displayStatus(): AuthResult {
const credentials = this.authManager.getCredentials();
private async displayStatus(): Promise<AuthResult> {
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
if (credentials) {
console.log(chalk.green('✓ Authenticated'));
console.log(chalk.gray(` Email: ${credentials.email || 'N/A'}`));
console.log(chalk.gray(` User ID: ${credentials.userId}`));
console.log(
chalk.gray(` Token Type: ${credentials.tokenType || 'standard'}`)
);
// Check if user has valid session
const hasSession = await this.authManager.hasValidSession();
if (credentials.expiresAt) {
const expiresAt = new Date(credentials.expiresAt);
if (hasSession) {
// Get session from Supabase (has tokens and expiry)
const session = await this.authManager.getSession();
// Get user context (has email, userId, org/brief selection)
const context = this.authManager.getContext();
const contextStore = this.authManager.getStoredContext();
console.log(chalk.green('✓ Authenticated'));
console.log(chalk.gray(` Email: ${contextStore?.email || 'N/A'}`));
console.log(chalk.gray(` User ID: ${contextStore?.userId || 'N/A'}`));
console.log(chalk.gray(` Token Type: standard`));
// Display expiration info
if (session?.expires_at) {
const expiresAt = new Date(session.expires_at * 1000);
const now = new Date();
const timeRemaining = expiresAt.getTime() - now.getTime();
const hoursRemaining = Math.floor(timeRemaining / (1000 * 60 * 60));
@@ -210,13 +233,32 @@ export class AuthCommand extends Command {
chalk.yellow(` Expired at: ${expiresAt.toLocaleString()}`)
);
}
} else {
console.log(chalk.gray(' Expires: Never (API key)'));
}
console.log(
chalk.gray(` Saved: ${new Date(credentials.savedAt).toLocaleString()}`)
);
// Display context if available
if (context) {
console.log(chalk.gray('\n Context:'));
if (context.orgName) {
console.log(chalk.gray(` Organization: ${context.orgName}`));
}
if (context.briefName) {
console.log(chalk.gray(` Brief: ${context.briefName}`));
}
}
// Build credentials for backward compatibility
const credentials = {
token: session?.access_token || '',
refreshToken: session?.refresh_token,
userId: contextStore?.userId || '',
email: contextStore?.email,
expiresAt: session?.expires_at
? new Date(session.expires_at * 1000).toISOString()
: undefined,
tokenType: 'standard' as const,
savedAt: contextStore?.lastUpdated || new Date().toISOString(),
selectedContext: context || undefined
};
return {
success: true,
@@ -307,11 +349,12 @@ export class AuthCommand extends Command {
/**
* Perform interactive authentication
*/
private async performInteractiveAuth(): Promise<AuthResult> {
private async performInteractiveAuth(yes?: boolean): Promise<AuthResult> {
ui.displayBanner('Task Master Authentication');
const isAuthenticated = await this.authManager.hasValidSession();
// Check if already authenticated
if (this.authManager.isAuthenticated()) {
// Check if already authenticated (skip if --yes is used)
if (isAuthenticated && !yes) {
const { continueAuth } = await inquirer.prompt([
{
type: 'confirm',
@@ -323,7 +366,7 @@ export class AuthCommand extends Command {
]);
if (!continueAuth) {
const credentials = this.authManager.getCredentials();
const credentials = await this.authManager.getAuthCredentials();
ui.displaySuccess('Using existing authentication');
if (credentials) {
@@ -349,35 +392,43 @@ export class AuthCommand extends Command {
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
);
// Post-auth: Set up workspace context
console.log(); // Add spacing
try {
const contextCommand = new ContextCommand();
const contextResult = await contextCommand.setupContextInteractive();
if (contextResult.success) {
if (contextResult.orgSelected && contextResult.briefSelected) {
// Post-auth: Set up workspace context (skip if --yes flag is used)
if (!yes) {
console.log(); // Add spacing
try {
const contextCommand = new ContextCommand();
const contextResult = await contextCommand.setupContextInteractive();
if (contextResult.success) {
if (contextResult.orgSelected && contextResult.briefSelected) {
console.log(
chalk.green('✓ Workspace context configured successfully')
);
} else if (contextResult.orgSelected) {
console.log(chalk.green('✓ Organization selected'));
}
} else {
console.log(
chalk.green('✓ Workspace context configured successfully')
chalk.yellow('⚠ Context setup was skipped or encountered issues')
);
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
} else if (contextResult.orgSelected) {
console.log(chalk.green('✓ Organization selected'));
}
} else {
console.log(
chalk.yellow('⚠ Context setup was skipped or encountered issues')
);
} catch (contextError) {
console.log(chalk.yellow('⚠ Context setup encountered an error'));
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
if (process.env.DEBUG) {
console.error(chalk.gray((contextError as Error).message));
}
}
} catch (contextError) {
console.log(chalk.yellow('⚠ Context setup encountered an error'));
} else {
console.log(
chalk.gray(' You can set up context later with "tm context"')
chalk.gray(
'\n Skipped interactive setup. Use "tm context" to configure later.'
)
);
if (process.env.DEBUG) {
console.error(chalk.gray((contextError as Error).message));
}
}
return {
@@ -450,6 +501,96 @@ export class AuthCommand extends Command {
}
}
/**
* Authenticate with token
*/
private async authenticateWithToken(token: string): Promise<AuthCredentials> {
const spinner = ora('Verifying authentication token...').start();
try {
const credentials = await this.authManager.authenticateWithCode(token);
spinner.succeed('Successfully authenticated!');
return credentials;
} catch (error) {
spinner.fail('Authentication failed');
throw error;
}
}
/**
* Perform token-based authentication flow
*/
private async performTokenAuth(
token: string,
yes?: boolean
): Promise<AuthResult> {
ui.displayBanner('Task Master Authentication');
try {
// Authenticate with the token
const credentials = await this.authenticateWithToken(token);
ui.displaySuccess('Authentication successful!');
console.log(
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
);
// Post-auth: Set up workspace context (skip if --yes flag is used)
if (!yes) {
console.log(); // Add spacing
try {
const contextCommand = new ContextCommand();
const contextResult = await contextCommand.setupContextInteractive();
if (contextResult.success) {
if (contextResult.orgSelected && contextResult.briefSelected) {
console.log(
chalk.green('✓ Workspace context configured successfully')
);
} else if (contextResult.orgSelected) {
console.log(chalk.green('✓ Organization selected'));
}
} else {
console.log(
chalk.yellow('⚠ Context setup was skipped or encountered issues')
);
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
}
} catch (contextError) {
console.log(chalk.yellow('⚠ Context setup encountered an error'));
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
if (process.env.DEBUG) {
console.error(chalk.gray((contextError as Error).message));
}
}
} else {
console.log(
chalk.gray(
'\n Skipped interactive setup. Use "tm context" to configure later.'
)
);
}
return {
success: true,
action: 'login',
credentials,
message: 'Authentication successful'
};
} catch (error) {
displayError(error, { skipExit: true });
return {
success: false,
action: 'login',
message: `Authentication failed: ${(error as Error).message}`
};
}
}
/**
* Set the last result for programmatic access
*/
@@ -464,18 +605,11 @@ export class AuthCommand extends Command {
return this.lastResult;
}
/**
* Get current authentication status (for programmatic usage)
*/
isAuthenticated(): boolean {
return this.authManager.isAuthenticated();
}
/**
* Get current credentials (for programmatic usage)
*/
getCredentials(): AuthCredentials | null {
return this.authManager.getCredentials();
async getCredentials(): Promise<AuthCredentials | null> {
return this.authManager.getAuthCredentials();
}
/**

View File

@@ -113,7 +113,7 @@ export class ContextCommand extends Command {
*/
private async executeShow(): Promise<void> {
try {
const result = this.displayContext();
const result = await this.displayContext();
this.setLastResult(result);
} catch (error: any) {
displayError(error);
@@ -123,9 +123,10 @@ export class ContextCommand extends Command {
/**
* Display current context
*/
private displayContext(): ContextResult {
private async displayContext(): Promise<ContextResult> {
// Check authentication first
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
console.log(chalk.yellow('✗ Not authenticated'));
console.log(chalk.gray('\n Run "tm auth login" to authenticate first'));
@@ -200,7 +201,8 @@ export class ContextCommand extends Command {
private async executeSelectOrg(): Promise<void> {
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
@@ -250,7 +252,7 @@ export class ContextCommand extends Command {
]);
// Update context
this.authManager.updateContext({
await this.authManager.updateContext({
orgId: selectedOrg.id,
orgName: selectedOrg.name,
orgSlug: selectedOrg.slug,
@@ -279,7 +281,8 @@ export class ContextCommand extends Command {
private async executeSelectBrief(): Promise<void> {
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
@@ -371,7 +374,7 @@ export class ContextCommand extends Command {
const briefName =
selectedBrief.document?.title ||
`Brief ${selectedBrief.id.slice(0, 8)}`;
this.authManager.updateContext({
await this.authManager.updateContext({
briefId: selectedBrief.id,
briefName: briefName
});
@@ -386,7 +389,7 @@ export class ContextCommand extends Command {
};
} else {
// Clear brief selection
this.authManager.updateContext({
await this.authManager.updateContext({
briefId: undefined,
briefName: undefined
});
@@ -412,7 +415,8 @@ export class ContextCommand extends Command {
private async executeClear(): Promise<void> {
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
@@ -458,7 +462,8 @@ export class ContextCommand extends Command {
private async executeSet(options: any): Promise<void> {
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
@@ -481,7 +486,8 @@ export class ContextCommand extends Command {
let spinner: Ora | undefined;
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}
@@ -520,7 +526,7 @@ export class ContextCommand extends Command {
// Update context: set org and brief
const briefName =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
this.authManager.updateContext({
await this.authManager.updateContext({
orgId: brief.accountId,
orgName,
orgSlug,
@@ -642,7 +648,7 @@ export class ContextCommand extends Command {
};
}
this.authManager.updateContext(context);
await this.authManager.updateContext(context);
ui.displaySuccess('Context updated');
// Display what was set

View File

@@ -96,7 +96,8 @@ export class ExportCommand extends Command {
try {
// Check authentication
if (!this.authManager.isAuthenticated()) {
const hasSession = await this.authManager.hasValidSession();
if (!hasSession) {
ui.displayError('Not authenticated. Run "tm auth login" first.');
process.exit(1);
}

View File

@@ -44,17 +44,38 @@ export class AuthDomain {
// ========== Authentication ==========
/**
* Check if user is authenticated
* Check if valid Supabase session exists
*/
isAuthenticated(): boolean {
return this.authManager.isAuthenticated();
async hasValidSession(): Promise<boolean> {
return this.authManager.hasValidSession();
}
/**
* Get the current Supabase session with full details
*/
async getSession() {
return this.authManager.getSession();
}
/**
* Get stored user context (userId, email)
*/
getStoredContext() {
return this.authManager.getStoredContext();
}
/**
* Get stored credentials
*/
getCredentials(): AuthCredentials | null {
return this.authManager.getCredentials();
async getCredentials(): Promise<AuthCredentials | null> {
return this.authManager.getAuthCredentials();
}
/**
* Get access token from current session
*/
async getAccessToken(): Promise<string | null> {
return this.authManager.getAccessToken();
}
/**
@@ -66,6 +87,14 @@ export class AuthDomain {
return this.authManager.authenticateWithOAuth(options);
}
/**
* Authenticate using a one-time token
* Useful for CLI authentication in SSH/remote environments
*/
async authenticateWithCode(token: string): Promise<AuthCredentials> {
return this.authManager.authenticateWithCode(token);
}
/**
* Get OAuth authorization URL
*/
@@ -99,14 +128,14 @@ export class AuthDomain {
/**
* Update user context
*/
updateContext(context: Partial<UserContext>): void {
async updateContext(context: Partial<UserContext>): Promise<void> {
return this.authManager.updateContext(context);
}
/**
* Clear user context
*/
clearContext(): void {
async clearContext(): Promise<void> {
return this.authManager.clearContext();
}

View File

@@ -4,7 +4,7 @@
export { AuthDomain, type StorageDisplayInfo } from './auth-domain.js';
export { AuthManager } from './managers/auth-manager.js';
export { CredentialStore } from './services/credential-store.js';
export { ContextStore, type StoredContext } from './services/context-store.js';
export { OAuthService } from './services/oauth-service.js';
export { SupabaseSessionStorage } from './services/supabase-session-storage.js';
export type {

View File

@@ -9,7 +9,7 @@ import {
AuthConfig,
UserContext
} from '../types.js';
import { CredentialStore } from '../services/credential-store.js';
import { ContextStore } from '../services/context-store.js';
import { OAuthService } from '../services/oauth-service.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import {
@@ -19,6 +19,9 @@ import {
type RemoteTask
} from '../services/organization.service.js';
import { getLogger } from '../../../common/logger/index.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
/**
* Authentication manager class
@@ -26,22 +29,38 @@ import { getLogger } from '../../../common/logger/index.js';
export class AuthManager {
private static instance: AuthManager | null = null;
private static readonly staticLogger = getLogger('AuthManager');
private credentialStore: CredentialStore;
private contextStore: ContextStore;
private oauthService: OAuthService;
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.credentialStore = CredentialStore.getInstance(config);
this.contextStore = ContextStore.getInstance();
this.supabaseClient = new SupabaseAuthClient();
this.oauthService = new OAuthService(this.credentialStore, config);
// 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
});
}
/**
@@ -56,6 +75,32 @@ export class AuthManager {
}
}
/**
* 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);
}
}
/**
* Get singleton instance
*/
@@ -76,16 +121,42 @@ export class AuthManager {
*/
static resetInstance(): void {
AuthManager.instance = null;
CredentialStore.resetInstance();
ContextStore.resetInstance();
}
/**
* Get stored authentication credentials
* Returns credentials as-is (even if expired). Refresh must be triggered explicitly
* via refreshToken() or will occur automatically when using the Supabase client for API calls.
* Get access token from current Supabase session
* @returns Access token or null if not authenticated
*/
getCredentials(): AuthCredentials | null {
return this.credentialStore.getCredentials();
async getAccessToken(): Promise<string | null> {
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> {
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
};
}
/**
@@ -97,6 +168,69 @@ export class AuthManager {
return this.oauthService.authenticate(options);
}
/**
* 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> {
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'
);
}
// 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'
);
}
}
/**
* Get the authorization URL (for browser opening)
*/
@@ -106,6 +240,8 @@ export class AuthManager {
/**
* 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> {
try {
@@ -119,13 +255,15 @@ export class AuthManager {
);
}
// Get existing credentials to preserve context
const existingCredentials = this.credentialStore.getCredentials({
allowExpired: true
// Sync user info to context store
this.contextStore.saveContext({
userId: session.user.id,
email: session.user.email
});
// Update authentication data from session
const newAuthData: AuthCredentials = {
// Build credentials response
const context = this.contextStore.getContext();
const credentials: AuthCredentials = {
token: session.access_token,
refreshToken: session.refresh_token,
userId: session.user.id,
@@ -134,11 +272,10 @@ export class AuthManager {
? new Date(session.expires_at * 1000).toISOString()
: undefined,
savedAt: new Date().toISOString(),
selectedContext: existingCredentials?.selectedContext
selectedContext: context?.selectedContext
};
this.credentialStore.saveCredentials(newAuthData);
return newAuthData;
return credentials;
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
@@ -162,81 +299,91 @@ export class AuthManager {
this.logger.warn('Failed to sign out from Supabase:', error);
}
// Always clear local credentials (removes auth.json file)
this.credentialStore.clearCredentials();
// 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');
}
}
/**
* Check if authenticated (credentials exist, regardless of expiration)
* @returns true if credentials are stored, including expired credentials
* Check if valid Supabase session exists
* @returns true if a valid session exists
*/
isAuthenticated(): boolean {
return this.credentialStore.hasCredentials();
async hasValidSession(): Promise<boolean> {
try {
const session = await this.supabaseClient.getSession();
return session !== null;
} catch {
return false;
}
}
/**
* Get the current Supabase session
*/
async getSession() {
return this.supabaseClient.getSession();
}
/**
* Get stored user context (userId, email)
*/
getStoredContext() {
return this.contextStore.getContext();
}
/**
* Get the current user context (org/brief selection)
*/
getContext(): UserContext | null {
const credentials = this.getCredentials();
return credentials?.selectedContext || null;
return this.contextStore.getUserContext();
}
/**
* Update the user context (org/brief selection)
*/
updateContext(context: Partial<UserContext>): void {
const credentials = this.getCredentials();
if (!credentials) {
async updateContext(context: Partial<UserContext>): Promise<void> {
if (!(await this.hasValidSession())) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
// Merge with existing context
const existingContext = credentials.selectedContext || {};
const newContext: UserContext = {
...existingContext,
...context,
updatedAt: new Date().toISOString()
};
// Save updated credentials with new context
const updatedCredentials: AuthCredentials = {
...credentials,
selectedContext: newContext
};
this.credentialStore.saveCredentials(updatedCredentials);
this.contextStore.updateUserContext(context);
}
/**
* Clear the user context
*/
clearContext(): void {
const credentials = this.getCredentials();
if (!credentials) {
async clearContext(): Promise<void> {
if (!(await this.hasValidSession())) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
// Remove context from credentials
const { selectedContext, ...credentialsWithoutContext } = credentials;
this.credentialStore.saveCredentials(credentialsWithoutContext);
this.contextStore.clearUserContext();
}
/**
* Get the organization service instance
* Uses the Supabase client with the current session or token
* Uses the Supabase client with the current session
*/
private async getOrganizationService(): Promise<OrganizationService> {
if (!this.organizationService) {
// First check if we have credentials with a token
const credentials = this.getCredentials();
if (!credentials || !credentials.token) {
// Check if we have a valid Supabase session
const session = await this.supabaseClient.getSession();
if (!session) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
// Initialize session if needed (this will load from our storage adapter)
await this.supabaseClient.initialize();
// Use the SupabaseAuthClient which now has the session
const supabaseClient = this.supabaseClient.getClient();
this.organizationService = new OrganizationService(supabaseClient as any);

View File

@@ -0,0 +1,181 @@
/**
* Context storage for app-specific user preferences
*
* This store manages user preferences and context separate from auth tokens.
* - selectedContext (org/brief selection)
* - userId and email (for convenience)
* - Any other app-specific data
*
* Stored at: ~/.taskmaster/context.json
*/
import fs from 'fs';
import path from 'path';
import { UserContext, AuthenticationError } from '../types.js';
import { getLogger } from '../../../common/logger/index.js';
const DEFAULT_CONTEXT_FILE = path.join(
process.env.HOME || process.env.USERPROFILE || '~',
'.taskmaster',
'context.json'
);
export interface StoredContext {
userId?: string;
email?: string;
selectedContext?: UserContext;
lastUpdated: string;
}
export class ContextStore {
private static instance: ContextStore | null = null;
private logger = getLogger('ContextStore');
private contextPath: string;
private constructor(contextPath: string = DEFAULT_CONTEXT_FILE) {
this.contextPath = contextPath;
}
/**
* Get singleton instance
*/
static getInstance(contextPath?: string): ContextStore {
if (!ContextStore.instance) {
ContextStore.instance = new ContextStore(contextPath);
}
return ContextStore.instance;
}
/**
* Reset singleton (for testing)
*/
static resetInstance(): void {
ContextStore.instance = null;
}
/**
* Get stored context
*/
getContext(): StoredContext | null {
try {
if (!fs.existsSync(this.contextPath)) {
return null;
}
const data = JSON.parse(fs.readFileSync(this.contextPath, 'utf8'));
this.logger.debug('Loaded context from disk');
return data;
} catch (error) {
this.logger.error('Failed to read context:', error);
return null;
}
}
/**
* Save context
*/
saveContext(context: Partial<StoredContext>): void {
try {
// Load existing context
const existing = this.getContext() || {};
// Merge with new data
const updated: StoredContext = {
...existing,
...context,
lastUpdated: new Date().toISOString()
};
// Ensure directory exists
const dir = path.dirname(this.contextPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Write atomically
const tempFile = `${this.contextPath}.tmp`;
fs.writeFileSync(tempFile, JSON.stringify(updated, null, 2), {
mode: 0o600
});
fs.renameSync(tempFile, this.contextPath);
this.logger.debug('Saved context to disk');
} catch (error) {
throw new AuthenticationError(
`Failed to save context: ${(error as Error).message}`,
'SAVE_FAILED',
error
);
}
}
/**
* Update user context (org/brief selection)
*/
updateUserContext(userContext: Partial<UserContext>): void {
const existing = this.getContext();
const currentUserContext = existing?.selectedContext || {};
const updated: UserContext = {
...currentUserContext,
...userContext,
updatedAt: new Date().toISOString()
};
this.saveContext({
...existing,
selectedContext: updated
});
}
/**
* Get user context (org/brief selection)
*/
getUserContext(): UserContext | null {
const context = this.getContext();
return context?.selectedContext || null;
}
/**
* Clear user context
*/
clearUserContext(): void {
const existing = this.getContext();
if (existing) {
const { selectedContext, ...rest } = existing;
this.saveContext(rest);
}
}
/**
* Clear all context
*/
clearContext(): void {
try {
if (fs.existsSync(this.contextPath)) {
fs.unlinkSync(this.contextPath);
this.logger.debug('Cleared context from disk');
}
} catch (error) {
throw new AuthenticationError(
`Failed to clear context: ${(error as Error).message}`,
'CLEAR_FAILED',
error
);
}
}
/**
* Check if context exists
*/
hasContext(): boolean {
return this.getContext() !== null;
}
/**
* Get context file path
*/
getContextPath(): string {
return this.contextPath;
}
}

View File

@@ -1,308 +0,0 @@
/**
* @fileoverview Unit tests for CredentialStore token expiration handling
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { CredentialStore } from './credential-store';
import type { AuthCredentials } from './types';
describe('CredentialStore - Token Expiration', () => {
let credentialStore: CredentialStore;
let tmpDir: string;
let authFile: string;
beforeEach(() => {
// Create temp directory for test credentials
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-cred-test-'));
authFile = path.join(tmpDir, 'auth.json');
// Create instance with test config
CredentialStore.resetInstance();
credentialStore = CredentialStore.getInstance({
configDir: tmpDir,
configFile: authFile
});
});
afterEach(() => {
// Clean up
try {
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
CredentialStore.resetInstance();
});
describe('Expiration Detection', () => {
it('should return null for expired token', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(), // 1 minute ago
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
it('should return credentials for valid token', () => {
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token');
});
it('should return expired token when allowExpired is true', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
const retrieved = credentialStore.getCredentials({ allowExpired: true });
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token');
});
it('should return expired token by default (allowExpired defaults to true)', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token-default',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
// Call without options - should default to allowExpired: true
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token-default');
});
});
describe('Clock Skew Tolerance', () => {
it('should reject token expiring within 30-second buffer', () => {
// Token expires in 15 seconds (within 30-second buffer)
const almostExpiredCredentials: AuthCredentials = {
token: 'almost-expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 15000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(almostExpiredCredentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
it('should accept token expiring outside 30-second buffer', () => {
// Token expires in 60 seconds (outside 30-second buffer)
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token');
});
});
describe('Timestamp Format Handling', () => {
it('should handle ISO string timestamps', () => {
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
});
it('should handle numeric timestamps', () => {
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: Date.now() + 3600000,
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number');
});
it('should return null for invalid timestamp format', () => {
// Manually write invalid timestamp to file
const invalidCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: 'invalid-date',
savedAt: new Date().toISOString()
};
fs.writeFileSync(authFile, JSON.stringify(invalidCredentials), {
mode: 0o600
});
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
it('should return null for missing expiresAt', () => {
const credentialsWithoutExpiry = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
savedAt: new Date().toISOString()
};
fs.writeFileSync(authFile, JSON.stringify(credentialsWithoutExpiry), {
mode: 0o600
});
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
});
describe('Storage Persistence', () => {
it('should persist expiresAt as ISO string', () => {
const expiryTime = Date.now() + 3600000;
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: expiryTime,
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
// Read raw file to verify format
const fileContent = fs.readFileSync(authFile, 'utf-8');
const parsed = JSON.parse(fileContent);
// Should be stored as ISO string
expect(typeof parsed.expiresAt).toBe('string');
expect(parsed.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO format
});
it('should normalize timestamp on retrieval', () => {
const credentials: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials({ allowExpired: false });
// Should be normalized to number for runtime use
expect(typeof retrieved?.expiresAt).toBe('number');
});
});
describe('hasCredentials', () => {
it('should return true for expired credentials', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
expect(credentialStore.hasCredentials()).toBe(true);
});
it('should return true for valid credentials', () => {
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(validCredentials);
expect(credentialStore.hasCredentials()).toBe(true);
});
it('should return false when no credentials exist', () => {
expect(credentialStore.hasCredentials()).toBe(false);
});
});
});

View File

@@ -1,600 +0,0 @@
/**
* Tests for CredentialStore with numeric and string timestamp handling
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { CredentialStore } from '../services/credential-store.js';
import { AuthenticationError } from '../types.js';
import type { AuthCredentials } from '../types.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
// Mock fs module
vi.mock('fs');
// Mock logger
const mockLogger = {
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
error: vi.fn()
};
vi.mock('../logger/index.js', () => ({
getLogger: () => mockLogger
}));
describe('CredentialStore', () => {
let store: CredentialStore;
const testDir = '/test/config';
const configFile = '/test/config/auth.json';
beforeEach(() => {
vi.clearAllMocks();
store = new CredentialStore({
configDir: testDir,
configFile: configFile,
baseUrl: 'https://api.test.com'
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getCredentials with timestamp migration', () => {
it('should handle string ISO timestamp correctly', () => {
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
const mockCredentials: AuthCredentials = {
token: 'test-token',
userId: 'user-123',
email: 'test@example.com',
expiresAt: futureDate.toISOString(),
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
expect(result).not.toBeNull();
expect(result?.token).toBe('test-token');
// The timestamp should be normalized to numeric milliseconds
expect(typeof result?.expiresAt).toBe('number');
expect(result?.expiresAt).toBe(futureDate.getTime());
});
it('should handle numeric timestamp correctly', () => {
const futureTimestamp = Date.now() + 7200000; // 2 hours from now
const mockCredentials = {
token: 'test-token',
userId: 'user-456',
email: 'test2@example.com',
expiresAt: futureTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
expect(result).not.toBeNull();
expect(result?.token).toBe('test-token');
// Numeric timestamp should remain as-is
expect(typeof result?.expiresAt).toBe('number');
expect(result?.expiresAt).toBe(futureTimestamp);
});
it('should reject invalid string timestamp', () => {
const mockCredentials = {
token: 'test-token',
userId: 'user-789',
expiresAt: 'invalid-date-string',
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
'No valid expiration time provided for token'
);
});
it('should reject NaN timestamp', () => {
const mockCredentials = {
token: 'test-token',
userId: 'user-nan',
expiresAt: NaN,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
'No valid expiration time provided for token'
);
});
it('should reject Infinity timestamp', () => {
const mockCredentials = {
token: 'test-token',
userId: 'user-inf',
expiresAt: Infinity,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
'No valid expiration time provided for token'
);
});
it('should handle missing expiresAt field', () => {
const mockCredentials = {
token: 'test-token',
userId: 'user-no-expiry',
tokenType: 'standard',
savedAt: new Date().toISOString()
// No expiresAt field
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
'No valid expiration time provided for token'
);
});
it('should check token expiration correctly', () => {
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
const mockCredentials = {
token: 'expired-token',
userId: 'user-expired',
expiresAt: expiredTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials({ allowExpired: false });
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Authentication token has expired'),
expect.any(Object)
);
});
it('should allow expired tokens when requested', () => {
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
const mockCredentials = {
token: 'expired-token',
userId: 'user-expired',
expiresAt: expiredTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
const result = store.getCredentials({ allowExpired: true });
expect(result).not.toBeNull();
expect(result?.token).toBe('expired-token');
});
it('should return expired tokens by default (allowExpired defaults to true)', () => {
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
const mockCredentials = {
token: 'expired-token-default',
userId: 'user-expired',
expiresAt: expiredTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
// Call without options - should default to allowExpired: true
const result = store.getCredentials();
expect(result).not.toBeNull();
expect(result?.token).toBe('expired-token-default');
expect(mockLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Authentication token has expired')
);
});
});
describe('saveCredentials with timestamp normalization', () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
});
it('should normalize string timestamp to ISO string when saving', () => {
const futureDate = new Date(Date.now() + 3600000);
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-123',
expiresAt: futureDate.toISOString(),
tokenType: 'standard',
savedAt: new Date().toISOString()
};
store.saveCredentials(credentials);
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('.tmp'),
expect.stringContaining('"expiresAt":'),
expect.any(Object)
);
// Check that the written data contains a valid ISO string
const writtenData = vi.mocked(fs.writeFileSync).mock
.calls[0][1] as string;
const parsed = JSON.parse(writtenData);
expect(typeof parsed.expiresAt).toBe('string');
expect(new Date(parsed.expiresAt).toISOString()).toBe(parsed.expiresAt);
});
it('should convert numeric timestamp to ISO string when saving', () => {
const futureTimestamp = Date.now() + 7200000;
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-456',
expiresAt: futureTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
store.saveCredentials(credentials);
const writtenData = vi.mocked(fs.writeFileSync).mock
.calls[0][1] as string;
const parsed = JSON.parse(writtenData);
expect(typeof parsed.expiresAt).toBe('string');
expect(new Date(parsed.expiresAt).getTime()).toBe(futureTimestamp);
});
it('should reject invalid string timestamp when saving', () => {
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-789',
expiresAt: 'invalid-date' as any,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
let err: unknown;
try {
store.saveCredentials(credentials);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(AuthenticationError);
expect((err as Error).message).toContain('Invalid expiresAt format');
});
it('should reject NaN timestamp when saving', () => {
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-nan',
expiresAt: NaN as any,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
let err: unknown;
try {
store.saveCredentials(credentials);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(AuthenticationError);
expect((err as Error).message).toContain('Invalid expiresAt format');
});
it('should reject Infinity timestamp when saving', () => {
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-inf',
expiresAt: Infinity as any,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
let err: unknown;
try {
store.saveCredentials(credentials);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(AuthenticationError);
expect((err as Error).message).toContain('Invalid expiresAt format');
});
it('should handle missing expiresAt when saving', () => {
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-no-expiry',
tokenType: 'standard',
savedAt: new Date().toISOString()
// No expiresAt
};
store.saveCredentials(credentials);
const writtenData = vi.mocked(fs.writeFileSync).mock
.calls[0][1] as string;
const parsed = JSON.parse(writtenData);
expect(parsed.expiresAt).toBeUndefined();
});
it('should not mutate the original credentials object', () => {
const originalTimestamp = Date.now() + 3600000;
const credentials: AuthCredentials = {
token: 'test-token',
userId: 'user-123',
expiresAt: originalTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
const originalCredentialsCopy = { ...credentials };
store.saveCredentials(credentials);
// Original object should not be modified
expect(credentials).toEqual(originalCredentialsCopy);
expect(credentials.expiresAt).toBe(originalTimestamp);
});
});
describe('corrupt file handling', () => {
it('should quarantine corrupt file on JSON parse error', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
const result = store.getCredentials();
expect(result).toBeNull();
expect(fs.renameSync).toHaveBeenCalledWith(
configFile,
expect.stringContaining('.corrupt-')
);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Quarantined corrupt auth file')
);
});
it('should handle quarantine failure gracefully', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
vi.mocked(fs.renameSync).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = store.getCredentials();
expect(result).toBeNull();
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Could not quarantine corrupt file')
);
});
});
describe('clearCredentials', () => {
it('should delete the auth file when it exists', () => {
// Mock file exists
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
store.clearCredentials();
expect(fs.existsSync).toHaveBeenCalledWith('/test/config/auth.json');
expect(fs.unlinkSync).toHaveBeenCalledWith('/test/config/auth.json');
});
it('should not throw when auth file does not exist', () => {
// Mock file does not exist
vi.mocked(fs.existsSync).mockReturnValue(false);
// Should not throw
expect(() => store.clearCredentials()).not.toThrow();
// Should not try to unlink non-existent file
expect(fs.unlinkSync).not.toHaveBeenCalled();
});
it('should throw AuthenticationError when unlink fails', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.unlinkSync).mockImplementation(() => {
throw new Error('Permission denied');
});
let err: unknown;
try {
store.clearCredentials();
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(AuthenticationError);
expect((err as Error).message).toContain('Failed to clear credentials');
expect((err as Error).message).toContain('Permission denied');
});
});
describe('hasCredentials', () => {
it('should return true when valid unexpired credentials exist', () => {
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
const credentials = {
token: 'valid-token',
userId: 'user-123',
expiresAt: futureDate.toISOString(),
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
expect(store.hasCredentials()).toBe(true);
});
it('should return true when credentials are expired', () => {
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
const credentials = {
token: 'expired-token',
userId: 'user-123',
expiresAt: pastDate.toISOString(),
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
expect(store.hasCredentials()).toBe(true);
});
it('should return false when no credentials exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
expect(store.hasCredentials()).toBe(false);
});
it('should return false when file contains invalid JSON', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
expect(store.hasCredentials()).toBe(false);
});
it('should return false for credentials without expiry', () => {
const credentials = {
token: 'no-expiry-token',
userId: 'user-123',
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
// Credentials without expiry are considered invalid
expect(store.hasCredentials()).toBe(false);
// Should log warning about missing expiration
expect(mockLogger.warn).toHaveBeenCalledWith(
'No valid expiration time provided for token'
);
});
it('should use allowExpired=true', () => {
// Spy on getCredentials to verify it's called with correct params
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
vi.mocked(fs.existsSync).mockReturnValue(false);
store.hasCredentials();
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: true });
});
});
describe('cleanupCorruptFiles', () => {
it('should remove old corrupt files', () => {
const now = Date.now();
const oldFile = 'auth.json.corrupt-' + (now - 8 * 24 * 60 * 60 * 1000); // 8 days old
const newFile = 'auth.json.corrupt-' + (now - 1000); // 1 second old
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
{ name: oldFile, isFile: () => true },
{ name: newFile, isFile: () => true },
{ name: 'auth.json', isFile: () => true }
] as any);
vi.mocked(fs.statSync).mockImplementation((filePath) => {
if (filePath.includes(oldFile)) {
return { mtimeMs: now - 8 * 24 * 60 * 60 * 1000 } as any;
}
return { mtimeMs: now - 1000 } as any;
});
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
store.cleanupCorruptFiles();
expect(fs.unlinkSync).toHaveBeenCalledWith(
expect.stringContaining(oldFile)
);
expect(fs.unlinkSync).not.toHaveBeenCalledWith(
expect.stringContaining(newFile)
);
});
it('should handle cleanup errors gracefully', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockImplementation(() => {
throw new Error('Permission denied');
});
// Should not throw
expect(() => store.cleanupCorruptFiles()).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Error during corrupt file cleanup')
);
});
});
});

View File

@@ -1,277 +0,0 @@
/**
* Credential storage and management
*/
import fs from 'fs';
import path from 'path';
import { AuthCredentials, AuthenticationError, AuthConfig } from '../types.js';
import { getAuthConfig } from '../config.js';
import { getLogger } from '../../../common/logger/index.js';
/**
* CredentialStore manages the persistence and retrieval of authentication credentials.
*
* Runtime vs Persisted Shape:
* - When retrieved (getCredentials): expiresAt is normalized to number (milliseconds since epoch)
* - When persisted (saveCredentials): expiresAt is stored as ISO string for readability
*
* This normalization ensures consistent runtime behavior while maintaining
* human-readable persisted format in the auth.json file.
*/
export class CredentialStore {
private static instance: CredentialStore | null = null;
private logger = getLogger('CredentialStore');
private config: AuthConfig;
// Clock skew tolerance for expiry checks (30 seconds)
private readonly CLOCK_SKEW_MS = 30_000;
// Track if we've already warned about missing expiration to avoid spam
private hasWarnedAboutMissingExpiration = false;
private constructor(config?: Partial<AuthConfig>) {
this.config = getAuthConfig(config);
}
/**
* Get the singleton instance of CredentialStore
*/
static getInstance(config?: Partial<AuthConfig>): CredentialStore {
if (!CredentialStore.instance) {
CredentialStore.instance = new CredentialStore(config);
} else if (config) {
// Warn if config is provided after initialization
const logger = getLogger('CredentialStore');
logger.warn(
'getInstance called with config after initialization; config is ignored.'
);
}
return CredentialStore.instance;
}
/**
* Reset the singleton instance (useful for testing)
*/
static resetInstance(): void {
CredentialStore.instance = null;
}
/**
* Get stored authentication credentials
* @param options.allowExpired - Whether to return expired credentials (default: true)
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
*/
getCredentials({
allowExpired = true
}: { allowExpired?: boolean } = {}): AuthCredentials | null {
try {
if (!fs.existsSync(this.config.configFile)) {
return null;
}
const authData = JSON.parse(
fs.readFileSync(this.config.configFile, 'utf-8')
) as AuthCredentials;
// Normalize/migrate timestamps to numeric (handles both number and ISO string)
let expiresAtMs: number | undefined;
if (typeof authData.expiresAt === 'number') {
expiresAtMs = Number.isFinite(authData.expiresAt)
? authData.expiresAt
: undefined;
} else if (typeof authData.expiresAt === 'string') {
const parsed = Date.parse(authData.expiresAt);
expiresAtMs = Number.isNaN(parsed) ? undefined : parsed;
} else {
expiresAtMs = undefined;
}
// Validate expiration time for tokens
if (expiresAtMs === undefined) {
// Only log this warning once to avoid spam during auth flows
if (!this.hasWarnedAboutMissingExpiration) {
this.logger.warn('No valid expiration time provided for token');
this.hasWarnedAboutMissingExpiration = true;
}
return null;
}
// Update the authData with normalized timestamp
authData.expiresAt = expiresAtMs;
// Check if the token has expired (with clock skew tolerance)
const now = Date.now();
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
this.logger.warn(
'Authentication token has expired or is about to expire',
{
expiresAt: authData.expiresAt,
currentTime: new Date(now).toISOString(),
skewWindow: `${this.CLOCK_SKEW_MS / 1000}s`
}
);
return null;
}
// Return credentials (even if expired) to enable refresh flows
return authData;
} catch (error) {
this.logger.error(
`Failed to read auth credentials: ${(error as Error).message}`
);
// Quarantine corrupt file to prevent repeated errors
try {
if (fs.existsSync(this.config.configFile)) {
const corruptFile = `${this.config.configFile}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
fs.renameSync(this.config.configFile, corruptFile);
this.logger.warn(`Quarantined corrupt auth file to: ${corruptFile}`);
}
} catch (quarantineError) {
// If we can't quarantine, log but don't throw
this.logger.debug(
`Could not quarantine corrupt file: ${(quarantineError as Error).message}`
);
}
return null;
}
}
/**
* Save authentication credentials
* @param authData - Credentials with expiresAt as number or string (will be persisted as ISO string)
*/
saveCredentials(authData: AuthCredentials): void {
try {
// Ensure directory exists
if (!fs.existsSync(this.config.configDir)) {
fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 });
}
// Add timestamp without mutating caller's object
authData = { ...authData, savedAt: new Date().toISOString() };
// Validate and normalize expiresAt timestamp
if (authData.expiresAt !== undefined) {
let validTimestamp: number | undefined;
if (typeof authData.expiresAt === 'number') {
validTimestamp = Number.isFinite(authData.expiresAt)
? authData.expiresAt
: undefined;
} else if (typeof authData.expiresAt === 'string') {
const parsed = Date.parse(authData.expiresAt);
validTimestamp = Number.isNaN(parsed) ? undefined : parsed;
}
if (validTimestamp === undefined) {
throw new AuthenticationError(
`Invalid expiresAt format: ${authData.expiresAt}`,
'SAVE_FAILED'
);
}
// Store as ISO string for consistency
authData.expiresAt = new Date(validTimestamp).toISOString();
}
// Save credentials atomically with secure permissions
const tempFile = `${this.config.configFile}.tmp`;
fs.writeFileSync(tempFile, JSON.stringify(authData, null, 2), {
mode: 0o600
});
fs.renameSync(tempFile, this.config.configFile);
// Reset the warning flag so it can be shown again for future invalid tokens
this.hasWarnedAboutMissingExpiration = false;
} catch (error) {
throw new AuthenticationError(
`Failed to save auth credentials: ${(error as Error).message}`,
'SAVE_FAILED',
error
);
}
}
/**
* Clear stored credentials
*/
clearCredentials(): void {
try {
if (fs.existsSync(this.config.configFile)) {
fs.unlinkSync(this.config.configFile);
}
} catch (error) {
throw new AuthenticationError(
`Failed to clear credentials: ${(error as Error).message}`,
'CLEAR_FAILED',
error
);
}
}
/**
* Check if credentials exist (regardless of expiration status)
* @returns true if credentials are stored, including expired credentials
*/
hasCredentials(): boolean {
const credentials = this.getCredentials({ allowExpired: true });
return credentials !== null;
}
/**
* Get configuration
*/
getConfig(): AuthConfig {
return { ...this.config };
}
/**
* Clean up old corrupt auth files
* Removes corrupt files older than the specified age
*/
cleanupCorruptFiles(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): void {
try {
const dir = path.dirname(this.config.configFile);
const baseName = path.basename(this.config.configFile);
const prefix = `${baseName}.corrupt-`;
if (!fs.existsSync(dir)) {
return;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
const now = Date.now();
for (const entry of entries) {
if (!entry.isFile()) continue;
const file = entry.name;
// Check if file matches pattern: baseName.corrupt-{timestamp}
if (!file.startsWith(prefix)) continue;
const suffix = file.slice(prefix.length);
if (!/^\d+$/.test(suffix)) continue; // Fixed regex, not from variable input
const filePath = path.join(dir, file);
try {
const stats = fs.statSync(filePath);
const age = now - stats.mtimeMs;
if (age > maxAgeMs) {
fs.unlinkSync(filePath);
this.logger.debug(`Cleaned up old corrupt file: ${file}`);
}
} catch (error) {
// Ignore errors for individual file cleanup
this.logger.debug(
`Could not clean up corrupt file ${file}: ${(error as Error).message}`
);
}
}
} catch (error) {
// Log but don't throw - this is a cleanup operation
this.logger.debug(
`Error during corrupt file cleanup: ${(error as Error).message}`
);
}
}
}

View File

@@ -13,15 +13,16 @@ import {
AuthConfig,
CliData
} from '../types.js';
import { CredentialStore } from '../services/credential-store.js';
import { ContextStore } from '../services/context-store.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { getAuthConfig } from '../config.js';
import { getLogger } from '../../../common/logger/index.js';
import packageJson from '../../../../../../package.json' with { type: 'json' };
import { Session } from '@supabase/supabase-js';
export class OAuthService {
private logger = getLogger('OAuthService');
private credentialStore: CredentialStore;
private contextStore: ContextStore;
private supabaseClient: SupabaseAuthClient;
private baseUrl: string;
private authorizationUrl: string | null = null;
@@ -30,11 +31,12 @@ export class OAuthService {
private resolveAuthorizationReady: (() => void) | null = null;
constructor(
credentialStore: CredentialStore,
contextStore: ContextStore,
supabaseClient: SupabaseAuthClient,
config: Partial<AuthConfig> = {}
) {
this.credentialStore = credentialStore;
this.supabaseClient = new SupabaseAuthClient();
this.contextStore = contextStore;
this.supabaseClient = supabaseClient;
const authConfig = getAuthConfig(config);
this.baseUrl = authConfig.baseUrl;
}
@@ -274,13 +276,19 @@ export class OAuthService {
// Handle authorization code for PKCE flow
const code = url.searchParams.get('code');
this.logger.info(`Code: ${code}, type: ${type}`);
if (code && type === 'pkce_callback') {
try {
this.logger.info('Received authorization code for PKCE flow');
// Exchange code for session using PKCE
const session = await this.supabaseClient.exchangeCodeForSession(code);
// Save user info to context store
this.contextStore.saveContext({
userId: session.user.id,
email: session.user.email
});
// Calculate expiration - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
@@ -294,7 +302,7 @@ export class OAuthService {
: undefined;
}
// Save authentication data
// Return credentials for backward compatibility
const authData: AuthCredentials = {
token: session.access_token,
refreshToken: session.refresh_token,
@@ -305,8 +313,6 @@ export class OAuthService {
savedAt: new Date().toISOString()
};
this.credentialStore.saveCredentials(authData);
if (server.listening) {
server.close();
}
@@ -331,26 +337,32 @@ export class OAuthService {
(type === 'oauth_success' || type === 'session_transfer')
) {
try {
this.logger.info(`Received tokens via ${type}`);
this.logger.info(
`\n\n==============================================\n Received tokens via ${type}\n==============================================\n`
);
// Create a session with the tokens and set it in Supabase client
const session = {
// This automatically saves the session to session.json via SupabaseSessionStorage
const session: Session = {
access_token: accessToken,
refresh_token: refreshToken || '',
expires_at: expiresIn
? Math.floor(Date.now() / 1000) + parseInt(expiresIn)
: undefined,
expires_in: expiresIn ? parseInt(expiresIn) : undefined,
expires_in: expiresIn ? parseInt(expiresIn) : 0,
token_type: 'bearer',
user: null as any // Will be populated by setSession
};
// Set the session in Supabase client
await this.supabaseClient.setSession(session as any);
await this.supabaseClient.setSession(session);
// Get user info from the session
const user = await this.supabaseClient.getUser();
// Save user info to context store
this.contextStore.saveContext({
userId: user?.id || 'unknown',
email: user?.email
});
// Calculate expiration time - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
@@ -364,7 +376,7 @@ export class OAuthService {
: undefined;
}
// Save authentication data
// Return credentials for backward compatibility
const authData: AuthCredentials = {
token: accessToken,
refreshToken: refreshToken || undefined,
@@ -375,8 +387,6 @@ export class OAuthService {
savedAt: new Date().toISOString()
};
this.credentialStore.saveCredentials(authData);
if (server.listening) {
server.close();
}

View File

@@ -1,206 +1,123 @@
/**
* Custom storage adapter for Supabase Auth sessions in CLI environment
* Implements the SupportedStorage interface required by Supabase Auth
* Barebones storage adapter for Supabase Auth sessions
*
* This adapter bridges Supabase's session management with our existing
* auth.json credential storage, maintaining backward compatibility
* This is a simple key-value store that lets Supabase manage sessions
* without interference. No parsing, no merging, no guessing - just storage.
*
* Supabase handles:
* - Session refresh and token rotation
* - Expiry checking
* - Token validation
*
* We handle:
* - Simple get/set/remove/clear operations
* - Persistence to ~/.taskmaster/session.json
*/
import type { SupportedStorage } from '@supabase/supabase-js';
import { CredentialStore } from './credential-store.js';
import type { AuthCredentials } from '../types.js';
import fs from 'fs';
import path from 'path';
import { getLogger } from '../../../common/logger/index.js';
const STORAGE_KEY = 'sb-taskmaster-auth-token';
const DEFAULT_SESSION_FILE = path.join(
process.env.HOME || process.env.USERPROFILE || '~',
'.taskmaster',
'session.json'
);
export class SupabaseSessionStorage implements SupportedStorage {
private store: CredentialStore;
private storage: Map<string, string> = new Map();
private persistPath: string;
private logger = getLogger('SupabaseSessionStorage');
constructor(store: CredentialStore) {
this.store = store;
constructor(persistPath: string = DEFAULT_SESSION_FILE) {
this.persistPath = persistPath;
this.load();
}
/**
* Build a Supabase session object from our credentials
* Load session data from disk on initialization
*/
private buildSessionFromCredentials(credentials: AuthCredentials): any {
// Create a session object that Supabase expects
const session = {
access_token: credentials.token,
refresh_token: credentials.refreshToken || '',
// Don't default to arbitrary values - let Supabase handle refresh
...(credentials.expiresAt && {
expires_at: Math.floor(new Date(credentials.expiresAt).getTime() / 1000)
}),
token_type: 'bearer',
user: {
id: credentials.userId,
email: credentials.email || ''
}
};
return session;
}
/**
* Parse a Supabase session back to our credentials
*/
private parseSessionToCredentials(
sessionData: any
): Partial<AuthCredentials> {
private load(): void {
try {
// Handle both string and object formats (Supabase may pass either)
const session =
typeof sessionData === 'string' ? JSON.parse(sessionData) : sessionData;
return {
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
};
if (fs.existsSync(this.persistPath)) {
const data = JSON.parse(fs.readFileSync(this.persistPath, 'utf8'));
Object.entries(data).forEach(([k, v]) =>
this.storage.set(k, v as string)
);
this.logger.debug('Loaded session from disk', {
keys: Array.from(this.storage.keys())
});
}
} catch (error) {
this.logger.error('Error parsing session:', error);
return {};
this.logger.error('Failed to load session:', error);
// Don't throw - allow starting with fresh session
}
}
/**
* Get item from storage - Supabase will request the session with a specific key
* Persist session data to disk immediately
*/
private persist(): void {
try {
// Ensure directory exists
const dir = path.dirname(this.persistPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Write atomically with temp file
const data = Object.fromEntries(this.storage);
const tempFile = `${this.persistPath}.tmp`;
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2), {
mode: 0o600
});
fs.renameSync(tempFile, this.persistPath);
this.logger.debug('Persisted session to disk');
} catch (error) {
this.logger.error('Failed to persist session:', error);
// Don't throw - session is still in memory
}
}
/**
* Get item from storage
* Supabase will call this to retrieve session data
*/
getItem(key: string): string | null {
// Supabase uses a specific key pattern for sessions
if (key === STORAGE_KEY || key.includes('auth-token')) {
try {
// Get credentials and let Supabase handle expiry/refresh internally
const credentials = this.store.getCredentials();
// Only return a session if we have BOTH access token AND refresh token
// Supabase will handle refresh if session is expired
if (!credentials?.token || !credentials?.refreshToken) {
this.logger.debug('No valid credentials found');
return null;
}
const session = this.buildSessionFromCredentials(credentials);
return JSON.stringify(session);
} catch (error) {
this.logger.error('Error getting session:', error);
}
}
// Return null if no valid session exists - Supabase expects this
return null;
const value = this.storage.get(key) ?? null;
this.logger.debug('getItem called', { key, hasValue: !!value });
return value;
}
/**
* Set item in storage - Supabase will store the session with a specific key
* CRITICAL: This is called during refresh token rotation - must be atomic
* Set item in storage
* Supabase will call this to store/update session data
* CRITICAL: This is called during token refresh - must persist immediately
*/
setItem(key: string, value: string): void {
// Only handle Supabase session keys
if (key === STORAGE_KEY || key.includes('auth-token')) {
try {
this.logger.info('Supabase called setItem - storing refreshed session');
// Parse the session and update our credentials
const sessionUpdates = this.parseSessionToCredentials(value);
const existingCredentials = this.store.getCredentials({
allowExpired: true
});
// CRITICAL: Only save if we have both tokens - prevents partial session states
// Refresh token rotation means we MUST persist the new refresh token immediately
if (!sessionUpdates.token || !sessionUpdates.refreshToken) {
this.logger.warn(
'Received incomplete session update - skipping save to prevent token rotation issues',
{
hasToken: !!sessionUpdates.token,
hasRefreshToken: !!sessionUpdates.refreshToken
}
);
return;
}
// Log the refresh token rotation for debugging
const isRotation =
existingCredentials?.refreshToken !== sessionUpdates.refreshToken;
if (isRotation) {
this.logger.debug(
'Refresh token rotated - storing new refresh token atomically'
);
}
// Build updated credentials - ATOMIC update of both tokens
const userId = sessionUpdates.userId ?? existingCredentials?.userId;
// Runtime assertion: userId is required for AuthCredentials
if (!userId) {
this.logger.error(
'Cannot save credentials: userId is missing from both session update and existing credentials'
);
throw new Error('Invalid session state: userId is required');
}
const updatedCredentials: AuthCredentials = {
...(existingCredentials ?? {}),
token: sessionUpdates.token,
refreshToken: sessionUpdates.refreshToken,
expiresAt: sessionUpdates.expiresAt,
userId,
email: sessionUpdates.email ?? existingCredentials?.email,
savedAt: new Date().toISOString(),
selectedContext: existingCredentials?.selectedContext
} as AuthCredentials;
// Save synchronously to ensure atomicity during refresh
this.store.saveCredentials(updatedCredentials);
this.logger.info(
'Successfully saved refreshed credentials from Supabase',
{
tokenRotated: isRotation,
expiresAt: updatedCredentials.expiresAt
}
);
} catch (error) {
this.logger.error('Error setting session:', error);
}
}
this.logger.debug('setItem called', { key });
this.storage.set(key, value);
this.persist(); // Immediately persist on every write
}
/**
* Remove item from storage - Called when signing out
* Remove item from storage
* Supabase will call this during sign out
*/
removeItem(key: string): void {
if (key === STORAGE_KEY || key.includes('auth-token')) {
// Don't actually remove credentials, just clear the tokens
// This preserves other data like selectedContext
try {
const credentials = this.store.getCredentials({ allowExpired: true });
if (credentials) {
// Keep context but clear auth tokens
const clearedCredentials: AuthCredentials = {
...credentials,
token: '',
refreshToken: undefined,
expiresAt: undefined
} as AuthCredentials;
this.store.saveCredentials(clearedCredentials);
}
} catch (error) {
this.logger.error('Error removing session:', error);
}
}
this.logger.debug('removeItem called', { key });
this.storage.delete(key);
this.persist();
}
/**
* Clear all session data
*/
clear(): void {
// Clear auth tokens but preserve context
this.removeItem(STORAGE_KEY);
this.logger.debug('clear called');
this.storage.clear();
this.persist();
}
}

View File

@@ -81,7 +81,9 @@ export type AuthErrorCode =
| 'PKCE_INIT_FAILED'
| 'PKCE_FAILED'
| 'CODE_EXCHANGE_FAILED'
| 'SESSION_SET_FAILED';
| 'SESSION_SET_FAILED'
| 'CODE_AUTH_FAILED'
| 'INVALID_CODE';
/**
* Authentication error class

View File

@@ -11,17 +11,14 @@ import {
import { AuthenticationError } from '../../auth/types.js';
import { getLogger } from '../../../common/logger/index.js';
import { SupabaseSessionStorage } from '../../auth/services/supabase-session-storage.js';
import { CredentialStore } from '../../auth/services/credential-store.js';
export class SupabaseAuthClient {
private client: SupabaseJSClient | null = null;
private sessionStorage: SupabaseSessionStorage;
private logger = getLogger('SupabaseAuthClient');
private credentialStore: CredentialStore;
constructor() {
this.credentialStore = CredentialStore.getInstance();
this.sessionStorage = new SupabaseSessionStorage(this.credentialStore);
this.sessionStorage = new SupabaseSessionStorage();
}
/**
@@ -314,4 +311,50 @@ export class SupabaseAuthClient {
);
}
}
/**
* Verify a one-time token and create a session
* Used for CLI authentication with pre-generated tokens
*/
async verifyOneTimeCode(token: string): Promise<Session> {
const client = this.getClient();
try {
this.logger.info('Verifying authentication token...');
// Use Supabase's verifyOtp for token verification
// Using token_hash with magiclink type doesn't require email
const { data, error } = await client.auth.verifyOtp({
token_hash: token,
type: 'magiclink'
});
if (error) {
this.logger.error('Failed to verify token:', error);
throw new AuthenticationError(
`Failed to verify token: ${error.message}`,
'INVALID_CODE'
);
}
if (!data?.session) {
throw new AuthenticationError(
'No session returned from token verification',
'INVALID_RESPONSE'
);
}
this.logger.info('Successfully verified authentication token');
return data.session;
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError(
`Token verification failed: ${(error as Error).message}`,
'CODE_AUTH_FAILED'
);
}
}
}

View File

@@ -99,8 +99,9 @@ export class ExportService {
* Export tasks to a brief
*/
async exportTasks(options: ExportTasksOptions): Promise<ExportResult> {
const isAuthenticated = await this.authManager.hasValidSession();
// Validate authentication
if (!this.authManager.isAuthenticated()) {
if (!isAuthenticated) {
throw new TaskMasterError(
'Authentication required for export',
ERROR_CODES.AUTHENTICATION_ERROR
@@ -383,8 +384,8 @@ export class ExportService {
};
// Get auth token
const credentials = await this.authManager.getCredentials();
if (!credentials || !credentials.token) {
const accessToken = await this.authManager.getAccessToken();
if (!accessToken) {
throw new Error('Not authenticated');
}
@@ -393,7 +394,7 @@ export class ExportService {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${credentials.token}`
Authorization: `Bearer ${accessToken}`
},
body: JSON.stringify(requestBody)
});

View File

@@ -29,10 +29,10 @@ export class StorageFactory {
* @param projectPath - Project root path (for file storage)
* @returns Storage implementation
*/
static createFromStorageConfig(
static async createFromStorageConfig(
storageConfig: RuntimeStorageConfig,
projectPath: string
): IStorage {
): Promise<IStorage> {
// Wrap the storage config in the expected format, including projectPath
// This ensures ApiStorage receives the projectPath for projectId
return StorageFactory.create(
@@ -47,10 +47,10 @@ export class StorageFactory {
* @param projectPath - Project root path (for file storage)
* @returns Storage implementation
*/
static create(
static async create(
config: Partial<IConfiguration>,
projectPath: string
): IStorage {
): Promise<IStorage> {
const storageType = config.storage?.type || 'auto';
const logger = getLogger('StorageFactory');
@@ -68,21 +68,22 @@ export class StorageFactory {
// Check if authenticated via AuthManager
const authManager = AuthManager.getInstance();
if (!authManager.isAuthenticated()) {
const hasSession = await authManager.hasValidSession();
if (!hasSession) {
throw new TaskMasterError(
`API storage not fully configured (${missing.join(', ') || 'credentials missing'}). Run: tm auth login, or set the missing field(s).`,
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api', missing }
);
}
// Use auth token from AuthManager (synchronous - no auto-refresh here)
const credentials = authManager.getCredentials();
if (credentials) {
// Use auth token from AuthManager
const accessToken = await authManager.getAccessToken();
if (accessToken) {
// Merge with existing storage config, ensuring required fields
const nextStorage: StorageSettings = {
...(config.storage as StorageSettings),
type: 'api',
apiAccessToken: credentials.token,
apiAccessToken: accessToken,
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.TM_BASE_DOMAIN ||
@@ -104,15 +105,26 @@ export class StorageFactory {
return StorageFactory.createApiStorage(config);
}
// Then check if authenticated via AuthManager
if (authManager.isAuthenticated()) {
const credentials = authManager.getCredentials();
if (credentials) {
// Configure API storage with auth credentials
// Then check if authenticated via Supabase
const hasSession = await authManager.hasValidSession();
if (hasSession) {
const accessToken = await authManager.getAccessToken();
const context = authManager.getContext();
// Validate we have the necessary context for API storage
if (!context?.briefId) {
logger.debug(
'📁 User authenticated but no brief selected, using file storage'
);
return StorageFactory.createFileStorage(projectPath, config);
}
if (accessToken) {
// Configure API storage with Supabase session token
const nextStorage: StorageSettings = {
...(config.storage as StorageSettings),
type: 'api',
apiAccessToken: credentials.token,
apiAccessToken: accessToken,
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.TM_BASE_DOMAIN ||

View File

@@ -71,7 +71,7 @@ export class TaskService {
const storageConfig = this.configManager.getStorageConfig();
const projectRoot = this.configManager.getProjectRoot();
this.storage = StorageFactory.createFromStorageConfig(
this.storage = await StorageFactory.createFromStorageConfig(
storageConfig,
projectRoot
);