Skip to main content

lash_provider_openai/codex/
oauth.rs

1//! Codex (OpenAI) device-code OAuth flow + token refresh. Public so
2//! Host applications such as `lash-cli` can drive the interactive login.
3
4use base64::Engine;
5
6use lash_provider_auth::{OAuthError, now_secs, url_form_encode};
7
8const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
9const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
10const CODEX_DEVICE_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode";
11const CODEX_DEVICE_POLL_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token";
12const CODEX_DEVICE_CALLBACK: &str = "https://auth.openai.com/deviceauth/callback";
13
14/// URL to show the user during interactive login. Setup UIs open this
15/// in the browser and then poll `poll_device_auth`.
16pub const CODEX_DEVICE_VERIFY_URL: &str = "https://auth.openai.com/codex/device";
17
18fn codex_user_agent() -> String {
19    format!(
20        "lash/{} ({}; {})",
21        env!("CARGO_PKG_VERSION"),
22        std::env::consts::OS,
23        std::env::consts::ARCH
24    )
25}
26
27#[derive(Debug)]
28pub struct DeviceCode {
29    pub device_auth_id: String,
30    pub user_code: String,
31    pub interval: u64,
32}
33
34#[derive(Debug)]
35pub struct CodexTokens {
36    pub access_token: String,
37    pub refresh_token: String,
38    pub expires_at: u64,
39    pub account_id: Option<String>,
40}
41
42/// Request a device code from OpenAI for the Codex auth flow.
43pub async fn request_device_code() -> Result<DeviceCode, OAuthError> {
44    let client = reqwest::Client::new();
45    let resp = client
46        .post(CODEX_DEVICE_CODE_URL)
47        .header("User-Agent", codex_user_agent())
48        .json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID }))
49        .send()
50        .await?;
51
52    let status = resp.status();
53    let body: serde_json::Value = resp.json().await?;
54
55    if !status.is_success() {
56        let err = body["error"]
57            .as_str()
58            .unwrap_or("failed to initiate device authorization");
59        return Err(OAuthError::TokenExchange(err.to_string()));
60    }
61
62    Ok(DeviceCode {
63        device_auth_id: body["device_auth_id"]
64            .as_str()
65            .ok_or_else(|| OAuthError::TokenExchange("missing device_auth_id".into()))?
66            .to_string(),
67        user_code: body["user_code"]
68            .as_str()
69            .ok_or_else(|| OAuthError::TokenExchange("missing user_code".into()))?
70            .to_string(),
71        interval: body["interval"]
72            .as_str()
73            .and_then(|s| s.parse().ok())
74            .or(body["interval"].as_u64())
75            .map(|v| v.max(1))
76            .unwrap_or(5),
77    })
78}
79
80/// Poll the device auth endpoint. Returns `Ok(Some((auth_code,
81/// code_verifier)))` when approved, `Ok(None)` when still pending,
82/// `Err` on failure.
83pub async fn poll_device_auth(
84    device_auth_id: &str,
85    user_code: &str,
86) -> Result<Option<(String, String)>, OAuthError> {
87    let client = reqwest::Client::new();
88    let resp = client
89        .post(CODEX_DEVICE_POLL_URL)
90        .header("User-Agent", codex_user_agent())
91        .json(&serde_json::json!({
92            "device_auth_id": device_auth_id,
93            "user_code": user_code,
94        }))
95        .send()
96        .await?;
97
98    if resp.status().is_success() {
99        let body: serde_json::Value = resp.json().await?;
100        let auth_code = body["authorization_code"]
101            .as_str()
102            .ok_or_else(|| OAuthError::TokenExchange("missing authorization_code".into()))?
103            .to_string();
104        let code_verifier = body["code_verifier"]
105            .as_str()
106            .ok_or_else(|| OAuthError::TokenExchange("missing code_verifier".into()))?
107            .to_string();
108        Ok(Some((auth_code, code_verifier)))
109    } else if resp.status().as_u16() == 403 || resp.status().as_u16() == 404 {
110        Ok(None)
111    } else {
112        let body: serde_json::Value = resp.json().await.unwrap_or_default();
113        let err = body["error"]
114            .as_str()
115            .unwrap_or("device auth polling failed");
116        Err(OAuthError::TokenExchange(err.to_string()))
117    }
118}
119
120/// Exchange the device authorization code for tokens. Uses
121/// form-urlencoded as required by OpenAI's token endpoint.
122pub async fn exchange_code(code: &str, code_verifier: &str) -> Result<CodexTokens, OAuthError> {
123    let client = reqwest::Client::new();
124    let resp = client
125        .post(CODEX_TOKEN_URL)
126        .header("Content-Type", "application/x-www-form-urlencoded")
127        .body(url_form_encode(&[
128            ("grant_type", "authorization_code"),
129            ("code", code),
130            ("redirect_uri", CODEX_DEVICE_CALLBACK),
131            ("client_id", CODEX_CLIENT_ID),
132            ("code_verifier", code_verifier),
133        ]))
134        .send()
135        .await?;
136
137    let status = resp.status();
138    let body: serde_json::Value = resp.json().await?;
139
140    if !status.is_success() {
141        let err = body["error_description"]
142            .as_str()
143            .or(body["error"].as_str())
144            .unwrap_or("token exchange failed");
145        return Err(OAuthError::TokenExchange(err.to_string()));
146    }
147
148    let now = now_secs();
149    let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
150
151    let access_token = body["access_token"]
152        .as_str()
153        .ok_or_else(|| OAuthError::TokenExchange("missing access_token".into()))?
154        .to_string();
155    let refresh_token = body["refresh_token"]
156        .as_str()
157        .ok_or_else(|| OAuthError::TokenExchange("missing refresh_token".into()))?
158        .to_string();
159
160    let account_id = body["id_token"]
161        .as_str()
162        .and_then(extract_account_id)
163        .or_else(|| extract_account_id(&access_token));
164
165    Ok(CodexTokens {
166        access_token,
167        refresh_token,
168        expires_at: now + expires_in,
169        account_id,
170    })
171}
172
173/// Refresh Codex OAuth tokens.
174pub async fn refresh_tokens(refresh: &str) -> Result<CodexTokens, OAuthError> {
175    let client = reqwest::Client::new();
176    let resp = client
177        .post(CODEX_TOKEN_URL)
178        .header("Content-Type", "application/x-www-form-urlencoded")
179        .body(url_form_encode(&[
180            ("grant_type", "refresh_token"),
181            ("refresh_token", refresh),
182            ("client_id", CODEX_CLIENT_ID),
183        ]))
184        .send()
185        .await?;
186
187    let status = resp.status();
188    let response_body = resp.text().await?;
189
190    if !status.is_success() {
191        return Err(OAuthError::token_endpoint(
192            status.as_u16(),
193            &response_body,
194            "token refresh failed",
195        ));
196    }
197    let body: serde_json::Value = serde_json::from_str(&response_body)?;
198
199    let now = now_secs();
200    let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
201
202    let access_token = body["access_token"]
203        .as_str()
204        .ok_or_else(|| OAuthError::TokenExchange("missing access_token".into()))?
205        .to_string();
206    let refresh_token = body["refresh_token"]
207        .as_str()
208        .unwrap_or(refresh)
209        .to_string();
210    let account_id = body["id_token"]
211        .as_str()
212        .and_then(extract_account_id)
213        .or_else(|| extract_account_id(&access_token));
214
215    Ok(CodexTokens {
216        access_token,
217        refresh_token,
218        expires_at: now + expires_in,
219        account_id,
220    })
221}
222
223/// Extract the ChatGPT account ID from a JWT token (no crypto
224/// verification needed — we're only reading claims on a token we
225/// just received from OpenAI's token endpoint).
226fn extract_account_id(jwt: &str) -> Option<String> {
227    let parts: Vec<&str> = jwt.split('.').collect();
228    if parts.len() != 3 {
229        return None;
230    }
231    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
232        .decode(parts[1])
233        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(parts[1]))
234        .ok()?;
235    let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?;
236
237    if let Some(id) = claims["chatgpt_account_id"].as_str()
238        && !id.is_empty()
239    {
240        return Some(id.to_string());
241    }
242    if let Some(id) = claims["https://api.openai.com/auth"]["chatgpt_account_id"].as_str()
243        && !id.is_empty()
244    {
245        return Some(id.to_string());
246    }
247    if let Some(orgs) = claims["organizations"].as_array()
248        && let Some(org) = orgs.first()
249        && let Some(id) = org["id"].as_str()
250        && !id.is_empty()
251    {
252        return Some(id.to_string());
253    }
254    None
255}