From c569a03f548a3cf5ec498d71a38565bd19e3e802 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 08:28:03 +0200 Subject: [PATCH] fix(auth): make API-key setup and status truthful --- .changeset/fix-pi-0-80-8-modelruntime-auth.md | 2 +- src/client/src/api/clients.ts | 1 + .../src/api/federatedRouteContract.test.ts | 1 + src/client/src/api/parsers.test.ts | 11 +- src/client/src/api/parsers.ts | 10 +- src/client/src/components/AuthDialog.ts | 6 +- .../src/controllers/authController.test.ts | 28 ++++ src/client/src/controllers/authController.ts | 11 +- .../sessions/authProviderOptions.test.ts | 31 ++++- src/server/sessions/authProviderOptions.ts | 15 +- src/server/sessions/authRoutes.ts | 10 ++ src/server/sessions/authService.test.ts | 129 +++++++++++++++++- src/server/sessions/authService.ts | 12 ++ .../sessions/oauthLoginFlowService.test.ts | 49 +++++-- src/server/sessions/oauthLoginFlowService.ts | 26 ++-- src/shared/apiTypes.ts | 2 + src/shared/federatedRoutes.ts | 1 + 17 files changed, 300 insertions(+), 45 deletions(-) diff --git a/.changeset/fix-pi-0-80-8-modelruntime-auth.md b/.changeset/fix-pi-0-80-8-modelruntime-auth.md index 822b379..24951dd 100644 --- a/.changeset/fix-pi-0-80-8-modelruntime-auth.md +++ b/.changeset/fix-pi-0-80-8-modelruntime-auth.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, OAuth prompts retain their input, selection, and device-code semantics, committed OAuth login remains truthful when cancellation races the final refresh, and unsupported multi-step API-key setup fails safely instead of storing malformed credentials. PI WEB now requires Node.js `>=22.19.0`. +Restore session-daemon startup and authentication on supported Pi `>=0.80.8 <0.81` releases by migrating model and credential handling to `ModelRuntime`. Login options now follow each provider's interactive API-key and OAuth capabilities, provider-driven API-key setup supports multi-step prompts while legacy one-secret clients still fail safely before storing malformed credentials, OAuth prompts retain their input, selection, and device-code semantics, and committed login remains truthful when cancellation races the final refresh. PI WEB now requires Node.js `>=22.19.0`. diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 7bf96e6..9bdc898 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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), diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index d8fa3c7..748bcf0 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -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)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 6020eb5..cf60131 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -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", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index d97aa17..1ed5ba8 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 7663a55..900f90f 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -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`
- +
`; case "providers": return html`
${state.providers.length === 0 ? html`
No providers available.
` : state.providers.map((provider) => this.renderProviderButton(provider))}
`; @@ -183,7 +183,7 @@ export function oauthPromptInputType(promptType: NonNullable { 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( diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts index b9df1c9..d9e2ba6 100644 --- a/src/client/src/controllers/authController.ts +++ b/src/client/src/controllers/authController.ts @@ -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 { + private async startLoginFlow(provider: AuthProviderOption): Promise { 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); diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index b7d5112..778403d 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions"; -function runtime(): AuthProviderRuntime { +function runtime(configuredProviders: ReadonlySet = 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" } }), ]); }); }); diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 529ae90..f188413 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -23,6 +23,7 @@ export interface AuthProviderRuntime { getProviders(): readonly AuthProviderInfo[]; listCredentials(): Promise; 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)); diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts index a8f516c..70089af 100644 --- a/src/server/sessions/authRoutes.ts +++ b/src/server/sessions/authRoutes.ts @@ -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); diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index d3fb5ae..e427e6c 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -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 = {}, logger?: return { auth, runtime, credentials, changes }; } +async function createFileBackedAuthService(seed: Record) { + 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, diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 4e26c04..5a237ff 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -105,12 +105,24 @@ export class AuthService { return { accepted: true }; } + async startApiKeyLogin(providerId: string): Promise { + 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 { 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" }), }); } diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 90c0500..713d743 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -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(); 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 { +function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick { 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 }); + }, }; } diff --git a/src/server/sessions/oauthLoginFlowService.ts b/src/server/sessions/oauthLoginFlowService.ts index d38cf18..2e15130 100644 --- a/src/server/sessions/oauthLoginFlowService.ts +++ b/src/server/sessions/oauthLoginFlowService.ts @@ -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(); 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; }): 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 { diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 78666d4..d687d43 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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 { diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 8a4558f..6614c79 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -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" },