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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 01:07:59 -07:00
parent df56790c9b
commit 5f12044168
14 changed files with 847 additions and 217 deletions

View File

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

View File

@@ -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. 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=<id>&instance=<id>` 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 ## 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. - 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.

View File

@@ -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 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 persisted-but-inert in this release; runtime credential resolution will consume them in the
follow-up runtime-resolution work. 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.

View File

@@ -5,6 +5,7 @@
* while implementation lives under `app/api/*` modules. * while implementation lives under `app/api/*` modules.
*/ */
export * from "./api/legacy"; export * from "./api/legacy";
export * from "./api/provider-status";
export * from "./api/chat"; export * from "./api/chat";
export * from "./api-node"; export * from "./api-node";
export * from "./api/report"; export * from "./api/report";

View File

@@ -6,9 +6,22 @@
import { api } from "./client.js"; import { api } from "./client.js";
import type { FetchOptions } from "./client.js"; import type { FetchOptions } from "./client.js";
import { dedupe } from "./dedupe.js"; import { dedupe } from "./dedupe.js";
// --- Auth API --- // --- 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 */ /** OAuth provider with current authentication status */
export interface AuthProvider { export interface AuthProvider {
id: string; id: string;
@@ -40,6 +53,19 @@ export interface AuthProvider {
type?: "oauth" | "api_key" | "cli"; type?: "oauth" | "api_key" | "cli";
/** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */ /** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */
keyHint?: string; 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 { export interface ManualOAuthCodeInfo {
@@ -751,20 +777,31 @@ export interface GitCliStatus {
} }
/** Fetch authentication status for all OAuth providers */ /** 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[]; providers: AuthProvider[];
ghCli?: { available: boolean; authenticated: boolean }; ghCli?: { available: boolean; authenticated: boolean };
gitCli?: GitCliStatus; 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[]; providers: AuthProvider[];
ghCli?: { available: boolean; authenticated: boolean }; ghCli?: { available: boolean; authenticated: boolean };
gitCli?: GitCliStatus; gitCli?: GitCliStatus;
}>("/auth/status"), options); }>(url), options);
} }
/** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */ /** 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; url: string;
instructions?: string; instructions?: string;
manualCode?: ManualOAuthCodeInfo; manualCode?: ManualOAuthCodeInfo;
@@ -777,36 +814,36 @@ export function loginProvider(provider: string): Promise<{
deviceCode?: OAuthDeviceCodeInfo; deviceCode?: OAuthDeviceCodeInfo;
}>("/auth/login", { }>("/auth/login", {
method: "POST", 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. */ /** 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", { return api<{ success: boolean; submitted: boolean }>("/auth/manual-code", {
method: "POST", method: "POST",
body: JSON.stringify({ provider, code }), body: JSON.stringify({ provider, code, ...(instance ? { instance } : {}) }),
}); });
} }
/** Logout from a provider, removing stored credentials. */ /** 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", { return api<{ success: boolean }>("/auth/logout", {
method: "POST", method: "POST",
body: JSON.stringify({ provider }), body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }),
}); });
} }
/** Cancel an in-progress OAuth login attempt for a provider. */ /** 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", { return api<{ success: boolean; cancelled: boolean }>("/auth/cancel", {
method: "POST", method: "POST",
body: JSON.stringify({ provider }), body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }),
}); });
} }
/** Save an API key for an API-key-backed provider. */ /** 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; success: boolean;
modelsRefreshed?: number; modelsRefreshed?: number;
refreshReason?: string; refreshReason?: string;
@@ -819,15 +856,40 @@ export function saveApiKey(provider: string, apiKey: string): Promise<{
refreshError?: string; refreshError?: string;
}>("/auth/api-key", { }>("/auth/api-key", {
method: "POST", 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. */ /** 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", { return api<{ success: boolean }>("/auth/api-key", {
method: "DELETE", 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> = []): 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" });
}

View File

@@ -8,7 +8,7 @@ import {
} from "@fusion/core"; } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { DEFAULT_GLOBAL_SETTINGS } 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 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 { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
import { import {
@@ -1429,7 +1429,7 @@ export function SettingsModal({
// Auth state (independent of the settings save flow) // Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]); const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null); const [authActionInProgress, setAuthActionInProgress] = useState<Record<string, boolean>>({});
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({}); const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({}); const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({});
const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({}); const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({});
@@ -1441,7 +1441,7 @@ export function SettingsModal({
tone: "success" | "error"; tone: "success" | "error";
message: string; message: string;
}>>({}); }>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollIntervalRef = useRef<Record<string, ReturnType<typeof setInterval>>>({});
const lastAutoCopiedDeviceCodesRef = useRef<Record<string, string>>({}); const lastAutoCopiedDeviceCodesRef = useRef<Record<string, string>>({});
// Model state // Model state
@@ -2350,12 +2350,10 @@ export function SettingsModal({
setAuthLoading(true); setAuthLoading(true);
loadAuthStatus().finally(() => setAuthLoading(false)); 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 () => { return () => {
if (pollIntervalRef.current) { for (const interval of Object.values(pollIntervalRef.current)) clearInterval(interval);
clearInterval(pollIntervalRef.current); pollIntervalRef.current = {};
pollIntervalRef.current = null;
}
}; };
}, [activeSection, loadAuthStatus]); }, [activeSection, loadAuthStatus]);
@@ -2456,7 +2454,8 @@ export function SettingsModal({
void copyTextToClipboard(copilotDeviceCode.userCode); void copyTextToClipboard(copilotDeviceCode.userCode);
}, [deviceCodes]); }, [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); const provider = authProviders.find((entry) => entry.id === providerId);
if (provider?.requiresManualCode === true) { if (provider?.requiresManualCode === true) {
const shouldContinue = await confirm({ const shouldContinue = await confirm({
@@ -2470,38 +2469,38 @@ export function SettingsModal({
} }
} }
setAuthActionInProgress(providerId); setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
clearAuthLoginUiState(providerId); clearAuthLoginUiState(stateKey);
try { 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)) { if (instructions?.trim() && !(providerId === "github-copilot" && deviceCode)) {
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions })); setLoginInstructions((prev) => ({ ...prev, [stateKey]: instructions }));
} }
if (manualCode) { if (manualCode) {
setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode })); setManualCodeConfigs((prev) => ({ ...prev, [stateKey]: manualCode }));
} }
if (deviceCode && providerId === "github-copilot") { if (deviceCode && providerId === "github-copilot") {
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode })); setDeviceCodes((prev) => ({ ...prev, [stateKey]: deviceCode }));
} }
if (providerId !== "github-copilot" || !deviceCode) { if (providerId !== "github-copilot" || !deviceCode) {
openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url)); openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url));
} }
// Poll for auth completion every 2 seconds // Poll for auth completion every 2 seconds
pollIntervalRef.current = setInterval(async () => { pollIntervalRef.current[stateKey] = setInterval(async () => {
try { try {
const { providers } = await fetchAuthStatus(); const { providers } = await fetchAuthStatus({ provider: providerId, instance: instanceId });
const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers); const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
setAuthProviders(visibleProviders); setAuthProviders(visibleProviders);
const provider = visibleProviders.find((p) => p.id === providerId); const provider = visibleProviders.find((p) => p.id === providerId);
if (provider?.authenticated) { if (provider?.authenticated) {
if (pollIntervalRef.current) { if (pollIntervalRef.current[stateKey]) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current[stateKey]);
pollIntervalRef.current = null; delete pollIntervalRef.current[stateKey];
} }
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
clearAuthLoginUiState(providerId); clearAuthLoginUiState(stateKey);
addToast(t("settings.auth.loginSuccessful", "Login successful"), "success"); addToast(t("settings.auth.loginSuccessful", "Login successful"), "success");
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } })); window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } }));
scrollSettingsToTop(); scrollSettingsToTop();
@@ -2509,12 +2508,12 @@ export function SettingsModal({
} }
if (!provider?.loginInProgress) { if (!provider?.loginInProgress) {
if (pollIntervalRef.current) { if (pollIntervalRef.current[stateKey]) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current[stateKey]);
pollIntervalRef.current = null; delete pollIntervalRef.current[stateKey];
} }
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
clearAuthLoginUiState(providerId); clearAuthLoginUiState(stateKey);
addToast(t("settings.auth.loginDidNotComplete", "Login did not complete. Please try again."), "error"); addToast(t("settings.auth.loginDidNotComplete", "Login did not complete. Please try again."), "error");
} }
} catch { } catch {
@@ -2530,28 +2529,29 @@ export function SettingsModal({
} else { } else {
addToast(message, "error"); addToast(message, "error");
} }
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
clearAuthLoginUiState(providerId); clearAuthLoginUiState(stateKey);
} }
}, [addToast, authProviders, clearAuthLoginUiState, confirm, loadAuthStatus, scrollSettingsToTop]); }, [addToast, authProviders, clearAuthLoginUiState, confirm, loadAuthStatus, scrollSettingsToTop]);
const handleSubmitManualCode = useCallback(async (providerId: string) => { const handleSubmitManualCode = useCallback(async (providerId: string, instanceId?: string) => {
const code = manualCodeInputs[providerId]?.trim(); const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
const code = manualCodeInputs[stateKey]?.trim();
if (!code) { if (!code) {
addToast(t("settings.auth.pasteRedirectUrlFirst", "Paste the full redirect URL or authorization code first."), "warning"); addToast(t("settings.auth.pasteRedirectUrlFirst", "Paste the full redirect URL or authorization code first."), "warning");
return; return;
} }
setManualCodeSubmitInProgress(providerId); setManualCodeSubmitInProgress(stateKey);
try { try {
const result = await submitProviderManualCode(providerId, code); const result = await submitProviderManualCode(providerId, code, instanceId);
if (result.submitted) { if (result.submitted) {
setManualCodeInputs((prev) => { setManualCodeInputs((prev) => {
if (!(providerId in prev)) { if (!(stateKey in prev)) {
return prev; return prev;
} }
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
addToast(t("settings.auth.authCodeReceived", "Authorization code received. Finishing login…"), "success"); addToast(t("settings.auth.authCodeReceived", "Authorization code received. Finishing login…"), "success");
@@ -2565,58 +2565,59 @@ export function SettingsModal({
} }
}, [addToast, manualCodeInputs]); }, [addToast, manualCodeInputs]);
const handleCancelLogin = useCallback(async (providerId: string) => { const handleCancelLogin = useCallback(async (providerId: string, instanceId?: string) => {
setAuthActionInProgress(providerId); const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
setAuthProviders((prev) => prev.map((provider) => setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
provider.id === providerId ? { ...provider, loginInProgress: false } : provider, // Provider status is shared; do not optimistically clear a concurrent instance's login flag.
));
try { try {
await cancelProviderLogin(providerId); await cancelProviderLogin(providerId, instanceId);
clearAuthLoginUiState(providerId); clearAuthLoginUiState(stateKey);
await loadAuthStatus().catch(() => {}); await loadAuthStatus().catch(() => {});
addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success"); addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success");
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || "Failed to cancel login", "error"); addToast(getErrorMessage(err) || "Failed to cancel login", "error");
} finally { } finally {
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
setManualCodeSubmitInProgress((prev) => prev === providerId ? null : prev); setManualCodeSubmitInProgress((prev) => prev === stateKey ? null : prev);
if (pollIntervalRef.current) { if (pollIntervalRef.current[stateKey]) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current[stateKey]);
pollIntervalRef.current = null; delete pollIntervalRef.current[stateKey];
} }
} }
}, [addToast, clearAuthLoginUiState, loadAuthStatus]); }, [addToast, clearAuthLoginUiState, loadAuthStatus]);
const handleLogout = useCallback(async (providerId: string) => { const handleLogout = useCallback(async (providerId: string, instanceId?: string) => {
setAuthActionInProgress(providerId); const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
try { try {
await logoutProvider(providerId); await logoutProvider(providerId, instanceId);
await loadAuthStatus(); await loadAuthStatus();
addToast(t("settings.auth.loggedOut", "Logged out"), "success"); addToast(t("settings.auth.loggedOut", "Logged out"), "success");
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || "Logout failed", "error"); addToast(getErrorMessage(err) || "Logout failed", "error");
} finally { } finally {
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
} }
}, [addToast, loadAuthStatus]); }, [addToast, loadAuthStatus]);
const handleSaveApiKey = useCallback(async (providerId: string) => { const handleSaveApiKey = useCallback(async (providerId: string, instanceId?: string, label?: string) => {
const key = apiKeyInputs[providerId]?.trim(); const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
const key = apiKeyInputs[stateKey]?.trim();
if (!key) { if (!key) {
setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required" })); setApiKeyErrors((prev) => ({ ...prev, [stateKey]: "API key is required" }));
return; return;
} }
setAuthActionInProgress(providerId); setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
setApiKeyErrors((prev) => { setApiKeyErrors((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
try { try {
const saveResult = await saveApiKey(providerId, key); const saveResult = await saveApiKey(providerId, key, instanceId, label);
setApiKeyInputs((prev) => { setApiKeyInputs((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
await loadAuthStatus(); await loadAuthStatus();
@@ -2651,7 +2652,7 @@ export function SettingsModal({
} else { } else {
setOpencodeApiKeyRefreshStatus((prev) => { setOpencodeApiKeyRefreshStatus((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
} }
@@ -2659,31 +2660,32 @@ export function SettingsModal({
addToast(t("settings.auth.apiKeySaved", "API key saved"), "success"); addToast(t("settings.auth.apiKeySaved", "API key saved"), "success");
scrollSettingsToTop(); scrollSettingsToTop();
} catch (err) { } 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") { if (providerId === "opencode" || providerId === "opencode-go") {
setOpencodeApiKeyRefreshStatus((prev) => { setOpencodeApiKeyRefreshStatus((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
} }
} finally { } finally {
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
} }
}, [apiKeyInputs, addToast, loadAuthStatus, scrollSettingsToTop]); }, [apiKeyInputs, addToast, loadAuthStatus, scrollSettingsToTop]);
const handleClearApiKey = useCallback(async (providerId: string) => { const handleClearApiKey = useCallback(async (providerId: string, instanceId?: string) => {
setAuthActionInProgress(providerId); const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
try { try {
await clearApiKey(providerId); await clearApiKey(providerId, instanceId);
setApiKeyInputs((prev) => { setApiKeyInputs((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
setApiKeyErrors((prev) => { setApiKeyErrors((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[providerId]; delete next[stateKey];
return next; return next;
}); });
await loadAuthStatus(); await loadAuthStatus();
@@ -2691,7 +2693,7 @@ export function SettingsModal({
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || "Failed to clear API key", "error"); addToast(getErrorMessage(err) || "Failed to clear API key", "error");
} finally { } finally {
setAuthActionInProgress(null); setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
} }
}, [addToast, loadAuthStatus]); }, [addToast, loadAuthStatus]);

View File

@@ -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<typeof import("../../api")>()),
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: () => <span /> }));
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<Record<string, string>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
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 <AuthenticationSection auth={auth} />;
}
render(<Harness />);
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");
});
});

View File

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

View File

@@ -1,5 +1,7 @@
import { useState } from "react";
import type { Dispatch, SetStateAction } 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 type { ToastType } from "../../../hooks/useToast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard"; import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard";
@@ -20,12 +22,13 @@ import { copyTextToClipboard } from "../../../utils/copyToClipboard";
import { appendTokenQuery } from "../../../auth"; import { appendTokenQuery } from "../../../auth";
import { openExternalUrl } from "../../../utils/open-external"; import { openExternalUrl } from "../../../utils/open-external";
import { refreshModelsCache } from "../../../hooks/useModelsCache"; import { refreshModelsCache } from "../../../hooks/useModelsCache";
import "./AuthenticationSection.css";
export interface AuthenticationSectionData { export interface AuthenticationSectionData {
projectId?: string; projectId?: string;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
authProviders: AuthProvider[]; authProviders: AuthProvider[];
authLoading: boolean; authLoading: boolean;
authActionInProgress: string | null; authActionInProgress: string | null | Record<string, boolean>;
apiKeyInputs: Record<string, string>; apiKeyInputs: Record<string, string>;
setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>; setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>;
apiKeyErrors: Record<string, string>; apiKeyErrors: Record<string, string>;
@@ -40,12 +43,12 @@ export interface AuthenticationSectionData {
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>; setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
manualCodeSubmitInProgress: string | null; manualCodeSubmitInProgress: string | null;
loadAuthStatus: () => void | Promise<void>; loadAuthStatus: () => void | Promise<void>;
handleLogin: (providerId: string) => void; handleLogin: (providerId: string, instanceId?: string, label?: string) => void;
handleLogout: (providerId: string) => void; handleLogout: (providerId: string, instanceId?: string) => void;
handleCancelLogin: (providerId: string) => void; handleCancelLogin: (providerId: string, instanceId?: string) => void;
handleSaveApiKey: (providerId: string) => void; handleSaveApiKey: (providerId: string, instanceId?: string, label?: string) => void;
handleClearApiKey: (providerId: string) => void; handleClearApiKey: (providerId: string, instanceId?: string) => void;
handleSubmitManualCode: (providerId: string) => void | Promise<void>; handleSubmitManualCode: (providerId: string, instanceId?: string) => void | Promise<void>;
onReopenOnboarding?: () => void; onReopenOnboarding?: () => void;
} }
export interface AuthenticationSectionProps { export interface AuthenticationSectionProps {
@@ -85,6 +88,10 @@ const compareAuthProviderDisplayOrder = (a: AuthProvider, b: AuthProvider) => {
export function AuthenticationSection({ auth, form, setForm }: AuthenticationSectionProps) { export function AuthenticationSection({ auth, form, setForm }: AuthenticationSectionProps) {
const { t } = useTranslation("app"); 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 { 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<Record<string, { instanceId: string; label: string }>>({});
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"); const hasSeparatedAnthropicProvider = authProviders.some((p) => p.id === "anthropic-subscription" || p.id === "anthropic-api-key");
/* /*
FNXC:ProviderAuth 2026-06-29-23:50: FNXC:ProviderAuth 2026-06-29-23:50:
@@ -191,77 +198,119 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
const showAvailableGroup = unauthenticatedProviders.length > 0; const showAvailableGroup = unauthenticatedProviders.length > 0;
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; 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 <div className="auth-instance-controls">
{instances.length > 1 && <div className="auth-instance-list" data-testid={`auth-instances-${provider.id}`}>
{instances.map((item: ProviderCredentialInstance) => {
const rowProvider = instanceProvider(provider, item);
return <div className="auth-instance-row" key={item.instanceId}>
<span>{item.label || item.instanceId}{item.isDefault ? ` (${t("settings.auth.default", "Default")})` : ""}</span>
{!item.isDefault && <button className="btn btn-sm" onClick={() => void setProviderDefaultInstance(provider.id, item.instanceId).then(loadAuthStatus)}>{t("settings.auth.makeDefault", "Make default")}</button>}
<button className="btn btn-sm" onClick={() => {
const label = window.prompt(t("settings.auth.renameAccount", "Account name"), item.label || "");
if (label !== null) void renameProviderInstance(provider.id, item.instanceId, label).then(loadAuthStatus);
}}>{t("settings.actions.rename", "Rename")}</button>
<button className="btn btn-sm" onClick={() => void removeProviderInstance(provider.id, item.instanceId).then(loadAuthStatus)}>{t("settings.actions.remove", "Remove")}</button>
{providerSupportsApiKey(rowProvider)
? renderApiKeySection(rowProvider, item.instanceId)
: item.authenticated
? renderAuthenticatedOAuthActions(rowProvider, item.instanceId)
: renderAvailableOAuthActions(rowProvider, item.instanceId)}
</div>;
})}
</div>}
{pending && <div className="auth-instance-pending" data-testid={`auth-pending-instance-${provider.id}`}>
<input className="input" aria-label={t("settings.auth.accountLabel", "Account name")} value={pending.label} onChange={(event) => 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)}
<button className="btn btn-sm" onClick={discard}>{t("settings.actions.cancel", "Cancel")}</button>
</div>}
{!pending && <button className="btn btn-sm" onClick={add}>{t("settings.auth.addAnotherAccount", "Add another account")}</button>}
</div>;
};
/*
FNXC:ProviderAuth 2026-07-14-15:54: 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. 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 const renderProviderAuthError = (provider: AuthProvider) => provider.loginError
? (<small className="form-error" role="alert">{provider.loginError}</small>) ? (<small className="form-error" role="alert">{provider.loginError}</small>)
: null; : null;
const renderApiKeySection = (provider: AuthProvider) => (<div className="auth-apikey-section"> const renderApiKeySection = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string, isPending = false) => {
<div className="auth-apikey-input-row"> const instanceId = selectedInstanceId ?? provider.instanceId;
<input type="password" className="auth-apikey-input" placeholder={t("settings.authentication.enterAPIKey", "Enter API key")} value={apiKeyInputs[provider.id] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" });
{provider.keyHint && !apiKeyInputs[provider.id] ? (<button className="btn btn-sm" onClick={() => handleClearApiKey(provider.id)} disabled={authActionInProgress === provider.id}> return <div className="auth-apikey-section">
{t("settings.auth.clearKey", "Clear")} <div className="auth-apikey-input-row">
</button>) : (<button className="btn btn-primary btn-sm" onClick={() => handleSaveApiKey(provider.id)} disabled={authActionInProgress === provider.id}> <input type="password" className="auth-apikey-input" placeholder={t("settings.authentication.enterAPIKey", "Enter API key")} value={apiKeyInputs[stateKey] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [stateKey]: e.target.value }))} disabled={isAuthActionActive(stateKey)}/>
{t("settings.actions.save", "Save")} {provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? <button className="btn btn-sm" onClick={() => selectedInstanceId ? handleClearApiKey(provider.id, selectedInstanceId) : handleClearApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.auth.clearKey", "Clear")}</button> : <button className="btn btn-primary btn-sm" onClick={() => selectedInstanceId ? handleSaveApiKey(provider.id, selectedInstanceId, pendingLabel || undefined) : handleSaveApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.actions.save", "Save")}</button>}
</button>)} </div>
</div> {isAuthActionActive(stateKey) && <small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>}
{authActionInProgress === provider.id && (<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>)} {apiKeyErrors[stateKey] && <small className="auth-apikey-error">{apiKeyErrors[stateKey]}</small>}
{apiKeyErrors[provider.id] && (<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>)} {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[stateKey] && <small className={opencodeApiKeyRefreshStatus[stateKey].tone === "error" ? "form-error" : "text-muted"}>{opencodeApiKeyRefreshStatus[stateKey].message}</small>}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}> </div>;
{opencodeApiKeyRefreshStatus[provider.id].message} };
</small>)} const renderAuthenticatedOAuthActions = (provider: AuthProvider, selectedInstanceId?: string) => {
</div>); const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: selectedInstanceId ?? provider.instanceId ?? "default" });
const renderAuthenticatedOAuthActions = (provider: AuthProvider) => (<div> return <div>
{authActionInProgress === provider.id ? (<button className="btn btn-sm" disabled> {isAuthActionActive(stateKey) ? <button className="btn btn-sm" disabled>{t("settings.auth.loggingOut", "Logging out…")}</button>
{t("settings.auth.loggingOut", "Logging out…")} : provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => selectedInstanceId ? handleCancelLogin(provider.id, selectedInstanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
</button>) : provider.loginInProgress ? (<div className="auth-provider-actions-row"> : <button className="btn btn-sm" onClick={() => selectedInstanceId ? handleLogout(provider.id, selectedInstanceId) : handleLogout(provider.id)}>{t("settings.auth.logout", "Logout")}</button>}
<button className="btn btn-sm" disabled> </div>;
{t("settings.auth.waitingForLogin", "Waiting for login…")} };
</button> const renderAvailableOAuthActions = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string) => {
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}> const instanceId = selectedInstanceId ?? provider.instanceId;
{t("settings.actions.cancel", "Cancel")} const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" });
</button> const isActive = provider.loginInProgress || isAuthActionActive(stateKey);
</div>) : (<button className="btn btn-sm" onClick={() => handleLogout(provider.id)}> return <div>
{t("settings.auth.logout", "Logout")} {isAuthActionActive(stateKey) ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id, selectedInstanceId)}>{t("settings.actions.cancel", "Cancel")}</button></div>
</button>)} : provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => selectedInstanceId ? handleCancelLogin(provider.id, selectedInstanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
</div>); : <button className="btn btn-primary btn-sm" onClick={() => selectedInstanceId ? handleLogin(provider.id, selectedInstanceId, pendingLabel) : handleLogin(provider.id)}>{t("settings.auth.login", "Login")}</button>}
const renderAvailableOAuthActions = (provider: AuthProvider) => (<div> {provider.id === "github-copilot" && deviceCodes[stateKey] && isActive && <div className="auth-device-code-panel" data-testid={`auth-device-code-${stateKey}`}>
{authActionInProgress === provider.id ? (<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>) : provider.loginInProgress ? (<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>) : (<button className="btn btn-primary btn-sm" onClick={() => handleLogin(provider.id)}>
{t("settings.auth.login", "Login")}
</button>)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong> <strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div> <div className="auth-device-code-pill">{deviceCodes[stateKey].userCode}</div>
<div className="auth-provider-actions-row"> <div className="auth-provider-actions-row">
<button className="btn btn-sm" onClick={() => { <button className="btn btn-sm" onClick={() => void copyTextToClipboard(deviceCodes[stateKey].userCode).then((copied) => addToast(copied ? t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard") : t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), copied ? "success" : "error"))}>{t("settings.auth.copyCode", "Copy code")}</button>
void (async () => { <button className="btn btn-sm" onClick={() => openExternalUrl(appendTokenQuery(deviceCodes[stateKey].verificationUri))}>{t("settings.auth.openGitHub", "Open GitHub")}</button>
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}>
{t("settings.auth.copyCode", "Copy code")}
</button>
<button className="btn btn-sm" onClick={() => openExternalUrl(appendTokenQuery(deviceCodes[provider.id].verificationUri))}>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div> </div>
</div>)} </div>}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<LoginInstructions instructions={loginInstructions[provider.id]} data-testid={`auth-login-instructions-${provider.id}`}/>)} {loginInstructions[stateKey] && isActive && <LoginInstructions instructions={loginInstructions[stateKey]} data-testid={`auth-login-instructions-${stateKey}`}/>}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<OAuthManualCodeForm value={manualCodeInputs[provider.id] ?? ""} onChange={(value) => 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}`}/>)}</div>); {manualCodeConfigs[stateKey] && isActive && <OAuthManualCodeForm value={manualCodeInputs[stateKey] ?? ""} onChange={(value) => 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}`}/>}
</div>;
};
/* /*
FNXC:ProviderAuth 2026-06-29-22:18: FNXC:ProviderAuth 2026-06-29-22:18:
Settings must render Anthropic subscription OAuth and raw Anthropic API-key auth as separate provider cards. 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)} {renderAnthropicPrecedenceBadge(provider)}
{provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)} {provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
</div> </div>
{provider.type !== "api_key" && <div>{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}</div>} {provider.type !== "api_key" && !hasMultipleInstances(provider) && <div>{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}</div>}
{providerSupportsApiKey(provider) && renderApiKeySection(provider)} {providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)}
</div> </div>
{renderInstanceControls(provider)}
</div>))} </div>))}
{renderAnthropicPrecedenceRow()} {renderAnthropicPrecedenceRow()}
</div>)} </div>)}
@@ -318,9 +368,10 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
</span> </span>
{provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)} {provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
</div> </div>
{provider.type !== "api_key" && <div>{renderAvailableOAuthActions(provider)}{renderProviderAuthError(provider)}</div>} {provider.type !== "api_key" && !hasMultipleInstances(provider) && <div>{renderAvailableOAuthActions(provider)}{renderProviderAuthError(provider)}</div>}
{providerSupportsApiKey(provider) && renderApiKeySection(provider)} {providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)}
</div> </div>
{renderInstanceControls(provider)}
</div>))} </div>))}
</div>)} </div>)}
</div>)} </div>)}

View File

@@ -984,14 +984,14 @@ describe("GET /auth/status", () => {
"zai", "zai",
]); ]);
const githubCopilot = providers.find((p: any) => p.id === "github-copilot"); 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"); 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"); 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. // Catalog-only entries (not reported by storage) still surface, present-but-unauthenticated.
const brave = providers.find((p: any) => p.id === "brave"); 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(); expect(authStorage.reload).toHaveBeenCalled();
}); });
@@ -1054,14 +1054,14 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot"); const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot");
expect(githubCopilot).toEqual({ expect(githubCopilot).toEqual(expect.objectContaining({
id: "github-copilot", id: "github-copilot",
name: "GitHub Copilot", name: "GitHub Copilot",
authenticated: true, authenticated: true,
type: "oauth", type: "oauth",
expired: false, expired: false,
loginInProgress: false, loginInProgress: false,
}); }));
}); });
it("includes oauth and model-registry-derived API key providers in one response", async () => { it("includes oauth and model-registry-derived API key providers in one response", async () => {
@@ -1101,15 +1101,15 @@ describe("GET /auth/status", () => {
"acme-extension", "acme-extension",
]); ]);
const githubCopilot = providers.find((p: any) => p.id === "github-copilot"); 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"); 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"); 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"); 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"); 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])( it.each(["https://my-host.example.com", undefined])(
@@ -3311,11 +3311,13 @@ describe("GET /auth/oauth-callback", () => {
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([ (authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "google", name: "Google" }, { id: "google", name: "Google" },
]); ]);
let finishLogin: (() => void) | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => { (authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ callbacks.onAuth({
url: `https://accounts.example.com/o/oauth2/v2/auth?state=test-state&redirect_uri=${encodeURIComponent(`http://localhost:${port}/oauth2callback`)}`, 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<void>((resolve) => { finishLogin = resolve; });
}); });
const app = buildApp(); 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"); const res = await REQUEST(app, "GET", "/api/auth/oauth-callback?code=test-code&state=test-state");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(String(res.body)).toContain("proxied:test-code:test-state"); expect(String(res.body)).toContain("proxied:test-code:test-state");
finishLogin?.();
} finally { } finally {
await new Promise<void>((resolve, reject) => callbackListener.close((err) => (err ? reject(err) : resolve()))); await new Promise<void>((resolve, reject) => callbackListener.close((err) => (err ? reject(err) : resolve())));
} }

View File

@@ -10,7 +10,7 @@ import multer from "multer";
import { resolve, sep, join, isAbsolute } from "node:path"; import { resolve, sep, join, isAbsolute } from "node:path";
import * as nodeFs from "node:fs"; 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 { import {
type Task, type Task,
type PiExtensionEntry, type PiExtensionEntry,
@@ -168,6 +168,16 @@ export interface AuthStorageLike {
getApiKey?(providerId: string): string | null | undefined | Promise<string | null | undefined>; getApiKey?(providerId: string): string | null | undefined | Promise<string | null | undefined>;
/** Get raw stored credentials for usage providers. */ /** 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; 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<void>;
loginInstance?(ref: ProviderInstanceRef, callbacks: Parameters<AuthStorageLike["login"]>[1], label?: string): Promise<void>;
logoutInstance?(ref: ProviderInstanceRef): Promise<void>;
clearInstanceApiKey?(ref: ProviderInstanceRef): Promise<void>;
removeInstance?(ref: ProviderInstanceRef): Promise<void>;
getDefaultInstance?(providerId: string): ProviderInstanceRef | undefined;
setDefaultInstance?(ref: ProviderInstanceRef): Promise<void>;
renameInstance?(ref: ProviderInstanceRef, label?: string): Promise<void>;
} }
/* /*

View File

@@ -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"); const severityAuditLog = createLogger("dashboard-register-auth-routes");
import type { Request } from "express"; 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) * - If key length <= 8: return 8 bullets (never reveal short keys)
* - Otherwise: first 3 chars + 5 bullets + last 4 chars * - 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 { function maskApiKey(key: string): string {
if (key.length <= 8) { if (key.length <= 8) {
return "••••••••"; return "••••••••";
@@ -220,19 +248,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}; };
type PendingLogin = { type PendingLogin = {
provider: string;
abortController: AbortController; abortController: AbortController;
inputPromise: Promise<string>; inputPromise: Promise<string>;
resolveInput: (input: string) => void; resolveInput: (input: string) => void;
rejectInput: (error: Error) => void; rejectInput: (error: Error) => void;
inputSubmitted: boolean; inputSubmitted: boolean;
manualCode?: ManualCodeConfig; manualCode?: ManualCodeConfig;
instanceId: string;
label?: string;
}; };
/** /*
* Track in-progress login flows to prevent concurrent logins for the same provider. FNXC:ProviderAuth 2026-08-01-06:25:
* Maps provider ID → pending interactive login state. 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<string, PendingLogin>(); const loginInProgress = new Map<string, PendingLogin>();
const loginKey = (providerId: string, instanceId: string) => `${providerId}::${instanceId}`;
/* /*
FNXC:ProviderAuth 2026-07-05-00:00: FNXC:ProviderAuth 2026-07-05-00:00:
@@ -242,7 +276,8 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
const lastLoginError = new Map<string, string>(); const lastLoginError = new Map<string, string>();
const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000; const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000;
const oauthSessions = new Map<string, { port: number; path: string; originalRedirectUri: string; expiresAt: number }>(); type OauthSession = { port: number; path: string; originalRedirectUri: string; expiresAt: number; flowKey: string; provider: string; instanceId: string; timeout: ReturnType<typeof setTimeout> };
const oauthSessions = new Map<string, OauthSession>();
function isLocalhostOrigin(origin: string): boolean { function isLocalhostOrigin(origin: string): boolean {
try { try {
@@ -259,25 +294,57 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${safeTitle}</title></head><body><h2>${safeTitle}</h2>${safeDetail ? `<p>${safeDetail}</p>` : ""}<p>You can close this tab.</p></body></html>`; return `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${safeTitle}</title></head><body><h2>${safeTitle}</h2>${safeDetail ? `<p>${safeDetail}</p>` : ""}<p>You can close this tab.</p></body></html>`;
} }
function cleanupExpiredOauthSessions(): void { function deleteOauthSession(state: string): void {
const now = Date.now(); 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()) { for (const [state, session] of oauthSessions.entries()) {
if (session.expiresAt <= now) { if (session.flowKey === flowKey) deleteOauthSession(state);
oauthSessions.delete(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(); cleanupExpiredOauthSessions();
oauthSessions.set(state, { ...details, expiresAt: Date.now() + OAUTH_SESSION_TTL_MS }); const expiresAt = Date.now() + OAUTH_SESSION_TTL_MS;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
const current = oauthSessions.get(state); const current = oauthSessions.get(state);
if (current && current.expiresAt <= Date.now()) { if (current?.expiresAt === expiresAt) expireOauthSession(state, current);
oauthSessions.delete(state);
}
}, OAUTH_SESSION_TTL_MS + 1_000); }, OAUTH_SESSION_TTL_MS + 1_000);
timeout.unref(); timeout.unref();
oauthSessions.set(state, { ...details, expiresAt, timeout });
} }
function rewriteAuthUrl(authUrl: string, origin: string): { url: string; state: string; originalRedirectUri: string; port: number; path: string } { 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 { try {
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined; const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
const storage = getAuthStorage(); 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(); storage.reload();
/* /*
FNXC:ProviderAuth 2026-07-07-00:00: FNXC:ProviderAuth 2026-07-07-00:00:
@@ -631,7 +704,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
authenticated: hasAuth && !expired && !missingInferenceScope, authenticated: hasAuth && !expired && !missingInferenceScope,
type: "oauth" as const, type: "oauth" as const,
expired: expired || missingInferenceScope, 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, requiresManualCode: getManualCodeConfig(toOauthLoginProviderId(statusProvider.id), origin) !== undefined || undefined,
loginError: lastLoginError.get(statusProvider.id) ?? scopeLoginError ?? expiryLoginError, loginError: lastLoginError.get(statusProvider.id) ?? scopeLoginError ?? expiryLoginError,
}; };
@@ -809,7 +882,61 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
gitCli = { available: false, installUrl: GIT_INSTALL_URL }; 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) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;
@@ -1429,18 +1556,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/ */
router.post("/auth/login", async (req, res) => { router.post("/auth/login", async (req, res) => {
try { try {
const { provider, origin } = req.body; const { provider, origin, instance, label } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
throw badRequest("provider is required"); throw badRequest("provider is required");
} }
if (origin !== undefined && typeof origin !== "string") { if (origin !== undefined && typeof origin !== "string") {
throw badRequest("origin must be a string when provided"); 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 storageProvider = toOauthLoginProviderId(provider);
const flowKey = loginKey(provider, instanceId);
// Prevent concurrent logins for the same provider // Different accounts may authenticate together; only a duplicate account flow conflicts.
if (loginInProgress.has(provider)) { if (loginInProgress.has(flowKey)) {
throw conflict(`Login already in progress for ${provider}`); throw conflict(`Login already in progress for ${provider}`);
} }
@@ -1484,14 +1616,17 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
const pendingLogin: PendingLogin = { const pendingLogin: PendingLogin = {
provider,
abortController, abortController,
inputPromise, inputPromise,
resolveInput, resolveInput,
rejectInput, rejectInput,
inputSubmitted: false, inputSubmitted: false,
manualCode: getManualCodeConfig(storageProvider, origin), manualCode: getManualCodeConfig(storageProvider, origin),
instanceId,
label: instanceLabel,
}; };
loginInProgress.set(provider, pendingLogin); loginInProgress.set(flowKey, pendingLogin);
let autoPromptConsumed = false; let autoPromptConsumed = false;
@@ -1518,7 +1653,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
let resolvedDeviceCode: DeviceCodeInfo | undefined; let resolvedDeviceCode: DeviceCodeInfo | undefined;
// Start login flow in background — don't await the full login // Start login flow in background — don't await the full login
const loginPromise = storage.login(loginProvider, { const loginCallbacks: Parameters<AuthStorageLike["login"]>[1] = {
onAuth: (info) => { onAuth: (info) => {
if (!resolvedDeviceCode) { if (!resolvedDeviceCode) {
const parsedUserCode = const parsedUserCode =
@@ -1565,7 +1700,10 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
onProgress: () => {}, // no-op for web UI onProgress: () => {}, // no-op for web UI
onSelect: async (prompt) => selectOauthOption(storageProvider, prompt), onSelect: async (prompt) => selectOauthOption(storageProvider, prompt),
signal: abortController.signal, 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 // Race: either we get the auth URL or the login completes/fails first
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -1591,7 +1729,8 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}) })
.finally(() => { .finally(() => {
clearTimeout(timeout); clearTimeout(timeout);
loginInProgress.delete(provider); loginInProgress.delete(flowKey);
deleteOauthSessionsForFlow(flowKey);
}); });
const authInfo = await authUrlPromise; const authInfo = await authUrlPromise;
@@ -1604,6 +1743,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
port: rewritten.port, port: rewritten.port,
path: rewritten.path, path: rewritten.path,
originalRedirectUri: rewritten.originalRedirectUri, originalRedirectUri: rewritten.originalRedirectUri,
flowKey,
provider,
instanceId,
}); });
responseUrl = rewritten.url; responseUrl = rewritten.url;
} }
@@ -1620,7 +1762,11 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
// Clean up on error // Clean up on error
const provider = req.body?.provider; 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); rethrowAsApiError(err);
} }
}); });
@@ -1633,21 +1779,19 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/ */
router.post("/auth/cancel", (req, res) => { router.post("/auth/cancel", (req, res) => {
try { try {
const { provider } = req.body; const { provider, instance } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
throw badRequest("provider is required"); throw badRequest("provider is required");
} }
const activeLogin = loginInProgress.get(provider); const activeLogin = loginInProgress.get(loginKey(provider, resolveInstanceId(instance)));
if (!activeLogin) { if (!activeLogin) {
res.json({ success: true, cancelled: false }); res.json({ success: true, cancelled: false });
return; return;
} }
loginInProgress.delete(provider); const activeFlowKey = loginKey(provider, activeLogin.instanceId);
activeLogin.inputSubmitted = true; cancelOauthFlow(activeFlowKey, new Error("cancelled"));
activeLogin.rejectInput(new Error("cancelled"));
activeLogin.abortController.abort();
res.json({ success: true, cancelled: true }); res.json({ success: true, cancelled: true });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -1665,7 +1809,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/ */
router.post("/auth/manual-code", async (req, res) => { router.post("/auth/manual-code", async (req, res) => {
try { try {
const { provider, code } = req.body; const { provider, code, instance } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
throw badRequest("provider is required"); throw badRequest("provider is required");
} }
@@ -1673,11 +1817,17 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest("code is required"); 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 (!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}`); 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) { if (activeLogin.inputSubmitted) {
res.json({ success: true, submitted: false }); res.json({ success: true, submitted: false });
return; return;
@@ -1703,6 +1853,16 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
const state = typeof req.query.state === "string" ? req.query.state : undefined; const state = typeof req.query.state === "string" ? req.query.state : undefined;
if (error) { 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)); return res.status(400).type("text/html").send(simpleErrorHtml("OAuth failed", error));
} }
@@ -1712,21 +1872,29 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
cleanupExpiredOauthSessions(); cleanupExpiredOauthSessions();
const session = oauthSessions.get(state); const session = oauthSessions.get(state);
if (!session || session.expiresAt <= Date.now()) { const activeLogin = session ? loginInProgress.get(session.flowKey) : undefined;
oauthSessions.delete(state); 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")); 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}`); 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("code", code);
callbackUrl.searchParams.set("state", state); 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 responseBody = await callbackResponse.text();
const contentType = callbackResponse.headers.get("content-type") ?? "text/html"; const contentType = callbackResponse.headers.get("content-type") ?? "text/html";
oauthSessions.delete(state);
return res.status(callbackResponse.status).type(contentType).send(responseBody); return res.status(callbackResponse.status).type(contentType).send(responseBody);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -1744,13 +1912,21 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/ */
router.post("/auth/logout", async (req, res) => { router.post("/auth/logout", async (req, res) => {
try { try {
const { provider } = req.body; const { provider, instance } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
throw badRequest("provider is required"); throw badRequest("provider is required");
} }
const storage = getAuthStorage(); 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(); clearUsageCache();
res.json({ success: true }); res.json({ success: true });
} catch (err: unknown) { } catch (err: unknown) {
@@ -1772,7 +1948,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/ */
router.post("/auth/api-key", async (req, res) => { router.post("/auth/api-key", async (req, res) => {
try { try {
const { provider, apiKey } = req.body; const { provider, apiKey, instance, label } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
throw badRequest("provider is required"); throw badRequest("provider is required");
} }
@@ -1794,7 +1970,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest(`Unknown API key provider: ${provider}`); 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 modelsRefreshed: number | undefined;
let refreshReason: "no-models-from-cli" | "cli-failed" | "disabled-by-settings" | 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) => { router.delete("/auth/api-key", async (req, res) => {
try { try {
const { provider } = req.body; const { provider, instance } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
throw badRequest("provider is required"); throw badRequest("provider is required");
} }
@@ -1854,7 +2036,14 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest(`Unknown API key provider: ${provider}`); 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. // No model refresh needed on delete: removing the key leaves nothing to sync.
clearUsageCache(); clearUsageCache();
res.json({ success: true }); res.json({ success: true });
@@ -1865,4 +2054,61 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
rethrowAsApiError(err); 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); }
});
}; };

View File

@@ -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<string, { type: "oauth"; expires: number }>();
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");
});
});

View File

@@ -18,6 +18,8 @@ import {
choosePreferredStoredCredential, choosePreferredStoredCredential,
readStoredCredentialsFromAuthFile, readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential, shouldHydrateStoredCredential,
DEFAULT_PROVIDER_INSTANCE_ID,
type ProviderInstanceRef,
type StoredAuthCredential, type StoredAuthCredential,
} from "@fusion/core"; } from "@fusion/core";
export interface LoginCallbacks { export interface LoginCallbacks {
@@ -42,6 +44,17 @@ export interface DashboardAuthStorage {
hasApiKey(providerId: string): boolean; hasApiKey(providerId: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>; getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): { type?: string; key?: string } | undefined; 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<void>;
clearInstanceApiKey?(ref: ProviderInstanceRef): Promise<void>;
loginInstance?(ref: ProviderInstanceRef, callbacks: LoginCallbacks, label?: string): Promise<void>;
logoutInstance?(ref: ProviderInstanceRef): Promise<void>;
removeInstance?(ref: ProviderInstanceRef): Promise<void>;
getDefaultInstance?(providerId: string): ProviderInstanceRef | undefined;
setDefaultInstance?(ref: ProviderInstanceRef): Promise<void>;
renameInstance?(ref: ProviderInstanceRef, label?: string): Promise<void>;
} }
interface ReadFallbackAuthStorage { interface ReadFallbackAuthStorage {
@@ -245,6 +258,82 @@ export function wrapAuthStorageWithApiKeyProviders(
} }
return mergedAuthStorage.getApiKey(storageProviderId); 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) => { get: (providerId) => {
if (providerId === ANTHROPIC_API_KEY_PROVIDER_ID) { if (providerId === ANTHROPIC_API_KEY_PROVIDER_ID) {
const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);