Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"

export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"

export namespace Auth {
export const Oauth = z
.object({
Expand Down
109 changes: 109 additions & 0 deletions packages/opencode/src/codex/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import crypto from "crypto"

export namespace CodexAuth {
const ISSUER = "https://auth.openai.com"
const CLIENT_ID = "openai-codex-cli"

// Pending OAuth sessions: state -> { verifier, redirectUri }
const pending = new Map<string, { verifier: string; redirectUri: string }>()

function generatePkce() {
const verifier = crypto.randomBytes(64).toString("base64url")
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url")
return { verifier, challenge }
}

function generateState() {
return crypto.randomBytes(32).toString("base64url")
}

export function authorize(redirectUri: string) {
const pkce = generatePkce()
const state = generateState()

pending.set(state, { verifier: pkce.verifier, redirectUri })

// Clean up after 15 minutes
setTimeout(() => pending.delete(state), 15 * 60 * 1000)

const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})

return { url: `${ISSUER}/oauth/authorize?${params}`, state }
}

export async function callback(code: string, state: string) {
const session = pending.get(state)
if (!session) throw new Error("Invalid or expired OAuth state")
pending.delete(state)

const resp = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: session.redirectUri,
client_id: CLIENT_ID,
code_verifier: session.verifier,
}),
})

if (!resp.ok) {
const text = await resp.text()
throw new Error(`Token exchange failed: ${resp.status} ${text}`)
}

const tokens = (await resp.json()) as {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}

return {
access: tokens.access_token,
refresh: tokens.refresh_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}
}

export async function refresh(refreshToken: string) {
const resp = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
})

if (!resp.ok) {
const text = await resp.text()
throw new Error(`Token refresh failed: ${resp.status} ${text}`)
}

const tokens = (await resp.json()) as {
access_token: string
refresh_token?: string
expires_in?: number
}

return {
access: tokens.access_token,
refresh: tokens.refresh_token ?? refreshToken,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}
}
}
Loading