diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 3dd7bcc35dd..991d12ab20c 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -295,6 +295,7 @@ export const AuthLoginCommand = cmd({ opencode: "recommended", anthropic: "Claude Max or API key", openai: "ChatGPT Plus/Pro or API key", + openrouter: "OAuth or API key", }[x.id], })), ), diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f57b46a3521..8a84e1be42f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -8,6 +8,7 @@ import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" +import { OpenRouterAuthPlugin } from "./openrouter" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -15,7 +16,7 @@ export namespace Plugin { const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"] // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, OpenRouterAuthPlugin] const state = Instance.state(async () => { const client = createOpencodeClient({ diff --git a/packages/opencode/src/plugin/openrouter.ts b/packages/opencode/src/plugin/openrouter.ts new file mode 100644 index 00000000000..ad0b672d511 --- /dev/null +++ b/packages/opencode/src/plugin/openrouter.ts @@ -0,0 +1,321 @@ +/** + * OpenRouter OAuth PKCE authentication plugin. + * Allows users to authenticate with their OpenRouter account instead of manually entering an API key. + * See: https://openrouter.ai/docs/guides/overview/auth/oauth + */ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { Log } from "../util/log" +import open from "open" + +const log = Log.create({ service: "plugin.openrouter" }) + +const OAUTH_PORT = 3000 +const OPENROUTER_AUTH_URL = "https://openrouter.ai/auth" +const OPENROUTER_KEYS_URL = "https://openrouter.ai/api/v1/auth/keys" + +interface PkceCodes { + verifier: string + challenge: string +} + +async function generatePKCE(): Promise { + const verifier = generateRandomString(43) + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const hash = await crypto.subtle.digest("SHA-256", data) + const challenge = base64UrlEncode(hash) + return { verifier, challenge } +} + +function generateRandomString(length: number): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const bytes = crypto.getRandomValues(new Uint8Array(length)) + return Array.from(bytes) + .map((b) => chars[b % chars.length]) + .join("") +} + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes): string { + const params = new URLSearchParams({ + callback_url: redirectUri, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + }) + return `${OPENROUTER_AUTH_URL}?${params.toString()}` +} + +interface KeyResponse { + key: string +} + +async function exchangeCodeForKey(code: string, pkce: PkceCodes): Promise { + const response = await fetch(OPENROUTER_KEYS_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + body: JSON.stringify({ + code, + code_verifier: pkce.verifier, + code_challenge_method: "S256", + }), + }) + if (!response.ok) { + const text = await response.text() + throw new Error(`Key exchange failed: ${response.status} - ${text}`) + } + return response.json() +} + +const HTML_SUCCESS = ` + + + OpenCode - OpenRouter Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +` + +const HTML_ERROR = (error: string) => ` + + + OpenCode - OpenRouter Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${error}
+
+ +` + +interface PendingOAuth { + pkce: PkceCodes + resolve: (key: string) => void + reject: (error: Error) => void +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { + if (oauthServer) { + return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } + } + + oauthServer = Bun.serve({ + port: OAUTH_PORT, + fetch(req) { + const url = new URL(req.url) + + if (url.pathname === "/auth/callback") { + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + if (error) { + const errorMsg = errorDescription || error + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + return new Response(HTML_ERROR(errorMsg), { + headers: { "Content-Type": "text/html" }, + }) + } + + if (!code) { + const errorMsg = "Missing authorization code" + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + return new Response(HTML_ERROR(errorMsg), { + status: 400, + headers: { "Content-Type": "text/html" }, + }) + } + + if (!pendingOAuth) { + const errorMsg = "No pending OAuth request" + return new Response(HTML_ERROR(errorMsg), { + status: 400, + headers: { "Content-Type": "text/html" }, + }) + } + + const current = pendingOAuth + pendingOAuth = undefined + + exchangeCodeForKey(code, current.pkce) + .then((result) => current.resolve(result.key)) + .catch((err) => current.reject(err)) + + return new Response(HTML_SUCCESS, { + headers: { "Content-Type": "text/html" }, + }) + } + + return new Response("Not found", { status: 404 }) + }, + }) + + log.info("openrouter oauth server started", { port: OAUTH_PORT }) + return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } +} + +function stopOAuthServer() { + if (oauthServer) { + oauthServer.stop() + oauthServer = undefined + log.info("openrouter oauth server stopped") + } +} + +function waitForOAuthCallback(pkce: PkceCodes): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) + + pendingOAuth = { + pkce, + resolve: (key) => { + clearTimeout(timeout) + resolve(key) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +export async function OpenRouterAuthPlugin(_input: PluginInput): Promise { + return { + auth: { + provider: "openrouter", + methods: [ + { + label: "OpenRouter Account", + type: "oauth", + authorize: async () => { + const { redirectUri } = await startOAuthServer() + const pkce = await generatePKCE() + const authUrl = buildAuthorizeUrl(redirectUri, pkce) + + const callbackPromise = waitForOAuthCallback(pkce) + + log.info("opening browser for openrouter oauth", { url: authUrl }) + open(authUrl).catch(() => {}) + + return { + url: authUrl, + instructions: "Complete authorization in your browser. This window will close automatically.", + method: "auto" as const, + callback: async () => { + try { + const key = await callbackPromise + stopOAuthServer() + return { + type: "success" as const, + key, + } + } catch (error) { + stopOAuthServer() + log.error("openrouter oauth failed", { error }) + return { + type: "failed" as const, + } + } + }, + } + }, + }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, + } +}