Archived
fix(auth): make API-key setup and status truthful
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@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`.
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const sessionsApi = {
|
|||||||
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
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 }) }),
|
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 }) }),
|
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 }) }),
|
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),
|
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.detachParent(session, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.startInteractiveApiKeyLogin("amazon-bedrock", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
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", () => {
|
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", () => {
|
it("preserves additive OAuth interaction semantics", () => {
|
||||||
expect(parseOAuthFlowState({
|
expect(parseOAuthFlowState({
|
||||||
flowId: "flow-1",
|
flowId: "flow-1",
|
||||||
|
|||||||
@@ -369,7 +369,15 @@ function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
|
|||||||
|
|
||||||
function parseAuthProviderOption(value: unknown): AuthProviderOption {
|
function parseAuthProviderOption(value: unknown): AuthProviderOption {
|
||||||
const record = requireRecord(value);
|
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 {
|
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class AuthDialog extends LitElement {
|
|||||||
private dialogTitle(state: AuthDialogState): string {
|
private dialogTitle(state: AuthDialogState): string {
|
||||||
switch (state.step) {
|
switch (state.step) {
|
||||||
case "method": return "Configure provider authentication";
|
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 "apiKey": return `API key for ${state.provider.name}`;
|
||||||
case "oauth": return `Login to ${state.flow.providerName}`;
|
case "oauth": return `Login to ${state.flow.providerName}`;
|
||||||
case "logout": return "Remove stored provider authentication";
|
case "logout": return "Remove stored provider authentication";
|
||||||
@@ -54,7 +54,7 @@ export class AuthDialog extends LitElement {
|
|||||||
case "method": return html`
|
case "method": return html`
|
||||||
<div class="options">
|
<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?.("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>
|
</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>`;
|
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 {
|
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 {
|
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" } });
|
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 () => {
|
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 flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||||
const { controller, getState } = createController(
|
const { controller, getState } = createController(
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class AuthController {
|
|||||||
if (dialog?.step !== "providers") return;
|
if (dialog?.step !== "providers") return;
|
||||||
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
|
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
|
||||||
if (provider === undefined) return;
|
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: "" } });
|
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,19 +189,22 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
const provider = exact[0];
|
const provider = exact[0];
|
||||||
if (provider === undefined) return;
|
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: "" } });
|
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(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;
|
if (this.rejectRemoteOAuth("login", provider)) return;
|
||||||
const operationGeneration = ++this.oauthOperationGeneration;
|
const operationGeneration = ++this.oauthOperationGeneration;
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
try {
|
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;
|
if (operationGeneration !== this.oauthOperationGeneration) return;
|
||||||
this.updateOAuthFlow(flow);
|
this.updateOAuthFlow(flow);
|
||||||
if (flow.status === "running") this.startPolling(flow.flowId);
|
if (flow.status === "running") this.startPolling(flow.flowId);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getLoginProviderOptions, getLogoutProviderOptions, type AuthProviderRuntime } from "./authProviderOptions";
|
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 }];
|
const credentials = [{ providerId: "openai", type: "api_key" as const }];
|
||||||
// Auth shapes mirror what the Pi SDK actually reports for these providers:
|
// Auth shapes mirror what the Pi SDK actually reports for these providers:
|
||||||
// github-copilot supports both methods, openai-codex is OAuth-only, and
|
// 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-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", auth: { oauth: {} } },
|
||||||
{ id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
|
{ id: "openai", name: "OpenAI", auth: { apiKey: { login: () => undefined } } },
|
||||||
{ id: "custom", name: "Custom", 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: {} } },
|
{ id: "ambient", name: "Ambient credentials", auth: { apiKey: {} } },
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
getProviders: () => providers,
|
getProviders: () => providers,
|
||||||
listCredentials: () => Promise.resolve(credentials),
|
listCredentials: () => Promise.resolve(credentials),
|
||||||
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
|
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" }),
|
expect.objectContaining({ id: "github-copilot", authType: "api_key" }),
|
||||||
// OAuth-only provider surfaces only oauth.
|
// OAuth-only provider surfaces only oauth.
|
||||||
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
|
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
|
||||||
// API-key-only providers surface only api_key.
|
// API-key options use the generic AuthInteraction flow, including
|
||||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
|
// multi-field and select-first providers the legacy form cannot execute.
|
||||||
expect.objectContaining({ id: "custom", authType: "api_key" }),
|
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-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: "openai", authType: "oauth" })]));
|
||||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "ambient", authType: "api_key" })]));
|
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 () => {
|
it("returns only currently stored credentials for logout", async () => {
|
||||||
expect(await getLogoutProviderOptions(runtime())).toEqual([
|
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[];
|
getProviders(): readonly AuthProviderInfo[];
|
||||||
listCredentials(): Promise<readonly AuthProviderCredentialInfo[]>;
|
listCredentials(): Promise<readonly AuthProviderCredentialInfo[]>;
|
||||||
getProviderAuthStatus(providerId: string): AuthProviderStatus;
|
getProviderAuthStatus(providerId: string): AuthProviderStatus;
|
||||||
|
hasConfiguredAuth(providerId: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
|
export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] {
|
||||||
@@ -35,7 +36,7 @@ export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?:
|
|||||||
id: provider.id,
|
id: provider.id,
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
authType: "oauth",
|
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,
|
id: provider.id,
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
authType: "api_key",
|
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,
|
id: credential.providerId,
|
||||||
name: providerNames.get(credential.providerId) ?? credential.providerId,
|
name: providerNames.get(credential.providerId) ?? credential.providerId,
|
||||||
authType: credential.type,
|
authType: credential.type,
|
||||||
status: runtime.getProviderAuthStatus(credential.providerId),
|
status: truthfulProviderStatus(runtime, credential.providerId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return filterAndSort(options);
|
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[] {
|
function filterAndSort(options: AuthProviderOption[], authType?: AuthType): AuthProviderOption[] {
|
||||||
const filtered = authType === undefined ? options : options.filter((option) => option.authType === authType);
|
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));
|
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) => {
|
app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await auth.logoutProvider(request.body.providerId);
|
return await auth.logoutProvider(request.body.providerId);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
|||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,14 +99,22 @@ describe("AuthService", () => {
|
|||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects Cloudflare multi-field setup without storing the secret as provider metadata", async () => {
|
it("keeps existing file-backed credentials unchanged when legacy Cloudflare setup cannot finish", async () => {
|
||||||
const { auth, credentials, changes } = await createAuthService();
|
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",
|
"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([]);
|
expect(changes).toEqual([]);
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
@@ -113,18 +122,113 @@ describe("AuthService", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{ providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
|
{ providerId: "amazon-bedrock", providerName: "Amazon Bedrock" },
|
||||||
{ providerId: "google-vertex", providerName: "Google Vertex AI" },
|
{ providerId: "google-vertex", providerName: "Google Vertex AI" },
|
||||||
])("rejects $providerName select-first setup without storing the secret", async ({ providerId, providerName }) => {
|
])("keeps an empty file-backed store unchanged when legacy $providerName setup starts with a selection", async ({ providerId, providerName }) => {
|
||||||
const { auth, credentials, changes } = await createAuthService();
|
const { auth, authPath, changes } = await createFileBackedAuthService({});
|
||||||
|
const before = await readFile(authPath, "utf8");
|
||||||
|
|
||||||
await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow(
|
await expect(auth.saveApiKey(providerId, "submitted-secret")).rejects.toThrow(
|
||||||
`${providerName} requires interactive setup; use Pi's generic /login flow`,
|
`${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([]);
|
expect(changes).toEqual([]);
|
||||||
auth.dispose();
|
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([
|
it.each([
|
||||||
{ label: "text", prompt: { type: "text", message: "Account" } satisfies AuthPrompt },
|
{ 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 };
|
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(
|
function mockLoginPromptsBeforePersistence(
|
||||||
runtime: ModelRuntime,
|
runtime: ModelRuntime,
|
||||||
credentials: InMemoryCredentialStore,
|
credentials: InMemoryCredentialStore,
|
||||||
|
|||||||
@@ -105,12 +105,24 @@ export class AuthService {
|
|||||||
return { accepted: true };
|
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> {
|
async startOAuthLogin(providerId: string): Promise<OAuthFlowState> {
|
||||||
const provider = await this.requireOAuthLoginProvider(providerId);
|
const provider = await this.requireOAuthLoginProvider(providerId);
|
||||||
return this.authFlows.start({
|
return this.authFlows.start({
|
||||||
providerId,
|
providerId,
|
||||||
providerName: provider.name,
|
providerName: provider.name,
|
||||||
runtime: this.runtime,
|
runtime: this.runtime,
|
||||||
|
authType: "oauth",
|
||||||
onComplete: () => this.emit({}, { operation: "login", providerId, 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 type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||||
@@ -41,6 +41,30 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
service.dispose();
|
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 () => {
|
it("awaits async completion propagation before marking the flow complete", async () => {
|
||||||
const completion = deferred<undefined>();
|
const completion = deferred<undefined>();
|
||||||
const service = new OAuthLoginFlowService();
|
const service = new OAuthLoginFlowService();
|
||||||
@@ -79,7 +103,7 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
expect(onComplete).toHaveBeenCalledOnce();
|
expect(onComplete).toHaveBeenCalledOnce();
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
{ err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
|
{ err: completionFailure, flowId: state.flowId, providerId: "test-provider" },
|
||||||
"OAuth login completion callback failed",
|
"login completion callback failed",
|
||||||
);
|
);
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
@@ -227,7 +251,7 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
|
|
||||||
const select = state.select;
|
const select = state.select;
|
||||||
if (select === undefined) throw new Error("Expected select prompt");
|
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);
|
expect(service.get(state.flowId).select).toEqual(select);
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
@@ -338,7 +362,7 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
|
|
||||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
|
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", () => {
|
it("rejects stale or duplicate responses", () => {
|
||||||
@@ -355,7 +379,7 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
if (prompt === undefined) throw new Error("Expected prompt");
|
if (prompt === undefined) throw new Error("Expected prompt");
|
||||||
|
|
||||||
service.respond(state.flowId, prompt.requestId, "abc123");
|
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();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -378,19 +402,24 @@ describe("OAuthLoginFlowService", () => {
|
|||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "OAuth login flow expired" });
|
expect(service.get(state.flowId)).toMatchObject({ status: "error", error: "Login flow expired" });
|
||||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "OAuth login flow expired" });
|
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login flow expired" });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
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();
|
service.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeRuntime(login: LoginHandler): Pick<ModelRuntime, "login"> {
|
function fakeRuntime(login: LoginHandler, authTypes?: AuthType[]): Pick<ModelRuntime, "login"> {
|
||||||
return {
|
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 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 { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||||
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
|
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 DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
|
||||||
const noopLogger: OAuthLoginFlowLogger = { error() { /* no-op */ } };
|
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 {
|
export class OAuthLoginFlowService {
|
||||||
private readonly flows = new Map<string, OAuthFlowRecord>();
|
private readonly flows = new Map<string, OAuthFlowRecord>();
|
||||||
private readonly terminalTtlMs: number;
|
private readonly terminalTtlMs: number;
|
||||||
@@ -60,6 +64,8 @@ export class OAuthLoginFlowService {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
runtime: OAuthLoginRuntime;
|
runtime: OAuthLoginRuntime;
|
||||||
|
/** Defaults to OAuth so established callers retain their existing behavior. */
|
||||||
|
authType?: AuthType;
|
||||||
onComplete?: () => void | Promise<void>;
|
onComplete?: () => void | Promise<void>;
|
||||||
}): OAuthFlowState {
|
}): OAuthFlowState {
|
||||||
const flowId = crypto.randomUUID();
|
const flowId = crypto.randomUUID();
|
||||||
@@ -88,7 +94,7 @@ export class OAuthLoginFlowService {
|
|||||||
notify: (event) => { this.handleEvent(record, event); },
|
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),
|
() => this.reconcileCommittedLogin(record, options.onComplete),
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
if (!this.isCurrent(record)) return;
|
if (!this.isCurrent(record)) return;
|
||||||
@@ -103,18 +109,18 @@ export class OAuthLoginFlowService {
|
|||||||
|
|
||||||
get(flowId: string): OAuthFlowState {
|
get(flowId: string): OAuthFlowState {
|
||||||
const record = this.flows.get(flowId);
|
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);
|
return cloneState(record.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
|
respond(flowId: string, requestId: string, value: string): OAuthFlowState {
|
||||||
const record = this.flows.get(flowId);
|
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);
|
if (record.state.status !== "running") return cloneState(record.state);
|
||||||
const pending = record.pending;
|
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.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.clearPending(record);
|
||||||
this.updateState(record, withoutInteraction(record.state));
|
this.updateState(record, withoutInteraction(record.state));
|
||||||
pending.resolve(value);
|
pending.resolve(value);
|
||||||
@@ -123,7 +129,7 @@ export class OAuthLoginFlowService {
|
|||||||
|
|
||||||
cancel(flowId: string): OAuthFlowState {
|
cancel(flowId: string): OAuthFlowState {
|
||||||
const record = this.flows.get(flowId);
|
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") {
|
if (record.state.status === "running") {
|
||||||
record.abort.abort();
|
record.abort.abort();
|
||||||
const pending = this.clearPending(record);
|
const pending = this.clearPending(record);
|
||||||
@@ -287,7 +293,7 @@ export class OAuthLoginFlowService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logErrorNoThrow(
|
this.logErrorNoThrow(
|
||||||
{ err: error, flowId: record.flowId, providerId: record.state.providerId },
|
{ err: error, flowId: record.flowId, providerId: record.state.providerId },
|
||||||
"OAuth login completion callback failed",
|
"login completion callback failed",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!this.isCurrent(record)) return;
|
if (!this.isCurrent(record)) return;
|
||||||
@@ -352,8 +358,8 @@ export class OAuthLoginFlowService {
|
|||||||
if (!this.isCurrentRunning(record)) return;
|
if (!this.isCurrentRunning(record)) return;
|
||||||
record.abort.abort();
|
record.abort.abort();
|
||||||
const pending = this.clearPending(record);
|
const pending = this.clearPending(record);
|
||||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
|
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "Login flow expired" });
|
||||||
pending?.reject(new Error("OAuth login flow expired"));
|
pending?.reject(new Error("Login flow expired"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ export interface AuthProviderOption {
|
|||||||
name: string;
|
name: string;
|
||||||
authType: AuthType;
|
authType: AuthType;
|
||||||
status: AuthProviderStatus;
|
status: AuthProviderStatus;
|
||||||
|
/** Additive hint: use the generic AuthInteraction transport instead of the legacy one-secret form. */
|
||||||
|
loginFlow?: "interactive";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthProvidersResponse {
|
export interface AuthProvidersResponse {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
|||||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||||
{ method: "GET", path: "/auth/providers" },
|
{ method: "GET", path: "/auth/providers" },
|
||||||
{ method: "POST", path: "/auth/api-key" },
|
{ method: "POST", path: "/auth/api-key" },
|
||||||
|
{ method: "POST", path: "/auth/api-key/interactive" },
|
||||||
{ method: "POST", path: "/auth/logout" },
|
{ method: "POST", path: "/auth/logout" },
|
||||||
{ method: "POST", path: "/auth/oauth" },
|
{ method: "POST", path: "/auth/oauth" },
|
||||||
{ method: "GET", path: "/auth/oauth/:flowId" },
|
{ method: "GET", path: "/auth/oauth/:flowId" },
|
||||||
|
|||||||
Reference in New Issue
Block a user