fix(auth): make API-key setup and status truthful

This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 08:28:03 +02:00
parent cc8f379143
commit c569a03f54
17 changed files with 300 additions and 45 deletions
+1
View File
@@ -241,6 +241,7 @@ export const sessionsApi = {
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
},
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
startInteractiveApiKeyLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key/interactive`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
@@ -88,6 +88,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)),
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
+10 -1
View File
@@ -1,8 +1,17 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
const base = { id: "openai", name: "OpenAI", authType: "api_key", status: { configured: false } };
expect(parseAuthProvidersResponse({ providers: [{ ...base, loginFlow: "interactive" }, base] }).providers).toEqual([
{ ...base, loginFlow: "interactive" },
base,
]);
});
it("preserves additive OAuth interaction semantics", () => {
expect(parseOAuthFlowState({
flowId: "flow-1",
+9 -1
View File
@@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
function parseAuthProviderOption(value: unknown): AuthProviderOption {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), authType: parseAuthType(record["authType"]), status: parseAuthProviderStatus(record["status"]) };
const loginFlow = record["loginFlow"];
if (loginFlow !== undefined && loginFlow !== "interactive") throw new Error("Invalid auth provider login flow");
return {
id: requireString(record, "id"),
name: requireString(record, "name"),
authType: parseAuthType(record["authType"]),
status: parseAuthProviderStatus(record["status"]),
...(loginFlow === undefined ? {} : { loginFlow }),
};
}
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
+3 -3
View File
@@ -42,7 +42,7 @@ export class AuthDialog extends LitElement {
private dialogTitle(state: AuthDialogState): string {
switch (state.step) {
case "method": return "Configure provider authentication";
case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select API key provider";
case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select credential provider";
case "apiKey": return `API key for ${state.provider.name}`;
case "oauth": return `Login to ${state.flow.providerName}`;
case "logout": return "Remove stored provider authentication";
@@ -54,7 +54,7 @@ export class AuthDialog extends LitElement {
case "method": return html`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the active Pi-compatible profile's auth.json</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use provider credentials</span><small>Configure an API key or provider-specific credentials in the active Pi-compatible profile's auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
@@ -183,7 +183,7 @@ export function oauthPromptInputType(promptType: NonNullable<OAuthFlowState["pro
}
function authTypeLabel(authType: "oauth" | "api_key"): string {
return authType === "oauth" ? "subscription" : "API key";
return authType === "oauth" ? "subscription" : "credentials";
}
function focusKey(state: AuthDialogState | undefined): string | undefined {
@@ -31,6 +31,34 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
});
it("starts provider-driven API-key interactions instead of opening the legacy one-secret form", async () => {
vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined });
const provider: AuthProviderOption = { ...authProvider("amazon-bedrock", "api_key"), loginFlow: "interactive" };
const calls: { providerId: string; machineId: string | undefined }[] = [];
const { controller, getState } = createController(
{ authDialog: { step: "providers", mode: "login", authType: "api_key", providers: [provider] } },
{
startInteractiveApiKeyLogin: (providerId, machineId) => {
calls.push({ providerId, machineId });
return Promise.resolve(oauthFlow({ providerId, providerName: "Amazon Bedrock", select: { requestId: "request-1", message: "Choose method", options: [] } }));
},
},
);
try {
await controller.selectLoginProvider(provider.id, "api_key");
expect(calls).toEqual([{ providerId: "amazon-bedrock", machineId: "local" }]);
expect(getState().authDialog).toMatchObject({
step: "oauth",
flow: { providerId: "amazon-bedrock", select: { requestId: "request-1" } },
});
} finally {
controller.dispose();
vi.unstubAllGlobals();
}
});
it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
+7 -4
View File
@@ -61,7 +61,7 @@ export class AuthController {
if (dialog?.step !== "providers") return;
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
}
@@ -189,19 +189,22 @@ export class AuthController {
}
const provider = exact[0];
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
if (provider.authType === "oauth" || provider.loginFlow === "interactive") await this.startLoginFlow(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async startOAuth(provider: AuthProviderOption): Promise<void> {
private async startLoginFlow(provider: AuthProviderOption): Promise<void> {
if (this.rejectRemoteOAuth("login", provider)) return;
const operationGeneration = ++this.oauthOperationGeneration;
this.stopPolling();
try {
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
const machineId = selectedMachineId(this.getState());
const flow = provider.authType === "oauth"
? await this.api.startOAuthLogin(provider.id, machineId)
: await this.api.startInteractiveApiKeyLogin(provider.id, machineId);
if (operationGeneration !== this.oauthOperationGeneration) return;
this.updateOAuthFlow(flow);
if (flow.status === "running") this.startPolling(flow.flowId);