@@ -174,24 +219,26 @@ function ConnectedPageContent() {
function DisconnectedPageContent() {
const [authUrl, setAuthUrl] = useState
(null);
- const { client: openRouter } = useOpenRouter();
useEffect(() => {
const generateAuthUrl = async () => {
// Generate PKCE code challenge
- const challenge = await openRouter.oAuth.createSHA256CodeChallenge();
+ const challenge = await createSHA256CodeChallenge();
+ const state = generateOAuthState();
// Store the code verifier for later use in the callback
localStorage.setItem(
OPENROUTER_CODE_VERIFIER_KEY,
challenge.codeVerifier,
);
+ localStorage.setItem(OPENROUTER_STATE_LOCALSTORAGE_KEY, state);
// Generate authorization URL with PKCE
- const url = await openRouter.oAuth.createAuthorizationUrl({
+ const url = await createAuthorizationUrl({
callbackUrl: OAUTH_CALLBACK_URL,
codeChallenge: challenge.codeChallenge,
codeChallengeMethod: "S256",
+ state,
});
setAuthUrl(url);
diff --git a/examples/nextjs-example/src/app/oauth/callback/route.ts b/examples/nextjs-example/src/app/oauth/callback/route.ts
index f2167de8..c1fc8482 100644
--- a/examples/nextjs-example/src/app/oauth/callback/route.ts
+++ b/examples/nextjs-example/src/app/oauth/callback/route.ts
@@ -3,12 +3,18 @@ import { redirect } from "next/navigation";
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
+ const error = url.searchParams.get("error");
+ const state = url.searchParams.get("state");
- if (!code) {
+ if (!code && !error) {
return redirect("/?error=missing_code");
}
- // Redirect to the main page with the code
- // The client-side code will handle the exchange using the stored code verifier
- redirect(`/?code=${code}`);
+ const params = new URLSearchParams();
+ if (error) params.set("error", error);
+ if (code) params.set("code", code);
+ if (state) params.set("state", state);
+
+ const dest = params.toString() ? `/?${params.toString()}` : "/";
+ redirect(dest);
}
diff --git a/examples/nextjs-example/src/lib/config.ts b/examples/nextjs-example/src/lib/config.ts
index 7abe3124..7d5e63cf 100644
--- a/examples/nextjs-example/src/lib/config.ts
+++ b/examples/nextjs-example/src/lib/config.ts
@@ -3,3 +3,4 @@ export const OAUTH_CALLBACK_URL = "http://localhost:3000/oauth/callback";
export const OPENROUTER_KEY_LOCALSTORAGE_KEY = "openrouter_key";
export const OPENROUTER_USER_ID_LOCALSTORAGE_KEY = "openrouter_user_id";
export const OPENROUTER_CODE_VERIFIER_KEY = "openrouter_code_verifier";
+export const OPENROUTER_STATE_LOCALSTORAGE_KEY = "openrouter_oauth_state";
diff --git a/examples/nextjs-example/src/lib/oauth.ts b/examples/nextjs-example/src/lib/oauth.ts
new file mode 100644
index 00000000..03d47b0c
--- /dev/null
+++ b/examples/nextjs-example/src/lib/oauth.ts
@@ -0,0 +1,271 @@
+import { Buffer } from "buffer";
+
+const DEFAULT_OPENROUTER_BASE_URL =
+ process.env.NEXT_PUBLIC_OPENROUTER_BASE_URL ?? "https://openrouter.ai";
+const DEFAULT_OPENROUTER_API_BASE_URL =
+ process.env.NEXT_PUBLIC_OPENROUTER_API_BASE_URL ??
+ `${DEFAULT_OPENROUTER_BASE_URL.replace(/\/+$/, "")}/api/v1`;
+
+const CODE_VERIFIER_REGEX = /^[A-Za-z0-9\-._~]+$/;
+const CODE_VERIFIER_MIN_LENGTH = 43;
+const CODE_VERIFIER_MAX_LENGTH = 128;
+const DEFAULT_RANDOM_BYTE_LENGTH = 32;
+const DEFAULT_STATE_BYTE_LENGTH = 16;
+
+export type PkceChallenge = {
+ codeChallenge: string;
+ codeVerifier: string;
+};
+
+export type CodeChallengeMethod = "S256" | "plain";
+
+export type CreateAuthorizationUrlOptions = {
+ callbackUrl: string | URL;
+ codeChallenge?: string;
+ codeChallengeMethod?: CodeChallengeMethod;
+ limit?: number;
+ state?: string;
+ baseUrl?: string | URL;
+};
+
+export type ExchangeAuthorizationCodeOptions = {
+ code: string;
+ codeVerifier: string;
+ codeChallengeMethod?: CodeChallengeMethod;
+ apiBaseUrl?: string | URL;
+};
+
+export type ExchangeAuthorizationCodeResponse = {
+ key: string;
+ userId?: string;
+};
+
+/**
+ * Convert an ArrayBuffer to base64url per RFC 4648.
+ */
+function arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
+ return Buffer.from(bytes)
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+function resolveBaseUrl(
+ value: string | URL | undefined,
+ fallback: string,
+): URL {
+ if (value) {
+ return typeof value === "string" ? new URL(value) : new URL(value.toString());
+ }
+ return new URL(fallback);
+}
+
+function buildUrl(base: URL, path: string): URL {
+ const normalized = new URL(base.toString());
+ if (!normalized.pathname.endsWith("/")) {
+ normalized.pathname += "/";
+ }
+ const relativePath = path.startsWith("/") ? path.slice(1) : path;
+ return new URL(relativePath, normalized);
+}
+
+function assertCodeVerifier(verifier: string) {
+ if (
+ verifier.length < CODE_VERIFIER_MIN_LENGTH ||
+ verifier.length > CODE_VERIFIER_MAX_LENGTH
+ ) {
+ throw new Error(
+ `Code verifier must be between ${CODE_VERIFIER_MIN_LENGTH} and ${CODE_VERIFIER_MAX_LENGTH} characters.`,
+ );
+ }
+
+ if (!CODE_VERIFIER_REGEX.test(verifier)) {
+ throw new Error(
+ "Code verifier must only contain unreserved characters: [A-Za-z0-9-._~].",
+ );
+ }
+}
+
+function assertWebCryptoSupport() {
+ if (!globalThis.crypto?.getRandomValues || !globalThis.crypto?.subtle) {
+ throw new Error("Web Crypto API is not available in this environment.");
+ }
+}
+
+/**
+ * Generate a random PKCE code verifier that satisfies RFC 7636.
+ */
+export function generateCodeVerifier(
+ randomByteLength: number = DEFAULT_RANDOM_BYTE_LENGTH,
+): string {
+ if (randomByteLength < 32 || randomByteLength > 96) {
+ throw new Error(
+ "randomByteLength must produce a code verifier between 43 and 128 characters.",
+ );
+ }
+
+ assertWebCryptoSupport();
+
+ const randomBytes = new Uint8Array(randomByteLength);
+ crypto.getRandomValues(randomBytes);
+ const codeVerifier = arrayBufferToBase64Url(randomBytes);
+
+ assertCodeVerifier(codeVerifier);
+ return codeVerifier;
+}
+
+/**
+ * Create a PKCE SHA-256 code challenge and verifier pair.
+ */
+export async function createSHA256CodeChallenge(
+ codeVerifier: string = generateCodeVerifier(),
+): Promise {
+ assertWebCryptoSupport();
+ assertCodeVerifier(codeVerifier);
+
+ const encoder = new TextEncoder();
+ const data = encoder.encode(codeVerifier);
+ const hash = await crypto.subtle.digest("SHA-256", data);
+ const codeChallenge = arrayBufferToBase64Url(hash);
+
+ return { codeVerifier, codeChallenge };
+}
+
+/**
+ * Generate an OAuth state token for CSRF protection.
+ */
+export function generateOAuthState(
+ randomByteLength: number = DEFAULT_STATE_BYTE_LENGTH,
+): string {
+ if (randomByteLength <= 0) {
+ throw new Error("randomByteLength must be a positive integer.");
+ }
+
+ assertWebCryptoSupport();
+ const randomBytes = new Uint8Array(randomByteLength);
+ crypto.getRandomValues(randomBytes);
+ return arrayBufferToBase64Url(randomBytes);
+}
+
+/**
+ * Build the OpenRouter OAuth authorization URL (step 2 of PKCE).
+ */
+export function createAuthorizationUrl(
+ options: CreateAuthorizationUrlOptions,
+): string {
+ const {
+ callbackUrl,
+ codeChallenge,
+ codeChallengeMethod,
+ limit,
+ state,
+ baseUrl,
+ } = options;
+
+ if (!callbackUrl) {
+ throw new Error("callbackUrl is required to start the OAuth flow.");
+ }
+
+ if (codeChallengeMethod && !codeChallenge) {
+ throw new Error(
+ "codeChallengeMethod was provided without a matching codeChallenge.",
+ );
+ }
+
+ const base = resolveBaseUrl(baseUrl, DEFAULT_OPENROUTER_BASE_URL);
+ const authUrl = buildUrl(base, "auth");
+
+ authUrl.searchParams.set("callback_url", callbackUrl.toString());
+
+ if (typeof limit === "number") {
+ authUrl.searchParams.set("limit", limit.toString());
+ }
+
+ if (state) {
+ authUrl.searchParams.set("state", state);
+ }
+
+ if (codeChallenge) {
+ authUrl.searchParams.set("code_challenge", codeChallenge);
+ authUrl.searchParams.set(
+ "code_challenge_method",
+ codeChallengeMethod ?? "S256",
+ );
+ }
+
+ return authUrl.toString();
+}
+
+/**
+ * Exchange an authorization code for an API key (step 4 of PKCE).
+ */
+export async function exchangeAuthorizationCode(
+ options: ExchangeAuthorizationCodeOptions,
+): Promise {
+ const {
+ code,
+ codeVerifier,
+ codeChallengeMethod = "S256",
+ apiBaseUrl,
+ } = options;
+
+ if (!code?.trim()) {
+ throw new Error("Authorization code is required.");
+ }
+
+ if (!codeVerifier?.trim()) {
+ throw new Error("Code verifier is required.");
+ }
+
+ const base = resolveBaseUrl(apiBaseUrl, DEFAULT_OPENROUTER_API_BASE_URL);
+ const url = buildUrl(base, "auth/keys");
+
+ let responseBody: unknown;
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ code,
+ code_verifier: codeVerifier,
+ code_challenge_method: codeChallengeMethod,
+ }),
+ cache: "no-store",
+ });
+
+ responseBody = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ const message =
+ (responseBody as { error?: { message?: string } })?.error?.message ??
+ `Failed to exchange authorization code (status ${response.status}).`;
+ throw new Error(message);
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ }
+ throw new Error("Failed to exchange authorization code.");
+ }
+
+ const payload = responseBody as {
+ key?: string;
+ user_id?: string;
+ error?: { message?: string };
+ };
+
+ if (!payload?.key) {
+ throw new Error("OpenRouter did not return an API key.");
+ }
+
+ return {
+ key: payload.key,
+ userId: payload.user_id,
+ };
+}