Archived
fix(auth): make API-key setup and status truthful
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user