/** * Default Account Credential Manager * * File-based adapter implementing AccountCredentialManager port. * Reads/writes credentials from ~/.cw/accounts//.credentials.json * and emits events on credential state changes. */ import { existsSync } from 'node:fs'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import type { EventBus } from '../../events/index.js'; import type { AccountCredentialManager, OAuthCredentials, RefreshResult, CredentialValidationResult, } from './types.js'; import type { AccountCredentialsRefreshedEvent, AccountCredentialsExpiredEvent, AccountCredentialsValidatedEvent, } from '../../events/types.js'; import { createModuleLogger } from '../../logger/index.js'; const log = createModuleLogger('credential-manager'); /** Anthropic OAuth token refresh endpoint */ const TOKEN_REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token'; /** OAuth client ID for Claude CLI */ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; /** Buffer before expiry to trigger refresh (5 minutes) */ const TOKEN_REFRESH_BUFFER_MS = 300_000; /** * DefaultAccountCredentialManager - File-based credential management with event emission. * * Implements the AccountCredentialManager port for managing OAuth credentials * stored in account config directories. */ export class DefaultAccountCredentialManager implements AccountCredentialManager { constructor(private eventBus?: EventBus) {} /** * Read credentials from a config directory. */ async read(configDir: string): Promise { try { const credPath = join(configDir, '.credentials.json'); if (!existsSync(credPath)) return null; const raw = await readFile(credPath, 'utf-8'); const parsed = JSON.parse(raw); const oauth = parsed.claudeAiOauth; if (!oauth || !oauth.accessToken) return null; return { accessToken: oauth.accessToken, refreshToken: oauth.refreshToken ?? null, expiresAt: oauth.expiresAt ?? null, subscriptionType: oauth.subscriptionType ?? null, rateLimitTier: oauth.rateLimitTier ?? null, }; } catch { return null; } } /** * Check if credentials are expired or about to expire. */ isExpired(credentials: OAuthCredentials): boolean { if (!credentials.expiresAt) return false; // Setup tokens without expiry are treated as non-expired return credentials.expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS; } /** * Refresh an access token using the refresh token. */ async refresh(configDir: string, refreshToken: string): Promise { try { const response = await fetch(TOKEN_REFRESH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: OAUTH_CLIENT_ID, scope: 'user:inference user:profile', }), }); if (!response.ok) { log.warn({ configDir, status: response.status }, 'token refresh failed'); return null; } const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, }; } catch (err) { log.error({ configDir, err: err instanceof Error ? err.message : String(err) }, 'token refresh error'); return null; } } /** * Write updated credentials to the config directory. */ async write( configDir: string, accessToken: string, refreshToken: string, expiresIn: number, ): Promise { const credPath = join(configDir, '.credentials.json'); // Read existing credentials to preserve other fields let existing: Record = {}; try { if (existsSync(credPath)) { existing = JSON.parse(await readFile(credPath, 'utf-8')); } } catch { // Start fresh if can't read } // Calculate expiry in milliseconds const nowMs = Date.now(); const expiresAt = nowMs + expiresIn * 1000; // Update claudeAiOauth section const claudeAiOauth = (existing.claudeAiOauth as Record) ?? {}; claudeAiOauth.accessToken = accessToken; claudeAiOauth.refreshToken = refreshToken; claudeAiOauth.expiresAt = expiresAt; existing.claudeAiOauth = claudeAiOauth; // Ensure directory exists await mkdir(dirname(credPath), { recursive: true }); // Write back (compact JSON for consistency) await writeFile(credPath, JSON.stringify(existing)); log.debug({ configDir }, 'credentials written after token refresh'); } /** * Ensure credentials are valid, refreshing if needed. */ async ensureValid(configDir: string, accountId?: string): Promise { const credentials = await this.read(configDir); if (!credentials) { log.warn({ configDir, accountId }, 'no credentials found'); this.emitExpired(accountId, 'credentials_missing', 'Credentials file not found'); return { valid: false, credentials: null, error: 'Credentials file not found', refreshed: false, }; } if (!this.isExpired(credentials)) { log.debug({ configDir, accountId }, 'credentials valid, no refresh needed'); this.emitValidated(accountId, true, credentials.expiresAt, false); return { valid: true, credentials, error: null, refreshed: false, }; } // Credentials expired — attempt refresh if we have a refresh token if (!credentials.refreshToken) { log.warn({ configDir, accountId }, 'setup token expired, no refresh token available'); this.emitExpired(accountId, 'token_expired', 'Setup token expired, no refresh token available'); return { valid: false, credentials: null, error: 'Setup token expired, no refresh token available', refreshed: false, }; } log.info({ configDir, accountId }, 'credentials expired, refreshing'); const previousExpiresAt = credentials.expiresAt; const refreshed = await this.refresh(configDir, credentials.refreshToken); if (!refreshed) { log.error({ configDir, accountId }, 'failed to refresh credentials'); this.emitExpired(accountId, 'refresh_failed', 'Token refresh failed'); return { valid: false, credentials: null, error: 'Token refresh failed', refreshed: false, }; } // Write refreshed credentials const newRefreshToken = refreshed.refreshToken || credentials.refreshToken; await this.write(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn); const newExpiresAt = Date.now() + refreshed.expiresIn * 1000; log.info({ configDir, accountId, expiresIn: refreshed.expiresIn }, 'credentials refreshed'); this.emitRefreshed(accountId, newExpiresAt, previousExpiresAt); this.emitValidated(accountId, true, newExpiresAt, true); // Read back updated credentials const updatedCredentials = await this.read(configDir); return { valid: true, credentials: updatedCredentials, error: null, refreshed: true, }; } /** * Validate credentials without attempting refresh. */ async validate(configDir: string, accountId?: string): Promise { const credentials = await this.read(configDir); if (!credentials) { this.emitValidated(accountId, false, null, false); return { valid: false, credentials: null, error: 'Credentials file not found', refreshed: false, }; } const expired = this.isExpired(credentials); this.emitValidated(accountId, !expired, credentials.expiresAt, false); if (expired) { return { valid: false, credentials, error: 'Token expired', refreshed: false, }; } return { valid: true, credentials, error: null, refreshed: false, }; } /** * Emit credentials refreshed event. */ private emitRefreshed( accountId: string | undefined, expiresAt: number, previousExpiresAt: number | null, ): void { if (!this.eventBus) return; const event: AccountCredentialsRefreshedEvent = { type: 'account:credentials_refreshed', timestamp: new Date(), payload: { accountId: accountId ?? null, expiresAt, previousExpiresAt, }, }; this.eventBus.emit(event); } /** * Emit credentials expired event. */ private emitExpired( accountId: string | undefined, reason: 'token_expired' | 'refresh_failed' | 'credentials_missing', error: string | null, ): void { if (!this.eventBus) return; const event: AccountCredentialsExpiredEvent = { type: 'account:credentials_expired', timestamp: new Date(), payload: { accountId: accountId ?? null, reason, error, }, }; this.eventBus.emit(event); } /** * Emit credentials validated event. */ private emitValidated( accountId: string | undefined, valid: boolean, expiresAt: number | null, wasRefreshed: boolean, ): void { if (!this.eventBus) return; const event: AccountCredentialsValidatedEvent = { type: 'account:credentials_validated', timestamp: new Date(), payload: { accountId: accountId ?? null, valid, expiresAt, wasRefreshed, }, }; this.eventBus.emit(event); } }