chore: resolve first batch of requested changes
This commit is contained in:
@@ -21,6 +21,7 @@ describe('AuthManager Singleton', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset singleton before each test
|
// Reset singleton before each test
|
||||||
AuthManager.resetInstance();
|
AuthManager.resetInstance();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the same instance on multiple calls', () => {
|
it('should return the same instance on multiple calls', () => {
|
||||||
@@ -39,6 +40,12 @@ describe('AuthManager Singleton', () => {
|
|||||||
|
|
||||||
const instance = AuthManager.getInstance(config);
|
const instance = AuthManager.getInstance(config);
|
||||||
expect(instance).toBeDefined();
|
expect(instance).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the config is passed to internal components
|
||||||
|
// This would be observable when attempting operations that use the config
|
||||||
|
// For example, getCredentials would look in the configured file path
|
||||||
|
const credentials = instance.getCredentials();
|
||||||
|
expect(credentials).toBeNull(); // File doesn't exist, but it should check the right path
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should warn when config is provided after initialization', () => {
|
it('should warn when config is provided after initialization', () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class AuthManager {
|
|||||||
'getInstance called with config after initialization; config is ignored.'
|
'getInstance called with config after initialization; config is ignored.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return AuthManager.instance;
|
return AuthManager.instance!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,29 +74,6 @@ export class AuthManager {
|
|||||||
return this.oauthService.getAuthorizationUrl();
|
return this.oauthService.getAuthorizationUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate with API key
|
|
||||||
* Note: This would require a custom implementation or Supabase RLS policies
|
|
||||||
*/
|
|
||||||
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
|
|
||||||
const token = apiKey.trim();
|
|
||||||
if (!token || token.length < 10) {
|
|
||||||
throw new AuthenticationError('Invalid API key', 'INVALID_API_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authData: AuthCredentials = {
|
|
||||||
token,
|
|
||||||
tokenType: 'api_key',
|
|
||||||
userId: 'api-user',
|
|
||||||
email: undefined,
|
|
||||||
expiresAt: undefined, // API keys don't expire
|
|
||||||
savedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.credentialStore.saveCredentials(authData);
|
|
||||||
return authData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh authentication token
|
* Refresh authentication token
|
||||||
*/
|
*/
|
||||||
|
|||||||
414
packages/tm-core/src/auth/credential-store.test.ts
Normal file
414
packages/tm-core/src/auth/credential-store.test.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Tests for CredentialStore with numeric and string timestamp handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { CredentialStore } from './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();
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => store.saveCredentials(credentials)).toThrow(AuthenticationError);
|
||||||
|
expect(() => store.saveCredentials(credentials)).toThrow('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()
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => store.saveCredentials(credentials)).toThrow(AuthenticationError);
|
||||||
|
expect(() => store.saveCredentials(credentials)).toThrow('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()
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => store.saveCredentials(credentials)).toThrow(AuthenticationError);
|
||||||
|
expect(() => store.saveCredentials(credentials)).toThrow('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('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')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,30 +29,38 @@ export class CredentialStore {
|
|||||||
fs.readFileSync(this.config.configFile, 'utf-8')
|
fs.readFileSync(this.config.configFile, 'utf-8')
|
||||||
) as AuthCredentials;
|
) as AuthCredentials;
|
||||||
|
|
||||||
// Parse expiration time for validation (expects ISO string format)
|
// Normalize/migrate timestamps to numeric (handles both number and ISO string)
|
||||||
let expiresAtMs: number | undefined;
|
let expiresAtMs: number | undefined;
|
||||||
|
if (typeof authData.expiresAt === 'number') {
|
||||||
if (authData.expiresAt) {
|
expiresAtMs = Number.isFinite(authData.expiresAt) ? authData.expiresAt : undefined;
|
||||||
expiresAtMs = Date.parse(authData.expiresAt);
|
} else if (typeof authData.expiresAt === 'string') {
|
||||||
if (isNaN(expiresAtMs)) {
|
const parsed = Date.parse(authData.expiresAt);
|
||||||
// Invalid date string - treat as expired
|
expiresAtMs = Number.isNaN(parsed) ? undefined : parsed;
|
||||||
this.logger.error(`Invalid expiresAt format: ${authData.expiresAt}`);
|
} else {
|
||||||
return null;
|
expiresAtMs = undefined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired (API keys never expire)
|
// Validate expiration time for tokens
|
||||||
const isApiKey = authData.tokenType === 'api_key';
|
if (expiresAtMs === undefined) {
|
||||||
if (
|
this.logger.warn('No valid expiration time provided for token');
|
||||||
!isApiKey &&
|
|
||||||
expiresAtMs &&
|
|
||||||
expiresAtMs < Date.now() &&
|
|
||||||
!options?.allowExpired
|
|
||||||
) {
|
|
||||||
this.logger.warn('Authentication token has expired');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the authData with normalized timestamp
|
||||||
|
authData.expiresAt = expiresAtMs;
|
||||||
|
|
||||||
|
// Check if the token has expired
|
||||||
|
const now = Date.now();
|
||||||
|
const allowExpired = options?.allowExpired ?? false;
|
||||||
|
if (now >= expiresAtMs && !allowExpired) {
|
||||||
|
this.logger.warn('Authentication token has expired', {
|
||||||
|
expiresAt: authData.expiresAt,
|
||||||
|
currentTime: new Date(now).toISOString()
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return valid token
|
||||||
return authData;
|
return authData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -87,18 +95,29 @@ export class CredentialStore {
|
|||||||
fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add timestamp
|
// Add timestamp without mutating caller's object
|
||||||
authData.savedAt = new Date().toISOString();
|
authData = { ...authData, savedAt: new Date().toISOString() };
|
||||||
|
|
||||||
// Validate expiresAt is a valid ISO string if present
|
// Validate and normalize expiresAt timestamp
|
||||||
if (authData.expiresAt) {
|
if (authData.expiresAt !== undefined) {
|
||||||
const ms = Date.parse(authData.expiresAt);
|
let validTimestamp: number | undefined;
|
||||||
if (isNaN(ms)) {
|
|
||||||
|
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(
|
throw new AuthenticationError(
|
||||||
`Invalid expiresAt format: ${authData.expiresAt}`,
|
`Invalid expiresAt format: ${authData.expiresAt}`,
|
||||||
'SAVE_FAILED'
|
'SAVE_FAILED'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store as ISO string for consistency
|
||||||
|
authData.expiresAt = new Date(validTimestamp).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save credentials atomically with secure permissions
|
// Save credentials atomically with secure permissions
|
||||||
@@ -156,16 +175,19 @@ export class CredentialStore {
|
|||||||
try {
|
try {
|
||||||
const dir = path.dirname(this.config.configFile);
|
const dir = path.dirname(this.config.configFile);
|
||||||
const baseName = path.basename(this.config.configFile);
|
const baseName = path.basename(this.config.configFile);
|
||||||
const corruptPattern = new RegExp(`^${baseName}\\.corrupt-\\d+$`);
|
const escapedBaseName = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const corruptPattern = new RegExp(`^${escapedBaseName}\\.corrupt-\\d+$`);
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const file of files) {
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const file = entry.name;
|
||||||
if (corruptPattern.test(file)) {
|
if (corruptPattern.test(file)) {
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export interface AuthCredentials {
|
|||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
expiresAt?: string;
|
expiresAt?: string | number;
|
||||||
tokenType?: 'standard' | 'api_key';
|
tokenType?: 'standard';
|
||||||
savedAt: string;
|
savedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,6 @@ export type AuthErrorCode =
|
|||||||
| 'INVALID_STATE'
|
| 'INVALID_STATE'
|
||||||
| 'NO_TOKEN'
|
| 'NO_TOKEN'
|
||||||
| 'TOKEN_EXCHANGE_FAILED'
|
| 'TOKEN_EXCHANGE_FAILED'
|
||||||
| 'INVALID_API_KEY'
|
|
||||||
| 'INVALID_CREDENTIALS'
|
| 'INVALID_CREDENTIALS'
|
||||||
| 'NO_REFRESH_TOKEN'
|
| 'NO_REFRESH_TOKEN'
|
||||||
| 'NOT_AUTHENTICATED'
|
| 'NOT_AUTHENTICATED'
|
||||||
@@ -65,7 +64,10 @@ export type AuthErrorCode =
|
|||||||
| 'CONFIG_MISSING'
|
| 'CONFIG_MISSING'
|
||||||
| 'SAVE_FAILED'
|
| 'SAVE_FAILED'
|
||||||
| 'CLEAR_FAILED'
|
| 'CLEAR_FAILED'
|
||||||
| 'STORAGE_ERROR';
|
| 'STORAGE_ERROR'
|
||||||
|
| 'NOT_SUPPORTED'
|
||||||
|
| 'REFRESH_FAILED'
|
||||||
|
| 'INVALID_RESPONSE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication error class
|
* Authentication error class
|
||||||
|
|||||||
@@ -142,17 +142,23 @@ export class ConfigManager {
|
|||||||
|
|
||||||
// Return the configured type (including 'auto')
|
// Return the configured type (including 'auto')
|
||||||
const storageType = storage?.type || 'auto';
|
const storageType = storage?.type || 'auto';
|
||||||
|
const basePath = storage?.basePath;
|
||||||
|
|
||||||
if (storageType === 'api' || storageType === 'auto') {
|
if (storageType === 'api' || storageType === 'auto') {
|
||||||
return {
|
return {
|
||||||
type: storageType,
|
type: storageType,
|
||||||
|
basePath,
|
||||||
apiEndpoint: storage?.apiEndpoint,
|
apiEndpoint: storage?.apiEndpoint,
|
||||||
apiAccessToken: storage?.apiAccessToken,
|
apiAccessToken: storage?.apiAccessToken,
|
||||||
apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken)
|
apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: storageType, apiConfigured: false };
|
return {
|
||||||
|
type: storageType,
|
||||||
|
basePath,
|
||||||
|
apiConfigured: false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,9 +189,10 @@ export class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if using API storage
|
* Check if explicitly configured to use API storage
|
||||||
|
* Excludes 'auto' type
|
||||||
*/
|
*/
|
||||||
isUsingApiStorage(): boolean {
|
isApiExplicitlyConfigured(): boolean {
|
||||||
return this.getStorageConfig().type === 'api';
|
return this.getStorageConfig().type === 'api';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +225,7 @@ export class ConfigManager {
|
|||||||
await this.persistence.saveConfig(this.config);
|
await this.persistence.saveConfig(this.config);
|
||||||
|
|
||||||
// Re-initialize to respect precedence
|
// Re-initialize to respect precedence
|
||||||
|
this.initialized = false;
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export type StorageType = 'file' | 'api' | 'auto';
|
|||||||
export interface RuntimeStorageConfig {
|
export interface RuntimeStorageConfig {
|
||||||
/** Storage backend type */
|
/** Storage backend type */
|
||||||
type: StorageType;
|
type: StorageType;
|
||||||
|
/** Base path for file storage (if configured) */
|
||||||
|
basePath?: string;
|
||||||
/** API endpoint for API storage (Hamster integration) */
|
/** API endpoint for API storage (Hamster integration) */
|
||||||
apiEndpoint?: string;
|
apiEndpoint?: string;
|
||||||
/** Access token for API authentication */
|
/** Access token for API authentication */
|
||||||
@@ -107,19 +109,15 @@ export interface RuntimeStorageConfig {
|
|||||||
export interface StorageSettings
|
export interface StorageSettings
|
||||||
extends Omit<RuntimeStorageConfig, 'apiConfigured'> {
|
extends Omit<RuntimeStorageConfig, 'apiConfigured'> {
|
||||||
/** Storage backend type - 'auto' detects based on auth status */
|
/** Storage backend type - 'auto' detects based on auth status */
|
||||||
type: 'file' | 'api' | 'auto';
|
type: StorageType;
|
||||||
/** Base path for file storage */
|
/** Base path for file storage */
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
/** API endpoint for API storage (Hamster integration) */
|
/**
|
||||||
apiEndpoint?: string;
|
* Indicates whether API is configured
|
||||||
/** Access token for API authentication */
|
|
||||||
apiAccessToken?: string;
|
|
||||||
/**
|
|
||||||
* Indicates whether API is configured (has endpoint or token)
|
|
||||||
* @computed Derived automatically from presence of apiEndpoint or apiAccessToken
|
* @computed Derived automatically from presence of apiEndpoint or apiAccessToken
|
||||||
* @internal Should not be set manually in user config - computed by ConfigManager
|
* @internal Should not be set manually in user config - computed by ConfigManager
|
||||||
*/
|
*/
|
||||||
apiConfigured?: boolean;
|
readonly apiConfigured?: boolean;
|
||||||
/** Enable automatic backups */
|
/** Enable automatic backups */
|
||||||
enableBackup: boolean;
|
enableBackup: boolean;
|
||||||
/** Maximum number of backups to retain */
|
/** Maximum number of backups to retain */
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ export class StorageFactory {
|
|||||||
storageConfig: RuntimeStorageConfig,
|
storageConfig: RuntimeStorageConfig,
|
||||||
projectPath: string
|
projectPath: string
|
||||||
): IStorage {
|
): IStorage {
|
||||||
// Wrap the storage config in the expected format
|
// Wrap the storage config in the expected format, including projectPath
|
||||||
|
// This ensures ApiStorage receives the projectPath for projectId
|
||||||
return StorageFactory.create(
|
return StorageFactory.create(
|
||||||
{ storage: storageConfig } as Partial<IConfiguration>,
|
{ storage: storageConfig, projectPath } as Partial<IConfiguration>,
|
||||||
projectPath
|
projectPath
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user