lash_provider_openai/codex/
oauth.rs1use 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
14pub 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
42pub 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
80pub 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
120pub 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
173pub 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
223fn 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}