diff --git a/.changeset/refresh-claude-oauth.md b/.changeset/refresh-claude-oauth.md new file mode 100644 index 0000000000..90d8b743a7 --- /dev/null +++ b/.changeset/refresh-claude-oauth.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Refresh expired Claude OAuth access tokens from Fusion auth storage instead of requiring repeated manual re-login. diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index bb51b2c341..ea17ee0ab1 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -563,8 +563,9 @@ vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); return createCliEngineMock(() => importOriginal(), { - ProjectEngine: mocks.projectEngineCtor, - ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { + createFusionAuthStorage: vi.fn(() => mocks.authStorage), + ProjectEngine: mocks.projectEngineCtor, + ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { const engines = new Map(); return { startAll: vi.fn(async () => { diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 37dc7badfd..e39b42c005 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -689,6 +689,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { // Keep real WorktreePool & AgentSemaphore WorktreePool: original.WorktreePool, AgentSemaphore: original.AgentSemaphore, + createFusionAuthStorage: vi.fn(() => mockAuthStorage), // Stub heavy classes/functions ProjectEngine, ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => { @@ -3137,9 +3138,9 @@ describe("runDashboard — merge stream sink routing", () => { resetGitHubMocks(); process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core"); - const { aiMergeTask } = await import("@fusion/engine"); + const { aiMergeTask, createFusionAuthStorage } = await import("@fusion/engine"); const { createServer } = await import("@fusion/dashboard"); - const { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@earendil-works/pi-coding-agent"); + const { DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@earendil-works/pi-coding-agent"); (TaskStore as unknown as ReturnType).mockImplementation(() => makeMockStore()); (AutomationStore as unknown as ReturnType).mockImplementation(() => ({ @@ -3168,7 +3169,7 @@ describe("runDashboard — merge stream sink routing", () => { listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), })); - (AuthStorage.create as unknown as ReturnType).mockReturnValue({ + (createFusionAuthStorage as unknown as ReturnType).mockReturnValue({ getApiKey: vi.fn().mockResolvedValue(undefined), getAuth: vi.fn(), setAuth: vi.fn(), diff --git a/packages/cli/src/commands/__tests__/onboard.test.ts b/packages/cli/src/commands/__tests__/onboard.test.ts index 6c5bdd8464..ade041b23c 100644 --- a/packages/cli/src/commands/__tests__/onboard.test.ts +++ b/packages/cli/src/commands/__tests__/onboard.test.ts @@ -35,19 +35,17 @@ class MockCentralCore { vi.mock("../init.js", () => ({ runInit: mockRunInit })); vi.mock("../project-context.js", () => ({ resolveProject: mockResolveProject })); vi.mock("../provider-auth.js", () => ({ - createReadOnlyAuthFileStorage: vi.fn(() => ({})), - mergeAuthStorageReads: vi.fn((primary) => primary), wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()), })); vi.mock("../auth-paths.js", () => ({ - getFusionAuthPath: vi.fn(() => "/tmp/auth.json"), - getLegacyAuthPaths: vi.fn(() => []), getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"), })); vi.mock("@earendil-works/pi-coding-agent", () => ({ - AuthStorage: { create: vi.fn(() => ({})) }, ModelRegistry: { create: vi.fn(() => ({})) }, })); +vi.mock("@fusion/engine", () => ({ + createFusionAuthStorage: vi.fn(() => ({})), +})); vi.mock("@fusion/core", () => ({ CentralCore: MockCentralCore, GlobalSettingsStore: MockGlobalSettingsStore, diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 29114ab488..d8c7d8390b 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -625,8 +625,9 @@ vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); return createCliEngineMock(() => importOriginal(), { - ProjectEngine: mocks.projectEngineCtor, - ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { + createFusionAuthStorage: vi.fn(() => mocks.authStorage), + ProjectEngine: mocks.projectEngineCtor, + ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { const engines = new Map(); return { startAll: vi.fn(async () => { diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 97dda060aa..c709dfd695 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -32,9 +32,9 @@ import { HybridExecutor, shouldUseHybridExecutor, setHostExtensionPaths, + createFusionAuthStorage, } from "@fusion/engine"; import { - AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, @@ -71,8 +71,8 @@ import { setCachedLlamaCppResolution, } from "./llama-cpp-extension.js"; import { resolveSelfExtension } from "./self-extension.js"; -import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; @@ -546,16 +546,10 @@ export async function runDaemon(opts: DaemonOptions = {}) { const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop(); const automationStore = primaryEngine.getAutomationStore(); - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage([ - ...getLegacyAuthPaths(), - getCodexCliAuthPath(), - ...getClaudeCodeCredentialPaths(), - ]); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); - const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails let packageManager: DefaultPackageManager | undefined; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index b6ea752456..0e31c717d3 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -51,8 +51,9 @@ import { HybridExecutor, shouldUseHybridExecutor, setHostExtensionPaths, + createFusionAuthStorage, } from "@fusion/engine"; -import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent"; +import { DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent"; import { getMergeStrategy, getTaskBranchName, @@ -65,8 +66,8 @@ import { import { promptForPort } from "./port-prompt.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; -import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureClaudeSkillsForAllProjectsOnStartup, @@ -1363,16 +1364,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // ModelRegistry discovers available models from configured providers. // Passing these to createServer enables the dashboard's Authentication // tab (login/logout) and Model selector. - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage([ - ...getLegacyAuthPaths(), - getCodexCliAuthPath(), - ...getClaudeCodeCredentialPaths(), - ]); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); + /* + FNXC:AuthRefresh 2026-06-13-22:46: + Dashboard status polling, model discovery, and execution-facing auth reads must share the engine auth store so expired Claude OAuth credentials refresh once and legacy Claude/Codex credentials keep working. + */ + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions")); - const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails. // packageManager.resolve() walks installed npm/git/local pi packages and is diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 7c4bdb2a04..ece6370d5a 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,19 +1,12 @@ import { existsSync } from "node:fs"; import { createInterface } from "node:readline"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core"; +import { createFusionAuthStorage } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; import { runInit } from "./init.js"; -import { - createReadOnlyAuthFileStorage, - mergeAuthStorageReads, - wrapAuthStorageWithApiKeyProviders, -} from "./provider-auth.js"; -import { - getFusionAuthPath, - getLegacyAuthPaths, - getModelRegistryModelsPath, -} from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath } from "./auth-paths.js"; export interface OnboardOptions { force?: boolean; @@ -186,11 +179,9 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { } } - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths()); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); - const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); + const providerAuth = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); await runSkippableStep(prompts, "AI provider setup", async () => { const apiProviders = providerAuth.getApiKeyProviders(); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index be332a35d3..f53430595b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -32,9 +32,9 @@ import { HybridExecutor, shouldUseHybridExecutor, setHostExtensionPaths, + createFusionAuthStorage, } from "@fusion/engine"; import { - AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, @@ -51,8 +51,8 @@ import { } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; -import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureClaudeSkillsForAllProjectsOnStartup, @@ -596,16 +596,10 @@ export async function runServe( const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop(); const automationStore = primaryEngine.getAutomationStore(); - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage([ - ...getLegacyAuthPaths(), - getCodexCliAuthPath(), - ...getClaudeCodeCredentialPaths(), - ]); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); - const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails let packageManager: DefaultPackageManager | undefined; diff --git a/packages/core/src/__tests__/oauth-credential-interop.test.ts b/packages/core/src/__tests__/oauth-credential-interop.test.ts index c3a1768307..37adab7f9d 100644 --- a/packages/core/src/__tests__/oauth-credential-interop.test.ts +++ b/packages/core/src/__tests__/oauth-credential-interop.test.ts @@ -94,6 +94,7 @@ describe("oauth credential interop", () => { accessToken: "claude-access", refreshToken: "claude-refresh", expiresAt: Date.now() + 3600_000, + scopes: ["user:profile", "org:create_api_key"], }, }); @@ -102,6 +103,7 @@ describe("oauth credential interop", () => { access: "claude-access", refresh: "claude-refresh", expires: expect.any(Number), + scopes: ["user:profile", "org:create_api_key"], }); }); diff --git a/packages/core/src/oauth-credential-interop.ts b/packages/core/src/oauth-credential-interop.ts index 6394f321a9..4573411cad 100644 --- a/packages/core/src/oauth-credential-interop.ts +++ b/packages/core/src/oauth-credential-interop.ts @@ -8,6 +8,7 @@ export type StoredAuthCredential = { access?: string; refresh?: string; expires?: number; + scopes?: string[]; accountId?: string; [key: string]: unknown; }; @@ -235,6 +236,9 @@ export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCreden const refresh = typeof oauthRecord.refreshToken === "string" ? oauthRecord.refreshToken : undefined; const expiresRaw = oauthRecord.expiresAt; const expires = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) ? expiresRaw : undefined; + const scopes = Array.isArray(oauthRecord.scopes) + ? oauthRecord.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0) + : undefined; if (!access || !refresh || expires === undefined) { return undefined; @@ -245,6 +249,7 @@ export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCreden access, refresh, expires, + ...(scopes && scopes.length > 0 ? { scopes } : {}), }; } diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index efb3025ea1..e392079ce7 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -544,6 +544,7 @@ function createMockAuthStorage(overrides: Partial = {}): AuthSt ]), hasAuth: vi.fn().mockReturnValue(false), get: vi.fn().mockReturnValue(undefined), + getApiKey: vi.fn().mockResolvedValue(undefined), login: vi.fn().mockImplementation((_provider: string, callbacks: any) => { // Simulate onAuth callback with a URL, then resolve callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" }); @@ -604,6 +605,7 @@ describe("GET /auth/status", () => { vi.mocked(authStorage.getOAuthProviders).mockReset(); vi.mocked(authStorage.hasAuth).mockReset(); vi.mocked(authStorage.get).mockReset(); + vi.mocked(authStorage.getApiKey).mockReset(); vi.mocked(authStorage.login).mockReset(); vi.mocked(authStorage.getApiKeyProviders).mockReset(); vi.mocked(authStorage.hasApiKey).mockReset(); @@ -614,6 +616,7 @@ describe("GET /auth/status", () => { vi.mocked(authStorage.getOAuthProviders).mockReturnValue([{ id: "github-copilot", name: "GitHub Copilot" }]); vi.mocked(authStorage.hasAuth).mockReturnValue(false); vi.mocked(authStorage.get).mockReturnValue(undefined); + vi.mocked(authStorage.getApiKey).mockResolvedValue(undefined); vi.mocked(authStorage.login).mockImplementation((_provider: string, callbacks: any) => { callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" }); return Promise.resolve(); @@ -760,6 +763,54 @@ describe("GET /auth/status", () => { expect(geminiOauth).toMatchObject({ authenticated: false, expired: false }); }); + it("attempts async refresh for expired oauth before reporting status", async () => { + const now = Date.now(); + let refreshed = false; + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.get as ReturnType).mockImplementation(() => ({ + type: "oauth", + access: refreshed ? "refreshed-token" : "expired-token", + refresh: "refresh", + expires: refreshed ? now + 3_600_000 : now - 1_000, + })); + (authStorage.getApiKey as ReturnType).mockImplementation(async () => { + refreshed = true; + return "refreshed-token"; + }); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic"); + expect(anthropic).toMatchObject({ authenticated: true, expired: false }); + }); + + it("keeps expired oauth status when async refresh fails", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.get as ReturnType).mockReturnValue({ + type: "oauth", + access: "expired-token", + refresh: "refresh", + expires: now - 1_000, + }); + (authStorage.getApiKey as ReturnType).mockRejectedValue(new Error("refresh failed")); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic"); + expect(anthropic).toMatchObject({ authenticated: false, expired: true }); + }); + it("reports loginInProgress for oauth providers with active logins", async () => { let releaseLogin: (() => void) | undefined; (authStorage.login as ReturnType).mockImplementation( diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 1b251952e8..d2bad8e590 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -271,9 +271,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { keyHint?: string; loginInProgress?: boolean; requiresManualCode?: boolean; - }[] = oauthProviders.map((p) => { - const hasAuth = storage.hasAuth(p.id); - const expired = hasAuth && isExpiredOauthCredential(p.id, storage); + }[] = await Promise.all(oauthProviders.map(async (p) => { + let hasAuth = storage.hasAuth(p.id); + let expired = hasAuth && isExpiredOauthCredential(p.id, storage); + if (expired && storage.getApiKey) { + /* + FNXC:ClaudeOAuth 2026-06-13-22:46: + The auth status poll should clear a Claude re-login banner after Fusion refreshes a stored OAuth token, without waiting for a separate model request to touch auth storage. + Keep this best-effort so providers without refresh support still report expired and ask the user to re-authenticate. + */ + try { + await storage.getApiKey(p.id); + } catch { + // Best-effort refresh only; preserve the expired status below. + } + hasAuth = storage.hasAuth(p.id); + expired = hasAuth && isExpiredOauthCredential(p.id, storage); + } return { id: p.id, name: p.name, @@ -283,7 +297,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { loginInProgress: loginInProgress.has(p.id), requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined, }; - }); + })); // Include API-key-backed providers if supported if (storage.getApiKeyProviders) { diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 2f80035c1c..b6a9858bbc 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -20,6 +20,7 @@ function createJwt(payload: Record): string { describe("createFusionAuthStorage", () => { // HOME override required — createFusionAuthStorage() has no dir parameter const originalHome = process.env.HOME; + const originalFetch = globalThis.fetch; let homeDir: string; beforeEach(async () => { @@ -37,6 +38,8 @@ describe("createFusionAuthStorage", () => { } else { process.env.HOME = originalHome; } + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); }); it("writes to Fusion auth and reads legacy Pi auth as fallback", async () => { @@ -165,6 +168,203 @@ describe("createFusionAuthStorage", () => { }); }); + it("refreshes and persists expired Claude OAuth credentials from Claude credential files", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "expired-claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() - 60_000, + scopes: ["user:profile", "org:create_api_key"], + }, + }), + ); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "refreshed-claude-access-token", + refresh_token: "rotated-claude-refresh-token", + expires_in: 3600, + scope: "user:profile org:create_api_key", + }), + } as Response); + globalThis.fetch = fetchMock as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token"); + expect(fetchMock).toHaveBeenCalledWith( + "https://platform.claude.com/v1/oauth/token", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""), + }), + ); + expect(authStorage.get("anthropic")).toEqual({ + type: "oauth", + access: "refreshed-claude-access-token", + refresh: "rotated-claude-refresh-token", + expires: expect.any(Number), + scopes: ["user:profile", "org:create_api_key"], + }); + + const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")); + expect(persisted.anthropic).toEqual({ + type: "oauth", + access: "refreshed-claude-access-token", + refresh: "rotated-claude-refresh-token", + expires: expect.any(Number), + scopes: ["user:profile", "org:create_api_key"], + }); + }); + + it("does not persist an invalid Claude OAuth refresh response", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "expired-claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() - 60_000, + }, + }), + ); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ expires_in: 3600 }), + } as Response) as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")); + expect(persisted.anthropic).toBeUndefined(); + }); + + it("cooldowns failed Claude OAuth refresh attempts", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "expired-claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() - 60_000, + }, + }), + ); + + const fetchMock = vi.fn().mockResolvedValue({ ok: false } as Response); + globalThis.fetch = fetchMock as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")); + expect(persisted.anthropic).toBeUndefined(); + }); + + it("coalesces concurrent Claude OAuth refresh attempts", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "expired-claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() - 60_000, + }, + }), + ); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "refreshed-claude-access-token", + expires_in: 3600, + }), + } as Response); + globalThis.fetch = fetchMock as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + await expect(Promise.all([ + authStorage.getApiKey("anthropic"), + authStorage.getApiKey("anthropic"), + authStorage.getApiKey("anthropic"), + ])).resolves.toEqual([ + "refreshed-claude-access-token", + "refreshed-claude-access-token", + "refreshed-claude-access-token", + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not let a stale Claude OAuth refresh overwrite a newer login", async () => { + const claudeDir = join(homeDir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + + writeFileSync( + join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "expired-claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: Date.now() - 60_000, + }, + }), + ); + + let resolveJson: ((value: unknown) => void) | undefined; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => new Promise((resolve) => { + resolveJson = resolve; + }), + } as Response); + globalThis.fetch = fetchMock as typeof fetch; + + const authStorage = createFusionAuthStorage(); + const pendingRefresh = authStorage.getApiKey("anthropic"); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + authStorage.set("anthropic", { + type: "oauth", + access: "fresh-login-access-token", + refresh: "fresh-login-refresh-token", + expires: Date.now() + 3_600_000, + }); + + resolveJson?.({ + access_token: "stale-refresh-access-token", + refresh_token: "stale-refresh-refresh-token", + expires_in: 3600, + }); + + await expect(pendingRefresh).resolves.toBe("fresh-login-access-token"); + expect(authStorage.get("anthropic")).toEqual({ + type: "oauth", + access: "fresh-login-access-token", + refresh: "fresh-login-refresh-token", + expires: expect.any(Number), + }); + }); + it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => { const fusionAgentDir = join(homeDir, ".fusion", "agent"); const codexDir = join(homeDir, ".codex"); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 258f6dca86..7c83d37499 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -16,6 +16,26 @@ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth"; type StoredCredential = StoredAuthCredential; +const OAUTH_REFRESH_BUFFER_MS = 60_000; +const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token"; +const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const ANTHROPIC_DEFAULT_SCOPES = ["user:profile"]; +const OAUTH_REFRESH_TIMEOUT_MS = 10_000; +const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000; + +type OAuthTokenResponse = { + access_token?: unknown; + accessToken?: unknown; + refresh_token?: unknown; + refreshToken?: unknown; + expires_in?: unknown; + expiresIn?: unknown; + expires_at?: unknown; + expiresAt?: unknown; + scope?: unknown; + scopes?: unknown; +}; + export function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); } @@ -95,6 +115,146 @@ function resolveOAuthApiKey(providerId: string, credential: StoredCredential): s return getOAuthProvider(providerId)?.getApiKey(credential as OAuthCredentials); } +function shouldRefreshOAuthCredential(credential: StoredCredential): boolean { + return credential.type === "oauth" + && typeof credential.refresh === "string" + && credential.refresh.length > 0 + && typeof credential.expires === "number" + && Number.isFinite(credential.expires) + && Date.now() >= credential.expires - OAUTH_REFRESH_BUFFER_MS; +} + +function isSameOAuthCredentialIdentity( + left: StoredCredential | undefined, + right: StoredCredential, +): boolean { + return left?.type === "oauth" + && right.type === "oauth" + && left.access === right.access + && left.refresh === right.refresh + && left.expires === right.expires; +} + +function getOAuthScopes(credential: StoredCredential): string[] { + const scopes = Array.isArray(credential.scopes) + ? credential.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0) + : []; + return scopes.length > 0 ? scopes : ANTHROPIC_DEFAULT_SCOPES; +} + +function parseExpiryMs(data: OAuthTokenResponse, now: number): number { + const expiresAt = data.expires_at ?? data.expiresAt; + if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { + return expiresAt; + } + if (typeof expiresAt === "string") { + const parsed = Date.parse(expiresAt); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + const expiresIn = data.expires_in ?? data.expiresIn; + if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) { + return now + expiresIn * 1000; + } + + return now + 3_600_000; +} + +function parseScopes(data: OAuthTokenResponse, fallback: string[]): string[] { + if (Array.isArray(data.scopes)) { + const scopes = data.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0); + if (scopes.length > 0) { + return scopes; + } + } + if (typeof data.scope === "string") { + const scopes = data.scope.split(/\s+/).filter(Boolean); + if (scopes.length > 0) { + return scopes; + } + } + return fallback; +} + +async function refreshAnthropicOAuthCredential(credential: StoredCredential): Promise { + const refresh = credential.refresh; + if (!refresh) { + return undefined; + } + + const scopes = getOAuthScopes(credential); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OAUTH_REFRESH_TIMEOUT_MS); + + try { + /* + FNXC:ClaudeOAuth 2026-06-13-22:46: + Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires. + Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths. + */ + const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "claude-code-fusion-dashboard", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refresh, + client_id: ANTHROPIC_OAUTH_CLIENT_ID, + scope: scopes.join(" "), + }), + signal: controller.signal, + }); + + if (!response.ok) { + return undefined; + } + + const data = await response.json() as OAuthTokenResponse; + const access = typeof data.access_token === "string" + ? data.access_token + : typeof data.accessToken === "string" + ? data.accessToken + : undefined; + if (!access) { + return undefined; + } + + const now = Date.now(); + const nextRefresh = typeof data.refresh_token === "string" + ? data.refresh_token + : typeof data.refreshToken === "string" + ? data.refreshToken + : refresh; + + return { + ...credential, + type: "oauth", + access, + refresh: nextRefresh, + expires: parseExpiryMs(data, now), + scopes: parseScopes(data, scopes), + }; + } catch { + return undefined; + } finally { + clearTimeout(timeout); + } +} + +async function refreshOAuthCredential(providerId: string, credential: StoredCredential): Promise { + if (!shouldRefreshOAuthCredential(credential)) { + return credential; + } + if (providerId !== "anthropic") { + return undefined; + } + return refreshAnthropicOAuthCredential(credential); +} + function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined { if (credential?.type === "api_key") { return resolveStoredApiKey(credential.key); @@ -146,6 +306,13 @@ export function createFusionAuthStorage(): AuthStorage { let supplementalCredentials = readSupplementalCredentials(); // models.json provider API keys — final fallback after primary auth and supplemental auth.json files let modelsJsonApiKeys = readModelsJsonApiKeys(); + /* + FNXC:ClaudeOAuth 2026-06-13-22:46: + Dashboard auth-status polling can run while model execution also resolves credentials, so expired Claude credentials need one refresh attempt per provider at a time. + Cache an in-flight refresh and briefly cool down failed attempts so repeated polls do not stampede the Anthropic token endpoint. + */ + const oauthRefreshInFlight = new Map>(); + const oauthRefreshCooldownUntil = new Map(); // Providers the user has explicitly logged out from. These should not be // "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json). @@ -174,6 +341,46 @@ export function createFusionAuthStorage(): AuthStorage { } }; + const refreshProviderOAuthCredential = async ( + provider: string, + credential: StoredCredential, + ): Promise => { + if (!shouldRefreshOAuthCredential(credential)) { + return credential; + } + + const now = Date.now(); + const cooldownUntil = oauthRefreshCooldownUntil.get(provider); + if (cooldownUntil && cooldownUntil > now) { + return undefined; + } + + const existing = oauthRefreshInFlight.get(provider); + if (existing) { + return existing; + } + + const refreshPromise = refreshOAuthCredential(provider, credential) + .then((refreshed) => { + if (refreshed) { + oauthRefreshCooldownUntil.delete(provider); + } else { + oauthRefreshCooldownUntil.set(provider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS); + } + return refreshed; + }) + .catch(() => { + oauthRefreshCooldownUntil.set(provider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS); + return undefined; + }) + .finally(() => { + oauthRefreshInFlight.delete(provider); + }); + + oauthRefreshInFlight.set(provider, refreshPromise); + return refreshPromise; + }; + syncSupplementalOauthCredentials(); return new Proxy(primary, { @@ -205,6 +412,7 @@ export function createFusionAuthStorage(): AuthStorage { return (provider: string, credential: AuthCredential) => { target.set(provider, credential); loggedOutProviders.delete(provider); + oauthRefreshCooldownUntil.delete(provider); }; } @@ -300,6 +508,32 @@ export function createFusionAuthStorage(): AuthStorage { if (primaryKey) return primaryKey; // 2. Supplemental auth.json credentials (.pi + .codex) + const refreshCandidate = choosePreferredStoredCredential( + target.get(provider) as StoredCredential | undefined, + supplementalCredentials[provider], + ) ?? {}; + const refreshWasNeeded = shouldRefreshOAuthCredential(refreshCandidate); + const refreshedCredential = await refreshProviderOAuthCredential(provider, refreshCandidate); + if (refreshedCredential?.type === "oauth" && refreshedCredential.access) { + if (refreshWasNeeded) { + /* + FNXC:ClaudeOAuth 2026-06-13-22:46: + A manual re-login or replacement credential must win over an older in-flight refresh response. + Re-check the credential identity before persisting so a delayed refresh cannot restore stale OAuth material after the user already fixed auth. + */ + const latestCredential = choosePreferredStoredCredential( + target.get(provider) as StoredCredential | undefined, + supplementalCredentials[provider], + ); + if (!isSameOAuthCredentialIdentity(latestCredential, refreshCandidate)) { + return resolveStoredCredentialApiKey(provider, latestCredential); + } + } + target.set(provider, refreshedCredential as AuthCredential); + loggedOutProviders.delete(provider); + return resolveStoredCredentialApiKey(provider, refreshedCredential); + } + const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]); if (supplementalKey) return supplementalKey; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 70bd3a348d..c5fde1d995 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -1,5 +1,6 @@ export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js"; export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js"; +export { createFusionAuthStorage } from "./auth-storage.js"; export { createTaskCreateTool, createTaskDocumentReadTool,