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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
|
||||
|
||||
function runtime(): AuthProviderRuntime {
|
||||
function runtime(configuredProviders: ReadonlySet<string> = new Set(["openai"])): AuthProviderRuntime {
|
||||
const credentials = [{ providerId: "openai", type: "api_key" as const }];
|
||||
// Auth shapes mirror what the Pi SDK actually reports for these providers:
|
||||
// github-copilot supports both methods, openai-codex is OAuth-only, and
|
||||
@@ -12,12 +12,17 @@ function runtime(): AuthProviderRuntime {
|
||||
{ id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } },
|
||||
{ id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "custom", name: "Custom", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "cloudflare-workers-ai", name: "Cloudflare Workers AI", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "amazon-bedrock", name: "Amazon Bedrock", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "google-vertex", name: "Google Vertex AI", auth: { apiKey: { login: () => undefined } } },
|
||||
{ id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } },
|
||||
];
|
||||
return {
|
||||
getProviders: () => providers,
|
||||
listCredentials: () => Promise.resolve(credentials),
|
||||
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
|
||||
hasConfiguredAuth: (provider: string) => configuredProviders.has(provider),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,18 +37,34 @@ describe("auth provider options", () => {
|
||||
expect.objectContaining({ id: "github-copilot", authType: "api_key" }),
|
||||
// OAuth-only provider surfaces only oauth.
|
||||
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
|
||||
// API-key-only providers surface only api_key.
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
|
||||
expect.objectContaining({ id: "custom", authType: "api_key" }),
|
||||
// API-key options use the generic AuthInteraction flow, including
|
||||
// multi-field and select-first providers the legacy form cannot execute.
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", loginFlow: "interactive", status: { configured: true, source: "stored" } }),
|
||||
expect.objectContaining({ id: "custom", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "cloudflare-ai-gateway", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "cloudflare-workers-ai", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "amazon-bedrock", authType: "api_key", loginFlow: "interactive" }),
|
||||
expect.objectContaining({ id: "google-vertex", authType: "api_key", loginFlow: "interactive" }),
|
||||
]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai", authType: "oauth" })]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })]));
|
||||
});
|
||||
|
||||
it("does not report a stored credential as configured when provider resolution is incomplete", async () => {
|
||||
const unresolvedRuntime = runtime(new Set());
|
||||
|
||||
expect(getLoginProviderOptions(unresolvedRuntime, "api_key")).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "openai", status: { configured: false } }),
|
||||
]));
|
||||
expect(await getLogoutProviderOptions(unresolvedRuntime)).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: false } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only currently stored credentials for logout", async () => {
|
||||
expect(await getLogoutProviderOptions(runtime())).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AuthProviderRuntime {
|
||||
getProviders(): readonly AuthProviderInfo[];
|
||||
listCredentials(): Promise<readonly AuthProviderCredentialInfo[]>;
|
||||
getProviderAuthStatus(providerId: string): AuthProviderStatus;
|
||||
hasConfiguredAuth(providerId: string): boolean;
|
||||
}
|
||||
|
||||
export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
|
||||
@@ -35,7 +36,7 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?:
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
authType: "oauth",
|
||||
status: runtime.getProviderAuthStatus(provider.id),
|
||||
status: truthfulProviderStatus(runtime, provider.id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,7 +46,8 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?:
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
authType: "api_key",
|
||||
status: runtime.getProviderAuthStatus(provider.id),
|
||||
status: truthfulProviderStatus(runtime, provider.id),
|
||||
loginFlow: "interactive",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,12 +62,19 @@ export async function getLogoutProviderOptions(runtime: AuthProviderRuntime): Pr
|
||||
id: credential.providerId,
|
||||
name: providerNames.get(credential.providerId) ?? credential.providerId,
|
||||
authType: credential.type,
|
||||
status: runtime.getProviderAuthStatus(credential.providerId),
|
||||
status: truthfulProviderStatus(runtime, credential.providerId),
|
||||
});
|
||||
}
|
||||
return filterAndSort(options);
|
||||
}
|
||||
|
||||
function truthfulProviderStatus(runtime: AuthProviderRuntime, providerId: string): AuthProviderStatus {
|
||||
const reported = runtime.getProviderAuthStatus(providerId);
|
||||
// ModelRuntime reports any stored entry as configured before checking whether
|
||||
// the provider can resolve all required credential and ambient fields.
|
||||
return reported.configured && !runtime.hasConfiguredAuth(providerId) ? { configured: false } : reported;
|
||||
}
|
||||
|
||||
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
|
||||
const filtered = authType === undefined ? options : options.filter((option) => option.authType === authType);
|
||||
return filtered.sort((a, b) => a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType) || a.id.localeCompare(b.id));
|
||||
|
||||
@@ -18,6 +18,16 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref
|
||||
}
|
||||
});
|
||||
|
||||
// Additive endpoint for newer browsers; the one-secret route remains for
|
||||
// rolling compatibility with older browser bundles.
|
||||
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/api-key/interactive`, async (request, reply) => {
|
||||
try {
|
||||
return await auth.startApiKeyLogin(request.body.providerId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
|
||||
try {
|
||||
return await auth.logoutProvider(request.body.providerId);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
@@ -98,14 +99,22 @@ describe("AuthService", () => {
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects Cloudflare multi-field setup without storing the secret as provider metadata", async () => {
|
||||
const { auth, credentials, changes } = await createAuthService();
|
||||
it("keeps existing file-backed credentials unchanged when legacy Cloudflare setup cannot finish", async () => {
|
||||
const seed = {
|
||||
"cloudflare-ai-gateway": {
|
||||
type: "api_key" as const,
|
||||
key: "existing-secret",
|
||||
env: { CLOUDFLARE_ACCOUNT_ID: "existing-account", CLOUDFLARE_GATEWAY_ID: "existing-gateway" },
|
||||
},
|
||||
};
|
||||
const { auth, authPath, changes } = await createFileBackedAuthService(seed);
|
||||
const before = await readFile(authPath, "utf8");
|
||||
|
||||
await expect(auth.saveApiKey("cloudflare-ai-gateway", "cf-secret")).rejects.toThrow(
|
||||
await expect(auth.saveApiKey("cloudflare-ai-gateway", "new-secret")).rejects.toThrow(
|
||||
"Cloudflare AI Gateway requires interactive setup; use Pi's generic /login flow",
|
||||
);
|
||||
|
||||
await expect(credentials.read("cloudflare-ai-gateway")).resolves.toBeUndefined();
|
||||
await expect(readFile(authPath, "utf8")).resolves.toBe(before);
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
@@ -113,18 +122,113 @@ describe("AuthService", () => {
|
||||
it.each([
|
||||
{ providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
|
||||
{ providerId: "google-vertex", providerName: "Google Vertex AI" },
|
||||
])("rejects $providerName select-first setup without storing the secret", async ({ providerId, providerName }) => {
|
||||
const { auth, credentials, changes } = await createAuthService();
|
||||
])("keeps an empty file-backed store unchanged when legacy $providerName setup starts with a selection", async ({ providerId, providerName }) => {
|
||||
const { auth, authPath, changes } = await createFileBackedAuthService({});
|
||||
const before = await readFile(authPath, "utf8");
|
||||
|
||||
await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow(
|
||||
`${providerName} requires interactive setup; use Pi's generic /login flow`,
|
||||
);
|
||||
|
||||
await expect(credentials.read(providerId)).resolves.toBeUndefined();
|
||||
await expect(readFile(authPath, "utf8")).resolves.toBe(before);
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("executes Cloudflare multi-field API-key setup through the interactive flow", async () => {
|
||||
const { auth, credentials, changes } = await createAuthService();
|
||||
|
||||
const state = await auth.startApiKeyLogin("cloudflare-ai-gateway");
|
||||
expect(state.prompt).toMatchObject({ message: "Enter Cloudflare API key", promptType: "secret" });
|
||||
if (state.prompt === undefined) throw new Error("Expected Cloudflare key prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, state.prompt.requestId, "cf-secret");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare account ID", promptType: "text" });
|
||||
});
|
||||
const accountPrompt = auth.oauthFlow(state.flowId).prompt;
|
||||
if (accountPrompt === undefined) throw new Error("Expected Cloudflare account prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, accountPrompt.requestId, "account-1");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: "Enter Cloudflare AI Gateway ID", promptType: "text" });
|
||||
});
|
||||
const gatewayPrompt = auth.oauthFlow(state.flowId).prompt;
|
||||
if (gatewayPrompt === undefined) throw new Error("Expected Cloudflare gateway prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, gatewayPrompt.requestId, "gateway-1");
|
||||
|
||||
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
|
||||
await expect(credentials.read("cloudflare-ai-gateway")).resolves.toEqual({
|
||||
type: "api_key",
|
||||
key: "cf-secret",
|
||||
env: { CLOUDFLARE_ACCOUNT_ID: "account-1", CLOUDFLARE_GATEWAY_ID: "gateway-1" },
|
||||
});
|
||||
expect(changes).toEqual([{}]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ providerId: "amazon-bedrock", selection: "bearer-token", secretPrompt: "Enter Amazon Bedrock bearer token" },
|
||||
{ providerId: "google-vertex", selection: "api-key", secretPrompt: "Enter Google Cloud API key" },
|
||||
])("executes $providerId select-first API-key setup through the interactive flow", async ({ providerId, selection, secretPrompt }) => {
|
||||
const { auth, credentials, changes } = await createAuthService();
|
||||
|
||||
const state = await auth.startApiKeyLogin(providerId);
|
||||
expect(state.select).toBeDefined();
|
||||
if (state.select === undefined) throw new Error("Expected auth method selection");
|
||||
auth.respondToOAuthFlow(state.flowId, state.select.requestId, selection);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(auth.oauthFlow(state.flowId).prompt).toMatchObject({ message: secretPrompt, promptType: "secret" });
|
||||
});
|
||||
const prompt = auth.oauthFlow(state.flowId).prompt;
|
||||
if (prompt === undefined) throw new Error("Expected provider secret prompt");
|
||||
auth.respondToOAuthFlow(state.flowId, prompt.requestId, "provider-secret");
|
||||
|
||||
await vi.waitFor(() => { expect(auth.oauthFlow(state.flowId).status).toBe("complete"); });
|
||||
await expect(credentials.read(providerId)).resolves.toEqual({ type: "api_key", key: "provider-secret" });
|
||||
expect(changes).toEqual([{}]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("reports a key-only legacy Cloudflare credential as unconfigured", async () => {
|
||||
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "");
|
||||
vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "");
|
||||
const { auth } = await createFileBackedAuthService({
|
||||
"cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
|
||||
});
|
||||
|
||||
const response = await auth.authProviders("login", "api_key");
|
||||
|
||||
expect(response.providers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "cloudflare-ai-gateway",
|
||||
loginFlow: "interactive",
|
||||
status: { configured: false },
|
||||
}),
|
||||
]));
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("reports a stored Cloudflare key as configured when ambient fields complete it", async () => {
|
||||
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ambient-account");
|
||||
vi.stubEnv("CLOUDFLARE_GATEWAY_ID", "ambient-gateway");
|
||||
const { auth } = await createFileBackedAuthService({
|
||||
"cloudflare-ai-gateway": { type: "api_key", key: "legacy-secret" },
|
||||
});
|
||||
|
||||
const response = await auth.authProviders("login", "api_key");
|
||||
|
||||
expect(response.providers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "cloudflare-ai-gateway",
|
||||
loginFlow: "interactive",
|
||||
status: { configured: true, source: "stored" },
|
||||
}),
|
||||
]));
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt },
|
||||
{
|
||||
@@ -372,6 +476,17 @@ async function createAuthService(seed: Record<string, Credential> = {}, logger?:
|
||||
return { auth, runtime, credentials, changes };
|
||||
}
|
||||
|
||||
async function createFileBackedAuthService(seed: Record<string, Credential>) {
|
||||
const agentDir = await tempAgentDir();
|
||||
const authPath = join(agentDir, "auth.json");
|
||||
await writeFile(authPath, JSON.stringify(seed, null, 2));
|
||||
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
|
||||
const auth = await AuthService.create({ runtime });
|
||||
const changes: AuthChange[] = [];
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
return { auth, runtime, authPath, changes };
|
||||
}
|
||||
|
||||
function mockLoginPromptsBeforePersistence(
|
||||
runtime: ModelRuntime,
|
||||
credentials: InMemoryCredentialStore,
|
||||
|
||||
@@ -105,12 +105,24 @@ export class AuthService {
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
async startApiKeyLogin(providerId: string): Promise<OAuthFlowState> {
|
||||
const provider = await this.requireApiKeyLoginProvider(providerId);
|
||||
return this.authFlows.start({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
runtime: this.runtime,
|
||||
authType: "api_key",
|
||||
onComplete: () => this.emit({}, { operation: "login", providerId, authType: "api_key" }),
|
||||
});
|
||||
}
|
||||
|
||||
async startOAuthLogin(providerId: string): Promise<OAuthFlowState> {
|
||||
const provider = await this.requireOAuthLoginProvider(providerId);
|
||||
return this.authFlows.start({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
runtime: this.runtime,
|
||||
authType: "oauth",
|
||||
onComplete: () => this.emit({}, { operation: "login", providerId, authType: "oauth" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthInteraction } from "@earendil-works/pi-ai";
|
||||
import type { AuthInteraction, AuthType } from "@earendil-works/pi-ai";
|
||||
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
@@ -41,6 +41,30 @@ describe("OAuthLoginFlowService", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("runs API-key login through the same AuthInteraction transport", async () => {
|
||||
const authTypes: AuthType[] = [];
|
||||
let key: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
runtime: fakeRuntime(async (_providerId, interaction) => {
|
||||
key = await interaction.prompt({ type: "secret", message: "Enter API key" });
|
||||
}, authTypes),
|
||||
authType: "api_key",
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
if (prompt === undefined) throw new Error("Expected API-key prompt");
|
||||
service.respond(state.flowId, prompt.requestId, "sk-test");
|
||||
await flushAsyncLogin();
|
||||
|
||||
expect(authTypes).toEqual(["api_key"]);
|
||||
expect(key).toBe("sk-test");
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("awaits async completion propagation before marking the flow complete", async () => {
|
||||
const completion = deferred<undefined>();
|
||||
const service = new OAuthLoginFlowService();
|
||||
@@ -79,7 +103,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
expect(onComplete).toHaveBeenCalledOnce();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
{ err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
|
||||
"OAuth login completion callback failed",
|
||||
"login completion callback failed",
|
||||
);
|
||||
service.dispose();
|
||||
});
|
||||
@@ -227,7 +251,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
|
||||
const select = state.select;
|
||||
if (select === undefined) throw new Error("Expected select prompt");
|
||||
expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid OAuth selection");
|
||||
expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid login selection");
|
||||
expect(service.get(state.flowId).select).toEqual(select);
|
||||
service.dispose();
|
||||
});
|
||||
@@ -338,7 +362,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
service.dispose();
|
||||
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
|
||||
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
|
||||
expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
|
||||
});
|
||||
|
||||
it("rejects stale or duplicate responses", () => {
|
||||
@@ -355,7 +379,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
if (prompt === undefined) throw new Error("Expected prompt");
|
||||
|
||||
service.respond(state.flowId, prompt.requestId, "abc123");
|
||||
expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("OAuth login request expired");
|
||||
expect(() => { service.respond(state.flowId, prompt.requestId, "abc123"); }).toThrow("Login request expired");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
@@ -378,19 +402,24 @@ describe("OAuthLoginFlowService", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "OAuth login flow expired" });
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "OAuth login flow expired" });
|
||||
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" });
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
|
||||
expect(() => { service.get(state.flowId); }).toThrow("Login flow not found");
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeRuntime(login: LoginHandler): Pick<ModelRuntime, "login"> {
|
||||
function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick<ModelRuntime, "login"> {
|
||||
return {
|
||||
login: (providerId, _type, interaction) => login(providerId, interaction).then(() => ({ type: "oauth", refresh: "r", access: "a", expires: 0 })),
|
||||
login: (providerId, type, interaction) => {
|
||||
authTypes?.push(type);
|
||||
return login(providerId, interaction).then(() => type === "api_key"
|
||||
? { type: "api_key", key: "test" }
|
||||
: { type: "oauth", refresh: "r", access: "a", expires: 0 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AuthEvent, AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType } from "@earendil-works/pi-ai";
|
||||
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
|
||||
@@ -42,6 +42,10 @@ const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
|
||||
const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } };
|
||||
|
||||
/**
|
||||
* AuthInteraction transport shared by OAuth and provider-driven API-key login.
|
||||
* The historical class and wire names remain for rolling browser/sessiond compatibility.
|
||||
*/
|
||||
export class OAuthLoginFlowService {
|
||||
private readonly flows = new Map<string, OAuthFlowRecord>();
|
||||
private readonly terminalTtlMs: number;
|
||||
@@ -60,6 +64,8 @@ export class OAuthLoginFlowService {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
runtime: OAuthLoginRuntime;
|
||||
/** Defaults to OAuth so established callers retain their existing behavior. */
|
||||
authType?: AuthType;
|
||||
onComplete?: () => void | Promise<void>;
|
||||
}): OAuthFlowState {
|
||||
const flowId = crypto.randomUUID();
|
||||
@@ -88,7 +94,7 @@ export class OAuthLoginFlowService {
|
||||
notify: (event) => { this.handleEvent(record, event); },
|
||||
};
|
||||
|
||||
void options.runtime.login(options.providerId, "oauth", interaction).then(
|
||||
void options.runtime.login(options.providerId, options.authType ?? "oauth", interaction).then(
|
||||
() => this.reconcileCommittedLogin(record, options.onComplete),
|
||||
(error: unknown) => {
|
||||
if (!this.isCurrent(record)) return;
|
||||
@@ -103,18 +109,18 @@ export class OAuthLoginFlowService {
|
||||
|
||||
get(flowId: string): OAuthFlowState {
|
||||
const record = this.flows.get(flowId);
|
||||
if (record === undefined) throw new Error("OAuth login flow not found");
|
||||
if (record === undefined) throw new Error("Login flow not found");
|
||||
return cloneState(record.state);
|
||||
}
|
||||
|
||||
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
|
||||
const record = this.flows.get(flowId);
|
||||
if (record === undefined) throw new Error("OAuth login flow not found");
|
||||
if (record === undefined) throw new Error("Login flow not found");
|
||||
if (record.state.status !== "running") return cloneState(record.state);
|
||||
const pending = record.pending;
|
||||
if (pending?.requestId !== requestId) throw new Error("OAuth login request expired");
|
||||
if (pending?.requestId !== requestId) throw new Error("Login request expired");
|
||||
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
|
||||
if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid OAuth selection");
|
||||
if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid login selection");
|
||||
this.clearPending(record);
|
||||
this.updateState(record, withoutInteraction(record.state));
|
||||
pending.resolve(value);
|
||||
@@ -123,7 +129,7 @@ export class OAuthLoginFlowService {
|
||||
|
||||
cancel(flowId: string): OAuthFlowState {
|
||||
const record = this.flows.get(flowId);
|
||||
if (record === undefined) throw new Error("OAuth login flow not found");
|
||||
if (record === undefined) throw new Error("Login flow not found");
|
||||
if (record.state.status === "running") {
|
||||
record.abort.abort();
|
||||
const pending = this.clearPending(record);
|
||||
@@ -287,7 +293,7 @@ export class OAuthLoginFlowService {
|
||||
} catch (error) {
|
||||
this.logErrorNoThrow(
|
||||
{ err: error, flowId: record.flowId, providerId: record.state.providerId },
|
||||
"OAuth login completion callback failed",
|
||||
"login completion callback failed",
|
||||
);
|
||||
}
|
||||
if (!this.isCurrent(record)) return;
|
||||
@@ -352,8 +358,8 @@ export class OAuthLoginFlowService {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
record.abort.abort();
|
||||
const pending = this.clearPending(record);
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
|
||||
pending?.reject(new Error("OAuth login flow expired"));
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" });
|
||||
pending?.reject(new Error("Login flow expired"));
|
||||
}
|
||||
|
||||
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
||||
|
||||
@@ -373,6 +373,8 @@ export interface AuthProviderOption {
|
||||
name: string;
|
||||
authType: AuthType;
|
||||
status: AuthProviderStatus;
|
||||
/** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */
|
||||
loginFlow?: "interactive";
|
||||
}
|
||||
|
||||
export interface AuthProvidersResponse {
|
||||
|
||||
@@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||
{ method: "GET", path: "/auth/providers" },
|
||||
{ method: "POST", path: "/auth/api-key" },
|
||||
{ method: "POST", path: "/auth/api-key/interactive" },
|
||||
{ method: "POST", path: "/auth/logout" },
|
||||
{ method: "POST", path: "/auth/oauth" },
|
||||
{ method: "GET", path: "/auth/oauth/:flowId" },
|
||||
|
||||
Reference in New Issue
Block a user