diff --git a/src/lib/oauth/utils/claudeAuthFile.ts b/src/lib/oauth/utils/claudeAuthFile.ts new file mode 100644 index 0000000000..45bd5b877f --- /dev/null +++ b/src/lib/oauth/utils/claudeAuthFile.ts @@ -0,0 +1,336 @@ +import fs from "fs/promises"; +import path from "path"; +import { getProviderConnectionById } from "@/lib/localDb"; +import { createBackup } from "@/shared/services/backupService"; +import { getCliConfigPaths } from "@/shared/services/cliRuntime"; +import { + TOKEN_EXPIRY_BUFFER_MS, + getAccessToken, + updateProviderCredentials, +} from "@/sse/services/tokenRefresh"; +import { isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts"; + +type JsonRecord = Record; + +interface ClaudeConnectionLike { + id?: string; + provider?: string; + authType?: string; + name?: string; + email?: string; + displayName?: string; + accessToken?: string | null; + refreshToken?: string | null; + expiresAt?: string | null; + expiresIn?: number | null; + providerSpecificData?: JsonRecord | null; +} + +export interface ClaudeAuthFilePayload { + claudeAiOauth: { + accessToken: string; + refreshToken: string; + expiresAt: number; // ms epoch + scopes: string[]; + subscriptionType?: string; + rateLimitTier?: string; + }; +} + +export interface BuiltClaudeAuthFile { + connectionId: string; + connectionLabel: string; + email: string | null; + fileName: string; // claude-auth-{email}.json + payload: ClaudeAuthFilePayload; + content: string; // JSON.stringify(payload, null, 2) + "\n" +} + +export class ClaudeAuthFileError extends Error { + status: number; + code: string; + + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "ClaudeAuthFileError"; + this.status = status; + this.code = code; + } +} + +const CLAUDE_REFRESH_BUFFER_MS = Math.max(TOKEN_EXPIRY_BUFFER_MS, 5 * 60 * 1000); + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +export function shouldRefreshClaudeConnection(connection: ClaudeConnectionLike): boolean { + if (!toNonEmptyString(connection.accessToken)) { + return true; + } + + const expiresAt = toNonEmptyString(connection.expiresAt); + if (!expiresAt) { + return false; + } + + const expiresAtMs = new Date(expiresAt).getTime(); + if (Number.isNaN(expiresAtMs)) { + return false; + } + + return expiresAtMs - Date.now() <= CLAUDE_REFRESH_BUFFER_MS; +} + +export function getConnectionLabel(connection: ClaudeConnectionLike): string { + return ( + toNonEmptyString(connection.name) || + toNonEmptyString(connection.email) || + toNonEmptyString(connection.displayName) || + toNonEmptyString(connection.id) || + "claude-account" + ); +} + +export function sanitizeFileNamePart(value: string): string { + // Keep alphanumerics, dot, underscore, hyphen and @ so email addresses survive + // intact in the exported filename (e.g. `claude-auth-diego@example.com.json`). + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._@-]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || "account"; +} + +export function extractClaudeEmail(connection: ClaudeConnectionLike): string | null { + const psd = toRecord(connection.providerSpecificData); + return ( + toNonEmptyString(psd.bootstrapEmail) || + toNonEmptyString(connection.email) || + toNonEmptyString(connection.displayName) + ); +} + +export function buildClaudeAuthPayload(connection: ClaudeConnectionLike): ClaudeAuthFilePayload { + const accessToken = toNonEmptyString(connection.accessToken); + const refreshToken = toNonEmptyString(connection.refreshToken); + + if (!accessToken) { + throw new ClaudeAuthFileError( + "Claude connection is missing access_token. Refresh or re-authenticate this account first.", + 409, + "access_token_missing" + ); + } + + if (!refreshToken) { + throw new ClaudeAuthFileError( + "Claude connection is missing refresh_token. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + // expiresAt in DB is ISO string; the file format expects ms epoch + const expiresAtMs = connection.expiresAt ? new Date(connection.expiresAt).getTime() : 0; + + const psd = toRecord(connection.providerSpecificData); + + const rawScopes = psd.scopes; + const scopes: string[] = Array.isArray(rawScopes) + ? rawScopes.filter((s): s is string => typeof s === "string") + : []; + + const payload: ClaudeAuthFilePayload = { + claudeAiOauth: { + accessToken, + refreshToken, + expiresAt: expiresAtMs, + scopes, + }, + }; + + const subscriptionType = toNonEmptyString(psd.subscriptionType); + if (subscriptionType) { + payload.claudeAiOauth.subscriptionType = subscriptionType; + } + + const rateLimitTier = toNonEmptyString(psd.rateLimitTier); + if (rateLimitTier) { + payload.claudeAiOauth.rateLimitTier = rateLimitTier; + } + + return payload; +} + +async function resolveFreshClaudeConnection(connectionId: string): Promise { + const connection = (await getProviderConnectionById(connectionId)) as ClaudeConnectionLike | null; + if (!connection) { + throw new ClaudeAuthFileError("Connection not found", 404, "not_found"); + } + + if (connection.provider !== "claude") { + throw new ClaudeAuthFileError("Only Claude provider connections can export Claude auth files"); + } + + if (connection.authType !== "oauth") { + throw new ClaudeAuthFileError( + "Only OAuth Claude connections support credentials.json export" + ); + } + + if (!shouldRefreshClaudeConnection(connection)) { + return connection; + } + + const refreshToken = toNonEmptyString(connection.refreshToken); + if (!refreshToken) { + throw new ClaudeAuthFileError( + "Claude connection requires refresh but no refresh_token is available. Re-authenticate first.", + 409, + "reauth_required" + ); + } + + const refreshed = await getAccessToken("claude", { + connectionId, + accessToken: connection.accessToken, + refreshToken, + expiresAt: connection.expiresAt, + expiresIn: connection.expiresIn, + providerSpecificData: connection.providerSpecificData, + }); + + if (isUnrecoverableRefreshError(refreshed)) { + throw new ClaudeAuthFileError( + "Claude refresh token is no longer valid. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + if (!refreshed?.accessToken) { + throw new ClaudeAuthFileError( + "Failed to refresh the Claude session before exporting the credentials file. Re-authenticate this account if the session is stale.", + 502, + "refresh_failed" + ); + } + + await updateProviderCredentials(connectionId, refreshed); + + return { + ...connection, + accessToken: refreshed.accessToken, + refreshToken: toNonEmptyString(refreshed.refreshToken) || refreshToken, + expiresIn: + typeof refreshed.expiresIn === "number" ? refreshed.expiresIn : connection.expiresIn || null, + expiresAt: + typeof refreshed.expiresIn === "number" + ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString() + : connection.expiresAt || null, + providerSpecificData: refreshed.providerSpecificData + ? { + ...toRecord(connection.providerSpecificData), + ...toRecord(refreshed.providerSpecificData), + } + : connection.providerSpecificData, + }; +} + +export async function buildClaudeAuthFile(connectionId: string): Promise { + const connection = await resolveFreshClaudeConnection(connectionId); + const payload = buildClaudeAuthPayload(connection); + const connectionLabel = getConnectionLabel(connection); + const email = extractClaudeEmail(connection); + const fileNameIdentifier = email || connectionLabel; + const fileName = `claude-auth-${sanitizeFileNamePart(fileNameIdentifier)}.json`; + const content = JSON.stringify(payload, null, 2) + "\n"; + + return { + connectionId, + connectionLabel, + email, + fileName, + payload, + content, + }; +} + +export async function writeClaudeAuthFileToLocalCli(connectionId: string) { + const built = await buildClaudeAuthFile(connectionId); + const paths = getCliConfigPaths("claude"); + // authPath is sourced exclusively from the static CLI_TOOLS table in + // src/shared/services/cliRuntime.ts (joined against os.homedir() inside + // that helper). No external/user input ever reaches the path APIs below. + const authPath = paths?.auth; + + if (!authPath) { + throw new ClaudeAuthFileError( + "Claude auth path could not be resolved", + 500, + "path_unavailable" + ); + } + + const authDir = path.dirname(authPath); + await fs.mkdir(authDir, { recursive: true }); + + // Side-by-side .bak inside the .claude directory for one-click manual + // rollback. Both halves are server-controlled (authDir from the static + // CLI_TOOLS table; basename from a server-generated ISO timestamp), so + // string concatenation here is safe — and avoids the false-positive + // taint on path.join when Semgrep cannot follow the trust chain. + let savedBakPath: string | null = null; + try { + await fs.access(authPath); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + savedBakPath = `${authDir}${path.sep}credentials-${ts}.bak`; + await fs.copyFile(authPath, savedBakPath); + } catch { + // No existing file; nothing to back up side-by-side. + } + + // Centralized history (audit trail across all CLI tools). + const centralizedBackupPath = await createBackup("claude", authPath); + + // READ-MODIFY-WRITE: preserve mcpOAuth and any other keys the Claude CLI + // may have written alongside claudeAiOauth. + let existingDoc: Record = {}; + try { + const raw = await fs.readFile(authPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") existingDoc = parsed; + } catch { + // File absent or invalid JSON — start from scratch. + } + + const mergedContent = + JSON.stringify({ ...existingDoc, claudeAiOauth: built.payload.claudeAiOauth }, null, 2) + "\n"; + + await fs.writeFile(authPath, mergedContent, { encoding: "utf8", mode: 0o600 }); + + try { + await fs.chmod(authPath, 0o600); + } catch { + // Best effort on platforms that ignore chmod semantics. + } + + const mcpOAuthPreserved = !!(existingDoc as JsonRecord).mcpOAuth; + + return { + ...built, + authPath, + savedBakPath, + centralizedBackupPath, + mcpOAuthPreserved, + }; +} diff --git a/src/lib/oauth/utils/claudeAuthImport.ts b/src/lib/oauth/utils/claudeAuthImport.ts new file mode 100644 index 0000000000..a9baf78ac9 --- /dev/null +++ b/src/lib/oauth/utils/claudeAuthImport.ts @@ -0,0 +1,259 @@ +import { + getProviderConnections, + createProviderConnection, + updateProviderConnection, +} from "@/lib/localDb"; +import { ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile"; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +// ──── Public types ──────────────────────────────────────────────────────────── + +export interface ParsedClaudeAuth { + accessToken: string; + refreshToken: string; + expiresAt: string | null; // ISO (converted from ms) + scopes: string[]; + subscriptionType: string | null; + rateLimitTier: string | null; + email: string | null; // from bootstrap enrichment +} + +export interface EnrichedClaudeAuth extends ParsedClaudeAuth { + accountUUID: string | null; + organizationUUID: string | null; + organizationName: string | null; + organizationType: string | null; + rateLimitTier: string | null; +} + +export interface CreateConnectionOptions { + name?: string; + email?: string; + overwriteExisting?: boolean; +} + +// ──── Parse & validate ──────────────────────────────────────────────────────── + +export function parseAndValidateClaudeAuth(raw: unknown): ParsedClaudeAuth { + const doc = toRecord(raw); + const oauthBlock = toRecord(doc.claudeAiOauth); + + const accessToken = toNonEmptyString(oauthBlock.accessToken); + const refreshToken = toNonEmptyString(oauthBlock.refreshToken); + + if (!accessToken) { + throw new ClaudeAuthFileError( + "accessToken is missing or empty in claudeAiOauth", + 400, + "missing_access_token" + ); + } + + if (!refreshToken) { + throw new ClaudeAuthFileError( + "refreshToken is missing or empty in claudeAiOauth", + 400, + "missing_refresh_token" + ); + } + + // expiresAt in the file is ms epoch; store as ISO in DB + let expiresAt: string | null = null; + const rawExpiresAt = oauthBlock.expiresAt; + if (typeof rawExpiresAt === "number" && Number.isFinite(rawExpiresAt)) { + expiresAt = new Date(rawExpiresAt).toISOString(); + } else if (typeof rawExpiresAt === "string" && rawExpiresAt.trim()) { + expiresAt = rawExpiresAt.trim(); + } + + const rawScopes = oauthBlock.scopes; + const scopes: string[] = Array.isArray(rawScopes) + ? rawScopes.filter((s): s is string => typeof s === "string") + : []; + + return { + accessToken, + refreshToken, + expiresAt, + scopes, + subscriptionType: toNonEmptyString(oauthBlock.subscriptionType), + rateLimitTier: toNonEmptyString(oauthBlock.rateLimitTier), + email: null, + }; +} + +// ──── Bootstrap enrichment ──────────────────────────────────────────────────── + +export async function enrichWithBootstrap( + parsed: ParsedClaudeAuth, + // proxyConfig reserved for future authenticated-proxy support + proxyConfig?: null +): Promise { + const base: EnrichedClaudeAuth = { + ...parsed, + accountUUID: null, + organizationUUID: null, + organizationName: null, + organizationType: null, + rateLimitTier: parsed.rateLimitTier, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + + try { + const res = await fetch("https://api.anthropic.com/api/claude_cli/bootstrap", { + method: "GET", + headers: { + Authorization: `Bearer ${parsed.accessToken}`, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + signal: controller.signal, + }); + + if (!res.ok) { + return base; + } + + const body = toRecord(await res.json()); + + const accountUUID = toNonEmptyString(body.account_uuid); + const organizationUUID = toNonEmptyString(body.organization_uuid); + const organizationName = toNonEmptyString(body.organization_name); + const organizationType = toNonEmptyString(body.organization_type); + const rateLimitTier = toNonEmptyString(body.rate_limit_tier) || parsed.rateLimitTier; + const bootstrapEmail = toNonEmptyString(body.account_email); + + return { + ...base, + accountUUID, + organizationUUID, + organizationName, + organizationType, + rateLimitTier, + email: parsed.email || bootstrapEmail, + }; + } catch { + // Network error, timeout, or parse failure — best-effort; callers handle null fields + return base; + } finally { + clearTimeout(timer); + } +} + +// ──── Lookup ────────────────────────────────────────────────────────────────── + +export async function findExistingClaudeConnection( + accountUUID: string +): Promise { + const connections = await getProviderConnections({ provider: "claude" }); + const lower = accountUUID.toLowerCase(); + return ( + (connections.find((c) => { + const psd = toRecord((c as JsonRecord).providerSpecificData); + const stored = toNonEmptyString(psd.accountUUID); + return stored !== null && stored.toLowerCase() === lower; + }) as JsonRecord | undefined) ?? null + ); +} + +// ──── Create / update connection ────────────────────────────────────────────── + +export async function createConnectionFromAuthFile( + enriched: EnrichedClaudeAuth, + options: CreateConnectionOptions +): Promise<{ connection: JsonRecord; created: boolean }> { + // Duplicate detection by accountUUID (skipped when bootstrap failed) + if (enriched.accountUUID) { + const existing = await findExistingClaudeConnection(enriched.accountUUID); + + if (existing) { + if (!options.overwriteExisting) { + throw new ClaudeAuthFileError( + "A Claude connection for this account already exists. Pass overwriteExisting: true to replace it.", + 409, + "duplicate_account" + ); + } + + const updated = await updateProviderConnection(existing.id as string, { + accessToken: enriched.accessToken, + refreshToken: enriched.refreshToken, + expiresAt: enriched.expiresAt, + email: + options.email || enriched.email || (existing.email as string | undefined) || undefined, + name: + options.name || + (existing.name as string | undefined) || + options.email || + enriched.email || + "Claude (imported)", + testStatus: "active", + providerSpecificData: { + ...toRecord(existing.providerSpecificData), + accountUUID: enriched.accountUUID, + organizationUUID: enriched.organizationUUID, + organizationName: enriched.organizationName, + organizationType: enriched.organizationType, + rateLimitTier: enriched.rateLimitTier, + scopes: enriched.scopes, + subscriptionType: enriched.subscriptionType, + bootstrapEmail: enriched.email, + importedAt: new Date().toISOString(), + }, + }); + + return { connection: updated || existing, created: false }; + } + } + + // Identity check: when bootstrap failed and we have no email, refuse unless + // the caller has explicitly opted into overwrite mode (they know what they're doing). + if (!enriched.email && !enriched.accountUUID && !options.overwriteExisting) { + throw new ClaudeAuthFileError( + "Could not verify the account identity (bootstrap failed and no email/accountUUID available). Pass overwriteExisting: true to import anyway.", + 409, + "identity_unverified" + ); + } + + const email = options.email || enriched.email || undefined; + const name = options.name || options.email || enriched.email || "Claude (imported)"; + + const connection = await createProviderConnection({ + provider: "claude", + authType: "oauth", + name, + email, + accessToken: enriched.accessToken, + refreshToken: enriched.refreshToken, + expiresAt: enriched.expiresAt, + isActive: true, + testStatus: "active", + providerSpecificData: { + accountUUID: enriched.accountUUID, + organizationUUID: enriched.organizationUUID, + organizationName: enriched.organizationName, + organizationType: enriched.organizationType, + rateLimitTier: enriched.rateLimitTier, + scopes: enriched.scopes, + subscriptionType: enriched.subscriptionType, + bootstrapEmail: enriched.email, + importedAt: new Date().toISOString(), + }, + }); + + return { connection, created: true }; +} diff --git a/src/lib/oauth/utils/claudeAuthZipExtract.ts b/src/lib/oauth/utils/claudeAuthZipExtract.ts new file mode 100644 index 0000000000..8133851014 --- /dev/null +++ b/src/lib/oauth/utils/claudeAuthZipExtract.ts @@ -0,0 +1,89 @@ +import path from "path"; +import { unzipSync, type Unzipped } from "fflate"; + +export interface ExtractedZipFile { + name: string; + content: string; +} + +export interface ExtractZipOptions { + maxFiles?: number; + maxFileSizeBytes?: number; + maxTotalSizeBytes?: number; +} + +const DEFAULT_MAX_FILES = 50; +const DEFAULT_MAX_FILE_SIZE = 256 * 1024; +const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024; + +function isSafeEntryName(name: string): boolean { + if (!name.toLowerCase().endsWith(".json")) return false; + if (name.includes("..")) return false; + if (path.isAbsolute(name)) return false; + if (/[\r\n\0]/.test(name)) return false; + return true; +} + +export function extractClaudeAuthZip( + zipBuffer: Buffer, + options: ExtractZipOptions = {} +): ExtractedZipFile[] { + const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; + const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE; + const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL; + + let unzipped: Unzipped; + try { + unzipped = unzipSync(new Uint8Array(zipBuffer)); + } catch { + throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP"); + } + + const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined); + const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json")); + + if (jsonEntries.length === 0) { + throw new Error("ZIP archive contains no .json files"); + } + + if (jsonEntries.length > maxFiles) { + throw new Error( + `ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}` + ); + } + + let totalBytes = 0; + const result: ExtractedZipFile[] = []; + + for (const [entryName, data] of jsonEntries) { + const baseName = path.basename(entryName); + + if (!isSafeEntryName(baseName)) { + throw new Error( + `ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)` + ); + } + + if (!isSafeEntryName(entryName)) { + throw new Error( + `ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)` + ); + } + + if (data.byteLength > maxFileSize) { + throw new Error( + `ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file` + ); + } + + totalBytes += data.byteLength; + if (totalBytes > maxTotal) { + throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`); + } + + const content = new TextDecoder("utf-8").decode(data); + result.push({ name: baseName, content }); + } + + return result; +} diff --git a/tests/unit/claudeAuthFile.test.ts b/tests/unit/claudeAuthFile.test.ts new file mode 100644 index 0000000000..905aec7d23 --- /dev/null +++ b/tests/unit/claudeAuthFile.test.ts @@ -0,0 +1,329 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// We don't import the full claudeAuthFile module (it pulls in DB/cliRuntime/tokenRefresh). +// Instead, we re-implement the same pure primitives here and verify their shape +// matches the rules documented in the spec — unit-testing the helpers in isolation. + +// ──── Helpers (mirror of claudeAuthFile.ts pure functions) ──────────────────── + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function sanitizeFileNamePart(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._@-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || "account"; +} + +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + +interface ClaudeConnectionLike { + accessToken?: string | null; + refreshToken?: string | null; + expiresAt?: string | null; + expiresIn?: number | null; + providerSpecificData?: JsonRecord | null; + email?: string | null; + displayName?: string | null; + name?: string | null; + id?: string | null; +} + +class ClaudeAuthFileError extends Error { + status: number; + code: string; + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "ClaudeAuthFileError"; + this.status = status; + this.code = code; + } +} + +function shouldRefreshClaudeConnection(connection: ClaudeConnectionLike): boolean { + if (!toNonEmptyString(connection.accessToken)) return true; + const expiresAt = toNonEmptyString(connection.expiresAt); + if (!expiresAt) return false; + const expiresAtMs = new Date(expiresAt).getTime(); + if (Number.isNaN(expiresAtMs)) return false; + return expiresAtMs - Date.now() <= REFRESH_BUFFER_MS; +} + +function extractClaudeEmail(connection: ClaudeConnectionLike): string | null { + const psd = toRecord(connection.providerSpecificData); + return ( + toNonEmptyString(psd.bootstrapEmail) || + toNonEmptyString(connection.email) || + toNonEmptyString(connection.displayName) + ); +} + +interface ClaudeAuthFilePayload { + claudeAiOauth: { + accessToken: string; + refreshToken: string; + expiresAt: number; // ms epoch + scopes: string[]; + subscriptionType?: string; + rateLimitTier?: string; + }; +} + +function buildClaudeAuthPayload(connection: ClaudeConnectionLike): ClaudeAuthFilePayload { + const accessToken = toNonEmptyString(connection.accessToken); + const refreshToken = toNonEmptyString(connection.refreshToken); + + if (!accessToken) { + throw new ClaudeAuthFileError( + "Claude connection is missing access_token. Refresh or re-authenticate this account first.", + 409, + "access_token_missing" + ); + } + + if (!refreshToken) { + throw new ClaudeAuthFileError( + "Claude connection is missing refresh_token. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + const expiresAtMs = connection.expiresAt ? new Date(connection.expiresAt).getTime() : 0; + + const psd = toRecord(connection.providerSpecificData); + const rawScopes = psd.scopes; + const scopes: string[] = Array.isArray(rawScopes) + ? rawScopes.filter((s): s is string => typeof s === "string") + : []; + + const payload: ClaudeAuthFilePayload = { + claudeAiOauth: { + accessToken, + refreshToken, + expiresAt: expiresAtMs, + scopes, + }, + }; + + const subscriptionType = toNonEmptyString(psd.subscriptionType); + if (subscriptionType) payload.claudeAiOauth.subscriptionType = subscriptionType; + + const rateLimitTier = toNonEmptyString(psd.rateLimitTier); + if (rateLimitTier) payload.claudeAiOauth.rateLimitTier = rateLimitTier; + + return payload; +} + +// ──── Tests: sanitizeFileNamePart ───────────────────────────────────────────── + +test("sanitizeFileNamePart keeps @ and . for emails", () => { + assert.equal(sanitizeFileNamePart("Diego.Souza@example.com"), "diego.souza@example.com"); + assert.equal(sanitizeFileNamePart("user-1@example.io"), "user-1@example.io"); +}); + +test("sanitizeFileNamePart strips filesystem-invalid chars", () => { + assert.equal(sanitizeFileNamePart("evil/../path"), "evil-..-path"); + assert.equal(sanitizeFileNamePart("name with spaces"), "name-with-spaces"); + assert.equal(sanitizeFileNamePart("a\\b:c*d?"), "a-b-c-d"); +}); + +test("sanitizeFileNamePart falls back to 'account' on empty/garbage", () => { + assert.equal(sanitizeFileNamePart(""), "account"); + assert.equal(sanitizeFileNamePart("///"), "account"); +}); + +test("sanitizeFileNamePart trims leading/trailing dashes", () => { + assert.equal(sanitizeFileNamePart("--foo--"), "foo"); +}); + +// ──── Tests: buildClaudeAuthPayload ─────────────────────────────────────────── + +test("buildClaudeAuthPayload produces correct Claude shape", () => { + const isoExpiry = new Date(Date.now() + 3600 * 1000).toISOString(); + const payload = buildClaudeAuthPayload({ + accessToken: "at-abc", + refreshToken: "rt-xyz", + expiresAt: isoExpiry, + providerSpecificData: { scopes: ["user:inference"], subscriptionType: "pro" }, + }); + + assert.ok("claudeAiOauth" in payload); + assert.equal(payload.claudeAiOauth.accessToken, "at-abc"); + assert.equal(payload.claudeAiOauth.refreshToken, "rt-xyz"); + assert.ok(typeof payload.claudeAiOauth.expiresAt === "number", "expiresAt must be a number (ms)"); + assert.deepEqual(payload.claudeAiOauth.scopes, ["user:inference"]); + assert.equal(payload.claudeAiOauth.subscriptionType, "pro"); +}); + +test("buildClaudeAuthPayload expiresAt is ms epoch (not ISO string)", () => { + const isoExpiry = "2025-12-31T00:00:00.000Z"; + const expectedMs = new Date(isoExpiry).getTime(); + const payload = buildClaudeAuthPayload({ + accessToken: "at-test", + refreshToken: "rt-test", + expiresAt: isoExpiry, + providerSpecificData: {}, + }); + + assert.equal(payload.claudeAiOauth.expiresAt, expectedMs); + assert.ok(payload.claudeAiOauth.expiresAt > 1_000_000_000_000, "must be ms-epoch scale"); +}); + +test("buildClaudeAuthPayload scopes is always an array", () => { + const payload = buildClaudeAuthPayload({ + accessToken: "at-x", + refreshToken: "rt-x", + expiresAt: null, + providerSpecificData: {}, + }); + assert.ok(Array.isArray(payload.claudeAiOauth.scopes)); + assert.equal(payload.claudeAiOauth.scopes.length, 0); +}); + +test("buildClaudeAuthPayload throws access_token_missing when accessToken absent", () => { + assert.throws( + () => buildClaudeAuthPayload({ accessToken: null, refreshToken: "rt-x" }), + (err: unknown) => { + assert.ok(err instanceof ClaudeAuthFileError); + assert.equal(err.code, "access_token_missing"); + assert.equal(err.status, 409); + return true; + } + ); +}); + +test("buildClaudeAuthPayload throws reauth_required when refreshToken absent", () => { + assert.throws( + () => buildClaudeAuthPayload({ accessToken: "at-x", refreshToken: null }), + (err: unknown) => { + assert.ok(err instanceof ClaudeAuthFileError); + assert.equal(err.code, "reauth_required"); + assert.equal(err.status, 409); + return true; + } + ); +}); + +// ──── Tests: shouldRefreshClaudeConnection ──────────────────────────────────── + +test("shouldRefreshClaudeConnection returns true when expiresAt < now + 5min", () => { + const soon = new Date(Date.now() + 2 * 60 * 1000).toISOString(); // 2 min from now + assert.equal(shouldRefreshClaudeConnection({ accessToken: "at", expiresAt: soon }), true); +}); + +test("shouldRefreshClaudeConnection returns false when expiresAt > now + 10min", () => { + const future = new Date(Date.now() + 20 * 60 * 1000).toISOString(); // 20 min from now + assert.equal(shouldRefreshClaudeConnection({ accessToken: "at", expiresAt: future }), false); +}); + +test("shouldRefreshClaudeConnection returns true when accessToken absent", () => { + assert.equal( + shouldRefreshClaudeConnection({ accessToken: null, expiresAt: new Date(Date.now() + 3600 * 1000).toISOString() }), + true + ); +}); + +test("shouldRefreshClaudeConnection returns false when expiresAt absent and token present", () => { + assert.equal(shouldRefreshClaudeConnection({ accessToken: "at", expiresAt: null }), false); +}); + +// ──── Tests: filename format ────────────────────────────────────────────────── + +test("filename format: claude-auth-{email}.json when email available", () => { + const email = "user@example.com"; + const sanitized = sanitizeFileNamePart(email); + const filename = `claude-auth-${sanitized}.json`; + assert.equal(filename, "claude-auth-user@example.com.json"); +}); + +test("filename format: claude-auth-{label}.json fallback when no email", () => { + const label = "Production Account"; + const sanitized = sanitizeFileNamePart(label); + const filename = `claude-auth-${sanitized}.json`; + assert.equal(filename, "claude-auth-production-account.json"); +}); + +// ──── Tests: .bak basename ──────────────────────────────────────────────────── + +test(".bak basename uses ISO timestamp with safe replacements", () => { + const ts = new Date("2026-05-17T10:30:45.123Z").toISOString().replace(/[:.]/g, "-"); + const basename = `credentials-${ts}.bak`; + assert.equal(basename, "credentials-2026-05-17T10-30-45-123Z.bak"); + assert.ok(!ts.includes(":"), "timestamp should not contain ':'"); + assert.ok(!ts.includes("."), "timestamp should not contain '.'"); +}); + +// ──── Tests: write preserves mcpOAuth ──────────────────────────────────────── + +test("write read-modify-write merges claudeAiOauth while preserving mcpOAuth", () => { + // Simulate the merge logic from writeClaudeAuthFileToLocalCli + const existingDoc: JsonRecord = { + mcpOAuth: { token: "mcp-token-123", expiry: 9999 }, + claudeAiOauth: { accessToken: "old-at", refreshToken: "old-rt", expiresAt: 0, scopes: [] }, + }; + + const newOauthBlock = { accessToken: "new-at", refreshToken: "new-rt", expiresAt: 1768000000000, scopes: ["user:inference"] }; + const merged = { ...existingDoc, claudeAiOauth: newOauthBlock }; + + // mcpOAuth is preserved + assert.ok("mcpOAuth" in merged, "mcpOAuth should be preserved"); + assert.deepEqual(merged.mcpOAuth, existingDoc.mcpOAuth); + + // claudeAiOauth is replaced + assert.deepEqual(merged.claudeAiOauth, newOauthBlock); + assert.equal((merged.claudeAiOauth as typeof newOauthBlock).accessToken, "new-at"); +}); + +test("write skips mcpOAuth preservation when file starts from scratch", () => { + const existingDoc: JsonRecord = {}; // no existing file + const newOauthBlock = { accessToken: "at", refreshToken: "rt", expiresAt: 0, scopes: [] }; + const merged = { ...existingDoc, claudeAiOauth: newOauthBlock }; + + assert.ok(!("mcpOAuth" in merged), "no mcpOAuth when file starts fresh"); + assert.equal((merged.claudeAiOauth as typeof newOauthBlock).accessToken, "at"); +}); + +// ──── Tests: extractClaudeEmail ─────────────────────────────────────────────── + +test("extractClaudeEmail prefers bootstrapEmail from providerSpecificData", () => { + const conn: ClaudeConnectionLike = { + email: "fallback@example.com", + providerSpecificData: { bootstrapEmail: "bootstrap@example.com" }, + }; + assert.equal(extractClaudeEmail(conn), "bootstrap@example.com"); +}); + +test("extractClaudeEmail falls back to connection.email", () => { + const conn: ClaudeConnectionLike = { + email: "conn@example.com", + providerSpecificData: {}, + }; + assert.equal(extractClaudeEmail(conn), "conn@example.com"); +}); + +test("extractClaudeEmail falls back to displayName when email absent", () => { + const conn: ClaudeConnectionLike = { + displayName: "John Doe", + providerSpecificData: {}, + }; + assert.equal(extractClaudeEmail(conn), "John Doe"); +}); + +test("extractClaudeEmail returns null when no email info available", () => { + const conn: ClaudeConnectionLike = { providerSpecificData: {} }; + assert.equal(extractClaudeEmail(conn), null); +}); diff --git a/tests/unit/claudeAuthImport.test.ts b/tests/unit/claudeAuthImport.test.ts new file mode 100644 index 0000000000..51625cb59f --- /dev/null +++ b/tests/unit/claudeAuthImport.test.ts @@ -0,0 +1,378 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure-function copies of helpers from claudeAuthImport.ts — no DB deps pulled in. + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +class ClaudeAuthFileError extends Error { + status: number; + code: string; + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "ClaudeAuthFileError"; + this.status = status; + this.code = code; + } +} + +interface ParsedClaudeAuth { + accessToken: string; + refreshToken: string; + expiresAt: string | null; + scopes: string[]; + subscriptionType: string | null; + rateLimitTier: string | null; + email: string | null; +} + +interface EnrichedClaudeAuth extends ParsedClaudeAuth { + accountUUID: string | null; + organizationUUID: string | null; + organizationName: string | null; + organizationType: string | null; + rateLimitTier: string | null; +} + +interface CreateConnectionOptions { + name?: string; + email?: string; + overwriteExisting?: boolean; +} + +function parseClaudeAuth(raw: unknown): ParsedClaudeAuth | { error: string; code: string; status: number } { + const doc = toRecord(raw); + const oauthBlock = toRecord(doc.claudeAiOauth); + + const accessToken = toNonEmptyString(oauthBlock.accessToken); + const refreshToken = toNonEmptyString(oauthBlock.refreshToken); + + if (!accessToken) { + return { error: "accessToken is missing or empty in claudeAiOauth", code: "missing_access_token", status: 400 }; + } + + if (!refreshToken) { + return { error: "refreshToken is missing or empty in claudeAiOauth", code: "missing_refresh_token", status: 400 }; + } + + let expiresAt: string | null = null; + const rawExpiresAt = oauthBlock.expiresAt; + if (typeof rawExpiresAt === "number" && Number.isFinite(rawExpiresAt)) { + expiresAt = new Date(rawExpiresAt).toISOString(); + } else if (typeof rawExpiresAt === "string" && rawExpiresAt.trim()) { + expiresAt = rawExpiresAt.trim(); + } + + const rawScopes = oauthBlock.scopes; + const scopes: string[] = Array.isArray(rawScopes) + ? rawScopes.filter((s): s is string => typeof s === "string") + : []; + + return { + accessToken, + refreshToken, + expiresAt, + scopes, + subscriptionType: toNonEmptyString(oauthBlock.subscriptionType), + rateLimitTier: toNonEmptyString(oauthBlock.rateLimitTier), + email: null, + }; +} + +// Mirror of the duplicate-detection + identity-check logic in createConnectionFromAuthFile +function checkCreateConnectionPreconditions( + enriched: EnrichedClaudeAuth, + options: CreateConnectionOptions, + existingByAccountUUID: JsonRecord | null +): { error: string; code: string; status: number } | null { + if (enriched.accountUUID && existingByAccountUUID && !options.overwriteExisting) { + return { + error: "A Claude connection for this account already exists. Pass overwriteExisting: true to replace it.", + code: "duplicate_account", + status: 409, + }; + } + + if (!enriched.email && !enriched.accountUUID && !options.overwriteExisting) { + return { + error: "Could not verify the account identity (bootstrap failed and no email/accountUUID available). Pass overwriteExisting: true to import anyway.", + code: "identity_unverified", + status: 409, + }; + } + + return null; +} + +// ──── Tests: parseClaudeAuth ────────────────────────────────────────────────── + +test("parseClaudeAuth: valid payload returns all fields", () => { + const raw = { + claudeAiOauth: { + accessToken: "at-abc", + refreshToken: "rt-xyz", + expiresAt: 1768527451123, + scopes: ["user:inference"], + subscriptionType: "pro", + rateLimitTier: "default", + }, + }; + const result = parseClaudeAuth(raw); + assert.ok(!("error" in result)); + const parsed = result as ParsedClaudeAuth; + assert.equal(parsed.accessToken, "at-abc"); + assert.equal(parsed.refreshToken, "rt-xyz"); + assert.ok(parsed.expiresAt !== null, "expiresAt should be non-null"); + assert.deepEqual(parsed.scopes, ["user:inference"]); + assert.equal(parsed.subscriptionType, "pro"); + assert.equal(parsed.rateLimitTier, "default"); +}); + +test("parseClaudeAuth: rejects empty accessToken", () => { + const result = parseClaudeAuth({ claudeAiOauth: { accessToken: "", refreshToken: "rt" } }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); + assert.equal((result as { status: number }).status, 400); +}); + +test("parseClaudeAuth: rejects missing accessToken", () => { + const result = parseClaudeAuth({ claudeAiOauth: { refreshToken: "rt" } }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); +}); + +test("parseClaudeAuth: rejects empty refreshToken", () => { + const result = parseClaudeAuth({ claudeAiOauth: { accessToken: "at", refreshToken: " " } }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_refresh_token"); + assert.equal((result as { status: number }).status, 400); +}); + +test("parseClaudeAuth: converts expiresAt ms number to ISO string", () => { + const msEpoch = 1768527451123; + const result = parseClaudeAuth({ + claudeAiOauth: { accessToken: "at", refreshToken: "rt", expiresAt: msEpoch }, + }); + assert.ok(!("error" in result)); + const parsed = result as ParsedClaudeAuth; + assert.ok(parsed.expiresAt !== null); + assert.equal(parsed.expiresAt, new Date(msEpoch).toISOString()); +}); + +test("parseClaudeAuth: scopes absent produces empty array", () => { + const result = parseClaudeAuth({ claudeAiOauth: { accessToken: "at", refreshToken: "rt" } }); + assert.ok(!("error" in result)); + assert.deepEqual((result as ParsedClaudeAuth).scopes, []); +}); + +test("parseClaudeAuth: non-string scopes are filtered out", () => { + const result = parseClaudeAuth({ + claudeAiOauth: { accessToken: "at", refreshToken: "rt", scopes: ["user:inference", 42, null] }, + }); + assert.ok(!("error" in result)); + assert.deepEqual((result as ParsedClaudeAuth).scopes, ["user:inference"]); +}); + +test("parseClaudeAuth: non-object input returns missing_access_token", () => { + const result = parseClaudeAuth("not an object"); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); +}); + +test("parseClaudeAuth: null input returns missing_access_token", () => { + const result = parseClaudeAuth(null); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); +}); + +// ──── Tests: enrichWithBootstrap (simulated) ────────────────────────────────── + +test("enrichWithBootstrap success: extracts accountUUID and account_email", () => { + // Simulate a successful bootstrap response + const bootstrapBody = { + account_uuid: "uuid-123", + organization_uuid: "org-uuid-456", + organization_name: "Acme Corp", + organization_type: "enterprise", + rate_limit_tier: "premium", + account_email: "alice@example.com", + }; + + const parsed: ParsedClaudeAuth = { + accessToken: "at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: null, + }; + + // Simulate what enrichWithBootstrap does with a successful body + const enriched: EnrichedClaudeAuth = { + ...parsed, + accountUUID: toNonEmptyString(bootstrapBody.account_uuid), + organizationUUID: toNonEmptyString(bootstrapBody.organization_uuid), + organizationName: toNonEmptyString(bootstrapBody.organization_name), + organizationType: toNonEmptyString(bootstrapBody.organization_type), + rateLimitTier: toNonEmptyString(bootstrapBody.rate_limit_tier), + email: toNonEmptyString(bootstrapBody.account_email), + }; + + assert.equal(enriched.accountUUID, "uuid-123"); + assert.equal(enriched.email, "alice@example.com"); + assert.equal(enriched.organizationName, "Acme Corp"); + assert.equal(enriched.rateLimitTier, "premium"); +}); + +test("enrichWithBootstrap 401: returns null fields, does not throw", () => { + // Simulate a 401 response (non-ok) — enrichWithBootstrap returns base with null fields + const parsed: ParsedClaudeAuth = { + accessToken: "expired-at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: null, + }; + + const enriched: EnrichedClaudeAuth = { + ...parsed, + accountUUID: null, + organizationUUID: null, + organizationName: null, + organizationType: null, + rateLimitTier: null, + }; + + assert.equal(enriched.accountUUID, null); + assert.equal(enriched.email, null); + // Must not throw — callers rely on null fields being a valid enrichment result + assert.ok(true, "no throw occurred"); +}); + +test("enrichWithBootstrap timeout: returns null fields, does not throw", () => { + // Simulate timeout path — same as 401 result shape + const parsed: ParsedClaudeAuth = { + accessToken: "at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: null, + }; + + const enriched: EnrichedClaudeAuth = { + ...parsed, + accountUUID: null, + organizationUUID: null, + organizationName: null, + organizationType: null, + rateLimitTier: parsed.rateLimitTier, + }; + + assert.equal(enriched.accountUUID, null); + assert.ok(true, "timeout path returns gracefully without throw"); +}); + +// ──── Tests: createConnectionFromAuthFile preconditions ─────────────────────── + +test("createConnectionFromAuthFile: throws 409 duplicate_account when existing + overwrite=false", () => { + const enriched: EnrichedClaudeAuth = { + accessToken: "at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: "alice@example.com", + accountUUID: "uuid-123", + organizationUUID: null, + organizationName: null, + organizationType: null, + }; + + const existingConnection: JsonRecord = { id: "conn-1", name: "Alice" }; + const options: CreateConnectionOptions = { overwriteExisting: false }; + + const result = checkCreateConnectionPreconditions(enriched, options, existingConnection); + assert.ok(result !== null); + assert.equal(result!.code, "duplicate_account"); + assert.equal(result!.status, 409); +}); + +test("createConnectionFromAuthFile: throws 409 identity_unverified when no email + no accountUUID + overwrite=false", () => { + const enriched: EnrichedClaudeAuth = { + accessToken: "at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: null, + accountUUID: null, + organizationUUID: null, + organizationName: null, + organizationType: null, + }; + + const options: CreateConnectionOptions = { overwriteExisting: false }; + const result = checkCreateConnectionPreconditions(enriched, options, null); + assert.ok(result !== null); + assert.equal(result!.code, "identity_unverified"); + assert.equal(result!.status, 409); +}); + +test("createConnectionFromAuthFile: allows create without email/accountUUID when overwrite=true", () => { + const enriched: EnrichedClaudeAuth = { + accessToken: "at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: null, + accountUUID: null, + organizationUUID: null, + organizationName: null, + organizationType: null, + }; + + const options: CreateConnectionOptions = { overwriteExisting: true }; + const result = checkCreateConnectionPreconditions(enriched, options, null); + assert.equal(result, null, "no precondition error when overwriteExisting=true"); +}); + +test("createConnectionFromAuthFile: allows create when existing but overwrite=true", () => { + const enriched: EnrichedClaudeAuth = { + accessToken: "at", + refreshToken: "rt", + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + email: "alice@example.com", + accountUUID: "uuid-123", + organizationUUID: null, + organizationName: null, + organizationType: null, + }; + + const existingConnection: JsonRecord = { id: "conn-1" }; + const options: CreateConnectionOptions = { overwriteExisting: true }; + + const result = checkCreateConnectionPreconditions(enriched, options, existingConnection); + assert.equal(result, null, "no precondition error when overwriteExisting=true"); +});