mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
Merge pull request #1354 from eyaltoledano/next Release 0.30.2
This commit is contained in:
5
.changeset/lazy-suits-smell.md
Normal file
5
.changeset/lazy-suits-smell.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"task-master-ai": patch
|
||||
---
|
||||
|
||||
Improve session persistence reliability
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
181
packages/tm-core/src/modules/auth/services/context-store.ts
Normal file
181
packages/tm-core/src/modules/auth/services/context-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user