9993c96907
Move provider auth and OAuth flows onto pi-ai Models, compose models.json and extension overlays through ModelRuntime, and retain ModelRegistry as an extension compatibility facade.
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
|
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
|
|
|
|
function jsonResponse(body: unknown, status: number = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
}
|
|
|
|
function getUrl(input: unknown): string {
|
|
if (typeof input === "string") {
|
|
return input;
|
|
}
|
|
if (input instanceof URL) {
|
|
return input.toString();
|
|
}
|
|
if (input instanceof Request) {
|
|
return input.url;
|
|
}
|
|
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
|
}
|
|
|
|
function getJsonBody(init?: RequestInit): Record<string, string> {
|
|
if (typeof init?.body !== "string") {
|
|
throw new Error(`Expected string request body, got ${typeof init?.body}`);
|
|
}
|
|
return JSON.parse(init.body) as Record<string, string>;
|
|
}
|
|
|
|
describe.sequential("Anthropic OAuth", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("keeps the localhost redirect_uri for manual callback login", async () => {
|
|
let authUrl = "";
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
|
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
|
expect(init?.method).toBe("POST");
|
|
const body = getJsonBody(init);
|
|
expect(body.grant_type).toBe("authorization_code");
|
|
expect(body.code).toBe("manual-code");
|
|
expect(body.redirect_uri).toBe("http://localhost:53692/callback");
|
|
return jsonResponse({
|
|
access_token: "access-token",
|
|
refresh_token: "refresh-token",
|
|
expires_in: 3600,
|
|
});
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const credentials = await anthropicOAuth.login({
|
|
notify: (event) => {
|
|
if (event.type === "auth_url") authUrl = event.url;
|
|
},
|
|
prompt: async (prompt) => {
|
|
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
|
const url = new URL(authUrl);
|
|
const state = url.searchParams.get("state");
|
|
const redirectUri = url.searchParams.get("redirect_uri");
|
|
if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL");
|
|
return `${redirectUri}?code=manual-code&state=${state}`;
|
|
},
|
|
});
|
|
|
|
expect(credentials.access).toBe("access-token");
|
|
expect(credentials.refresh).toBe("refresh-token");
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("omits scope from refresh token requests", async () => {
|
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
|
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
|
expect(init?.method).toBe("POST");
|
|
const body = getJsonBody(init);
|
|
expect(body.grant_type).toBe("refresh_token");
|
|
expect(body.client_id).toBeTruthy();
|
|
expect(body.refresh_token).toBe("refresh-token");
|
|
expect(body).not.toHaveProperty("scope");
|
|
return jsonResponse({
|
|
access_token: "new-access-token",
|
|
refresh_token: "new-refresh-token",
|
|
expires_in: 3600,
|
|
});
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const credentials = await anthropicOAuth.refresh({
|
|
type: "oauth",
|
|
access: "old-access-token",
|
|
refresh: "refresh-token",
|
|
expires: 0,
|
|
});
|
|
|
|
expect(credentials.access).toBe("new-access-token");
|
|
expect(credentials.refresh).toBe("new-refresh-token");
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("anthropicOAuth.login resolves through the manual_code prompt and aborts it after settling", async () => {
|
|
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : String(input);
|
|
if (url.includes("/oauth/token")) {
|
|
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const events: AuthEvent[] = [];
|
|
const prompts: AuthPrompt[] = [];
|
|
let manualSignal: AbortSignal | undefined;
|
|
|
|
const credential = await anthropicOAuth.login({
|
|
notify: (event) => events.push(event),
|
|
prompt: async (prompt) => {
|
|
prompts.push(prompt);
|
|
if (prompt.type === "manual_code") {
|
|
manualSignal = prompt.signal;
|
|
return "the-code";
|
|
}
|
|
throw new Error(`Unexpected prompt: ${prompt.type}`);
|
|
},
|
|
});
|
|
|
|
expect(credential.type).toBe("oauth");
|
|
expect(credential.access).toBe("access");
|
|
expect(events.some((e) => e.type === "auth_url")).toBe(true);
|
|
expect(prompts.some((p) => p.type === "manual_code")).toBe(true);
|
|
// the prompt's signal is aborted once login settles, so UIs can dismiss it
|
|
expect(manualSignal?.aborted).toBe(true);
|
|
});
|
|
});
|