@@ -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 };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user