use crate::Error;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
const ISSUER: &str = "https://auth.openai.com";
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CodexTokens {
pub access_token: String,
pub refresh_token: String,
pub last_refresh: String,
}
#[derive(Deserialize)]
struct DeviceAuthResponse {
user_code: String,
device_auth_id: String,
#[serde(default = "default_interval")]
interval: serde_json::Value,
}
fn default_interval() -> serde_json::Value {
serde_json::Value::from(5u64)
}
#[derive(Deserialize)]
struct PollResponse {
authorization_code: String,
code_verifier: String,
}
#[derive(Deserialize)]
struct TokenExchangeResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
}
pub async fn run_codex_oauth() -> Result<CodexTokens, Error> {
let client = reqwest::Client::new();
let device: DeviceAuthResponse = client
.post(format!("{ISSUER}/api/accounts/deviceauth/usercode"))
.json(&serde_json::json!({"client_id": CLIENT_ID}))
.send()
.await
.map_err(|e| Error::Runtime(format!("openai-codex: usercode request: {e}")))?
.error_for_status()
.map_err(|e| Error::Runtime(format!("openai-codex: usercode response: {e}")))?
.json()
.await
.map_err(|e| Error::Runtime(format!("openai-codex: parse usercode: {e}")))?;
let interval_secs = match &device.interval {
serde_json::Value::Number(n) => n.as_u64().unwrap_or(5),
serde_json::Value::String(s) => s.parse().unwrap_or(5),
_ => 5,
}
.max(3);
let verification_url = format!("{ISSUER}/codex/device");
eprintln!();
eprintln!("Open this URL in your browser: {verification_url}");
eprintln!("Enter this code: {}", device.user_code);
eprintln!();
let _ = open_browser_best_effort(&verification_url);
eprintln!("waiting for sign-in (polling every {interval_secs}s, Ctrl+C to cancel)…");
let max_wait = Duration::from_secs(15 * 60);
let deadline = Instant::now() + max_wait;
let mut auth_code: Option<PollResponse> = None;
while Instant::now() < deadline {
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
let resp = client
.post(format!("{ISSUER}/api/accounts/deviceauth/token"))
.json(&serde_json::json!({
"device_auth_id": device.device_auth_id,
"user_code": device.user_code,
}))
.send()
.await
.map_err(|e| Error::Runtime(format!("openai-codex: poll request: {e}")))?;
let status = resp.status();
if status.is_success() {
let body: PollResponse = resp.json().await.map_err(|e| {
Error::Runtime(format!("openai-codex: parse poll response: {e}"))
})?;
auth_code = Some(body);
break;
} else if status.as_u16() == 403 || status.as_u16() == 404 {
continue;
} else {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Runtime(format!(
"openai-codex: poll returned {status}: {body}"
)));
}
}
let auth_code = auth_code.ok_or_else(|| {
Error::Runtime("openai-codex: device authorization timed out (15m)".into())
})?;
let redirect_uri = format!("{ISSUER}/deviceauth/callback");
let token_resp = client
.post(format!("{ISSUER}/oauth/token"))
.form(&[
("grant_type", "authorization_code"),
("code", auth_code.authorization_code.as_str()),
("redirect_uri", redirect_uri.as_str()),
("client_id", CLIENT_ID),
("code_verifier", auth_code.code_verifier.as_str()),
])
.send()
.await
.map_err(|e| Error::Runtime(format!("openai-codex: token exchange request: {e}")))?;
if !token_resp.status().is_success() {
let status = token_resp.status();
let body = token_resp.text().await.unwrap_or_default();
return Err(Error::Runtime(format!(
"openai-codex: token exchange {status}: {body}"
)));
}
let tokens: TokenExchangeResponse = token_resp.json().await.map_err(|e| {
Error::Runtime(format!("openai-codex: parse token exchange: {e}"))
})?;
let refresh = tokens.refresh_token.ok_or_else(|| {
Error::Runtime("openai-codex: token exchange did not return a refresh_token".into())
})?;
Ok(CodexTokens {
access_token: tokens.access_token,
refresh_token: refresh,
last_refresh: now_iso8601(),
})
}
fn now_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}.000Z")
}
fn open_browser_best_effort(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
let cmd = "xdg-open";
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(target_os = "windows")]
let cmd = "cmd";
#[cfg(target_os = "windows")]
let args: &[&str] = &["/C", "start", url];
#[cfg(not(target_os = "windows"))]
let args: &[&str] = &[url];
std::process::Command::new(cmd).args(args).spawn().map(|_| ())
}