From 5f12044168184e23ad3382d48020ba36982fc859 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 01:07:59 -0700 Subject: [PATCH] FN-8652: add multiple provider credential instances Enable operators to create, select, and remove named credentials for each supported provider. - Add provider-auth instance discovery and credential mutation endpoints. - Add dashboard authentication controls, status handling, and instance coverage. - Document instance behavior and add a release changeset. Files changed: .changeset/fn-8652-provider-credential-instances.md | 7 + docs/secrets.md | 2 + docs/settings-reference.md | 4 + packages/dashboard/app/api.ts | 1 + packages/dashboard/app/api/provider-status.ts | 94 +++++- .../dashboard/app/components/SettingsModal.tsx | 138 ++++----- .../AuthenticationSection.instances.test.tsx | 75 +++++ .../settings/sections/AuthenticationSection.css | 13 + .../settings/sections/AuthenticationSection.tsx | 199 +++++++----- .../dashboard/src/__tests__/routes-auth.test.ts | 27 +- packages/dashboard/src/routes.ts | 12 +- .../dashboard/src/routes/register-auth-routes.ts | 334 ++++++++++++++++++--- .../src/__tests__/provider-auth-instances.test.ts | 65 ++++ packages/engine/src/provider-auth.ts | 89 ++++++ 14 files changed, 845 insertions(+), 215 deletions(-) Fusion-Task-Id: FN-8652 Fusion-Task-Lineage: 39568d58-7f57-4d17-97cb-1837323b3a94 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8652-provider-credential-instances.md | 7 + docs/secrets.md | 2 + docs/settings-reference.md | 4 + packages/dashboard/app/api.ts | 1 + packages/dashboard/app/api/provider-status.ts | 94 ++++- .../app/components/SettingsModal.tsx | 138 +++---- .../AuthenticationSection.instances.test.tsx | 75 ++++ .../sections/AuthenticationSection.css | 13 + .../sections/AuthenticationSection.tsx | 199 +++++++---- .../src/__tests__/routes-auth.test.ts | 27 +- packages/dashboard/src/routes.ts | 12 +- .../src/routes/register-auth-routes.ts | 338 +++++++++++++++--- .../__tests__/provider-auth-instances.test.ts | 65 ++++ packages/engine/src/provider-auth.ts | 89 +++++ 14 files changed, 847 insertions(+), 217 deletions(-) create mode 100644 .changeset/fn-8652-provider-credential-instances.md create mode 100644 packages/dashboard/app/components/__tests__/AuthenticationSection.instances.test.tsx create mode 100644 packages/dashboard/app/components/settings/sections/AuthenticationSection.css create mode 100644 packages/engine/src/__tests__/provider-auth-instances.test.ts diff --git a/.changeset/fn-8652-provider-credential-instances.md b/.changeset/fn-8652-provider-credential-instances.md new file mode 100644 index 0000000000..e85322a9b8 --- /dev/null +++ b/.changeset/fn-8652-provider-credential-instances.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add support for managing multiple credential accounts per provider. +category: feature +dev: Adds instance-aware dashboard auth route and client API surfaces. diff --git a/docs/secrets.md b/docs/secrets.md index 7dfbe1a720..0551865866 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -212,6 +212,8 @@ Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys. +Dashboard auth routes accept an optional instance id; omitted or blank ids retain default-instance behavior. `GET /api/auth/status?provider=&instance=` keeps the full provider envelope but describes the requested instance at that provider entry. A valid missing id is an unauthenticated `200` result, never a fallback to another account. Credential-establishing login and API-key writes may create a supplied id; rename, default, logout, and delete require an existing id. OAuth binds the id and optional opaque label to its server-side flow state, so a callback cannot fall back to the default account. Instance listings expose only ids, labels, auth status, and masked key hints; raw key and token material never leaves storage. `removeInstance` deletes the credential row and its default participation, while credential clear only removes its usable credential state. + ## Operational Notes - Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6f237dacf6..e8cc5202e6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1882,3 +1882,7 @@ validation also visits every `modelPresets[]` element: one invalid executor or v id rejects the entire settings write without changing the stored presets. These values are persisted-but-inert in this release; runtime credential resolution will consume them in the follow-up runtime-resolution work. + +### Authentication credential instances + +Settings → Authentication can hold multiple named credential accounts for each non-CLI provider. Select **Add another account** to create a client-generated account id, optionally label it, and complete OAuth or save an API key; an abandoned pending account is not stored. The first credential becomes the provider default; later accounts do not change it. Operators can rename, remove, or make an existing account default. Labels are display-only, optional, and need not be unique. CLI-backed provider cards retain their own credential handling and do not support Fusion credential instances. diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 2de05fdb6c..50757b9cb3 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -5,6 +5,7 @@ * while implementation lives under `app/api/*` modules. */ export * from "./api/legacy"; +export * from "./api/provider-status"; export * from "./api/chat"; export * from "./api-node"; export * from "./api/report"; diff --git a/packages/dashboard/app/api/provider-status.ts b/packages/dashboard/app/api/provider-status.ts index f50cf5a1f0..ee54e2d568 100644 --- a/packages/dashboard/app/api/provider-status.ts +++ b/packages/dashboard/app/api/provider-status.ts @@ -6,9 +6,22 @@ import { api } from "./client.js"; import type { FetchOptions } from "./client.js"; import { dedupe } from "./dedupe.js"; - // --- Auth API --- +/* +FNXC:ProviderAuth 2026-08-01-06:25: +The browser bundle cannot import the runtime core helper, so this mirrors its constrained instance-id +grammar solely to generate opaque client ids. Labels are never part of identity or generation. +*/ +const isValidProviderInstanceId = (value: unknown): value is string => typeof value === "string" + && value.length > 0 && value.length <= 64 && !/\s/.test(value) + && !value.includes("[") && !value.includes("]"); + +/** Browser-safe counterpart of the core auth.json key grammar for local UI state only. */ +export function formatProviderInstanceKey(ref: { providerId: string; instanceId: string }): string { + return ref.instanceId === "default" ? ref.providerId : `${ref.providerId}[${ref.instanceId}]`; +} + /** OAuth provider with current authentication status */ export interface AuthProvider { id: string; @@ -40,6 +53,19 @@ export interface AuthProvider { type?: "oauth" | "api_key" | "cli"; /** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */ keyHint?: string; + /** Credential instance described by the top-level status fields. */ + instanceId?: string; + instances?: ProviderCredentialInstance[]; +} + +export interface ProviderCredentialInstance { + instanceId: string; + label?: string; + isDefault: boolean; + authenticated: boolean; + expired?: boolean; + type?: "oauth" | "api_key"; + keyHint?: string; } export interface ManualOAuthCodeInfo { @@ -751,20 +777,31 @@ export interface GitCliStatus { } /** Fetch authentication status for all OAuth providers */ -export function fetchAuthStatus(options?: FetchOptions): Promise<{ +/* +FNXC:ProviderAuth 2026-08-01-06:11: +Targeted status requests must dedupe by provider and instance, not a global key, or concurrent +account rows can receive each other's status payload. The zero-argument request retains its URL. +*/ +export function fetchAuthStatus(options?: FetchOptions & { provider?: string; instance?: string }): Promise<{ providers: AuthProvider[]; ghCli?: { available: boolean; authenticated: boolean }; gitCli?: GitCliStatus; }> { - return dedupe("/auth/status", () => api<{ + const params = new URLSearchParams(); + if (options?.provider) params.set("provider", options.provider); + if (options?.instance?.trim()) params.set("instance", options.instance.trim()); + const query = params.toString(); + const url = query ? `/auth/status?${query}` : "/auth/status"; + const key = `${options?.provider ?? "*"}::${options?.instance?.trim() || "default"}`; + return dedupe(`/auth/status:${key}`, () => api<{ providers: AuthProvider[]; ghCli?: { available: boolean; authenticated: boolean }; gitCli?: GitCliStatus; - }>("/auth/status"), options); + }>(url), options); } /** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */ -export function loginProvider(provider: string): Promise<{ +export function loginProvider(provider: string, instance?: string, label?: string): Promise<{ url: string; instructions?: string; manualCode?: ManualOAuthCodeInfo; @@ -777,36 +814,36 @@ export function loginProvider(provider: string): Promise<{ deviceCode?: OAuthDeviceCodeInfo; }>("/auth/login", { method: "POST", - body: JSON.stringify({ provider, origin: window.location.origin }), + body: JSON.stringify({ provider, origin: window.location.origin, ...(instance ? { instance } : {}), ...(label ? { label } : {}) }), }); } /** Submit a pasted OAuth callback URL or authorization code for an active login. */ -export function submitProviderManualCode(provider: string, code: string): Promise<{ success: boolean; submitted: boolean }> { +export function submitProviderManualCode(provider: string, code: string, instance?: string): Promise<{ success: boolean; submitted: boolean }> { return api<{ success: boolean; submitted: boolean }>("/auth/manual-code", { method: "POST", - body: JSON.stringify({ provider, code }), + body: JSON.stringify({ provider, code, ...(instance ? { instance } : {}) }), }); } /** Logout from a provider, removing stored credentials. */ -export function logoutProvider(provider: string): Promise<{ success: boolean }> { +export function logoutProvider(provider: string, instance?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>("/auth/logout", { method: "POST", - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }), }); } /** Cancel an in-progress OAuth login attempt for a provider. */ -export function cancelProviderLogin(provider: string): Promise<{ success: boolean; cancelled: boolean }> { +export function cancelProviderLogin(provider: string, instance?: string): Promise<{ success: boolean; cancelled: boolean }> { return api<{ success: boolean; cancelled: boolean }>("/auth/cancel", { method: "POST", - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }), }); } /** Save an API key for an API-key-backed provider. */ -export function saveApiKey(provider: string, apiKey: string): Promise<{ +export function saveApiKey(provider: string, apiKey: string, instance?: string, label?: string): Promise<{ success: boolean; modelsRefreshed?: number; refreshReason?: string; @@ -819,15 +856,40 @@ export function saveApiKey(provider: string, apiKey: string): Promise<{ refreshError?: string; }>("/auth/api-key", { method: "POST", - body: JSON.stringify({ provider, apiKey }), + body: JSON.stringify({ provider, apiKey, ...(instance ? { instance } : {}), ...(label ? { label } : {}) }), }); } /** Remove an API key for an API-key-backed provider. */ -export function clearApiKey(provider: string): Promise<{ success: boolean }> { +export function clearApiKey(provider: string, instance?: string): Promise<{ success: boolean }> { return api<{ success: boolean }>("/auth/api-key", { method: "DELETE", - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }), }); } +/** Generates opaque client-side account ids; labels are deliberately not identifiers. */ +export function newProviderInstanceId(knownInstanceIds: Iterable = []): string { + const known = new Set(knownInstanceIds); + for (;;) { + const random = globalThis.crypto?.getRandomValues + ? Array.from(globalThis.crypto.getRandomValues(new Uint32Array(2))).map((part) => part.toString(36)).join("") + : Math.random().toString(36).slice(2); + const id = `acct-${random}`.slice(0, 64); + if (isValidProviderInstanceId(id) && !known.has(id)) return id; + } +} + +export function listProviderInstances(provider: string): Promise<{ instances: ProviderCredentialInstance[] }> { + return api(`/auth/providers/${encodeURIComponent(provider)}/instances`); +} +export function renameProviderInstance(provider: string, instance: string, label: string): Promise<{ success: boolean }> { + return api(`/auth/providers/${encodeURIComponent(provider)}/instances/${encodeURIComponent(instance)}/rename`, { method: "POST", body: JSON.stringify({ label }) }); +} +export function setProviderDefaultInstance(provider: string, instance: string): Promise<{ success: boolean }> { + return api(`/auth/providers/${encodeURIComponent(provider)}/default-instance`, { method: "POST", body: JSON.stringify({ instance }) }); +} +export function removeProviderInstance(provider: string, instance: string): Promise<{ success: boolean }> { + return api(`/auth/providers/${encodeURIComponent(provider)}/instances/${encodeURIComponent(instance)}`, { method: "DELETE" }); +} + diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index c07c541c49..d5137a4e65 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -8,7 +8,7 @@ import { } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core"; -import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode, fetchPlugins } from "../api"; +import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode, fetchPlugins, formatProviderInstanceKey } from "../api"; import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api"; import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split"; import { @@ -1429,7 +1429,7 @@ export function SettingsModal({ // Auth state (independent of the settings save flow) const [authProviders, setAuthProviders] = useState([]); const [authLoading, setAuthLoading] = useState(false); - const [authActionInProgress, setAuthActionInProgress] = useState(null); + const [authActionInProgress, setAuthActionInProgress] = useState>({}); const [loginInstructions, setLoginInstructions] = useState>({}); const [manualCodeConfigs, setManualCodeConfigs] = useState>({}); const [deviceCodes, setDeviceCodes] = useState>({}); @@ -1441,7 +1441,7 @@ export function SettingsModal({ tone: "success" | "error"; message: string; }>>({}); - const pollIntervalRef = useRef | null>(null); + const pollIntervalRef = useRef>>({}); const lastAutoCopiedDeviceCodesRef = useRef>({}); // Model state @@ -2350,12 +2350,10 @@ export function SettingsModal({ setAuthLoading(true); loadAuthStatus().finally(() => setAuthLoading(false)); } - // Clean up polling when leaving auth section + // Each instance owns its own login poll; leaving Settings stops every active account poll. return () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } + for (const interval of Object.values(pollIntervalRef.current)) clearInterval(interval); + pollIntervalRef.current = {}; }; }, [activeSection, loadAuthStatus]); @@ -2456,7 +2454,8 @@ export function SettingsModal({ void copyTextToClipboard(copilotDeviceCode.userCode); }, [deviceCodes]); - const handleLogin = useCallback(async (providerId: string) => { + const handleLogin = useCallback(async (providerId: string, instanceId?: string, label?: string) => { + const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); const provider = authProviders.find((entry) => entry.id === providerId); if (provider?.requiresManualCode === true) { const shouldContinue = await confirm({ @@ -2470,38 +2469,38 @@ export function SettingsModal({ } } - setAuthActionInProgress(providerId); - clearAuthLoginUiState(providerId); + setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); + clearAuthLoginUiState(stateKey); try { - const { url, instructions, manualCode, deviceCode } = await loginProvider(providerId); + const { url, instructions, manualCode, deviceCode } = await loginProvider(providerId, instanceId, label); if (instructions?.trim() && !(providerId === "github-copilot" && deviceCode)) { - setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions })); + setLoginInstructions((prev) => ({ ...prev, [stateKey]: instructions })); } if (manualCode) { - setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode })); + setManualCodeConfigs((prev) => ({ ...prev, [stateKey]: manualCode })); } if (deviceCode && providerId === "github-copilot") { - setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode })); + setDeviceCodes((prev) => ({ ...prev, [stateKey]: deviceCode })); } if (providerId !== "github-copilot" || !deviceCode) { openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url)); } // Poll for auth completion every 2 seconds - pollIntervalRef.current = setInterval(async () => { + pollIntervalRef.current[stateKey] = setInterval(async () => { try { - const { providers } = await fetchAuthStatus(); + const { providers } = await fetchAuthStatus({ provider: providerId, instance: instanceId }); const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers); setAuthProviders(visibleProviders); const provider = visibleProviders.find((p) => p.id === providerId); if (provider?.authenticated) { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; + if (pollIntervalRef.current[stateKey]) { + clearInterval(pollIntervalRef.current[stateKey]); + delete pollIntervalRef.current[stateKey]; } - setAuthActionInProgress(null); - clearAuthLoginUiState(providerId); + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); + clearAuthLoginUiState(stateKey); addToast(t("settings.auth.loginSuccessful", "Login successful"), "success"); window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } })); scrollSettingsToTop(); @@ -2509,12 +2508,12 @@ export function SettingsModal({ } if (!provider?.loginInProgress) { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; + if (pollIntervalRef.current[stateKey]) { + clearInterval(pollIntervalRef.current[stateKey]); + delete pollIntervalRef.current[stateKey]; } - setAuthActionInProgress(null); - clearAuthLoginUiState(providerId); + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); + clearAuthLoginUiState(stateKey); addToast(t("settings.auth.loginDidNotComplete", "Login did not complete. Please try again."), "error"); } } catch { @@ -2530,28 +2529,29 @@ export function SettingsModal({ } else { addToast(message, "error"); } - setAuthActionInProgress(null); - clearAuthLoginUiState(providerId); + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); + clearAuthLoginUiState(stateKey); } }, [addToast, authProviders, clearAuthLoginUiState, confirm, loadAuthStatus, scrollSettingsToTop]); - const handleSubmitManualCode = useCallback(async (providerId: string) => { - const code = manualCodeInputs[providerId]?.trim(); + const handleSubmitManualCode = useCallback(async (providerId: string, instanceId?: string) => { + const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); + const code = manualCodeInputs[stateKey]?.trim(); if (!code) { addToast(t("settings.auth.pasteRedirectUrlFirst", "Paste the full redirect URL or authorization code first."), "warning"); return; } - setManualCodeSubmitInProgress(providerId); + setManualCodeSubmitInProgress(stateKey); try { - const result = await submitProviderManualCode(providerId, code); + const result = await submitProviderManualCode(providerId, code, instanceId); if (result.submitted) { setManualCodeInputs((prev) => { - if (!(providerId in prev)) { + if (!(stateKey in prev)) { return prev; } const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); addToast(t("settings.auth.authCodeReceived", "Authorization code received. Finishing login…"), "success"); @@ -2565,58 +2565,59 @@ export function SettingsModal({ } }, [addToast, manualCodeInputs]); - const handleCancelLogin = useCallback(async (providerId: string) => { - setAuthActionInProgress(providerId); - setAuthProviders((prev) => prev.map((provider) => - provider.id === providerId ? { ...provider, loginInProgress: false } : provider, - )); + const handleCancelLogin = useCallback(async (providerId: string, instanceId?: string) => { + const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); + setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); + // Provider status is shared; do not optimistically clear a concurrent instance's login flag. try { - await cancelProviderLogin(providerId); - clearAuthLoginUiState(providerId); + await cancelProviderLogin(providerId, instanceId); + clearAuthLoginUiState(stateKey); await loadAuthStatus().catch(() => {}); addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success"); } catch (err) { addToast(getErrorMessage(err) || "Failed to cancel login", "error"); } finally { - setAuthActionInProgress(null); - setManualCodeSubmitInProgress((prev) => prev === providerId ? null : prev); - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); + setManualCodeSubmitInProgress((prev) => prev === stateKey ? null : prev); + if (pollIntervalRef.current[stateKey]) { + clearInterval(pollIntervalRef.current[stateKey]); + delete pollIntervalRef.current[stateKey]; } } }, [addToast, clearAuthLoginUiState, loadAuthStatus]); - const handleLogout = useCallback(async (providerId: string) => { - setAuthActionInProgress(providerId); + const handleLogout = useCallback(async (providerId: string, instanceId?: string) => { + const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); + setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); try { - await logoutProvider(providerId); + await logoutProvider(providerId, instanceId); await loadAuthStatus(); addToast(t("settings.auth.loggedOut", "Logged out"), "success"); } catch (err) { addToast(getErrorMessage(err) || "Logout failed", "error"); } finally { - setAuthActionInProgress(null); + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); } }, [addToast, loadAuthStatus]); - const handleSaveApiKey = useCallback(async (providerId: string) => { - const key = apiKeyInputs[providerId]?.trim(); + const handleSaveApiKey = useCallback(async (providerId: string, instanceId?: string, label?: string) => { + const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); + const key = apiKeyInputs[stateKey]?.trim(); if (!key) { - setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required" })); + setApiKeyErrors((prev) => ({ ...prev, [stateKey]: "API key is required" })); return; } - setAuthActionInProgress(providerId); + setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); setApiKeyErrors((prev) => { const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); try { - const saveResult = await saveApiKey(providerId, key); + const saveResult = await saveApiKey(providerId, key, instanceId, label); setApiKeyInputs((prev) => { const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); await loadAuthStatus(); @@ -2651,7 +2652,7 @@ export function SettingsModal({ } else { setOpencodeApiKeyRefreshStatus((prev) => { const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); } @@ -2659,31 +2660,32 @@ export function SettingsModal({ addToast(t("settings.auth.apiKeySaved", "API key saved"), "success"); scrollSettingsToTop(); } catch (err) { - setApiKeyErrors((prev) => ({ ...prev, [providerId]: getErrorMessage(err) || "Failed to save API key" })); + setApiKeyErrors((prev) => ({ ...prev, [stateKey]: getErrorMessage(err) || "Failed to save API key" })); if (providerId === "opencode" || providerId === "opencode-go") { setOpencodeApiKeyRefreshStatus((prev) => { const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); } } finally { - setAuthActionInProgress(null); + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); } }, [apiKeyInputs, addToast, loadAuthStatus, scrollSettingsToTop]); - const handleClearApiKey = useCallback(async (providerId: string) => { - setAuthActionInProgress(providerId); + const handleClearApiKey = useCallback(async (providerId: string, instanceId?: string) => { + const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); + setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); try { - await clearApiKey(providerId); + await clearApiKey(providerId, instanceId); setApiKeyInputs((prev) => { const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); setApiKeyErrors((prev) => { const next = { ...prev }; - delete next[providerId]; + delete next[stateKey]; return next; }); await loadAuthStatus(); @@ -2691,7 +2693,7 @@ export function SettingsModal({ } catch (err) { addToast(getErrorMessage(err) || "Failed to clear API key", "error"); } finally { - setAuthActionInProgress(null); + setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); } }, [addToast, loadAuthStatus]); diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.instances.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.instances.test.tsx new file mode 100644 index 0000000000..dcaa9cc000 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.instances.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { useState } from "react"; +import { AuthenticationSection, type AuthenticationSectionData } from "../settings/sections/AuthenticationSection"; +import type { AuthProvider } from "../../api"; + +vi.mock("../../api", async (importOriginal) => ({ + ...(await importOriginal()), + newProviderInstanceId: () => "acct-new", + removeProviderInstance: vi.fn().mockResolvedValue({ success: true }), + renameProviderInstance: vi.fn().mockResolvedValue({ success: true }), + setProviderDefaultInstance: vi.fn().mockResolvedValue({ success: true }), +})); +vi.mock("../ProviderIcon", () => ({ ProviderIcon: () => })); +vi.mock("../PluginSlot", () => ({ PluginSlot: () => null })); +vi.mock("../LoginInstructions", () => ({ LoginInstructions: () => null })); +vi.mock("../LoadingSpinner", () => ({ LoadingSpinner: () => null })); +vi.mock("../OAuthManualCodeForm", () => ({ OAuthManualCodeForm: () => null })); +vi.mock("../CustomProvidersSection", () => ({ CustomProvidersSection: () => null })); + +function renderSection(providers: AuthProvider[]) { + const handlers = { + handleLogin: vi.fn(), handleLogout: vi.fn(), handleCancelLogin: vi.fn(), + handleSaveApiKey: vi.fn(), handleClearApiKey: vi.fn(), handleSubmitManualCode: vi.fn(), + }; + function Harness() { + const [apiKeyInputs, setApiKeyInputs] = useState>({}); + const [manualCodeInputs, setManualCodeInputs] = useState>({}); + const auth: AuthenticationSectionData = { + addToast: vi.fn(), authProviders: providers, authLoading: false, authActionInProgress: null, + apiKeyInputs, setApiKeyInputs, apiKeyErrors: {}, opencodeApiKeyRefreshStatus: {}, deviceCodes: {}, + loginInstructions: {}, manualCodeConfigs: {}, manualCodeInputs, setManualCodeInputs, + manualCodeSubmitInProgress: null, loadAuthStatus: vi.fn(), ...handlers, + }; + return ; + } + render(); + return handlers; +} + +describe("AuthenticationSection credential instances", () => { + it("keeps a single account card free of instance chrome", () => { + renderSection([{ id: "brave", name: "Brave", authenticated: true, type: "api_key", instances: [{ instanceId: "default", authenticated: true, isDefault: true, type: "api_key" }] }]); + expect(screen.queryByTestId("auth-instances-brave")).not.toBeInTheDocument(); + expect(screen.getByText("Add another account")).toBeInTheDocument(); + }); + + it("scopes every multi-instance API-key save to its account and pending row", () => { + const handlers = renderSection([{ id: "brave", name: "Brave", authenticated: true, type: "api_key", instances: [ + { instanceId: "default", label: "Primary", authenticated: true, isDefault: true, type: "api_key", keyHint: "abc••••defg" }, + { instanceId: "acct-two", label: "Second", authenticated: false, isDefault: false, type: "api_key" }, + ] }]); + const list = screen.getByTestId("auth-instances-brave"); + const second = within(list).getByText("Second").closest(".auth-instance-row") as HTMLElement; + fireEvent.change(within(second).getByPlaceholderText("Enter API key"), { target: { value: "second-key" } }); + fireEvent.click(within(second).getByText("Save")); + expect(handlers.handleSaveApiKey).toHaveBeenLastCalledWith("brave", "acct-two", undefined); + + fireEvent.click(screen.getByText("Add another account")); + const pending = screen.getByTestId("auth-pending-instance-brave"); + fireEvent.change(within(pending).getByPlaceholderText("Enter API key"), { target: { value: "pending-key" } }); + fireEvent.click(within(pending).getByText("Save")); + expect(handlers.handleSaveApiKey).toHaveBeenLastCalledWith("brave", "acct-new", undefined); + }); + + it("binds OAuth instance actions to the selected instance", () => { + const handlers = renderSection([{ id: "github-copilot", name: "GitHub", authenticated: true, type: "oauth", instances: [ + { instanceId: "default", authenticated: true, isDefault: true, type: "oauth" }, + { instanceId: "acct-two", authenticated: true, isDefault: false, type: "oauth" }, + ] }]); + const row = within(screen.getByTestId("auth-instances-github-copilot")).getByText("acct-two").closest(".auth-instance-row") as HTMLElement; + fireEvent.click(within(row).getByText("Logout")); + expect(handlers.handleLogout).toHaveBeenCalledWith("github-copilot", "acct-two"); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.css b/packages/dashboard/app/components/settings/sections/AuthenticationSection.css new file mode 100644 index 0000000000..e8cd0cc9f1 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.css @@ -0,0 +1,13 @@ +/* +FNXC:ProviderAuth 2026-08-01-06:25: +Credential instance actions remain compact within the existing provider card and wrap on a narrow +Settings modal so account controls stay reachable without introducing a second card system. +*/ +.auth-instance-controls { display: flex; flex-direction: column; gap: var(--space-sm); margin-top: var(--space-sm); } +.auth-instance-list { display: flex; flex-direction: column; gap: var(--space-xs); } +.auth-instance-row, .auth-instance-pending { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-xs); } +.auth-instance-row > span { flex: 1; min-width: 0; } +@media (max-width: 768px) { + .auth-instance-row, .auth-instance-pending { align-items: stretch; } + .auth-instance-row > .btn, .auth-instance-pending > .btn { flex: 1; } +} diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index e0b4a9671e..0b05d64a5b 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import type { Dispatch, SetStateAction } from "react"; -import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo } from "../../../api"; +import { formatProviderInstanceKey, removeProviderInstance, renameProviderInstance, setProviderDefaultInstance, newProviderInstanceId } from "../../../api"; +import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo, ProviderCredentialInstance } from "../../../api"; import type { ToastType } from "../../../hooks/useToast"; import { useTranslation } from "react-i18next"; import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard"; @@ -20,12 +22,13 @@ import { copyTextToClipboard } from "../../../utils/copyToClipboard"; import { appendTokenQuery } from "../../../auth"; import { openExternalUrl } from "../../../utils/open-external"; import { refreshModelsCache } from "../../../hooks/useModelsCache"; +import "./AuthenticationSection.css"; export interface AuthenticationSectionData { projectId?: string; addToast: (message: string, type?: ToastType) => void; authProviders: AuthProvider[]; authLoading: boolean; - authActionInProgress: string | null; + authActionInProgress: string | null | Record; apiKeyInputs: Record; setApiKeyInputs: Dispatch>>; apiKeyErrors: Record; @@ -40,12 +43,12 @@ export interface AuthenticationSectionData { setManualCodeInputs: Dispatch>>; manualCodeSubmitInProgress: string | null; loadAuthStatus: () => void | Promise; - handleLogin: (providerId: string) => void; - handleLogout: (providerId: string) => void; - handleCancelLogin: (providerId: string) => void; - handleSaveApiKey: (providerId: string) => void; - handleClearApiKey: (providerId: string) => void; - handleSubmitManualCode: (providerId: string) => void | Promise; + handleLogin: (providerId: string, instanceId?: string, label?: string) => void; + handleLogout: (providerId: string, instanceId?: string) => void; + handleCancelLogin: (providerId: string, instanceId?: string) => void; + handleSaveApiKey: (providerId: string, instanceId?: string, label?: string) => void; + handleClearApiKey: (providerId: string, instanceId?: string) => void; + handleSubmitManualCode: (providerId: string, instanceId?: string) => void | Promise; onReopenOnboarding?: () => void; } export interface AuthenticationSectionProps { @@ -85,6 +88,10 @@ const compareAuthProviderDisplayOrder = (a: AuthProvider, b: AuthProvider) => { export function AuthenticationSection({ auth, form, setForm }: AuthenticationSectionProps) { const { t } = useTranslation("app"); const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth; + const [pendingInstances, setPendingInstances] = useState>({}); + const isAuthActionActive = (stateKey: string) => typeof authActionInProgress === "string" + ? authActionInProgress === stateKey + : Boolean(authActionInProgress?.[stateKey]); const hasSeparatedAnthropicProvider = authProviders.some((p) => p.id === "anthropic-subscription" || p.id === "anthropic-api-key"); /* FNXC:ProviderAuth 2026-06-29-23:50: @@ -191,77 +198,119 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec const showAvailableGroup = unauthenticatedProviders.length > 0; const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; /* + FNXC:ProviderAuth 2026-08-01-06:25: + Keep single and empty cards free of instance-list chrome. Extra account controls appear only + after a second credential exists; client-generated ids are opaque and labels remain display-only. + */ + const hasMultipleInstances = (provider: AuthProvider) => (provider.instances?.length ?? 0) > 1; + const instanceProvider = (provider: AuthProvider, instance: ProviderCredentialInstance): AuthProvider => ({ + ...provider, + authenticated: instance.authenticated, + expired: instance.expired, + keyHint: instance.keyHint, + instanceId: instance.instanceId, + type: instance.type ?? provider.type, + // A provider-level status can only say that some account is logging in; a row is active + // only when its instance-keyed local state says so. + loginInProgress: false, + }); + /* + FNXC:ProviderAuth 2026-08-01-06:57: + Multi-account cards render credential actions inside their matching instance row. This prevents + a legacy provider-level Save, Cancel, or manual-code control from silently targeting default. + Single and empty cards deliberately retain the existing provider-level markup and behavior. + */ + const renderInstanceControls = (provider: AuthProvider) => { + if (provider.type === "cli") return null; + const instances = provider.instances ?? []; + const pending = pendingInstances[provider.id]; + const add = () => setPendingInstances((current) => ({ + ...current, + [provider.id]: { instanceId: newProviderInstanceId(instances.map((item) => item.instanceId)), label: "" }, + })); + const discard = () => setPendingInstances((current) => { + const next = { ...current }; + delete next[provider.id]; + return next; + }); + return
+ {instances.length > 1 &&
+ {instances.map((item: ProviderCredentialInstance) => { + const rowProvider = instanceProvider(provider, item); + return
+ {item.label || item.instanceId}{item.isDefault ? ` (${t("settings.auth.default", "Default")})` : ""} + {!item.isDefault && } + + + {providerSupportsApiKey(rowProvider) + ? renderApiKeySection(rowProvider, item.instanceId) + : item.authenticated + ? renderAuthenticatedOAuthActions(rowProvider, item.instanceId) + : renderAvailableOAuthActions(rowProvider, item.instanceId)} +
; + })} +
} + {pending &&
+ setPendingInstances((current) => ({ ...current, [provider.id]: { ...pending, label: event.target.value } }))} /> + {providerSupportsApiKey(provider) + ? renderApiKeySection(provider, pending.instanceId, pending.label, true) + : renderAvailableOAuthActions(provider, pending.instanceId, pending.label || undefined)} + +
} + {!pending && } +
; + }; + /* FNXC:ProviderAuth 2026-07-14-15:54: Provider authentication failures must remain visible on the affected card. Toasts are transient and can fire while Settings is closed, so render the server's loginError beside the provider actions as the durable re-auth remediation. */ const renderProviderAuthError = (provider: AuthProvider) => provider.loginError ? ({provider.loginError}) : null; - const renderApiKeySection = (provider: AuthProvider) => (
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> - {provider.keyHint && !apiKeyInputs[provider.id] ? () : ()} -
- {authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} - {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - {opencodeApiKeyRefreshStatus[provider.id].message} - )} -
); - const renderAuthenticatedOAuthActions = (provider: AuthProvider) => (
- {authActionInProgress === provider.id ? () : provider.loginInProgress ? (
- - -
) : ()} -
); - const renderAvailableOAuthActions = (provider: AuthProvider) => (
- {authActionInProgress === provider.id ? () : provider.loginInProgress ? (
- - -
) : ()} - {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
+ const renderApiKeySection = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string, isPending = false) => { + const instanceId = selectedInstanceId ?? provider.instanceId; + const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" }); + return
+
+ setApiKeyInputs((prev) => ({ ...prev, [stateKey]: e.target.value }))} disabled={isAuthActionActive(stateKey)}/> + {provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? : } +
+ {isAuthActionActive(stateKey) && {t("settings.auth.savingKey", "Saving…")}} + {apiKeyErrors[stateKey] && {apiKeyErrors[stateKey]}} + {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[stateKey] && {opencodeApiKeyRefreshStatus[stateKey].message}} +
; + }; + const renderAuthenticatedOAuthActions = (provider: AuthProvider, selectedInstanceId?: string) => { + const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: selectedInstanceId ?? provider.instanceId ?? "default" }); + return
+ {isAuthActionActive(stateKey) ? + : provider.loginInProgress ?
+ : } +
; + }; + const renderAvailableOAuthActions = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string) => { + const instanceId = selectedInstanceId ?? provider.instanceId; + const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" }); + const isActive = provider.loginInProgress || isAuthActionActive(stateKey); + return
+ {isAuthActionActive(stateKey) ?
+ : provider.loginInProgress ?
+ : } + {provider.id === "github-copilot" && deviceCodes[stateKey] && isActive &&
{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")} -
{deviceCodes[provider.id].userCode}
+
{deviceCodes[stateKey].userCode}
- - + +
-
)} - {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ()} - {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id)} prompt={manualCodeConfigs[provider.id].prompt} placeholder={manualCodeConfigs[provider.id].placeholder} helpText={manualCodeConfigs[provider.id].helpText} disabled={manualCodeSubmitInProgress === provider.id} submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${provider.id}`}/>)}
); +
} + {loginInstructions[stateKey] && isActive && } + {manualCodeConfigs[stateKey] && isActive && setManualCodeInputs((prev) => ({ ...prev, [stateKey]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id, instanceId)} prompt={manualCodeConfigs[stateKey].prompt} placeholder={manualCodeConfigs[stateKey].placeholder} helpText={manualCodeConfigs[stateKey].helpText} disabled={manualCodeSubmitInProgress === stateKey} submitLabel={manualCodeSubmitInProgress === stateKey ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${stateKey}`}/>} +
; + }; /* FNXC:ProviderAuth 2026-06-29-22:18: Settings must render Anthropic subscription OAuth and raw Anthropic API-key auth as separate provider cards. @@ -297,9 +346,10 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec {renderAnthropicPrecedenceBadge(provider)} {provider.authenticated && provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})} - {provider.type !== "api_key" &&
{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}
} - {providerSupportsApiKey(provider) && renderApiKeySection(provider)} + {provider.type !== "api_key" && !hasMultipleInstances(provider) &&
{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}
} + {providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)} + {renderInstanceControls(provider)} ))} {renderAnthropicPrecedenceRow()} )} @@ -318,9 +368,10 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
{provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})} - {provider.type !== "api_key" &&
{renderAvailableOAuthActions(provider)}{renderProviderAuthError(provider)}
} - {providerSupportsApiKey(provider) && renderApiKeySection(provider)} + {provider.type !== "api_key" && !hasMultipleInstances(provider) &&
{renderAvailableOAuthActions(provider)}{renderProviderAuthError(provider)}
} + {providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)} + {renderInstanceControls(provider)} ))} )} )} diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 853359340b..de12e7ae17 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -984,14 +984,14 @@ describe("GET /auth/status", () => { "zai", ]); const githubCopilot = providers.find((p: any) => p.id === "github-copilot"); - expect(githubCopilot).toEqual({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false }); + expect(githubCopilot).toEqual(expect.objectContaining({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false })); const openrouter = providers.find((p: any) => p.id === "openrouter"); - expect(openrouter).toEqual({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }); + expect(openrouter).toEqual(expect.objectContaining({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" })); const kimiCoding = providers.find((p: any) => p.id === "kimi-coding"); - expect(kimiCoding).toEqual({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }); + expect(kimiCoding).toEqual(expect.objectContaining({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" })); // Catalog-only entries (not reported by storage) still surface, present-but-unauthenticated. const brave = providers.find((p: any) => p.id === "brave"); - expect(brave).toEqual({ id: "brave", name: "Brave Search", authenticated: false, type: "api_key" }); + expect(brave).toEqual(expect.objectContaining({ id: "brave", name: "Brave Search", authenticated: false, type: "api_key" })); expect(authStorage.reload).toHaveBeenCalled(); }); @@ -1054,14 +1054,14 @@ describe("GET /auth/status", () => { expect(res.status).toBe(200); const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot"); - expect(githubCopilot).toEqual({ + expect(githubCopilot).toEqual(expect.objectContaining({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false, - }); + })); }); it("includes oauth and model-registry-derived API key providers in one response", async () => { @@ -1101,15 +1101,15 @@ describe("GET /auth/status", () => { "acme-extension", ]); const githubCopilot = providers.find((p: any) => p.id === "github-copilot"); - expect(githubCopilot).toEqual({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false }); + expect(githubCopilot).toEqual(expect.objectContaining({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false })); const openaiCodex = providers.find((p: any) => p.id === "openai-codex"); - expect(openaiCodex).toEqual({ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true }); + expect(openaiCodex).toEqual(expect.objectContaining({ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true })); const openrouter = providers.find((p: any) => p.id === "openrouter"); - expect(openrouter).toEqual({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }); + expect(openrouter).toEqual(expect.objectContaining({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" })); const kimiCoding = providers.find((p: any) => p.id === "kimi-coding"); - expect(kimiCoding).toEqual({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }); + expect(kimiCoding).toEqual(expect.objectContaining({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" })); const acmeExtension = providers.find((p: any) => p.id === "acme-extension"); - expect(acmeExtension).toEqual({ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" }); + expect(acmeExtension).toEqual(expect.objectContaining({ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" })); }); it.each(["https://my-host.example.com", undefined])( @@ -3311,11 +3311,13 @@ describe("GET /auth/oauth-callback", () => { (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ { id: "google", name: "Google" }, ]); + let finishLogin: (() => void) | undefined; (authStorage.login as ReturnType).mockImplementation((_provider: string, callbacks: any) => { callbacks.onAuth({ url: `https://accounts.example.com/o/oauth2/v2/auth?state=test-state&redirect_uri=${encodeURIComponent(`http://localhost:${port}/oauth2callback`)}`, }); - return Promise.resolve(); + // The proxy session is valid only while its bound login remains active. + return new Promise((resolve) => { finishLogin = resolve; }); }); const app = buildApp(); @@ -3331,6 +3333,7 @@ describe("GET /auth/oauth-callback", () => { const res = await REQUEST(app, "GET", "/api/auth/oauth-callback?code=test-code&state=test-state"); expect(res.status).toBe(200); expect(String(res.body)).toContain("proxied:test-code:test-state"); + finishLogin?.(); } finally { await new Promise((resolve, reject) => callbackListener.close((err) => (err ? reject(err) : resolve()))); } diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 47b30ff113..723e0ae03c 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -10,7 +10,7 @@ import multer from "multer"; import { resolve, sep, join, isAbsolute } from "node:path"; import * as nodeFs from "node:fs"; -import type { AnthropicProviderRegistration, TaskStore, ModelPreset, ThinkingLevel } from "@fusion/core"; +import type { AnthropicProviderRegistration, TaskStore, ModelPreset, ThinkingLevel, ProviderInstanceRef } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -168,6 +168,16 @@ export interface AuthStorageLike { getApiKey?(providerId: string): string | null | undefined | Promise; /** Get raw stored credentials for usage providers. */ get?(providerId: string): { type?: string; key?: string; access?: string; refresh?: string; expires?: number; [key: string]: unknown } | null | undefined; + listInstances?(providerId: string): ProviderInstanceRef[]; + getInstance?(ref: ProviderInstanceRef): { type?: string; key?: string; access?: string; refresh?: string; expires?: number; [key: string]: unknown } | null | undefined; + setInstanceApiKey?(ref: ProviderInstanceRef, apiKey: string, label?: string): Promise; + loginInstance?(ref: ProviderInstanceRef, callbacks: Parameters[1], label?: string): Promise; + logoutInstance?(ref: ProviderInstanceRef): Promise; + clearInstanceApiKey?(ref: ProviderInstanceRef): Promise; + removeInstance?(ref: ProviderInstanceRef): Promise; + getDefaultInstance?(providerId: string): ProviderInstanceRef | undefined; + setDefaultInstance?(ref: ProviderInstanceRef): Promise; + renameInstance?(ref: ProviderInstanceRef, label?: string): Promise; } /* diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 1aaaf7836c..a2a949e5d4 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -1,4 +1,4 @@ -import { createLogger } from "@fusion/core"; +import { createLogger, DEFAULT_PROVIDER_INSTANCE_ID, isValidProviderInstanceId } from "@fusion/core"; const severityAuditLog = createLogger("dashboard-register-auth-routes"); import type { Request } from "express"; @@ -117,6 +117,34 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { * - If key length <= 8: return 8 bullets (never reveal short keys) * - Otherwise: first 3 chars + 5 bullets + last 4 chars */ + /* + FNXC:ProviderAuth 2026-08-01-06:11: + Instance ids are scoped to a provider. Credential-establishing writes may create a supplied id, + but all management mutations require an existing row; labels are optional opaque display text. + */ + function resolveInstanceId(value: unknown): string { + if (value === undefined || value === null || (typeof value === "string" && !value.trim())) return DEFAULT_PROVIDER_INSTANCE_ID; + if (!isValidProviderInstanceId(value)) throw badRequest("instance must be a valid provider instance id"); + return value; + } + + // Empty strings are wire-compatible with omission, including for CLI-provider validation. + function hasExplicitInstance(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; + } + + function validateLabel(value: unknown, required = false): string | undefined { + if (value === undefined || value === null || value === "") { + if (required) throw badRequest("label is required"); + return undefined; + } + if (typeof value !== "string") throw badRequest("label must be a string"); + const label = value.trim(); + if (label.length > 60) throw badRequest("label must be at most 60 characters"); + if (required && !label) throw badRequest("label is required"); + return label || undefined; + } + function maskApiKey(key: string): string { if (key.length <= 8) { return "••••••••"; @@ -220,19 +248,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { }; type PendingLogin = { + provider: string; abortController: AbortController; inputPromise: Promise; resolveInput: (input: string) => void; rejectInput: (error: Error) => void; inputSubmitted: boolean; manualCode?: ManualCodeConfig; + instanceId: string; + label?: string; }; - /** - * Track in-progress login flows to prevent concurrent logins for the same provider. - * Maps provider ID → pending interactive login state. - */ + /* + FNXC:ProviderAuth 2026-08-01-06:25: + OAuth is a multi-request flow, so its server-side entry is keyed by provider plus instance. + The bound id and opaque label must survive login, manual-code, cancel, and callback handling; + an absent or mismatched flow must fail rather than re-targeting the default credential. + */ const loginInProgress = new Map(); + const loginKey = (providerId: string, instanceId: string) => `${providerId}::${instanceId}`; /* FNXC:ProviderAuth 2026-07-05-00:00: @@ -242,7 +276,8 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { const lastLoginError = new Map(); const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000; - const oauthSessions = new Map(); + type OauthSession = { port: number; path: string; originalRedirectUri: string; expiresAt: number; flowKey: string; provider: string; instanceId: string; timeout: ReturnType }; + const oauthSessions = new Map(); function isLocalhostOrigin(origin: string): boolean { try { @@ -259,25 +294,57 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return `${safeTitle}

${safeTitle}

${safeDetail ? `

${safeDetail}

` : ""}

You can close this tab.

`; } - function cleanupExpiredOauthSessions(): void { - const now = Date.now(); + function deleteOauthSession(state: string): void { + const session = oauthSessions.get(state); + if (session) clearTimeout(session.timeout); + oauthSessions.delete(state); + } + + function deleteOauthSessionsForFlow(flowKey: string): void { for (const [state, session] of oauthSessions.entries()) { - if (session.expiresAt <= now) { - oauthSessions.delete(state); - } + if (session.flowKey === flowKey) deleteOauthSession(state); } } - function setOauthSession(state: string, details: { port: number; path: string; originalRedirectUri: string }): void { + function cancelOauthFlow(flowKey: string, reason: Error): void { + const activeLogin = loginInProgress.get(flowKey); + if (!activeLogin) return; + loginInProgress.delete(flowKey); + deleteOauthSessionsForFlow(flowKey); + activeLogin.inputSubmitted = true; + activeLogin.rejectInput(reason); + activeLogin.abortController.abort(); + } + + function expireOauthSession(state: string, session: OauthSession): void { + deleteOauthSession(state); + // A proxy state is the only callback route for this dashboard-origin flow. Once it expires, + // terminate its bound login so a later callback cannot persist a cancelled account. + cancelOauthFlow(session.flowKey, new Error("OAuth session expired")); + } + + function cleanupExpiredOauthSessions(): void { + const now = Date.now(); + for (const [state, session] of oauthSessions.entries()) { + if (session.expiresAt <= now) expireOauthSession(state, session); + } + } + + /* + FNXC:ProviderAuth 2026-08-01-07:20: + Redirect callbacks carry only OAuth state. Bind that state to the active provider-instance flow + and delete it on every terminal path, so a cancelled or forged callback can never resume or + overwrite a default credential. + */ + function setOauthSession(state: string, details: { port: number; path: string; originalRedirectUri: string; flowKey: string; provider: string; instanceId: string }): void { cleanupExpiredOauthSessions(); - oauthSessions.set(state, { ...details, expiresAt: Date.now() + OAUTH_SESSION_TTL_MS }); + const expiresAt = Date.now() + OAUTH_SESSION_TTL_MS; const timeout = setTimeout(() => { const current = oauthSessions.get(state); - if (current && current.expiresAt <= Date.now()) { - oauthSessions.delete(state); - } + if (current?.expiresAt === expiresAt) expireOauthSession(state, current); }, OAUTH_SESSION_TTL_MS + 1_000); timeout.unref(); + oauthSessions.set(state, { ...details, expiresAt, timeout }); } function rewriteAuthUrl(authUrl: string, origin: string): { url: string; state: string; originalRedirectUri: string; port: number; path: string } { @@ -569,6 +636,12 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { try { const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined; const storage = getAuthStorage(); + const requestedProvider = typeof req.query.provider === "string" ? req.query.provider : undefined; + const rawRequestedInstance = typeof req.query.instance === "string" ? req.query.instance : undefined; + if (rawRequestedInstance?.trim() && !requestedProvider) throw badRequest("instance requires provider"); + if (rawRequestedInstance?.trim() && !isValidProviderInstanceId(rawRequestedInstance.trim())) throw badRequest("instance must be a valid provider instance id"); + if (rawRequestedInstance?.trim() && requestedProvider && syntheticCliProviderIds.has(requestedProvider)) throw badRequest("CLI providers do not support credential instances"); + const requestedInstance = rawRequestedInstance?.trim() || undefined; storage.reload(); /* FNXC:ProviderAuth 2026-07-07-00:00: @@ -631,7 +704,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { authenticated: hasAuth && !expired && !missingInferenceScope, type: "oauth" as const, expired: expired || missingInferenceScope, - loginInProgress: loginInProgress.has(statusProvider.id), + loginInProgress: [...loginInProgress.keys()].some((key) => key.startsWith(`${statusProvider.id}::`)), requiresManualCode: getManualCodeConfig(toOauthLoginProviderId(statusProvider.id), origin) !== undefined || undefined, loginError: lastLoginError.get(statusProvider.id) ?? scopeLoginError ?? expiryLoginError, }; @@ -809,7 +882,61 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { gitCli = { available: false, installUrl: GIT_INSTALL_URL }; } - res.json({ providers, ghCli, gitCli }); + /* + FNXC:ProviderAuth 2026-08-01-06:11: + Status remains the full provider envelope: a targeted instance only re-points its named + provider. A missing well-formed target is safely unauthenticated, never default fallback. + */ + const instanceProviders = providers.map((provider) => { + if (syntheticCliProviderIds.has(provider.id)) return provider; + const target = requestedProvider === provider.id ? requestedInstance : undefined; + const defaultRef = storage.getDefaultInstance?.(provider.id); + const instanceId = target ?? defaultRef?.instanceId ?? DEFAULT_PROVIDER_INSTANCE_ID; + const ref = { providerId: provider.id, instanceId }; + const credential = target ? storage.getInstance?.(ref) : undefined; + if (target && !credential && storage.getInstance) { + return { ...provider, instanceId, authenticated: false, expired: false, keyHint: undefined, instances: [] }; + } + const refs = storage.listInstances?.(provider.id) ?? []; + const targetedCredential = target ? credential : undefined; + const targetedKey = targetedCredential?.type === "api_key" && typeof targetedCredential.key === "string" + ? targetedCredential.key : undefined; + const targetExpired = targetedCredential?.type === "oauth" + && (typeof targetedCredential.expires !== "number" || Date.now() >= targetedCredential.expires); + return { + ...provider, + /* + FNXC:ProviderAuth 2026-08-01-06:57: + A targeted poll is also the UI's per-row flow signal, so it must not report a sibling + account's in-flight login as this account's activity. + */ + ...(target ? { loginInProgress: loginInProgress.has(loginKey(provider.id, instanceId)) } : {}), + ...(targetedCredential ? { + authenticated: targetedCredential.type === "api_key" ? Boolean(targetedKey) : !targetExpired, + expired: Boolean(targetExpired), + ...(targetedKey ? { keyHint: maskApiKey(targetedKey) } : { keyHint: undefined }), + } : {}), + instanceId, + instances: (target ? refs.filter((item) => item.instanceId === instanceId) : refs) + .map((item) => { + const instanceCredential = storage.getInstance?.({ providerId: provider.id, instanceId: item.instanceId }); + const instanceKey = instanceCredential?.type === "api_key" && typeof instanceCredential.key === "string" + ? instanceCredential.key : undefined; + const expired = instanceCredential?.type === "oauth" + && (typeof instanceCredential.expires !== "number" || Date.now() >= instanceCredential.expires); + return { + instanceId: item.instanceId, + ...(typeof instanceCredential?.label === "string" ? { label: instanceCredential.label } : {}), + isDefault: item.instanceId === defaultRef?.instanceId, + authenticated: instanceCredential?.type === "api_key" ? Boolean(instanceKey) : Boolean(instanceCredential) && !expired, + expired: Boolean(expired), + ...(instanceCredential?.type ? { type: instanceCredential.type } : {}), + ...(instanceKey ? { keyHint: maskApiKey(instanceKey) } : {}), + }; + }), + }; + }); + res.json({ providers: instanceProviders, ghCli, gitCli }); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -1429,18 +1556,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/auth/login", async (req, res) => { try { - const { provider, origin } = req.body; + const { provider, origin, instance, label } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } if (origin !== undefined && typeof origin !== "string") { throw badRequest("origin must be a string when provided"); } - + // Validate before invoking OAuth; storage fallback retains legacy default behavior. + const instanceId = resolveInstanceId(instance); + const hasNamedInstance = hasExplicitInstance(instance); + const instanceLabel = validateLabel(label); + if (hasNamedInstance && syntheticCliProviderIds.has(provider)) throw badRequest("CLI providers do not support credential instances"); const storageProvider = toOauthLoginProviderId(provider); + const flowKey = loginKey(provider, instanceId); - // Prevent concurrent logins for the same provider - if (loginInProgress.has(provider)) { + // Different accounts may authenticate together; only a duplicate account flow conflicts. + if (loginInProgress.has(flowKey)) { throw conflict(`Login already in progress for ${provider}`); } @@ -1484,14 +1616,17 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } }); const pendingLogin: PendingLogin = { + provider, abortController, inputPromise, resolveInput, rejectInput, inputSubmitted: false, manualCode: getManualCodeConfig(storageProvider, origin), + instanceId, + label: instanceLabel, }; - loginInProgress.set(provider, pendingLogin); + loginInProgress.set(flowKey, pendingLogin); let autoPromptConsumed = false; @@ -1518,7 +1653,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { let resolvedDeviceCode: DeviceCodeInfo | undefined; // Start login flow in background — don't await the full login - const loginPromise = storage.login(loginProvider, { + const loginCallbacks: Parameters[1] = { onAuth: (info) => { if (!resolvedDeviceCode) { const parsedUserCode = @@ -1565,7 +1700,10 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { onProgress: () => {}, // no-op for web UI onSelect: async (prompt) => selectOauthOption(storageProvider, prompt), signal: abortController.signal, - }); + }; + const loginPromise = hasNamedInstance && storage.loginInstance + ? storage.loginInstance({ providerId: loginProvider, instanceId }, loginCallbacks, instanceLabel) + : storage.login(loginProvider, loginCallbacks); // Race: either we get the auth URL or the login completes/fails first const timeout = setTimeout(() => { @@ -1591,7 +1729,8 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { }) .finally(() => { clearTimeout(timeout); - loginInProgress.delete(provider); + loginInProgress.delete(flowKey); + deleteOauthSessionsForFlow(flowKey); }); const authInfo = await authUrlPromise; @@ -1604,6 +1743,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { port: rewritten.port, path: rewritten.path, originalRedirectUri: rewritten.originalRedirectUri, + flowKey, + provider, + instanceId, }); responseUrl = rewritten.url; } @@ -1620,7 +1762,11 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } // Clean up on error const provider = req.body?.provider; - if (provider) loginInProgress.delete(provider); + if (provider) { + const flowKey = loginKey(provider, resolveInstanceId(req.body?.instance)); + loginInProgress.delete(flowKey); + deleteOauthSessionsForFlow(flowKey); + } rethrowAsApiError(err); } }); @@ -1633,21 +1779,19 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/auth/cancel", (req, res) => { try { - const { provider } = req.body; + const { provider, instance } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } - const activeLogin = loginInProgress.get(provider); + const activeLogin = loginInProgress.get(loginKey(provider, resolveInstanceId(instance))); if (!activeLogin) { res.json({ success: true, cancelled: false }); return; } - loginInProgress.delete(provider); - activeLogin.inputSubmitted = true; - activeLogin.rejectInput(new Error("cancelled")); - activeLogin.abortController.abort(); + const activeFlowKey = loginKey(provider, activeLogin.instanceId); + cancelOauthFlow(activeFlowKey, new Error("cancelled")); res.json({ success: true, cancelled: true }); } catch (err: unknown) { if (err instanceof ApiError) { @@ -1665,7 +1809,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/auth/manual-code", async (req, res) => { try { - const { provider, code } = req.body; + const { provider, code, instance } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } @@ -1673,11 +1817,17 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest("code is required"); } - const activeLogin = loginInProgress.get(provider); + const instanceId = resolveInstanceId(instance); + const activeLogin = loginInProgress.get(loginKey(provider, instanceId)); + const providerFlow = [...loginInProgress.values()].find((flow) => flow.provider === provider); if (!activeLogin) { + if (hasExplicitInstance(instance) && providerFlow && providerFlow.instanceId !== instanceId) { + throw badRequest("instance does not match the active login flow"); + } throw conflict(`No login in progress for ${provider}`); } + if (hasExplicitInstance(instance) && instanceId !== activeLogin.instanceId) throw badRequest("instance does not match the active login flow"); if (activeLogin.inputSubmitted) { res.json({ success: true, submitted: false }); return; @@ -1703,6 +1853,16 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { const state = typeof req.query.state === "string" ? req.query.state : undefined; if (error) { + if (state) { + const failedSession = oauthSessions.get(state); + if (failedSession) { + deleteOauthSession(state); + const failedLogin = loginInProgress.get(failedSession.flowKey); + if (failedLogin) { + cancelOauthFlow(failedSession.flowKey, new Error("cancelled")); + } + } + } return res.status(400).type("text/html").send(simpleErrorHtml("OAuth failed", error)); } @@ -1712,21 +1872,29 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { cleanupExpiredOauthSessions(); const session = oauthSessions.get(state); - if (!session || session.expiresAt <= Date.now()) { - oauthSessions.delete(state); + const activeLogin = session ? loginInProgress.get(session.flowKey) : undefined; + if (!session || session.expiresAt <= Date.now() || !activeLogin || activeLogin.provider !== session.provider || activeLogin.instanceId !== session.instanceId) { + if (session) deleteOauthSession(state); return res.status(400).type("text/html").send(simpleErrorHtml("OAuth session expired or not found")); } const callbackUrl = new URL(`http://localhost:${session.port}${session.path}`); + // Consume before proxying so a network error cannot leave a replayable state session. + deleteOauthSession(state); callbackUrl.searchParams.set("code", code); callbackUrl.searchParams.set("state", state); - const callbackResponse = await fetch(callbackUrl, { method: "GET" }); + let callbackResponse: Response; + try { + callbackResponse = await fetch(callbackUrl, { method: "GET" }); + } catch (error) { + // The state was consumed before forwarding; stop its bound flow on transport failure too. + cancelOauthFlow(session.flowKey, error instanceof Error ? error : new Error(String(error))); + throw error; + } const responseBody = await callbackResponse.text(); const contentType = callbackResponse.headers.get("content-type") ?? "text/html"; - oauthSessions.delete(state); - return res.status(callbackResponse.status).type(contentType).send(responseBody); } catch (err: unknown) { if (err instanceof ApiError) { @@ -1744,13 +1912,21 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/auth/logout", async (req, res) => { try { - const { provider } = req.body; + const { provider, instance } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } const storage = getAuthStorage(); - await storage.logout(toOauthCredentialProviderId(provider)); + const instanceId = resolveInstanceId(instance); + if (hasExplicitInstance(instance) && syntheticCliProviderIds.has(provider)) throw badRequest("CLI providers do not support credential instances"); + if (hasExplicitInstance(instance) && storage.logoutInstance) { + const ref = { providerId: toOauthCredentialProviderId(provider), instanceId }; + if (!storage.getInstance?.(ref)) throw new ApiError(404, "Credential instance not found"); + await storage.logoutInstance(ref); + } else { + await storage.logout(toOauthCredentialProviderId(provider)); + } clearUsageCache(); res.json({ success: true }); } catch (err: unknown) { @@ -1772,7 +1948,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ router.post("/auth/api-key", async (req, res) => { try { - const { provider, apiKey } = req.body; + const { provider, apiKey, instance, label } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } @@ -1794,7 +1970,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest(`Unknown API key provider: ${provider}`); } - await storage.setApiKey(provider, apiKey.trim()); + const instanceId = resolveInstanceId(instance); + const instanceLabel = validateLabel(label); + if (hasExplicitInstance(instance) && storage.setInstanceApiKey) { + await storage.setInstanceApiKey({ providerId: provider, instanceId }, apiKey.trim(), instanceLabel); + } else { + await storage.setApiKey(provider, apiKey.trim()); + } let modelsRefreshed: number | undefined; let refreshReason: "no-models-from-cli" | "cli-failed" | "disabled-by-settings" | undefined; @@ -1834,7 +2016,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ router.delete("/auth/api-key", async (req, res) => { try { - const { provider } = req.body; + const { provider, instance } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } @@ -1854,7 +2036,14 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest(`Unknown API key provider: ${provider}`); } - await storage.clearApiKey(provider); + const instanceId = resolveInstanceId(instance); + if (hasExplicitInstance(instance) && storage.clearInstanceApiKey) { + const ref = { providerId: provider, instanceId }; + if (!storage.getInstance?.(ref)) throw new ApiError(404, "Credential instance not found"); + await storage.clearInstanceApiKey(ref); + } else { + await storage.clearApiKey(provider); + } // No model refresh needed on delete: removing the key leaves nothing to sync. clearUsageCache(); res.json({ success: true }); @@ -1865,4 +2054,61 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { rethrowAsApiError(err); } }); + + router.get("/auth/providers/:provider/instances", (req, res) => { + try { + const provider = req.params.provider; + const storage = getAuthStorage(); + const refs = storage.listInstances?.(provider) ?? (storage.hasAuth(provider) || storage.hasApiKey?.(provider) + ? [{ providerId: provider, instanceId: DEFAULT_PROVIDER_INSTANCE_ID }] + : []); + const defaultRef = storage.getDefaultInstance?.(provider); + res.json({ instances: refs.map((ref) => { + const credential = storage.getInstance?.(ref); + const key = credential?.type === "api_key" && typeof credential.key === "string" ? credential.key : undefined; + // Instance listings must apply the same fail-safe OAuth expiry semantics as /auth/status. + const expired = credential?.type === "oauth" && (typeof credential.expires !== "number" || !Number.isFinite(credential.expires) || Date.now() >= credential.expires); + return { + instanceId: ref.instanceId, + ...(typeof credential?.label === "string" ? { label: credential.label } : {}), + isDefault: (defaultRef?.instanceId ?? DEFAULT_PROVIDER_INSTANCE_ID) === ref.instanceId, + authenticated: Boolean(credential ?? storage.hasAuth(provider)) && !expired, + ...(expired ? { expired: true } : {}), + ...(credential?.type ? { type: credential.type } : {}), + ...(key ? { keyHint: maskApiKey(key) } : {}), + }; + }) }); + } catch (err: unknown) { if (err instanceof ApiError) throw err; rethrowAsApiError(err); } + }); + + router.post("/auth/providers/:provider/instances/:instance/rename", async (req, res) => { + try { + const ref = { providerId: req.params.provider, instanceId: resolveInstanceId(req.params.instance) }; + const storage = getAuthStorage(); + if (!storage.getInstance?.(ref) || !storage.renameInstance) throw new ApiError(404, "Credential instance not found"); + await storage.renameInstance(ref, validateLabel(req.body?.label, true)); + res.json({ success: true }); + } catch (err: unknown) { if (err instanceof ApiError) throw err; rethrowAsApiError(err); } + }); + + router.post("/auth/providers/:provider/default-instance", async (req, res) => { + try { + const ref = { providerId: req.params.provider, instanceId: resolveInstanceId(req.body?.instance) }; + const storage = getAuthStorage(); + if (!storage.getInstance?.(ref) || !storage.setDefaultInstance) throw new ApiError(404, "Credential instance not found"); + await storage.setDefaultInstance(ref); + res.json({ success: true }); + } catch (err: unknown) { if (err instanceof ApiError) throw err; rethrowAsApiError(err); } + }); + + router.delete("/auth/providers/:provider/instances/:instance", async (req, res) => { + try { + const ref = { providerId: req.params.provider, instanceId: resolveInstanceId(req.params.instance) }; + const storage = getAuthStorage(); + if (!storage.getInstance?.(ref) || !storage.removeInstance) throw new ApiError(404, "Credential instance not found"); + await storage.removeInstance(ref); + clearUsageCache(); + res.json({ success: true }); + } catch (err: unknown) { if (err instanceof ApiError) throw err; rethrowAsApiError(err); } + }); }; diff --git a/packages/engine/src/__tests__/provider-auth-instances.test.ts b/packages/engine/src/__tests__/provider-auth-instances.test.ts new file mode 100644 index 0000000000..6ff3aeec2f --- /dev/null +++ b/packages/engine/src/__tests__/provider-auth-instances.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { FusionAuthStorage } from "../auth-storage.js"; +import { wrapAuthStorageWithApiKeyProviders } from "../provider-auth.js"; + +function storageFixture() { + const credential = { type: "api_key" as const, key: "secret", label: "Second account" }; + return { + reload: vi.fn(), getOAuthProviders: vi.fn(() => []), hasAuth: vi.fn(() => false), + login: vi.fn(), logout: vi.fn(), getApiKey: vi.fn(), getApiKeyProviders: vi.fn(() => []), + set: vi.fn(), remove: vi.fn(), get: vi.fn(), getAll: vi.fn(() => ({})), list: vi.fn(() => []), + modify: vi.fn(), setModelRuntime: vi.fn(), hasApiKey: vi.fn(() => false), + listInstances: vi.fn(() => [{ providerId: "brave", instanceId: "acct-two" }]), + getInstance: vi.fn(() => credential), setInstance: vi.fn(), removeInstance: vi.fn(), + getDefaultInstance: vi.fn(), setDefaultInstance: vi.fn(), + }; +} + +describe("DashboardAuthStorage instance facade", () => { + it("clears an API-key credential without deleting its named instance", async () => { + const storage = storageFixture(); + const facade = wrapAuthStorageWithApiKeyProviders(storage as unknown as FusionAuthStorage, {} as ModelRegistry); + await facade.clearInstanceApiKey?.({ providerId: "brave", instanceId: "acct-two" }); + expect(storage.setInstance).toHaveBeenCalledWith( + { providerId: "brave", instanceId: "acct-two" }, + { type: "api_key", key: "", label: "Second account" }, + ); + expect(storage.removeInstance).not.toHaveBeenCalled(); + }); + + it("delegates explicit removal to removeInstance rather than credential clear", async () => { + const storage = storageFixture(); + const facade = wrapAuthStorageWithApiKeyProviders(storage as unknown as FusionAuthStorage, {} as ModelRegistry); + await facade.removeInstance?.({ providerId: "brave", instanceId: "acct-two" }); + expect(storage.removeInstance).toHaveBeenCalledWith({ providerId: "brave", instanceId: "acct-two" }); + expect(storage.setInstance).not.toHaveBeenCalled(); + }); + + it("makes a first named OAuth login the default without retaining adapter default", async () => { + const credentials = new Map(); + let defaultId: string | undefined; + const storage = { + ...storageFixture(), + getDefaultInstance: vi.fn(() => defaultId ? { providerId: "openai-codex", instanceId: defaultId } : undefined), + getInstance: vi.fn((ref: { instanceId: string }) => credentials.get(ref.instanceId)), + get: vi.fn(() => defaultId ? credentials.get(defaultId) : undefined), + login: vi.fn(async () => { + defaultId = "default"; + credentials.set("default", { type: "oauth", expires: Date.now() + 60_000 }); + }), + setInstance: vi.fn(async (ref: { instanceId: string }, credential: { type: "oauth"; expires: number }) => { + credentials.set(ref.instanceId, credential); + }), + removeInstance: vi.fn(async (ref: { instanceId: string }) => { + credentials.delete(ref.instanceId); + if (defaultId === ref.instanceId) defaultId = undefined; + }), + setDefaultInstance: vi.fn(async (ref: { instanceId: string }) => { defaultId = ref.instanceId; }), + }; + const facade = wrapAuthStorageWithApiKeyProviders(storage as unknown as FusionAuthStorage, {} as ModelRegistry); + await facade.loginInstance?.({ providerId: "openai-codex", instanceId: "acct-first" }, {} as never); + expect(credentials.has("default")).toBe(false); + expect(defaultId).toBe("acct-first"); + }); +}); diff --git a/packages/engine/src/provider-auth.ts b/packages/engine/src/provider-auth.ts index 3179010c3f..5735b417ef 100644 --- a/packages/engine/src/provider-auth.ts +++ b/packages/engine/src/provider-auth.ts @@ -18,6 +18,8 @@ import { choosePreferredStoredCredential, readStoredCredentialsFromAuthFile, shouldHydrateStoredCredential, + DEFAULT_PROVIDER_INSTANCE_ID, + type ProviderInstanceRef, type StoredAuthCredential, } from "@fusion/core"; export interface LoginCallbacks { @@ -42,6 +44,17 @@ export interface DashboardAuthStorage { hasApiKey(providerId: string): boolean; getApiKey(providerId: string): Promise; get(providerId: string): { type?: string; key?: string } | undefined; + /** Optional while read-only and legacy storage adapters are still supported. */ + listInstances?(providerId: string): ProviderInstanceRef[]; + getInstance?(ref: ProviderInstanceRef): StoredCredential | undefined; + setInstanceApiKey?(ref: ProviderInstanceRef, apiKey: string, label?: string): Promise; + clearInstanceApiKey?(ref: ProviderInstanceRef): Promise; + loginInstance?(ref: ProviderInstanceRef, callbacks: LoginCallbacks, label?: string): Promise; + logoutInstance?(ref: ProviderInstanceRef): Promise; + removeInstance?(ref: ProviderInstanceRef): Promise; + getDefaultInstance?(providerId: string): ProviderInstanceRef | undefined; + setDefaultInstance?(ref: ProviderInstanceRef): Promise; + renameInstance?(ref: ProviderInstanceRef, label?: string): Promise; } interface ReadFallbackAuthStorage { @@ -245,6 +258,82 @@ export function wrapAuthStorageWithApiKeyProviders( } return mergedAuthStorage.getApiKey(storageProviderId); }, + /* + FNXC:ProviderAuth 2026-08-01-06:11: + Credential-establishing instance writes are the only creation seam: a client-generated id may + create or overwrite its credential, while rename/default/logout/remove target existing rows. + Labels are opaque display metadata, never keys; first-default selection remains owned by storage. + removeInstance deletes the row, unlike clearInstanceApiKey which only clears its credential. + */ + listInstances: (providerId) => mergedAuthStorage.listInstances(toApiKeyStorageProviderId(providerId)), + getInstance: (ref) => mergedAuthStorage.getInstance({ ...ref, providerId: toApiKeyStorageProviderId(ref.providerId) }), + setInstanceApiKey: async (ref, apiKey, label) => { + const providerId = toApiKeyStorageProviderId(ref.providerId); + if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) await migrateStoredAnthropicSubscriptionCredential(); + await mergedAuthStorage.setInstance({ providerId, instanceId: ref.instanceId }, { + type: "api_key", key: apiKey, ...(label ? { label } : {}), + }); + }, + clearInstanceApiKey: async (ref) => { + const providerId = toApiKeyStorageProviderId(ref.providerId); + const target = { providerId, instanceId: ref.instanceId }; + const credential = mergedAuthStorage.getInstance(target); + if (!credential) return; + // Preserve the instance metadata/default participation; removal is reserved for removeInstance. + await mergedAuthStorage.setInstance(target, { ...credential, type: "api_key", key: "" }); + }, + loginInstance: async (ref, callbacks, label) => { + const providerId = ref.providerId === ANTHROPIC_STORAGE_PROVIDER_ID + ? ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID + : ref.providerId; + const target = { providerId, instanceId: ref.instanceId }; + const previousDefault = mergedAuthStorage.getDefaultInstance(providerId); + const previousCredential = previousDefault && mergedAuthStorage.getInstance(previousDefault); + /* + FNXC:ProviderAuth 2026-08-01-06:48: + The runtime OAuth adapter only accepts a bare provider and therefore writes its resolved + default slot. Capture and restore that slot around the login before persisting the result to + the requested instance, so adding or reauthorizing an account never repoints its credential. + */ + await mergedAuthStorage.login(providerId, callbacks); + const credential = mergedAuthStorage.get(providerId); + if (!credential) return; + await mergedAuthStorage.setInstance(target, { ...credential, ...(label ? { label } : {}) }); + if (previousDefault && previousCredential && previousDefault.instanceId !== target.instanceId) { + await mergedAuthStorage.setInstance(previousDefault, previousCredential); + } else if (!previousDefault && target.instanceId !== DEFAULT_PROVIDER_INSTANCE_ID) { + /* + FNXC:ProviderAuth 2026-08-01-07:20: + Bare OAuth adapters materialize their first credential in `default`. When a first login + targets another client-generated id, remove that temporary slot and explicitly select the + target so no ghost default remains and the requested account becomes the provider default. + */ + await mergedAuthStorage.removeInstance({ providerId, instanceId: DEFAULT_PROVIDER_INSTANCE_ID }); + await mergedAuthStorage.setDefaultInstance(target); + } + }, + logoutInstance: async (ref) => { + await mergedAuthStorage.removeInstance({ + ...ref, + providerId: ref.providerId === ANTHROPIC_STORAGE_PROVIDER_ID + ? ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID + : ref.providerId, + }); + }, + removeInstance: async (ref) => { + await mergedAuthStorage.removeInstance({ ...ref, providerId: toApiKeyStorageProviderId(ref.providerId) }); + }, + getDefaultInstance: (providerId) => mergedAuthStorage.getDefaultInstance(toApiKeyStorageProviderId(providerId)), + setDefaultInstance: async (ref) => { + await mergedAuthStorage.setDefaultInstance({ ...ref, providerId: toApiKeyStorageProviderId(ref.providerId) }); + }, + renameInstance: async (ref, label) => { + const providerId = toApiKeyStorageProviderId(ref.providerId); + const target = { providerId, instanceId: ref.instanceId }; + const credential = mergedAuthStorage.getInstance(target); + if (!credential) throw new Error("Credential instance not found"); + await mergedAuthStorage.setInstance(target, { ...credential, ...(label ? { label } : {}) }); + }, get: (providerId) => { if (providerId === ANTHROPIC_API_KEY_PROVIDER_ID) { const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);