Archived
Add global web auth flows
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLoginProviderOptions, getLogoutProviderOptions, isApiKeyLoginProvider, type AuthProviderModelRegistry } from "./authProviderOptions";
|
||||
|
||||
function registry(): AuthProviderModelRegistry {
|
||||
const credentials = new Map<string, { type: "oauth" | "api_key" }>();
|
||||
credentials.set("openai", { type: "api_key" });
|
||||
return {
|
||||
authStorage: {
|
||||
getOAuthProviders: () => [
|
||||
{ id: "anthropic", name: "Anthropic (Claude Pro/Max)" },
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
{ id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)" },
|
||||
],
|
||||
list: () => Array.from(credentials.keys()),
|
||||
get: (provider: string) => credentials.get(provider),
|
||||
},
|
||||
getAll: () => [
|
||||
{ provider: "anthropic" },
|
||||
{ provider: "openai" },
|
||||
{ provider: "openai-codex" },
|
||||
{ provider: "github-copilot" },
|
||||
{ provider: "custom" },
|
||||
],
|
||||
getProviderDisplayName: (provider: string) => ({ anthropic: "Anthropic", openai: "OpenAI", custom: "Custom" }[provider] ?? provider),
|
||||
getProviderAuthStatus: (provider: string) => (provider === "openai" ? { configured: true, source: "stored" } : { configured: false }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("auth provider options", () => {
|
||||
it("keeps OAuth-only providers out of API key login options", () => {
|
||||
expect(isApiKeyLoginProvider("openai-codex", new Set(["openai-codex"]))).toBe(false);
|
||||
expect(isApiKeyLoginProvider("github-copilot", new Set(["github-copilot"]))).toBe(false);
|
||||
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes Anthropic in both OAuth and API key login options", () => {
|
||||
const options = getLoginProviderOptions(registry());
|
||||
expect(options).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
|
||||
expect.objectContaining({ id: "anthropic", authType: "api_key" }),
|
||||
expect.objectContaining({ id: "openai", authType: "api_key", status: { configured: true, source: "stored" } }),
|
||||
expect.objectContaining({ id: "openai-codex", authType: "oauth" }),
|
||||
]));
|
||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
|
||||
});
|
||||
|
||||
it("returns only stored credentials for logout", () => {
|
||||
expect(getLogoutProviderOptions(registry())).toEqual([
|
||||
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { getProviders } from "@earendil-works/pi-ai";
|
||||
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
|
||||
|
||||
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
|
||||
const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders());
|
||||
|
||||
export interface AuthProviderModelRegistry {
|
||||
authStorage: {
|
||||
getOAuthProviders(): { id: string; name: string }[];
|
||||
list(): string[];
|
||||
get(provider: string): { type: AuthType } | undefined;
|
||||
};
|
||||
getAll(): { provider: string }[];
|
||||
getProviderDisplayName(provider: string): string;
|
||||
getProviderAuthStatus(provider: string): AuthProviderStatus;
|
||||
}
|
||||
|
||||
export function getLoginProviderOptions(modelRegistry: AuthProviderModelRegistry, authType?: AuthType): AuthProviderOption[] {
|
||||
const oauthProviders = modelRegistry.authStorage.getOAuthProviders();
|
||||
const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id));
|
||||
const options: AuthProviderOption[] = oauthProviders.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
authType: "oauth",
|
||||
status: modelRegistry.getProviderAuthStatus(provider.id),
|
||||
}));
|
||||
|
||||
const modelProviders = new Set(modelRegistry.getAll().map((model) => model.provider));
|
||||
for (const providerId of modelProviders) {
|
||||
if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) continue;
|
||||
options.push({
|
||||
id: providerId,
|
||||
name: modelRegistry.getProviderDisplayName(providerId),
|
||||
authType: "api_key",
|
||||
status: modelRegistry.getProviderAuthStatus(providerId),
|
||||
});
|
||||
}
|
||||
|
||||
return filterAndSort(options, authType);
|
||||
}
|
||||
|
||||
export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistry): AuthProviderOption[] {
|
||||
const options: AuthProviderOption[] = [];
|
||||
for (const providerId of modelRegistry.authStorage.list()) {
|
||||
const credential = modelRegistry.authStorage.get(providerId);
|
||||
if (credential === undefined) continue;
|
||||
options.push({
|
||||
id: providerId,
|
||||
name: modelRegistry.getProviderDisplayName(providerId),
|
||||
authType: credential.type,
|
||||
status: modelRegistry.getProviderAuthStatus(providerId),
|
||||
});
|
||||
}
|
||||
return filterAndSort(options);
|
||||
}
|
||||
|
||||
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>, builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS): boolean {
|
||||
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
|
||||
if (providerId === "anthropic") return true;
|
||||
if (oauthProviderIds.has(providerId)) return false;
|
||||
if (builtInProviderIds.has(providerId)) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AuthService } from "./authService.js";
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void {
|
||||
app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => {
|
||||
try {
|
||||
return auth.authProviders(request.query.mode ?? "login", request.query.authType);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => {
|
||||
try {
|
||||
return auth.saveApiKey(request.body.providerId, request.body.key);
|
||||
} 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 auth.logoutProvider(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/oauth`, async (request, reply) => {
|
||||
try {
|
||||
return auth.startOAuthLogin(request.body.providerId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { flowId: string } }>(`${prefix}/auth/oauth/:flowId`, async (request, reply) => {
|
||||
try {
|
||||
return auth.oauthFlow(request.params.flowId);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { flowId: string }; Body: { requestId: string; value: string } }>(`${prefix}/auth/oauth/:flowId/respond`, async (request, reply) => {
|
||||
try {
|
||||
return auth.respondToOAuthFlow(request.params.flowId, request.body.requestId, request.body.value);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { flowId: string } }>(`${prefix}/auth/oauth/:flowId/cancel`, async (request, reply) => {
|
||||
try {
|
||||
return auth.cancelOAuthFlow(request.params.flowId);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AuthService, type AuthChange } from "./authService.js";
|
||||
|
||||
describe("AuthService", () => {
|
||||
it("saves API keys and emits a global auth change", () => {
|
||||
const { auth, authStorage, changes } = createAuthService();
|
||||
|
||||
expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true });
|
||||
|
||||
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" });
|
||||
expect(changes).toEqual([{}]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("logs out providers and emits the removed provider id", () => {
|
||||
const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } });
|
||||
|
||||
expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true });
|
||||
|
||||
expect(authStorage.get("anthropic")).toBeUndefined();
|
||||
expect(changes).toEqual([{ removedProviderId: "anthropic" }]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("rejects blank API keys", () => {
|
||||
const { auth, changes } = createAuthService();
|
||||
|
||||
expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required");
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}) {
|
||||
const authStorage = AuthStorage.inMemory(data);
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
const auth = new AuthService({ modelRegistry });
|
||||
const changes: AuthChange[] = [];
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
return { auth, authStorage, changes };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
|
||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
|
||||
export interface AuthChange {
|
||||
removedProviderId?: string;
|
||||
}
|
||||
|
||||
type AuthChangeListener = (change: AuthChange) => void;
|
||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||
|
||||
export interface AuthServiceDependencies {
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
authFlows?: OAuthLoginFlowService;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly authFlows: OAuthLoginFlowService;
|
||||
private readonly listeners = new Set<AuthChangeListener>();
|
||||
|
||||
constructor(deps: AuthServiceDependencies = {}) {
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
|
||||
}
|
||||
|
||||
subscribe(listener: AuthChangeListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.authFlows.dispose();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse {
|
||||
this.modelRegistry.refresh();
|
||||
const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType);
|
||||
return { providers };
|
||||
}
|
||||
|
||||
saveApiKey(providerId: string, key: string): { accepted: true } {
|
||||
if (key.trim() === "") throw new Error("API key is required");
|
||||
this.modelRegistry.authStorage.set(providerId, { type: "api_key", key });
|
||||
this.refreshAuthState();
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
logoutProvider(providerId: string): { accepted: true } {
|
||||
this.modelRegistry.authStorage.logout(providerId);
|
||||
this.refreshAuthState({ removedProviderId: providerId });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
startOAuthLogin(providerId: string): OAuthFlowState {
|
||||
const provider = this.requireOAuthLoginProvider(providerId);
|
||||
return this.authFlows.start({
|
||||
providerId,
|
||||
providerName: provider.name,
|
||||
authStorage: this.modelRegistry.authStorage,
|
||||
onComplete: () => {
|
||||
this.refreshAuthState();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
oauthFlow(flowId: string): OAuthFlowState {
|
||||
return this.authFlows.get(flowId);
|
||||
}
|
||||
|
||||
respondToOAuthFlow(flowId: string, requestId: string, value: string): OAuthFlowState {
|
||||
return this.authFlows.respond(flowId, requestId, value);
|
||||
}
|
||||
|
||||
cancelOAuthFlow(flowId: string): OAuthFlowState {
|
||||
return this.authFlows.cancel(flowId);
|
||||
}
|
||||
|
||||
private refreshAuthState(change: AuthChange = {}): void {
|
||||
this.modelRegistry.authStorage.reload();
|
||||
this.modelRegistry.refresh();
|
||||
this.emit(change);
|
||||
}
|
||||
|
||||
private emit(change: AuthChange): void {
|
||||
for (const listener of this.listeners) listener(change);
|
||||
}
|
||||
|
||||
private requireOAuthLoginProvider(providerId: string) {
|
||||
this.modelRegistry.refresh();
|
||||
const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId);
|
||||
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
||||
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
|
||||
type LoginHandler = (providerId: string, callbacks: OAuthLoginCallbacks) => Promise<void>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("OAuthLoginFlowService", () => {
|
||||
it("round-trips prompt responses and completes the flow", async () => {
|
||||
let promptValue: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
callbacks.onAuth({ url: "https://example.test/auth", instructions: "Open it" });
|
||||
callbacks.onProgress?.("Waiting for code");
|
||||
promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" });
|
||||
callbacks.onProgress?.(`Got ${promptValue}`);
|
||||
}),
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
if (prompt === undefined) throw new Error("Expected prompt");
|
||||
expect(state).toMatchObject({ auth: { url: "https://example.test/auth" }, progress: ["Waiting for code"] });
|
||||
expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" });
|
||||
|
||||
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
|
||||
expect(afterRespond.prompt).toBeUndefined();
|
||||
await flushAsyncLogin();
|
||||
|
||||
expect(promptValue).toBe("abc123");
|
||||
expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Waiting for code", "Got abc123", "Login complete"] });
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("round-trips select responses", async () => {
|
||||
let selectedValue: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
const select = callbacks.onSelect;
|
||||
if (select === undefined) throw new Error("Expected select callback");
|
||||
selectedValue = await select({
|
||||
message: "Choose account",
|
||||
options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }],
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const select = state.select;
|
||||
if (select === undefined) throw new Error("Expected select prompt");
|
||||
expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work" }, { value: "personal", label: "Personal" }] });
|
||||
|
||||
service.respond(state.flowId, select.requestId, "personal");
|
||||
await flushAsyncLogin();
|
||||
|
||||
expect(selectedValue).toBe("personal");
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("uses a manual-code prompt for callback-server flows", async () => {
|
||||
let manualValue: string | undefined;
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
const manualCodeInput = callbacks.onManualCodeInput;
|
||||
if (manualCodeInput === undefined) throw new Error("Expected manual-code callback");
|
||||
manualValue = await manualCodeInput();
|
||||
}),
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
if (prompt === undefined) throw new Error("Expected manual prompt");
|
||||
expect(prompt).toMatchObject({ kind: "manual", message: "Paste the callback URL or authorization code" });
|
||||
|
||||
service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc");
|
||||
await flushAsyncLogin();
|
||||
|
||||
expect(manualValue).toBe("https://localhost/callback?code=abc");
|
||||
expect(service.get(state.flowId).status).toBe("complete");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("rejects pending prompts when cancelled", async () => {
|
||||
const promptRejected = deferred<Error>();
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
try {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
expect(state.prompt).toBeDefined();
|
||||
expect(service.cancel(state.flowId)).toMatchObject({ status: "cancelled", error: "Login cancelled" });
|
||||
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
|
||||
expect(service.get(state.flowId).status).toBe("cancelled");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("rejects stale or duplicate responses", () => {
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
}),
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
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");
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("expires abandoned running flows and evicts terminal flows", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promptRejected = deferred<Error>();
|
||||
const service = new OAuthLoginFlowService({ runningTtlMs: 1000, terminalTtlMs: 1000 });
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
try {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeAuthStorage(login: LoginHandler): Pick<AuthStorage, "login"> {
|
||||
return { login };
|
||||
}
|
||||
|
||||
async function flushAsyncLogin(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolveValue: (value: T) => void = () => undefined;
|
||||
let rejectValue: (reason?: unknown) => void = () => undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveValue = resolve;
|
||||
rejectValue = reject;
|
||||
});
|
||||
return { promise, resolve: resolveValue, reject: rejectValue };
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { OAuthLoginCallbacks, OAuthSelectPrompt, OAuthPrompt } from "@earendil-works/pi-ai";
|
||||
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
||||
import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
|
||||
type OAuthLoginStorage = Pick<AuthStorage, "login">;
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
interface PendingOAuthRequest {
|
||||
requestId: string;
|
||||
allowEmpty: boolean;
|
||||
resolve: (value: string | undefined) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface OAuthFlowRecord {
|
||||
flowId: string;
|
||||
state: OAuthFlowState;
|
||||
abort: AbortController;
|
||||
pending: PendingOAuthRequest | undefined;
|
||||
terminalAt?: number;
|
||||
cleanupTimer?: TimerHandle;
|
||||
}
|
||||
|
||||
export interface OAuthLoginFlowServiceOptions {
|
||||
terminalTtlMs?: number;
|
||||
runningTtlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_RUNNING_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export class OAuthLoginFlowService {
|
||||
private readonly flows = new Map<string, OAuthFlowRecord>();
|
||||
private readonly terminalTtlMs: number;
|
||||
private readonly runningTtlMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: OAuthLoginFlowServiceOptions = {}) {
|
||||
this.terminalTtlMs = options.terminalTtlMs ?? DEFAULT_TERMINAL_TTL_MS;
|
||||
this.runningTtlMs = options.runningTtlMs ?? DEFAULT_RUNNING_TTL_MS;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
start(options: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
authStorage: OAuthLoginStorage;
|
||||
onComplete?: () => void;
|
||||
}): OAuthFlowState {
|
||||
const flowId = crypto.randomUUID();
|
||||
const abort = new AbortController();
|
||||
const record: OAuthFlowRecord = {
|
||||
flowId,
|
||||
abort,
|
||||
pending: undefined,
|
||||
state: {
|
||||
flowId,
|
||||
providerId: options.providerId,
|
||||
providerName: options.providerName,
|
||||
status: "running",
|
||||
progress: [],
|
||||
},
|
||||
};
|
||||
this.flows.set(flowId, record);
|
||||
this.scheduleRunningExpiry(record);
|
||||
|
||||
const callbacks: OAuthLoginCallbacks = {
|
||||
signal: abort.signal,
|
||||
onAuth: (info) => {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
this.updateState(record, { ...record.state, auth: info });
|
||||
},
|
||||
onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"),
|
||||
onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"),
|
||||
onSelect: (prompt) => this.waitForSelect(record, prompt),
|
||||
onProgress: (message) => {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
this.updateState(record, { ...record.state, progress: [...record.state.progress, message] });
|
||||
},
|
||||
};
|
||||
|
||||
void options.authStorage.login(options.providerId, callbacks)
|
||||
.then(() => {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
record.pending = undefined;
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] });
|
||||
options.onComplete?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.flows.get(record.flowId) !== record) return;
|
||||
record.pending = undefined;
|
||||
if (record.state.status !== "running") return;
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
return this.get(flowId);
|
||||
}
|
||||
|
||||
get(flowId: string): OAuthFlowState {
|
||||
const record = this.flows.get(flowId);
|
||||
if (record === undefined) throw new Error("OAuth 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.state.status !== "running") return cloneState(record.state);
|
||||
const pending = record.pending;
|
||||
if (pending?.requestId !== requestId) throw new Error("OAuth login request expired");
|
||||
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
|
||||
record.pending = undefined;
|
||||
this.updateState(record, withoutInteraction(record.state));
|
||||
pending.resolve(value);
|
||||
return cloneState(record.state);
|
||||
}
|
||||
|
||||
cancel(flowId: string): OAuthFlowState {
|
||||
const record = this.flows.get(flowId);
|
||||
if (record === undefined) throw new Error("OAuth login flow not found");
|
||||
if (record.state.status === "running") {
|
||||
record.abort.abort();
|
||||
const pending = record.pending;
|
||||
record.pending = undefined;
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
|
||||
pending?.reject(new Error("Login cancelled"));
|
||||
}
|
||||
return cloneState(record.state);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const record of this.flows.values()) {
|
||||
this.clearTimer(record);
|
||||
record.abort.abort();
|
||||
const pending = record.pending;
|
||||
record.pending = undefined;
|
||||
pending?.reject(new Error("Login cancelled"));
|
||||
}
|
||||
this.flows.clear();
|
||||
}
|
||||
|
||||
private waitForPrompt(record: OAuthFlowRecord, prompt: OAuthPrompt, kind: "prompt" | "manual"): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isCurrentRunning(record)) {
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
record.pending = { requestId, allowEmpty: prompt.allowEmpty === true, resolve: (value) => { resolve(value ?? ""); }, reject };
|
||||
const base = withoutInteraction(record.state);
|
||||
this.updateState(record, {
|
||||
...base,
|
||||
prompt: {
|
||||
requestId,
|
||||
message: prompt.message,
|
||||
kind,
|
||||
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
|
||||
...(prompt.allowEmpty === true ? { allowEmpty: true } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private waitForSelect(record: OAuthFlowRecord, prompt: OAuthSelectPrompt): Promise<string | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isCurrentRunning(record)) {
|
||||
reject(new Error("Login cancelled"));
|
||||
return;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const options: CommandOption[] = prompt.options.map((option) => ({ value: option.id, label: option.label }));
|
||||
record.pending = { requestId, allowEmpty: true, resolve, reject };
|
||||
const base = withoutInteraction(record.state);
|
||||
this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
|
||||
});
|
||||
}
|
||||
|
||||
private isCurrentRunning(record: OAuthFlowRecord): boolean {
|
||||
return this.flows.get(record.flowId) === record && record.state.status === "running";
|
||||
}
|
||||
|
||||
private updateState(record: OAuthFlowRecord, state: OAuthFlowState): void {
|
||||
record.state = state;
|
||||
}
|
||||
|
||||
private markTerminal(record: OAuthFlowRecord, state: OAuthFlowState): void {
|
||||
this.updateState(record, state);
|
||||
record.terminalAt = this.now();
|
||||
this.scheduleTerminalEviction(record);
|
||||
}
|
||||
|
||||
private scheduleRunningExpiry(record: OAuthFlowRecord): void {
|
||||
if (this.runningTtlMs <= 0) {
|
||||
this.expireRunningFlow(record);
|
||||
return;
|
||||
}
|
||||
this.setTimer(record, this.runningTtlMs, () => { this.expireRunningFlow(record); });
|
||||
}
|
||||
|
||||
private scheduleTerminalEviction(record: OAuthFlowRecord): void {
|
||||
if (this.terminalTtlMs <= 0) {
|
||||
this.flows.delete(record.flowId);
|
||||
this.clearTimer(record);
|
||||
return;
|
||||
}
|
||||
this.setTimer(record, this.terminalTtlMs, () => {
|
||||
if (this.flows.get(record.flowId) !== record) return;
|
||||
if (record.terminalAt === undefined) return;
|
||||
if (this.now() - record.terminalAt < this.terminalTtlMs) {
|
||||
this.scheduleTerminalEviction(record);
|
||||
return;
|
||||
}
|
||||
this.flows.delete(record.flowId);
|
||||
this.clearTimer(record);
|
||||
});
|
||||
}
|
||||
|
||||
private expireRunningFlow(record: OAuthFlowRecord): void {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
record.abort.abort();
|
||||
const pending = record.pending;
|
||||
record.pending = undefined;
|
||||
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
|
||||
pending?.reject(new Error("OAuth login flow expired"));
|
||||
}
|
||||
|
||||
private setTimer(record: OAuthFlowRecord, delayMs: number, callback: () => void): void {
|
||||
this.clearTimer(record);
|
||||
record.cleanupTimer = setTimeout(callback, delayMs);
|
||||
unrefTimer(record.cleanupTimer);
|
||||
}
|
||||
|
||||
private clearTimer(record: OAuthFlowRecord): void {
|
||||
if (record.cleanupTimer === undefined) return;
|
||||
clearTimeout(record.cleanupTimer);
|
||||
delete record.cleanupTimer;
|
||||
}
|
||||
}
|
||||
|
||||
function withoutInteraction(state: OAuthFlowState): OAuthFlowState {
|
||||
const rest = { ...state };
|
||||
delete rest.prompt;
|
||||
delete rest.select;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function cloneState(state: OAuthFlowState): OAuthFlowState {
|
||||
return {
|
||||
...state,
|
||||
progress: [...state.progress],
|
||||
...(state.auth === undefined ? {} : { auth: { ...state.auth } }),
|
||||
...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
|
||||
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
|
||||
};
|
||||
}
|
||||
|
||||
function unrefTimer(timer: TimerHandle): void {
|
||||
if (typeof timer !== "object" || !("unref" in timer) || typeof timer.unref !== "function") return;
|
||||
timer.unref();
|
||||
}
|
||||
@@ -244,6 +244,69 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("auth-session");
|
||||
(fake.runtime.session as unknown as { model: { provider: string; id: string } }).model = { provider: "anthropic", id: "claude-3-5-sonnet" };
|
||||
|
||||
const credentials = new Map<string, { type: "api_key" | "oauth"; key?: string }>([["anthropic", { type: "api_key", key: "sk-test" }]]);
|
||||
const authStorage = {
|
||||
get(provider: string) { return credentials.get(provider); },
|
||||
list(): string[] { return Array.from(credentials.keys()); },
|
||||
getOAuthProviders: () => [],
|
||||
hasAuth(provider: string): boolean { return credentials.has(provider); },
|
||||
getAuthStatus(provider: string) { return credentials.has(provider) ? { configured: true, source: "stored" as const } : { configured: false }; },
|
||||
};
|
||||
let refreshCalls = 0;
|
||||
const knownModels = [{ provider: "anthropic", id: "claude-3-5-sonnet" }];
|
||||
const modelRegistry = {
|
||||
authStorage,
|
||||
refresh(): void { refreshCalls += 1; },
|
||||
getAll: () => knownModels,
|
||||
getAvailable: () => credentials.has("anthropic") ? knownModels : [],
|
||||
find: (provider: string, id: string) => knownModels.find((model) => model.provider === provider && model.id === id),
|
||||
getProviderDisplayName: (provider: string) => provider,
|
||||
getProviderAuthStatus: (provider: string) => authStorage.getAuthStatus(provider),
|
||||
hasConfiguredAuth: (model: { provider: string }) => credentials.has(model.provider),
|
||||
};
|
||||
(fake.runtime.session as unknown as { modelRegistry: typeof modelRegistry }).modelRegistry = modelRegistry;
|
||||
|
||||
const service = new PiSessionService(hub, {
|
||||
modelRegistry: modelRegistry as unknown as NonNullable<NonNullable<ConstructorParameters<typeof PiSessionService>[1]>["modelRegistry"]>,
|
||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([]),
|
||||
listAll: () => Promise.resolve([{ id: "auth-session", path: "/sessions/auth-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("auth-session");
|
||||
hub.sessionEvents.length = 0;
|
||||
hub.globalEvents.length = 0;
|
||||
const refreshBefore = refreshCalls;
|
||||
|
||||
credentials.delete("anthropic");
|
||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||
|
||||
const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet")).length;
|
||||
expect(refreshCalls).toBeGreaterThan(refreshBefore);
|
||||
expect(warningCount()).toBe(1);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
|
||||
|
||||
credentials.set("anthropic", { type: "api_key", key: "sk-new" });
|
||||
service.applyAuthChange();
|
||||
credentials.delete("anthropic");
|
||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||
expect(warningCount()).toBe(2);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears queued messages when stopping a session runtime", async () => {
|
||||
const fake = fakeRuntime("stop-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
|
||||
@@ -17,19 +17,22 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
import { SessionCommandService } from "./sessionCommandService.js";
|
||||
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
}
|
||||
|
||||
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
|
||||
return `${sessionId}:${provider}/${modelId}`;
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "archive" | "restore" | "isArchived">;
|
||||
type SessionManagerGateway = Pick<typeof SessionManager, "list" | "create" | "listAll" | "open">;
|
||||
type CreateAgentRuntime = typeof createAgentSessionRuntime;
|
||||
|
||||
function createDefaultRuntimeFactory(): CreateAgentSessionRuntimeFactory {
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ReturnType<typeof ModelRegistry.create>): CreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||
const options = sessionStartEvent === undefined
|
||||
@@ -55,6 +58,7 @@ export class PiSessionService {
|
||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
private readonly commandService: SessionCommandService;
|
||||
private readonly authLossWarnings = new Set<string>();
|
||||
private readonly archiveStore: SessionArchiveRepository;
|
||||
private readonly agentDir: string;
|
||||
private readonly sessionManager: SessionManagerGateway;
|
||||
@@ -66,9 +70,9 @@ export class PiSessionService {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
this.agentDir = deps.agentDir ?? getAgentDir();
|
||||
this.sessionManager = deps.sessionManager ?? SessionManager;
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory();
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime;
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime;
|
||||
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
||||
this.commandService = new SessionCommandService(
|
||||
(sessionId) => this.getActive(sessionId),
|
||||
@@ -96,6 +100,7 @@ export class PiSessionService {
|
||||
const activeSessions = Array.from(new Set(this.active.values()));
|
||||
this.active.clear();
|
||||
this.activities.clear();
|
||||
this.authLossWarnings.clear();
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
await active.runtime.session.abort();
|
||||
@@ -320,6 +325,7 @@ export class PiSessionService {
|
||||
void active.runtime.session.abort().finally(() => active.runtime.dispose());
|
||||
this.active.delete(sessionId);
|
||||
this.activities.delete(sessionId);
|
||||
this.clearAuthLossWarningsForSession(sessionId);
|
||||
}
|
||||
|
||||
private async assertWritable(sessionId: string): Promise<void> {
|
||||
@@ -384,6 +390,43 @@ export class PiSessionService {
|
||||
this.publishSessionName(session);
|
||||
}
|
||||
|
||||
applyAuthChange(change: AuthChange = {}): void {
|
||||
this.modelRegistry.refresh();
|
||||
for (const active of this.active.values()) {
|
||||
const { session } = active.runtime;
|
||||
session.modelRegistry.refresh();
|
||||
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
|
||||
this.publishStatus(session);
|
||||
}
|
||||
}
|
||||
|
||||
private syncCurrentModelAuthWarning(session: AgentSession, removedProviderId: string | undefined): void {
|
||||
const model = session.model;
|
||||
if (model === undefined) return;
|
||||
if (model.provider === "unknown" && model.id === "unknown") return;
|
||||
const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
|
||||
const registered = session.modelRegistry.find(model.provider, model.id);
|
||||
if (registered === undefined) return;
|
||||
if (session.modelRegistry.hasConfiguredAuth(registered)) {
|
||||
this.authLossWarnings.delete(warningKey);
|
||||
return;
|
||||
}
|
||||
if (removedProviderId === undefined || model.provider !== removedProviderId || this.authLossWarnings.has(warningKey)) return;
|
||||
this.authLossWarnings.add(warningKey);
|
||||
this.events.publish(session.sessionId, {
|
||||
type: "command.output",
|
||||
level: "error",
|
||||
message: `Authentication for ${model.provider}/${model.id} was removed. Use /model to select another model.`,
|
||||
});
|
||||
}
|
||||
|
||||
private clearAuthLossWarningsForSession(sessionId: string): void {
|
||||
const prefix = `${sessionId}:`;
|
||||
for (const key of this.authLossWarnings) {
|
||||
if (key.startsWith(prefix)) this.authLossWarnings.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private publishSessionName(session: AgentSession): void {
|
||||
const event = session.sessionName === undefined
|
||||
? { type: "session.name", sessionId: session.sessionId } as const
|
||||
|
||||
Reference in New Issue
Block a user