@@ -1422,8 +1422,9 @@ Several providers support OAuth authentication instead of static API keys:
|
||||
- **Anthropic** (Claude Pro/Max subscription)
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
|
||||
- **GitHub Copilot** (Copilot subscription)
|
||||
- **OpenRouter** (OAuth PKCE that mints a user-controlled API key)
|
||||
|
||||
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` exchanges the refresh token, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh.
|
||||
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` refreshes expiring credentials when applicable, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh. OpenRouter's OAuth flow instead returns a permanent API key, so its refresh operation is a no-op.
|
||||
|
||||
```typescript
|
||||
import { createModels } from '@earendil-works/pi-ai';
|
||||
|
||||
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
|
||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||
@@ -42,6 +43,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||
};
|
||||
|
||||
export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.openrouter();
|
||||
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
|
||||
};
|
||||
|
||||
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.kimiCoding();
|
||||
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* OpenRouter OAuth PKCE flow.
|
||||
*
|
||||
* OpenRouter exchanges an authorization code for a permanent, user-controlled
|
||||
* API key rather than an expiring access/refresh token pair. The callback is
|
||||
* handled by a one-shot loopback server on an ephemeral port.
|
||||
*
|
||||
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
const AUTHORIZE_URL = "https://openrouter.ai/auth";
|
||||
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function getCallbackHost(): string {
|
||||
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type OpenRouterCallbackServer = {
|
||||
callbackUrl: string;
|
||||
credential: Promise<OAuthCredential>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, html: string): void {
|
||||
response.statusCode = status;
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.setHeader("cache-control", "no-store");
|
||||
response.end(html);
|
||||
}
|
||||
|
||||
function errorDetail(body: JsonObject): string | undefined {
|
||||
if (typeof body.error_description === "string") return body.error_description;
|
||||
if (typeof body.message === "string") return body.message;
|
||||
if (typeof body.error === "string") return body.error;
|
||||
if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) {
|
||||
const message = (body.error as JsonObject).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal?.reason);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new Error("OpenRouter OAuth token exchange timed out")),
|
||||
TOKEN_EXCHANGE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
let body: JsonObject = {};
|
||||
try {
|
||||
response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const parsed = (await response.json()) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject;
|
||||
} catch {
|
||||
if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON");
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = errorDetail(body);
|
||||
throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
if (typeof body.key !== "string" || body.key.length === 0) {
|
||||
throw new Error('OpenRouter OAuth response carries no "key"');
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
access: body.key,
|
||||
refresh: "",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
async function startCallbackServer(
|
||||
callbackPath: string,
|
||||
verifier: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OpenRouterCallbackServer> {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
const callbackHost = getCallbackHost();
|
||||
let resolveCredential: (credential: OAuthCredential) => void = () => {};
|
||||
let rejectCredential: (error: Error) => void = () => {};
|
||||
const credential = new Promise<OAuthCredential>((resolve, reject) => {
|
||||
resolveCredential = resolve;
|
||||
rejectCredential = reject;
|
||||
});
|
||||
|
||||
let server: Server;
|
||||
let claimed = false;
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
|
||||
const finish = (result: { credential: OAuthCredential } | { error: Error }): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
if ("credential" in result) resolveCredential(result.credential);
|
||||
else rejectCredential(result.error);
|
||||
};
|
||||
|
||||
server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`);
|
||||
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
|
||||
sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found."));
|
||||
return;
|
||||
}
|
||||
if (claimed || settled) {
|
||||
sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used."));
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthError = requestUrl.searchParams.get("error");
|
||||
if (oauthError) {
|
||||
const description = requestUrl.searchParams.get("error_description") ?? oauthError;
|
||||
sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description));
|
||||
finish({ error: new Error(`OpenRouter authorization failed: ${description}`) });
|
||||
return;
|
||||
}
|
||||
|
||||
const code = requestUrl.searchParams.get("code");
|
||||
if (!code) {
|
||||
sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code."));
|
||||
return;
|
||||
}
|
||||
claimed = true;
|
||||
|
||||
try {
|
||||
const result = await exchangeAuthorizationCode(code, verifier, signal);
|
||||
sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page."));
|
||||
finish({ credential: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown token exchange error";
|
||||
sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message));
|
||||
finish({ error: error instanceof Error ? error : new Error(message) });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, callbackHost, () => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (error) => finish({ error }));
|
||||
onAbort = () => finish({ error: new Error("Login cancelled") });
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
finish({ error: new Error("Could not determine the OpenRouter OAuth callback port") });
|
||||
throw new Error("Could not determine the OpenRouter OAuth callback port");
|
||||
}
|
||||
|
||||
return {
|
||||
callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
|
||||
credential,
|
||||
close: () => finish({ error: new Error("Login cancelled") }),
|
||||
};
|
||||
}
|
||||
|
||||
async function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
|
||||
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
|
||||
const authorizeUrl = new URL(AUTHORIZE_URL);
|
||||
authorizeUrl.search = new URLSearchParams({
|
||||
callback_url: callback.callbackUrl,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
}).toString();
|
||||
|
||||
interaction.notify({
|
||||
type: "progress",
|
||||
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
|
||||
});
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: authorizeUrl.toString(),
|
||||
instructions: "Complete sign-in in your browser.",
|
||||
});
|
||||
|
||||
try {
|
||||
return await callback.credential;
|
||||
} finally {
|
||||
callback.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const openRouterOAuth: OAuthAuth = {
|
||||
name: "OpenRouter OAuth",
|
||||
loginLabel: "Sign in with OpenRouter",
|
||||
login: loginOpenRouter,
|
||||
async refresh(credential) {
|
||||
return credential;
|
||||
},
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
||||
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
|
||||
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
||||
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
|
||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||
import { xaiOAuth } from "./auth/oauth/xai.ts";
|
||||
|
||||
@@ -12,6 +13,7 @@ export function registerBunOAuthFlows(): void {
|
||||
anthropic: () => anthropicOAuth,
|
||||
openaiCodex: () => openaiCodexOAuth,
|
||||
githubCopilot: () => githubCopilotOAuth,
|
||||
openrouter: () => openRouterOAuth,
|
||||
kimiCoding: () => kimiCodingOAuth,
|
||||
xai: () => xaiOAuth,
|
||||
radius: createRadiusOAuth,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
|
||||
import { IMAGE_MODELS } from "../image-models.generated.ts";
|
||||
import { createImagesProvider, type ImagesProvider } from "../images-models.ts";
|
||||
|
||||
@@ -7,7 +8,14 @@ export function openrouterImagesProvider(): ImagesProvider {
|
||||
return createImagesProvider({
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) },
|
||||
auth: {
|
||||
apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]),
|
||||
oauth: lazyOAuth({
|
||||
name: "OpenRouter OAuth",
|
||||
loginLabel: "Sign in with OpenRouter",
|
||||
load: loadOpenRouterOAuth,
|
||||
}),
|
||||
},
|
||||
models: Object.values(IMAGE_MODELS.openrouter),
|
||||
api: openrouterImagesApi(),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadOpenRouterOAuth } from "../auth/oauth/load.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { OPENROUTER_MODELS } from "./openrouter.models.ts";
|
||||
|
||||
@@ -8,7 +9,14 @@ export function openrouterProvider(): Provider<"openai-completions"> {
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) },
|
||||
auth: {
|
||||
apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]),
|
||||
oauth: lazyOAuth({
|
||||
name: "OpenRouter OAuth",
|
||||
loginLabel: "Sign in with OpenRouter",
|
||||
load: loadOpenRouterOAuth,
|
||||
}),
|
||||
},
|
||||
models: Object.values(OPENROUTER_MODELS),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
|
||||
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
|
||||
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
|
||||
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import * as extensionOAuthCompatibility from "../src/oauth.ts";
|
||||
@@ -33,6 +34,12 @@ describe.sequential("OAuthAuth adapters", () => {
|
||||
expect(auth).toEqual({ apiKey: "token" });
|
||||
});
|
||||
|
||||
it("openrouter derives the api key and keeps the permanent credential on refresh", async () => {
|
||||
const credential = { type: "oauth" as const, access: "token", refresh: "", expires: Number.MAX_SAFE_INTEGER };
|
||||
expect(await openRouterOAuth.toAuth(credential)).toEqual({ apiKey: "token" });
|
||||
expect(await openRouterOAuth.refresh(credential)).toBe(credential);
|
||||
});
|
||||
|
||||
it("xAI toAuth derives the api key from the access token", async () => {
|
||||
const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
|
||||
expect(auth).toEqual({ apiKey: "token" });
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
|
||||
import { createImagesModels } from "../src/images-models.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import { openrouterProvider } from "../src/providers/openrouter.ts";
|
||||
import { openrouterImagesProvider } from "../src/providers/openrouter-images.ts";
|
||||
|
||||
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
const nativeFetch = globalThis.fetch;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function base64url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
}
|
||||
|
||||
describe.sequential("OpenRouter OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("is exposed by both OpenRouter providers alongside API-key auth", () => {
|
||||
for (const provider of [openrouterProvider(), openrouterImagesProvider()]) {
|
||||
expect(provider.auth.apiKey).toBeDefined();
|
||||
expect(provider.auth.oauth).toBeDefined();
|
||||
expect(provider.auth.oauth?.loginLabel).toBe("Sign in with OpenRouter");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the same stored OAuth key for text and image providers", async () => {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
await credentials.modify("openrouter", async () => ({
|
||||
type: "oauth",
|
||||
access: "sk-or-stored",
|
||||
refresh: "",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
|
||||
const textModels = createModels({ credentials });
|
||||
textModels.setProvider(openrouterProvider());
|
||||
const imageModels = createImagesModels({ credentials });
|
||||
imageModels.setProvider(openrouterImagesProvider());
|
||||
|
||||
expect((await textModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored");
|
||||
expect((await imageModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored");
|
||||
});
|
||||
|
||||
it("runs PKCE on a one-shot loopback callback and exchanges the code for a permanent API key", async () => {
|
||||
let exchangeBody: Record<string, unknown> | undefined;
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url !== TOKEN_URL) return nativeFetch(input, init);
|
||||
exchangeBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
return jsonResponse({ key: "sk-or-test" });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let authorizeUrl: URL | undefined;
|
||||
let callbackResponse: Promise<Response> | undefined;
|
||||
const credential = await openRouterOAuth.login({
|
||||
prompt: async () => {
|
||||
throw new Error("OpenRouter login must not prompt for a code");
|
||||
},
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
authorizeUrl = new URL(event.url);
|
||||
const callbackUrl = new URL(authorizeUrl.searchParams.get("callback_url") ?? "");
|
||||
callbackUrl.searchParams.set("code", "authorization-code");
|
||||
callbackResponse = nativeFetch(callbackUrl);
|
||||
},
|
||||
});
|
||||
|
||||
expect(credential).toEqual({
|
||||
type: "oauth",
|
||||
access: "sk-or-test",
|
||||
refresh: "",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
expect((await callbackResponse)?.status).toBe(200);
|
||||
expect(authorizeUrl?.origin).toBe("https://openrouter.ai");
|
||||
expect(authorizeUrl?.pathname).toBe("/auth");
|
||||
expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
|
||||
const callbackUrl = new URL(authorizeUrl?.searchParams.get("callback_url") ?? "");
|
||||
expect(callbackUrl.hostname).toBe("127.0.0.1");
|
||||
expect(callbackUrl.pathname).toMatch(/^\/oauth\/callback\/[0-9a-f-]+$/);
|
||||
|
||||
expect(exchangeBody).toMatchObject({
|
||||
code: "authorization-code",
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
const verifier = exchangeBody?.code_verifier;
|
||||
expect(typeof verifier).toBe("string");
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(verifier)));
|
||||
expect(authorizeUrl?.searchParams.get("code_challenge")).toBe(base64url(new Uint8Array(digest)));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports token exchange failures through both the callback page and login", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ error: { message: "invalid code" } }, 403)),
|
||||
);
|
||||
|
||||
let callbackResponse: Promise<Response> | undefined;
|
||||
const login = openRouterOAuth.login({
|
||||
prompt: async () => "",
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
const callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
|
||||
callbackUrl.searchParams.set("code", "bad-code");
|
||||
callbackResponse = nativeFetch(callbackUrl);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(login).rejects.toThrow("OpenRouter OAuth key exchange failed (HTTP 403): invalid code");
|
||||
expect((await callbackResponse)?.status).toBe(502);
|
||||
});
|
||||
|
||||
it("allows only one token exchange for a callback", async () => {
|
||||
let completeExchange = (_response: Response): void => {
|
||||
throw new Error("Token exchange did not start");
|
||||
};
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Promise<Response>((resolve) => {
|
||||
completeExchange = resolve;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let callbackUrl: URL | undefined;
|
||||
let firstCallback: Promise<Response> | undefined;
|
||||
const login = openRouterOAuth.login({
|
||||
prompt: async () => "",
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
|
||||
callbackUrl.searchParams.set("code", "authorization-code");
|
||||
firstCallback = nativeFetch(callbackUrl);
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
if (!callbackUrl) throw new Error("OpenRouter did not provide a callback URL");
|
||||
expect((await nativeFetch(callbackUrl)).status).toBe(409);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
completeExchange(jsonResponse({ key: "sk-or-test" }));
|
||||
|
||||
await expect(login).resolves.toMatchObject({ access: "sk-or-test" });
|
||||
expect((await firstCallback)?.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a successful response that does not contain a key", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ user_id: "user-1" })),
|
||||
);
|
||||
|
||||
let callbackResponse: Promise<Response> | undefined;
|
||||
const login = openRouterOAuth.login({
|
||||
prompt: async () => "",
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
const callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
|
||||
callbackUrl.searchParams.set("code", "code-without-key");
|
||||
callbackResponse = nativeFetch(callbackUrl);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(login).rejects.toThrow('OpenRouter OAuth response carries no "key"');
|
||||
expect((await callbackResponse)?.status).toBe(502);
|
||||
});
|
||||
|
||||
it("closes the pending callback when login is cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
let callbackUrl: URL | undefined;
|
||||
const login = openRouterOAuth.login({
|
||||
signal: controller.signal,
|
||||
prompt: async () => "",
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
|
||||
controller.abort();
|
||||
},
|
||||
});
|
||||
|
||||
await expect(login).rejects.toThrow("Login cancelled");
|
||||
expect(callbackUrl).toBeDefined();
|
||||
await expect(nativeFetch(callbackUrl!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects before opening a callback server when login is already cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
openRouterOAuth.login({
|
||||
signal: controller.signal,
|
||||
prompt: async () => "",
|
||||
notify: () => {
|
||||
throw new Error("Cancelled login must not emit events");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Login cancelled");
|
||||
});
|
||||
|
||||
it("uses the configured OAuth callback host", async () => {
|
||||
vi.stubEnv("PI_OAUTH_CALLBACK_HOST", "localhost");
|
||||
const controller = new AbortController();
|
||||
let callbackUrl: URL | undefined;
|
||||
const login = openRouterOAuth.login({
|
||||
signal: controller.signal,
|
||||
prompt: async () => "",
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? "");
|
||||
controller.abort();
|
||||
},
|
||||
});
|
||||
|
||||
await expect(login).rejects.toThrow("Login cancelled");
|
||||
expect(callbackUrl?.hostname).toBe("localhost");
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,10 @@ Use `/login` in interactive mode, then select a provider:
|
||||
- Claude Pro/Max
|
||||
- GitHub Copilot
|
||||
- xAI (Grok/X subscription)
|
||||
- OpenRouter (OAuth-minted API key billed from OpenRouter credits)
|
||||
- Radius
|
||||
|
||||
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired.
|
||||
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. OpenRouter instead mints a user-controlled API key that does not expire automatically.
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
@@ -43,6 +44,12 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h
|
||||
- Run `/login xai`, then select **Use a subscription**
|
||||
- `XAI_API_KEY` remains available through **Use an API key**
|
||||
|
||||
### OpenRouter
|
||||
|
||||
- Run `/login openrouter`, then select **Sign in with OpenRouter** to open the OpenRouter PKCE authorization flow
|
||||
- The authorization creates a user-controlled OpenRouter API key billed from your OpenRouter credits
|
||||
- `OPENROUTER_API_KEY` remains available through **Use an API key**
|
||||
|
||||
### Radius
|
||||
|
||||
Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`.
|
||||
|
||||
Reference in New Issue
Block a user