fix(auth): refresh expired Claude OAuth tokens

Refresh stored Claude OAuth credentials before reporting dashboard auth status or resolving model auth so users do not need to repeatedly re-login after access-token expiry.

Coalesce concurrent refresh attempts, prevent stale refreshes from overwriting newer logins, and route CLI dashboard/serve/daemon/onboard auth wiring through the shared refresh-capable storage.
This commit is contained in:
gsxdsm
2026-06-13 23:20:06 -07:00
parent 52a0f9dfd8
commit aa71ace101
16 changed files with 559 additions and 70 deletions

View File

@@ -0,0 +1,3 @@
"@runfusion/fusion": patch
Refresh expired Claude OAuth access tokens from Fusion auth storage instead of requiring repeated manual re-login.

View File

@@ -563,8 +563,9 @@ vi.mock("@fusion/dashboard", () => ({
vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
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<string, any>();
return {
startAll: vi.fn(async () => {

View File

@@ -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<typeof vi.fn>).mockImplementation(() => makeMockStore());
(AutomationStore as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({
(createFusionAuthStorage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getApiKey: vi.fn().mockResolvedValue(undefined),
getAuth: vi.fn(),
setAuth: vi.fn(),

View File

@@ -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,

View File

@@ -625,8 +625,9 @@ vi.mock("@fusion/dashboard", () => ({
vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
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<string, any>();
return {
startAll: vi.fn(async () => {

View File

@@ -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;

View File

@@ -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

View File

@@ -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<void> {
}
}
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();

View File

@@ -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;

View File

@@ -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"],
});
});

View File

@@ -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 } : {}),
};
}

View File

@@ -544,6 +544,7 @@ function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): 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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation(() => ({
type: "oauth",
access: refreshed ? "refreshed-token" : "expired-token",
refresh: "refresh",
expires: refreshed ? now + 3_600_000 : now - 1_000,
}));
(authStorage.getApiKey as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
(authStorage.get as ReturnType<typeof vi.fn>).mockReturnValue({
type: "oauth",
access: "expired-token",
refresh: "refresh",
expires: now - 1_000,
});
(authStorage.getApiKey as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockImplementation(

View File

@@ -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) {

View File

@@ -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, unknown>): 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");

View File

@@ -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<StoredCredential | undefined> {
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<StoredCredential | undefined> {
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<string, Promise<StoredCredential | undefined>>();
const oauthRefreshCooldownUntil = new Map<string, number>();
// 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<StoredCredential | undefined> => {
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;

View File

@@ -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,