Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
|
|
import type { OAuthCredential, ProviderAuthInteraction } from "../src/auth/types.ts";
|
|
|
|
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
|
|
const REQUIRED_SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
|
|
const neverAbortedSignal = new AbortController().signal;
|
|
const DEVICE_ID = "e61bbe28-07ef-466d-8e5d-a344f94ab305";
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
function tokenResponse(scope = REQUIRED_SCOPE) {
|
|
return {
|
|
access_token: "access-token",
|
|
refresh_token: "refresh-token",
|
|
expires_in: 3600,
|
|
id_token: "id-token",
|
|
scope,
|
|
};
|
|
}
|
|
|
|
function stubTokenEndpoint(response: unknown, inspect?: (body: URLSearchParams) => void) {
|
|
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
expect(input instanceof Request ? input.url : String(input)).toBe(TOKEN_URL);
|
|
inspect?.(new URLSearchParams(String(init?.body)));
|
|
return jsonResponse(response);
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
function loginInteraction(options?: {
|
|
callbackClientId?: string;
|
|
onAuthorize?: (url: URL) => void;
|
|
}): ProviderAuthInteraction {
|
|
let authorizeUrl: URL | undefined;
|
|
return {
|
|
signal: neverAbortedSignal,
|
|
notify: (event) => {
|
|
if (event.type !== "auth_url") return;
|
|
authorizeUrl = new URL(event.url);
|
|
options?.onAuthorize?.(authorizeUrl);
|
|
},
|
|
prompt: async (prompt) => {
|
|
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
|
if (!authorizeUrl) throw new Error("Authorization URL was not emitted before the callback prompt");
|
|
const callback = new URL(authorizeUrl.searchParams.get("redirect_uri") ?? "");
|
|
callback.searchParams.set("code", "authorization-code");
|
|
callback.searchParams.set("state", authorizeUrl.searchParams.get("state") ?? "");
|
|
if (options?.callbackClientId) callback.searchParams.set("client_id", options.callbackClientId);
|
|
return callback.toString();
|
|
},
|
|
};
|
|
}
|
|
|
|
function connectedCredential(): OAuthCredential {
|
|
return {
|
|
type: "oauth",
|
|
access: "old-access",
|
|
refresh: "old-refresh",
|
|
expires: 0,
|
|
clientId: "oaiapp_existing",
|
|
scopes: REQUIRED_SCOPE.split(" "),
|
|
};
|
|
}
|
|
|
|
describe("OpenAI ChatGPT OAuth", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("registers a user-owned client and stores its issued ID and granted scopes", async () => {
|
|
let authorizeUrl: URL | undefined;
|
|
let exchangeBody: URLSearchParams | undefined;
|
|
stubTokenEndpoint(tokenResponse(), (body) => {
|
|
exchangeBody = body;
|
|
});
|
|
|
|
const credential = await openaiChatGPTOAuth.login(
|
|
loginInteraction({
|
|
callbackClientId: "oaiapp_issued",
|
|
onAuthorize: (url) => {
|
|
authorizeUrl = url;
|
|
},
|
|
}),
|
|
{ getDeviceId: () => DEVICE_ID },
|
|
);
|
|
|
|
expect(authorizeUrl?.searchParams.get("client_id")).toBe("dynamic_agent_client");
|
|
expect(authorizeUrl?.searchParams.get("agent_name_hint")).toBe("Pi");
|
|
expect(authorizeUrl?.searchParams.get("ext_agent_host_id")).toBe(`urn:uuid:${DEVICE_ID}`);
|
|
expect(authorizeUrl?.searchParams.get("scope")).toBe(REQUIRED_SCOPE);
|
|
expect(authorizeUrl?.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:1455/auth/callback");
|
|
expect(authorizeUrl?.searchParams.get("resource")).toBe("https://api.openai.com/v1");
|
|
expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256");
|
|
expect(exchangeBody?.get("client_id")).toBe("oaiapp_issued");
|
|
expect(exchangeBody?.get("code")).toBe("authorization-code");
|
|
expect(exchangeBody?.get("resource")).toBe("https://api.openai.com/v1");
|
|
expect(exchangeBody?.get("code_verifier")).toBeTruthy();
|
|
expect(credential).toMatchObject({
|
|
type: "oauth",
|
|
access: "access-token",
|
|
refresh: "refresh-token",
|
|
clientId: "oaiapp_issued",
|
|
scopes: REQUIRED_SCOPE.split(" "),
|
|
});
|
|
});
|
|
|
|
it("uses the app's agent name as the name hint", async () => {
|
|
let authorizeUrl: URL | undefined;
|
|
stubTokenEndpoint(tokenResponse());
|
|
|
|
await openaiChatGPTOAuth.login(
|
|
loginInteraction({
|
|
callbackClientId: "oaiapp_issued",
|
|
onAuthorize: (url) => {
|
|
authorizeUrl = url;
|
|
},
|
|
}),
|
|
{ getDeviceId: () => DEVICE_ID, agentName: "my-app" },
|
|
);
|
|
|
|
expect(authorizeUrl?.searchParams.get("agent_name_hint")).toBe("my-app");
|
|
});
|
|
|
|
it("rejects registration without an issued client ID", async () => {
|
|
const fetchMock = stubTokenEndpoint(tokenResponse());
|
|
|
|
await expect(openaiChatGPTOAuth.login(loginInteraction(), { getDeviceId: () => DEVICE_ID })).rejects.toThrow(
|
|
"registration callback did not contain an issued client ID",
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a token response that did not grant direct token use", async () => {
|
|
stubTokenEndpoint(tokenResponse("openid profile email offline_access resource.invoke"));
|
|
|
|
await expect(
|
|
openaiChatGPTOAuth.login(loginInteraction({ callbackClientId: "oaiapp_issued" }), {
|
|
getDeviceId: () => DEVICE_ID,
|
|
}),
|
|
).rejects.toThrow("grant did not include chatgpt.tokens.use.direct");
|
|
});
|
|
|
|
it("requires a device ID before starting authorization", async () => {
|
|
let authorizationStarted = false;
|
|
const interaction = loginInteraction({
|
|
onAuthorize: () => {
|
|
authorizationStarted = true;
|
|
},
|
|
});
|
|
|
|
await expect(openaiChatGPTOAuth.login(interaction)).rejects.toThrow("requires a device ID");
|
|
await expect(openaiChatGPTOAuth.login(interaction, { getDeviceId: () => "not-a-uuid" })).rejects.toThrow(
|
|
"requires a device ID",
|
|
);
|
|
expect(authorizationStarted).toBe(false);
|
|
});
|
|
|
|
it("requires refresh responses to rotate the refresh token", async () => {
|
|
const { refresh_token: _refreshToken, ...responseWithoutRefresh } = tokenResponse();
|
|
stubTokenEndpoint(responseWithoutRefresh);
|
|
|
|
await expect(openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal)).rejects.toThrow(
|
|
"token response has invalid refresh_token",
|
|
);
|
|
});
|
|
|
|
it("refreshes with the credential's issued client ID and stores replacement scopes", async () => {
|
|
let refreshBody: URLSearchParams | undefined;
|
|
stubTokenEndpoint({ ...tokenResponse(), access_token: "new-access", refresh_token: "new-refresh" }, (body) => {
|
|
refreshBody = body;
|
|
});
|
|
|
|
const before = Date.now();
|
|
const credential = await openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal);
|
|
|
|
// expires_in is 3600 seconds; the credential expires 3 minutes early so it is refreshed in time.
|
|
expect(credential.expires).toBeGreaterThanOrEqual(before + (3600 - 180) * 1000);
|
|
expect(credential.expires).toBeLessThanOrEqual(Date.now() + (3600 - 180) * 1000);
|
|
|
|
expect(refreshBody?.get("grant_type")).toBe("refresh_token");
|
|
expect(refreshBody?.get("client_id")).toBe("oaiapp_existing");
|
|
expect(refreshBody?.get("refresh_token")).toBe("old-refresh");
|
|
expect(refreshBody?.get("resource")).toBe("https://api.openai.com/v1");
|
|
expect(refreshBody?.has("scope")).toBe(false);
|
|
expect(credential).toMatchObject({
|
|
access: "new-access",
|
|
refresh: "new-refresh",
|
|
clientId: "oaiapp_existing",
|
|
scopes: REQUIRED_SCOPE.split(" "),
|
|
});
|
|
});
|
|
});
|