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,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" } }),
]);
});
});
+12 -3
View File
@@ -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));
+10
View File
@@ -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);
+122 -7
View File
@@ -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,
+12
View File
@@ -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 });
},
};
}
+16 -10
View File
@@ -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 {