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:
3
.changeset/refresh-claude-oauth.md
Normal file
3
.changeset/refresh-claude-oauth.md
Normal 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.
|
||||||
@@ -563,8 +563,9 @@ vi.mock("@fusion/dashboard", () => ({
|
|||||||
vi.mock("@fusion/engine", async (importOriginal) => {
|
vi.mock("@fusion/engine", async (importOriginal) => {
|
||||||
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
|
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
|
||||||
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
|
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
|
||||||
ProjectEngine: mocks.projectEngineCtor,
|
createFusionAuthStorage: vi.fn(() => mocks.authStorage),
|
||||||
ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) {
|
ProjectEngine: mocks.projectEngineCtor,
|
||||||
|
ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) {
|
||||||
const engines = new Map<string, any>();
|
const engines = new Map<string, any>();
|
||||||
return {
|
return {
|
||||||
startAll: vi.fn(async () => {
|
startAll: vi.fn(async () => {
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
|||||||
// Keep real WorktreePool & AgentSemaphore
|
// Keep real WorktreePool & AgentSemaphore
|
||||||
WorktreePool: original.WorktreePool,
|
WorktreePool: original.WorktreePool,
|
||||||
AgentSemaphore: original.AgentSemaphore,
|
AgentSemaphore: original.AgentSemaphore,
|
||||||
|
createFusionAuthStorage: vi.fn(() => mockAuthStorage),
|
||||||
// Stub heavy classes/functions
|
// Stub heavy classes/functions
|
||||||
ProjectEngine,
|
ProjectEngine,
|
||||||
ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => {
|
ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => {
|
||||||
@@ -3137,9 +3138,9 @@ describe("runDashboard — merge stream sink routing", () => {
|
|||||||
resetGitHubMocks();
|
resetGitHubMocks();
|
||||||
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
||||||
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
|
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 { 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());
|
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||||
(AutomationStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
(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() }]),
|
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),
|
getApiKey: vi.fn().mockResolvedValue(undefined),
|
||||||
getAuth: vi.fn(),
|
getAuth: vi.fn(),
|
||||||
setAuth: vi.fn(),
|
setAuth: vi.fn(),
|
||||||
|
|||||||
@@ -35,19 +35,17 @@ class MockCentralCore {
|
|||||||
vi.mock("../init.js", () => ({ runInit: mockRunInit }));
|
vi.mock("../init.js", () => ({ runInit: mockRunInit }));
|
||||||
vi.mock("../project-context.js", () => ({ resolveProject: mockResolveProject }));
|
vi.mock("../project-context.js", () => ({ resolveProject: mockResolveProject }));
|
||||||
vi.mock("../provider-auth.js", () => ({
|
vi.mock("../provider-auth.js", () => ({
|
||||||
createReadOnlyAuthFileStorage: vi.fn(() => ({})),
|
|
||||||
mergeAuthStorageReads: vi.fn((primary) => primary),
|
|
||||||
wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()),
|
wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()),
|
||||||
}));
|
}));
|
||||||
vi.mock("../auth-paths.js", () => ({
|
vi.mock("../auth-paths.js", () => ({
|
||||||
getFusionAuthPath: vi.fn(() => "/tmp/auth.json"),
|
|
||||||
getLegacyAuthPaths: vi.fn(() => []),
|
|
||||||
getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"),
|
getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"),
|
||||||
}));
|
}));
|
||||||
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||||
AuthStorage: { create: vi.fn(() => ({})) },
|
|
||||||
ModelRegistry: { create: vi.fn(() => ({})) },
|
ModelRegistry: { create: vi.fn(() => ({})) },
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@fusion/engine", () => ({
|
||||||
|
createFusionAuthStorage: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
vi.mock("@fusion/core", () => ({
|
vi.mock("@fusion/core", () => ({
|
||||||
CentralCore: MockCentralCore,
|
CentralCore: MockCentralCore,
|
||||||
GlobalSettingsStore: MockGlobalSettingsStore,
|
GlobalSettingsStore: MockGlobalSettingsStore,
|
||||||
|
|||||||
@@ -625,8 +625,9 @@ vi.mock("@fusion/dashboard", () => ({
|
|||||||
vi.mock("@fusion/engine", async (importOriginal) => {
|
vi.mock("@fusion/engine", async (importOriginal) => {
|
||||||
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
|
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
|
||||||
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
|
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
|
||||||
ProjectEngine: mocks.projectEngineCtor,
|
createFusionAuthStorage: vi.fn(() => mocks.authStorage),
|
||||||
ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) {
|
ProjectEngine: mocks.projectEngineCtor,
|
||||||
|
ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) {
|
||||||
const engines = new Map<string, any>();
|
const engines = new Map<string, any>();
|
||||||
return {
|
return {
|
||||||
startAll: vi.fn(async () => {
|
startAll: vi.fn(async () => {
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ import {
|
|||||||
HybridExecutor,
|
HybridExecutor,
|
||||||
shouldUseHybridExecutor,
|
shouldUseHybridExecutor,
|
||||||
setHostExtensionPaths,
|
setHostExtensionPaths,
|
||||||
|
createFusionAuthStorage,
|
||||||
} from "@fusion/engine";
|
} from "@fusion/engine";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
|
||||||
DefaultPackageManager,
|
DefaultPackageManager,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
@@ -71,8 +71,8 @@ import {
|
|||||||
setCachedLlamaCppResolution,
|
setCachedLlamaCppResolution,
|
||||||
} from "./llama-cpp-extension.js";
|
} from "./llama-cpp-extension.js";
|
||||||
import { resolveSelfExtension } from "./self-extension.js";
|
import { resolveSelfExtension } from "./self-extension.js";
|
||||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
|
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
|
||||||
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.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 missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
||||||
const automationStore = primaryEngine.getAutomationStore();
|
const automationStore = primaryEngine.getAutomationStore();
|
||||||
|
|
||||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
const authStorage = createFusionAuthStorage();
|
||||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||||
...getLegacyAuthPaths(),
|
|
||||||
getCodexCliAuthPath(),
|
|
||||||
...getClaudeCodeCredentialPaths(),
|
|
||||||
]);
|
|
||||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
|
||||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
|
||||||
registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
|
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
|
// PackageManager may be used for skills adapter even if extension loading fails
|
||||||
let packageManager: DefaultPackageManager | undefined;
|
let packageManager: DefaultPackageManager | undefined;
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ import {
|
|||||||
HybridExecutor,
|
HybridExecutor,
|
||||||
shouldUseHybridExecutor,
|
shouldUseHybridExecutor,
|
||||||
setHostExtensionPaths,
|
setHostExtensionPaths,
|
||||||
|
createFusionAuthStorage,
|
||||||
} from "@fusion/engine";
|
} 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 {
|
import {
|
||||||
getMergeStrategy,
|
getMergeStrategy,
|
||||||
getTaskBranchName,
|
getTaskBranchName,
|
||||||
@@ -65,8 +66,8 @@ import {
|
|||||||
import { promptForPort } from "./port-prompt.js";
|
import { promptForPort } from "./port-prompt.js";
|
||||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
import {
|
import {
|
||||||
ensureClaudeSkillsForAllProjectsOnStartup,
|
ensureClaudeSkillsForAllProjectsOnStartup,
|
||||||
@@ -1363,16 +1364,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
// ModelRegistry discovers available models from configured providers.
|
// ModelRegistry discovers available models from configured providers.
|
||||||
// Passing these to createServer enables the dashboard's Authentication
|
// Passing these to createServer enables the dashboard's Authentication
|
||||||
// tab (login/logout) and Model selector.
|
// tab (login/logout) and Model selector.
|
||||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
/*
|
||||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
FNXC:AuthRefresh 2026-06-13-22:46:
|
||||||
...getLegacyAuthPaths(),
|
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.
|
||||||
getCodexCliAuthPath(),
|
*/
|
||||||
...getClaudeCodeCredentialPaths(),
|
const authStorage = createFusionAuthStorage();
|
||||||
]);
|
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
|
||||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
|
||||||
registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions"));
|
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 may be used for skills adapter even if extension loading fails.
|
||||||
// packageManager.resolve() walks installed npm/git/local pi packages and is
|
// packageManager.resolve() walks installed npm/git/local pi packages and is
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { createInterface } from "node:readline";
|
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 { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core";
|
||||||
|
import { createFusionAuthStorage } from "@fusion/engine";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
import { runInit } from "./init.js";
|
import { runInit } from "./init.js";
|
||||||
import {
|
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
createReadOnlyAuthFileStorage,
|
import { getModelRegistryModelsPath } from "./auth-paths.js";
|
||||||
mergeAuthStorageReads,
|
|
||||||
wrapAuthStorageWithApiKeyProviders,
|
|
||||||
} from "./provider-auth.js";
|
|
||||||
import {
|
|
||||||
getFusionAuthPath,
|
|
||||||
getLegacyAuthPaths,
|
|
||||||
getModelRegistryModelsPath,
|
|
||||||
} from "./auth-paths.js";
|
|
||||||
|
|
||||||
export interface OnboardOptions {
|
export interface OnboardOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
@@ -186,11 +179,9 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
const authStorage = createFusionAuthStorage();
|
||||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
|
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
const providerAuth = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
|
||||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
|
||||||
const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
|
|
||||||
|
|
||||||
await runSkippableStep(prompts, "AI provider setup", async () => {
|
await runSkippableStep(prompts, "AI provider setup", async () => {
|
||||||
const apiProviders = providerAuth.getApiKeyProviders();
|
const apiProviders = providerAuth.getApiKeyProviders();
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ import {
|
|||||||
HybridExecutor,
|
HybridExecutor,
|
||||||
shouldUseHybridExecutor,
|
shouldUseHybridExecutor,
|
||||||
setHostExtensionPaths,
|
setHostExtensionPaths,
|
||||||
|
createFusionAuthStorage,
|
||||||
} from "@fusion/engine";
|
} from "@fusion/engine";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
|
||||||
DefaultPackageManager,
|
DefaultPackageManager,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
@@ -51,8 +51,8 @@ import {
|
|||||||
} from "./task-lifecycle.js";
|
} from "./task-lifecycle.js";
|
||||||
import { promptForPort } from "./port-prompt.js";
|
import { promptForPort } from "./port-prompt.js";
|
||||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
import {
|
import {
|
||||||
ensureClaudeSkillsForAllProjectsOnStartup,
|
ensureClaudeSkillsForAllProjectsOnStartup,
|
||||||
@@ -596,16 +596,10 @@ export async function runServe(
|
|||||||
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
||||||
const automationStore = primaryEngine.getAutomationStore();
|
const automationStore = primaryEngine.getAutomationStore();
|
||||||
|
|
||||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
const authStorage = createFusionAuthStorage();
|
||||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||||
...getLegacyAuthPaths(),
|
|
||||||
getCodexCliAuthPath(),
|
|
||||||
...getClaudeCodeCredentialPaths(),
|
|
||||||
]);
|
|
||||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
|
||||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
|
||||||
registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
|
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
|
// PackageManager may be used for skills adapter even if extension loading fails
|
||||||
let packageManager: DefaultPackageManager | undefined;
|
let packageManager: DefaultPackageManager | undefined;
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ describe("oauth credential interop", () => {
|
|||||||
accessToken: "claude-access",
|
accessToken: "claude-access",
|
||||||
refreshToken: "claude-refresh",
|
refreshToken: "claude-refresh",
|
||||||
expiresAt: Date.now() + 3600_000,
|
expiresAt: Date.now() + 3600_000,
|
||||||
|
scopes: ["user:profile", "org:create_api_key"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ describe("oauth credential interop", () => {
|
|||||||
access: "claude-access",
|
access: "claude-access",
|
||||||
refresh: "claude-refresh",
|
refresh: "claude-refresh",
|
||||||
expires: expect.any(Number),
|
expires: expect.any(Number),
|
||||||
|
scopes: ["user:profile", "org:create_api_key"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type StoredAuthCredential = {
|
|||||||
access?: string;
|
access?: string;
|
||||||
refresh?: string;
|
refresh?: string;
|
||||||
expires?: number;
|
expires?: number;
|
||||||
|
scopes?: string[];
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
@@ -235,6 +236,9 @@ export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCreden
|
|||||||
const refresh = typeof oauthRecord.refreshToken === "string" ? oauthRecord.refreshToken : undefined;
|
const refresh = typeof oauthRecord.refreshToken === "string" ? oauthRecord.refreshToken : undefined;
|
||||||
const expiresRaw = oauthRecord.expiresAt;
|
const expiresRaw = oauthRecord.expiresAt;
|
||||||
const expires = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) ? expiresRaw : undefined;
|
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) {
|
if (!access || !refresh || expires === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -245,6 +249,7 @@ export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCreden
|
|||||||
access,
|
access,
|
||||||
refresh,
|
refresh,
|
||||||
expires,
|
expires,
|
||||||
|
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -544,6 +544,7 @@ function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthSt
|
|||||||
]),
|
]),
|
||||||
hasAuth: vi.fn().mockReturnValue(false),
|
hasAuth: vi.fn().mockReturnValue(false),
|
||||||
get: vi.fn().mockReturnValue(undefined),
|
get: vi.fn().mockReturnValue(undefined),
|
||||||
|
getApiKey: vi.fn().mockResolvedValue(undefined),
|
||||||
login: vi.fn().mockImplementation((_provider: string, callbacks: any) => {
|
login: vi.fn().mockImplementation((_provider: string, callbacks: any) => {
|
||||||
// Simulate onAuth callback with a URL, then resolve
|
// Simulate onAuth callback with a URL, then resolve
|
||||||
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
|
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.getOAuthProviders).mockReset();
|
||||||
vi.mocked(authStorage.hasAuth).mockReset();
|
vi.mocked(authStorage.hasAuth).mockReset();
|
||||||
vi.mocked(authStorage.get).mockReset();
|
vi.mocked(authStorage.get).mockReset();
|
||||||
|
vi.mocked(authStorage.getApiKey).mockReset();
|
||||||
vi.mocked(authStorage.login).mockReset();
|
vi.mocked(authStorage.login).mockReset();
|
||||||
vi.mocked(authStorage.getApiKeyProviders).mockReset();
|
vi.mocked(authStorage.getApiKeyProviders).mockReset();
|
||||||
vi.mocked(authStorage.hasApiKey).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.getOAuthProviders).mockReturnValue([{ id: "github-copilot", name: "GitHub Copilot" }]);
|
||||||
vi.mocked(authStorage.hasAuth).mockReturnValue(false);
|
vi.mocked(authStorage.hasAuth).mockReturnValue(false);
|
||||||
vi.mocked(authStorage.get).mockReturnValue(undefined);
|
vi.mocked(authStorage.get).mockReturnValue(undefined);
|
||||||
|
vi.mocked(authStorage.getApiKey).mockResolvedValue(undefined);
|
||||||
vi.mocked(authStorage.login).mockImplementation((_provider: string, callbacks: any) => {
|
vi.mocked(authStorage.login).mockImplementation((_provider: string, callbacks: any) => {
|
||||||
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
|
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -760,6 +763,54 @@ describe("GET /auth/status", () => {
|
|||||||
expect(geminiOauth).toMatchObject({ authenticated: false, expired: false });
|
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 () => {
|
it("reports loginInProgress for oauth providers with active logins", async () => {
|
||||||
let releaseLogin: (() => void) | undefined;
|
let releaseLogin: (() => void) | undefined;
|
||||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
|
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
|||||||
@@ -271,9 +271,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
keyHint?: string;
|
keyHint?: string;
|
||||||
loginInProgress?: boolean;
|
loginInProgress?: boolean;
|
||||||
requiresManualCode?: boolean;
|
requiresManualCode?: boolean;
|
||||||
}[] = oauthProviders.map((p) => {
|
}[] = await Promise.all(oauthProviders.map(async (p) => {
|
||||||
const hasAuth = storage.hasAuth(p.id);
|
let hasAuth = storage.hasAuth(p.id);
|
||||||
const expired = hasAuth && isExpiredOauthCredential(p.id, storage);
|
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 {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -283,7 +297,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
loginInProgress: loginInProgress.has(p.id),
|
loginInProgress: loginInProgress.has(p.id),
|
||||||
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
|
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
|
||||||
};
|
};
|
||||||
});
|
}));
|
||||||
|
|
||||||
// Include API-key-backed providers if supported
|
// Include API-key-backed providers if supported
|
||||||
if (storage.getApiKeyProviders) {
|
if (storage.getApiKeyProviders) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -20,6 +20,7 @@ function createJwt(payload: Record<string, unknown>): string {
|
|||||||
describe("createFusionAuthStorage", () => {
|
describe("createFusionAuthStorage", () => {
|
||||||
// HOME override required — createFusionAuthStorage() has no dir parameter
|
// HOME override required — createFusionAuthStorage() has no dir parameter
|
||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
let homeDir: string;
|
let homeDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -37,6 +38,8 @@ describe("createFusionAuthStorage", () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.HOME = originalHome;
|
process.env.HOME = originalHome;
|
||||||
}
|
}
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes to Fusion auth and reads legacy Pi auth as fallback", async () => {
|
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 () => {
|
it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => {
|
||||||
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||||
const codexDir = join(homeDir, ".codex");
|
const codexDir = join(homeDir, ".codex");
|
||||||
|
|||||||
@@ -16,6 +16,26 @@ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
|
|||||||
|
|
||||||
type StoredCredential = StoredAuthCredential;
|
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 {
|
export function getHomeDir(): string {
|
||||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
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);
|
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 {
|
function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined {
|
||||||
if (credential?.type === "api_key") {
|
if (credential?.type === "api_key") {
|
||||||
return resolveStoredApiKey(credential.key);
|
return resolveStoredApiKey(credential.key);
|
||||||
@@ -146,6 +306,13 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
let supplementalCredentials = readSupplementalCredentials();
|
let supplementalCredentials = readSupplementalCredentials();
|
||||||
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
|
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
|
||||||
let modelsJsonApiKeys = readModelsJsonApiKeys();
|
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
|
// Providers the user has explicitly logged out from. These should not be
|
||||||
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json).
|
// "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();
|
syncSupplementalOauthCredentials();
|
||||||
|
|
||||||
return new Proxy(primary, {
|
return new Proxy(primary, {
|
||||||
@@ -205,6 +412,7 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
return (provider: string, credential: AuthCredential) => {
|
return (provider: string, credential: AuthCredential) => {
|
||||||
target.set(provider, credential);
|
target.set(provider, credential);
|
||||||
loggedOutProviders.delete(provider);
|
loggedOutProviders.delete(provider);
|
||||||
|
oauthRefreshCooldownUntil.delete(provider);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +508,32 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
if (primaryKey) return primaryKey;
|
if (primaryKey) return primaryKey;
|
||||||
|
|
||||||
// 2. Supplemental auth.json credentials (.pi + .codex)
|
// 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]);
|
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
|
||||||
if (supplementalKey) return supplementalKey;
|
if (supplementalKey) return supplementalKey;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
|
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
|
||||||
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
|
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
|
||||||
|
export { createFusionAuthStorage } from "./auth-storage.js";
|
||||||
export {
|
export {
|
||||||
createTaskCreateTool,
|
createTaskCreateTool,
|
||||||
createTaskDocumentReadTool,
|
createTaskDocumentReadTool,
|
||||||
|
|||||||
Reference in New Issue
Block a user