Use native Codex device OAuth flow

This commit is contained in:
2026-06-08 11:10:31 -06:00
parent d5c86c71c9
commit 2154e8e534
2 changed files with 290 additions and 1 deletions
+163 -1
View File
@@ -17,6 +17,7 @@ const fs = require("fs")
const path = require("path")
const { spawn } = require("child_process")
const os = require("os")
const crypto = require("crypto")
const { createPool, runMigrations } = require("./lib/db.cjs")
const { required } = require("./lib/config.cjs")
@@ -52,6 +53,11 @@ const GEMINI_CONFIG_DIR = process.env.GEMINI_CONFIG_DIR || path.join(os.homedir(
const ENV_FILE = path.join(HERMES_HOME, ".env")
const CONFIG_FILE = path.join(HERMES_HOME, "config.yaml")
const STATIC_DIR = __dirname
const CODEX_DEVICE_AUTH_BASE = (process.env.HERMES_CODEX_DEVICE_AUTH_BASE || "https://auth.openai.com").replace(/\/+$/, "")
const CODEX_DEVICE_VERIFY_URL = process.env.HERMES_CODEX_DEVICE_VERIFY_URL || "https://auth.openai.com/codex/device"
const CODEX_OAUTH_CLIENT_ID = process.env.HERMES_CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann"
const CODEX_OAUTH_TOKEN_URL = process.env.HERMES_CODEX_OAUTH_TOKEN_URL || "https://auth.openai.com/oauth/token"
const CODEX_BASE_URL = (process.env.HERMES_CODEX_BASE_URL || "https://chatgpt.com/backend-api/codex").replace(/\/+$/, "")
const DATABASE_URL = process.env.DATABASE_URL
const ADMIN_USERNAME = process.env.HERMES_ADMIN_USERNAME
@@ -113,6 +119,161 @@ function spawnHermesTracked(provider, args) {
return state
}
function appendTrackedLine(state, line) {
state.lines.push(line)
if (state.lines.length > 200) state.lines.shift()
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"Accept": "application/json",
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
})
const text = await response.text()
let body = null
try { body = text ? JSON.parse(text) : null } catch { body = text }
if (!response.ok) {
const detail = body && typeof body === "object"
? (body.error_description || body.error || JSON.stringify(body))
: String(body || response.statusText)
const err = new Error(`${response.status} ${detail}`)
err.status = response.status
throw err
}
return body || {}
}
function loadHermesAuthStore() {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(HERMES_HOME, "auth.json"), "utf-8"))
if (parsed && typeof parsed === "object") return parsed
} catch {}
return { version: 1, providers: {}, credential_pool: {} }
}
function saveHermesAuthStore(store) {
fs.mkdirSync(HERMES_HOME, { recursive: true })
store.version = store.version || 1
store.updated_at = new Date().toISOString()
const target = path.join(HERMES_HOME, "auth.json")
const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(6).toString("hex")}`
fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 })
fs.renameSync(tmp, target)
try { fs.chmodSync(target, 0o600) } catch {}
}
function labelFromToken(token, fallback) {
const identity = identityFromToken(token)
return identity?.email || identity?.name || identity?.id || fallback
}
function saveCodexCredential(tokens) {
const accessToken = String(tokens.access_token || "")
if (!accessToken) throw new Error("token exchange did not return access_token")
const store = loadHermesAuthStore()
if (!store.credential_pool || typeof store.credential_pool !== "object") store.credential_pool = {}
if (!Array.isArray(store.credential_pool["openai-codex"])) store.credential_pool["openai-codex"] = []
const entries = store.credential_pool["openai-codex"]
const label = labelFromToken(accessToken, `dashboard device code ${entries.length + 1}`)
entries.push({
id: crypto.randomBytes(3).toString("hex"),
label,
auth_type: "oauth",
priority: 0,
source: "manual:dashboard_device_code",
access_token: accessToken,
refresh_token: tokens.refresh_token || null,
base_url: CODEX_BASE_URL,
last_status: null,
last_status_at: null,
last_error_code: null,
last_error_reason: null,
last_error_message: null,
last_error_reset_at: null,
request_count: 0,
})
saveHermesAuthStore(store)
return label
}
function startCodexDeviceOauth() {
const provider = "openai-codex"
const state = {
proc: null,
lines: [],
done: false,
exitCode: null,
startedAt: Date.now(),
aborted: false,
}
runningProcs.set(provider, state)
;(async () => {
try {
const deviceData = await fetchJson(`${CODEX_DEVICE_AUTH_BASE}/api/accounts/deviceauth/usercode`, {
method: "POST",
body: JSON.stringify({ client_id: CODEX_OAUTH_CLIENT_ID }),
})
const userCode = String(deviceData.user_code || "")
const deviceAuthId = String(deviceData.device_auth_id || "")
const intervalSeconds = Math.max(3, Number(deviceData.interval || 5))
if (!userCode || !deviceAuthId) throw new Error("device-code response missing user_code or device_auth_id")
appendTrackedLine(state, `Open this URL to authorize Codex OAuth:\n${CODEX_DEVICE_VERIFY_URL}\n\nCode: ${userCode}\n`)
const deadline = Date.now() + (15 * 60 * 1000)
let authorizationCode = ""
let codeVerifier = ""
while (!state.aborted && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000))
try {
const poll = await fetchJson(`${CODEX_DEVICE_AUTH_BASE}/api/accounts/deviceauth/token`, {
method: "POST",
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
})
authorizationCode = String(poll.authorization_code || "")
codeVerifier = String(poll.code_verifier || "")
break
} catch (err) {
if (err.status === 403 || err.status === 404) continue
throw err
}
}
if (state.aborted) {
state.done = true
state.exitCode = -1
appendTrackedLine(state, "\nOAuth aborted.\n")
return
}
if (!authorizationCode || !codeVerifier) throw new Error("device code expired before approval")
const tokenResponse = await fetchJson(CODEX_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: authorizationCode,
redirect_uri: `${CODEX_DEVICE_AUTH_BASE}/deviceauth/callback`,
client_id: CODEX_OAUTH_CLIENT_ID,
code_verifier: codeVerifier,
}).toString(),
})
const label = saveCodexCredential(tokenResponse)
appendTrackedLine(state, `\nAdded openai-codex OAuth credential: "${label}"\n`)
state.done = true
state.exitCode = 0
} catch (err) {
appendTrackedLine(state, `\n[error] ${err.message}\n`)
state.done = true
state.exitCode = -1
}
})()
return state
}
// ─── HTTP helpers ─────────────────────────────────────────────────────────
function send(res, status, body, type = "application/json; charset=utf-8") {
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" })
@@ -627,7 +788,8 @@ async function h_addOauth(req, res) {
if (!SUPPORTED_OAUTH.has(provider)) return send(res, 400, { error: `Unsupported: ${provider}` })
const existing = runningProcs.get(provider)
if (existing && !existing.done) return send(res, 409, { error: "already running" })
spawnHermesTracked(provider, ["auth", "add", provider, "--type", "oauth"])
if (provider === "openai-codex") startCodexDeviceOauth()
else spawnHermesTracked(provider, ["auth", "add", provider, "--type", "oauth"])
send(res, 202, { status: "started", provider })
}
+127
View File
@@ -11,6 +11,12 @@ const { spawn } = require("child_process")
const root = path.join(__dirname, "..")
const serverPath = path.join(root, "server.cjs")
function jwt(claims) {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
return `${header}.${payload}.`
}
function request(port, requestPath) {
return new Promise((resolve, reject) => {
http.get({ hostname: "127.0.0.1", port, path: requestPath }, (res) => {
@@ -21,6 +27,35 @@ function request(port, requestPath) {
})
}
function postJson(port, requestPath, body) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body)
const req = http.request({
hostname: "127.0.0.1",
port,
path: requestPath,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
}, (res) => {
let raw = ""
res.on("data", (chunk) => { raw += chunk })
res.on("end", () => {
try {
resolve({ status: res.statusCode, body: JSON.parse(raw) })
} catch {
resolve({ status: res.statusCode, body: raw })
}
})
})
req.on("error", reject)
req.write(payload)
req.end()
})
}
async function waitForHealth(port, proc) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
@@ -64,3 +99,95 @@ test("async route errors return 500 without crashing the server", { timeout: 100
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("codex oauth start surfaces device login link without shelling out to browser", { timeout: 12000 }, async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-codex-oauth-test-"))
const port = 19746
let pollCount = 0
const fakeOpenAi = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/api/accounts/deviceauth/usercode") {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({
user_code: "ABCD-EFGH",
device_auth_id: "device-123",
interval: 3,
}))
return
}
if (req.method === "POST" && req.url === "/api/accounts/deviceauth/token") {
pollCount += 1
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({
authorization_code: "approved-code",
code_verifier: "approved-verifier",
}))
return
}
if (req.method === "POST" && req.url === "/oauth/token") {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({
access_token: jwt({ email: "dashboard@example.com", name: "Dashboard User" }),
refresh_token: "refresh-token",
}))
return
}
res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "not found" }))
})
await new Promise((resolve) => fakeOpenAi.listen(0, "127.0.0.1", resolve))
const fakeBase = `http://127.0.0.1:${fakeOpenAi.address().port}`
const fakeHermes = path.join(tmp, "hermes")
fs.writeFileSync(fakeHermes, "#!/bin/sh\necho unexpected hermes spawn >&2\nexit 9\n")
fs.chmodSync(fakeHermes, 0o755)
const proc = spawn(process.execPath, [serverPath], {
cwd: root,
env: {
...process.env,
HOME: tmp,
HERMES_HOME: path.join(tmp, ".hermes"),
HERMES_EXE: fakeHermes,
HERMES_SETUP_UI_HOST: "127.0.0.1",
HERMES_SETUP_UI_PORT: String(port),
HERMES_CODEX_DEVICE_AUTH_BASE: fakeBase,
HERMES_CODEX_OAUTH_TOKEN_URL: `${fakeBase}/oauth/token`,
DATABASE_URL: "",
},
stdio: ["ignore", "pipe", "pipe"],
})
try {
await waitForHealth(port, proc)
const start = await postJson(port, "/api/auth/add-oauth", { provider: "openai-codex" })
assert.equal(start.status, 202)
let progress = null
let body = null
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
progress = await request(port, "/api/auth/progress?provider=openai-codex")
assert.equal(progress.status, 200)
body = JSON.parse(progress.body)
if (body.output) break
await new Promise((resolve) => setTimeout(resolve, 50))
}
assert.equal(body.done, false)
assert.match(body.output, /https:\/\/auth\.openai\.com\/codex\/device/)
assert.match(body.output, /ABCD-EFGH/)
const approvalDeadline = Date.now() + 6000
while (Date.now() < approvalDeadline) {
progress = await request(port, "/api/auth/progress?provider=openai-codex")
body = JSON.parse(progress.body)
if (body.done) break
await new Promise((resolve) => setTimeout(resolve, 100))
}
assert.equal(body.exitCode, 0)
assert.match(body.output, /Added openai-codex OAuth credential/)
assert.equal(pollCount, 1)
const auth = JSON.parse(fs.readFileSync(path.join(tmp, ".hermes", "auth.json"), "utf-8"))
assert.equal(auth.credential_pool["openai-codex"][0].label, "dashboard@example.com")
} finally {
proc.kill()
fakeOpenAi.close()
fs.rmSync(tmp, { recursive: true, force: true })
}
})