Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})),
),
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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" })

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({
Expand Down
321 changes: 321 additions & 0 deletions packages/opencode/src/plugin/openrouter.ts
Original file line number Diff line number Diff line change
@@ -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<PkceCodes> {
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<KeyResponse> {
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 = `<!doctype html>
<html>
<head>
<title>OpenCode - OpenRouter Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`

const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - OpenRouter Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
</div>
</body>
</html>`

interface PendingOAuth {
pkce: PkceCodes
resolve: (key: string) => void
reject: (error: Error) => void
}

let oauthServer: ReturnType<typeof Bun.serve> | 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<string> {
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<Hooks> {
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",
},
],
},
}
}