thal 0.0.1

Reactive semantic runtime — molecules, reactions, and effect actors for building LLM-backed applications as dataflow programs.
Documentation
//! OpenAI's custom device-authorization flow used by Codex CLI / VS Code
//! Codex / Hermes for ChatGPT-account auth.
//!
//! Not RFC 8628 — there's an extra round-trip:
//!
//! 1. POST {issuer}/api/accounts/deviceauth/usercode  → user_code, device_auth_id
//! 2. Show user the verification URL + code, open browser.
//! 3. Poll POST {issuer}/api/accounts/deviceauth/token until 200 →
//!    authorization_code, code_verifier.
//! 4. Exchange POST {issuer}/oauth/token with grant_type=authorization_code →
//!    standard access_token + refresh_token response.
//!
//! The client ID `app_EMoamEEZ73f0CkXaXp7hrann` is the public Codex OAuth
//! client used by Codex CLI, the VS Code extension, and Hermes.

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,
    /// Best-effort ISO-8601 timestamp of when these tokens were minted.
    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();

    // Step 1: request a device code
    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)…");

    // Step 2 + 3: poll for the authorization_code
    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 {
            // user hasn't signed in yet
            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())
    })?;

    // Step 4: exchange the authorization_code for tokens
    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);
    // Lightweight ISO-8601 formatter without chrono. Year/month/day from secs
    // is non-trivial; punt to a Z-suffixed unix-millis-as-stringification — the
    // field is informational, not used for arithmetic.
    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(|_| ())
}