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) => {
|
||||
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 () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user