use crate::config::CodexAuth;
use anyhow::{Context, Result, anyhow};
use reqwest::Url;
use serde::Deserialize;
use std::collections::HashMap;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const CODEX_ORIGINATOR: &str = "yolop";
const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
const DEVICE_USER_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode";
const DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token";
const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
const DEVICE_USER_URL: &str = "https://auth.openai.com/codex/device";
const SCOPE: &str = "openid profile email offline_access";
const CALLBACK_PORT: u16 = 1455;
const CALLBACK_PATH: &str = "/auth/callback";
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const USER_AUTH_TIMEOUT: Duration = Duration::from_secs(10 * 60);
#[derive(Debug, Clone)]
pub struct DeviceLogin {
pub user_code: String,
pub verification_uri: String,
device_auth_id: String,
interval_secs: u64,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
id_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_auth_id: String,
user_code: String,
#[serde(default)]
verification_uri: Option<String>,
#[serde(default)]
verification_url: Option<String>,
#[serde(default)]
interval: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct DeviceTokenResponse {
authorization_code: String,
code_verifier: String,
}
pub async fn login_with_browser() -> Result<CodexAuth> {
let redirect_uri = format!("http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}");
let state = crate::auth::oauth_flow::random_token(32);
let verifier = crate::auth::oauth_flow::random_pkce_verifier();
let challenge = crate::auth::oauth_flow::pkce_challenge(&verifier);
let listener = tokio::net::TcpListener::bind(("127.0.0.1", CALLBACK_PORT))
.await
.with_context(|| format!("bind Codex OAuth callback on localhost:{CALLBACK_PORT}"))?;
let mut url = Url::parse(AUTHORIZE_URL)?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", CODEX_CLIENT_ID)
.append_pair("redirect_uri", &redirect_uri)
.append_pair("scope", SCOPE)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", &state)
.append_pair("id_token_add_organizations", "true")
.append_pair("codex_cli_simplified_flow", "true")
.append_pair("originator", CODEX_ORIGINATOR);
open_browser(url.as_str()).await?;
let code = tokio::time::timeout(USER_AUTH_TIMEOUT, wait_for_callback(listener, &state))
.await
.map_err(|_| anyhow!("Codex browser login timed out"))??;
exchange_code(&code, &verifier, &redirect_uri).await
}
pub async fn start_device_login() -> Result<DeviceLogin> {
let client = auth_http_client()?;
let response = client
.post(DEVICE_USER_CODE_URL)
.json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID }))
.send()
.await
.context("start Codex device login")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Codex device login start failed ({status}): {body}"
));
}
let parsed: DeviceCodeResponse = response.json().await.context("parse device login")?;
Ok(DeviceLogin {
user_code: parsed.user_code,
verification_uri: parsed
.verification_uri
.or(parsed.verification_url)
.unwrap_or_else(|| DEVICE_USER_URL.to_string()),
device_auth_id: parsed.device_auth_id,
interval_secs: parsed.interval.unwrap_or(5).max(1),
})
}
pub async fn complete_device_login(login: DeviceLogin) -> Result<CodexAuth> {
tokio::time::timeout(USER_AUTH_TIMEOUT, complete_device_login_inner(login))
.await
.map_err(|_| anyhow!("Codex device login timed out before authorization completed"))?
}
async fn complete_device_login_inner(login: DeviceLogin) -> Result<CodexAuth> {
let client = auth_http_client()?;
for _ in 0..120 {
tokio::time::sleep(std::time::Duration::from_secs(login.interval_secs)).await;
let response = client
.post(DEVICE_TOKEN_URL)
.json(&serde_json::json!({
"device_auth_id": login.device_auth_id,
"user_code": login.user_code,
}))
.send()
.await
.context("poll Codex device login")?;
if response.status().is_success() {
let token: DeviceTokenResponse =
response.json().await.context("parse Codex device token")?;
return exchange_code(
&token.authorization_code,
&token.code_verifier,
DEVICE_REDIRECT_URI,
)
.await;
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
if matches!(status.as_u16(), 403 | 404) {
continue;
}
return Err(anyhow!("Codex device login failed ({status}): {body}"));
}
Err(anyhow!(
"Codex device login timed out before authorization completed"
))
}
pub async fn refresh_with_token(refresh_token: &str) -> Result<CodexAuth> {
refresh_with_token_at(TOKEN_URL, refresh_token).await
}
pub async fn refresh_with_token_at(token_url: &str, refresh_token: &str) -> Result<CodexAuth> {
let client = auth_http_client()?;
let response = client
.post(token_url)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", CODEX_CLIENT_ID),
])
.send()
.await
.context("refresh Codex token")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Codex token refresh failed ({status}): {body}"));
}
let token: TokenResponse = response.json().await.context("parse Codex refresh token")?;
auth_from_token_response(token)
}
pub fn is_refresh_token_reused(err: &anyhow::Error) -> bool {
let message = format!("{err:#}");
message.contains("refresh_token_reused")
}
pub fn auth_from_access_token(access_token: String) -> CodexAuth {
CodexAuth {
account_id: extract_account_id(&access_token),
email: extract_email(&access_token),
access_token,
refresh_token: None,
expires_at: None,
}
}
pub fn should_refresh(expires_at: Option<i64>) -> bool {
expires_at
.map(|expires| expires <= now_epoch_millis() + 60_000)
.unwrap_or(false)
}
pub fn now_epoch_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64
}
pub fn extract_account_id(access_token: &str) -> Option<String> {
crate::auth::oauth_flow::jwt_payload(access_token).and_then(|payload| {
payload
.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(|value| value.as_str())
.or_else(|| {
payload
.get("chatgpt_account_id")
.and_then(|value| value.as_str())
})
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
fn extract_email(access_token: &str) -> Option<String> {
crate::auth::oauth_flow::jwt_payload(access_token).and_then(|payload| {
payload
.get("https://api.openai.com/profile")
.and_then(|profile| profile.get("email"))
.and_then(|value| value.as_str())
.or_else(|| payload.get("email").and_then(|value| value.as_str()))
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
fn auth_from_token_response(token: TokenResponse) -> Result<CodexAuth> {
let expires_at = token
.expires_in
.filter(|seconds| *seconds > 0)
.map(|seconds| now_epoch_millis() + seconds.saturating_mul(1000));
let account_id = extract_account_id(&token.access_token)
.or_else(|| token.id_token.as_deref().and_then(extract_account_id));
let email = extract_email(&token.access_token)
.or_else(|| token.id_token.as_deref().and_then(extract_email));
Ok(CodexAuth {
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at,
account_id,
email,
})
}
async fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> Result<CodexAuth> {
let client = auth_http_client()?;
let response = client
.post(TOKEN_URL)
.form(&[
("grant_type", "authorization_code"),
("client_id", CODEX_CLIENT_ID),
("code", code),
("code_verifier", verifier),
("redirect_uri", redirect_uri),
])
.send()
.await
.context("exchange Codex OAuth code")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Codex OAuth exchange failed ({status}): {body}"));
}
let token: TokenResponse = response.json().await.context("parse Codex OAuth token")?;
auth_from_token_response(token)
}
fn auth_http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(HTTP_TIMEOUT)
.build()
.context("build Codex auth HTTP client")
}
async fn wait_for_callback(
listener: tokio::net::TcpListener,
expected_state: &str,
) -> Result<String> {
loop {
let (mut socket, _) = listener.accept().await.context("accept OAuth callback")?;
let mut buffer = vec![0u8; 8192];
let n = socket
.read(&mut buffer)
.await
.context("read OAuth callback")?;
let request = String::from_utf8_lossy(&buffer[..n]);
let first_line = request.lines().next().unwrap_or_default();
let path = first_line
.split_whitespace()
.nth(1)
.ok_or_else(|| anyhow!("invalid OAuth callback request"))?;
let parsed =
Url::parse(&format!("http://localhost{path}")).context("parse OAuth callback URL")?;
let params: HashMap<_, _> = parsed.query_pairs().into_owned().collect();
let (status, body) = if parsed.path() != CALLBACK_PATH {
(
"404 Not Found",
"Yolop Codex login received an unexpected callback path.",
)
} else if params.get("state").map(String::as_str) != Some(expected_state) {
(
"400 Bad Request",
"Yolop Codex login rejected this callback because the state did not match.",
)
} else if let Some(error) = params.get("error") {
("400 Bad Request", error.as_str())
} else if let Some(code) = params.get("code") {
write_callback_response(
&mut socket,
"200 OK",
"Yolop Codex login complete. You can return to the terminal.",
)
.await?;
return Ok(code.clone());
} else {
(
"400 Bad Request",
"Yolop Codex login callback had no authorization code.",
)
};
write_callback_response(&mut socket, status, body).await?;
}
}
async fn write_callback_response(
socket: &mut tokio::net::TcpStream,
status: &str,
message: &str,
) -> Result<()> {
let body = callback_page(status, message);
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket
.write_all(response.as_bytes())
.await
.context("write OAuth callback response")
}
fn callback_page(status: &str, message: &str) -> String {
crate::auth::oauth_flow::callback_page(status, message, "Codex")
}
async fn open_browser(url: &str) -> Result<()> {
let mut command = if cfg!(target_os = "macos") {
let mut command = tokio::process::Command::new("open");
command.arg(url);
command
} else if cfg!(target_os = "windows") {
let mut command = tokio::process::Command::new("cmd");
command.args(["/C", "start", "", url]);
command
} else {
let mut command = tokio::process::Command::new("xdg-open");
command.arg(url);
command
};
command.kill_on_drop(true);
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let status = command
.status()
.await
.context("open browser for Codex login")?;
if status.success() {
Ok(())
} else {
Err(anyhow!("browser opener exited with {status}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn jwt_with_payload(payload: serde_json::Value) -> String {
format!(
"header.{}.sig",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap())
)
}
#[test]
fn extracts_account_id_from_codex_jwt_claim() {
let token = jwt_with_payload(serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc_123"
}
}));
assert_eq!(extract_account_id(&token).as_deref(), Some("acc_123"));
}
#[test]
fn should_refresh_only_near_expiry() {
assert!(!should_refresh(None));
assert!(should_refresh(Some(now_epoch_millis() + 1_000)));
assert!(!should_refresh(Some(now_epoch_millis() + 300_000)));
}
#[test]
fn detects_refresh_token_reused_error() {
let err = anyhow!(
"Codex token refresh failed (401 Unauthorized): {{\"error\":{{\"code\":\"refresh_token_reused\"}}}}"
);
assert!(is_refresh_token_reused(&err));
assert!(!is_refresh_token_reused(&anyhow!("network down")));
}
#[test]
fn callback_page_includes_logo_and_success_copy() {
let page = callback_page("200 OK", "Yolop Codex login complete.");
assert!(page.contains("<svg"));
assert!(page.contains("Codex is connected."));
assert!(page.contains("Your terminal is already warming up the keyboard."));
assert!(page.contains("Yolop Codex login complete."));
}
}