Files
Codewalkers/apps/server/agent/credentials/default-credential-manager.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

331 lines
9.4 KiB
TypeScript

/**
* Default Account Credential Manager
*
* File-based adapter implementing AccountCredentialManager port.
* Reads/writes credentials from ~/.cw/accounts/<uuid>/.credentials.json
* and emits events on credential state changes.
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
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.
*/
read(configDir: string): OAuthCredentials | null {
try {
const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null;
const raw = readFileSync(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<RefreshResult | null> {
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.
*/
write(
configDir: string,
accessToken: string,
refreshToken: string,
expiresIn: number,
): void {
const credPath = join(configDir, '.credentials.json');
// Read existing credentials to preserve other fields
let existing: Record<string, unknown> = {};
try {
if (existsSync(credPath)) {
existing = JSON.parse(readFileSync(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<string, unknown>) ?? {};
claudeAiOauth.accessToken = accessToken;
claudeAiOauth.refreshToken = refreshToken;
claudeAiOauth.expiresAt = expiresAt;
existing.claudeAiOauth = claudeAiOauth;
// Ensure directory exists
mkdirSync(dirname(credPath), { recursive: true });
// Write back (compact JSON for consistency)
writeFileSync(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<CredentialValidationResult> {
const credentials = 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;
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 = this.read(configDir);
return {
valid: true,
credentials: updatedCredentials,
error: null,
refreshed: true,
};
}
/**
* Validate credentials without attempting refresh.
*/
async validate(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
const credentials = 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);
}
}