Use native Codex device OAuth flow
This commit is contained in:
+163
-1
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user