1use base64::Engine as _;
6use serde_json::{Map, Value};
7use tokio_util::sync::CancellationToken;
8use yapi_types::auth::OAuthCredential;
9
10use super::callback::{Received, callback_or_manual, start_code_server};
11use super::device::{Poll, poll_device_code};
12use super::{
13 AuthError, AuthEvent, AuthPrompt, BoxFuture, Interaction, LoginOptions, OAuthProvider,
14 callback_host, error_text, expiry, form, parse_authorization_input, pkce, send,
15};
16
17const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
18const SCOPE: &str = "openid profile email offline_access";
19const JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
20const DEVICE_CODE_TIMEOUT_SECONDS: f64 = 15.0 * 60.0;
21const BROWSER: &str = "browser";
22const DEVICE_CODE: &str = "device_code";
23const ORIGINATOR: &str = "pi";
25
26#[derive(Clone, Debug)]
28pub struct CodexOAuth {
29 pub auth_base_url: String,
31 pub callback_port: u16,
33 pub redirect_uri: String,
35}
36
37impl Default for CodexOAuth {
38 fn default() -> CodexOAuth {
39 CodexOAuth {
40 auth_base_url: "https://auth.openai.com".into(),
41 callback_port: 1455,
42 redirect_uri: "http://localhost:1455/auth/callback".into(),
43 }
44 }
45}
46
47struct Token {
48 access: String,
49 refresh: String,
50 expires: u64,
51}
52
53fn decode_jwt(token: &str) -> Option<Value> {
55 let parts: Vec<&str> = token.split('.').collect();
56 let [_, payload, _] = parts.as_slice() else {
57 return None;
58 };
59 let trimmed = payload.trim_end_matches('=');
60 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
61 .decode(trimmed)
62 .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(trimmed))
63 .ok()?;
64 serde_json::from_slice(&bytes).ok()
65}
66
67pub(crate) fn account_id(access: &str) -> Option<String> {
69 decode_jwt(access)?
70 .get(JWT_CLAIM_PATH)?
71 .get("chatgpt_account_id")?
72 .as_str()
73 .filter(|id| !id.is_empty())
74 .map(str::to_owned)
75}
76
77fn credential(token: Token) -> Result<OAuthCredential, AuthError> {
78 let account_id = account_id(&token.access)
79 .ok_or_else(|| AuthError::failed("Failed to extract accountId from token"))?;
80 let mut extra = Map::new();
81 extra.insert("accountId".into(), Value::String(account_id));
82 Ok(OAuthCredential {
83 access: token.access,
84 refresh: token.refresh,
85 expires: token.expires as f64,
86 extra,
87 })
88}
89
90impl CodexOAuth {
91 fn url(&self, path: &str) -> String {
92 format!("{}{path}", self.auth_base_url.trim_end_matches('/'))
93 }
94
95 async fn read_token(response: reqwest::Response, operation: &str) -> Result<Token, AuthError> {
96 if !response.status().is_success() {
97 let status = response.status().as_u16();
98 let text = error_text(response).await;
99 return Err(AuthError::Failed(format!(
100 "OpenAI Codex token {operation} failed ({status}): {text}"
101 )));
102 }
103 let json: Value = super::json_body(response).await;
104 let field = |name: &str| {
105 json.get(name)
106 .and_then(Value::as_str)
107 .filter(|s| !s.is_empty())
108 };
109 match (
110 field("access_token"),
111 field("refresh_token"),
112 json.get("expires_in").and_then(Value::as_f64),
113 ) {
114 (Some(access), Some(refresh), Some(expires_in)) => Ok(Token {
115 access: access.to_owned(),
116 refresh: refresh.to_owned(),
117 expires: expiry(expires_in, 0.0),
118 }),
119 _ => Err(AuthError::Failed(format!(
120 "OpenAI Codex token {operation} response missing fields: {}",
121 yapi_types::json::stringify(&json)
122 ))),
123 }
124 }
125
126 async fn exchange(
127 &self,
128 code: &str,
129 verifier: &str,
130 redirect_uri: &str,
131 cancel: &CancellationToken,
132 ) -> Result<OAuthCredential, AuthError> {
133 let request = crate::http::client()
134 .post(self.url("/oauth/token"))
135 .header("Content-Type", "application/x-www-form-urlencoded")
136 .body(form(&[
137 ("grant_type", "authorization_code"),
138 ("client_id", CLIENT_ID),
139 ("code", code),
140 ("code_verifier", verifier),
141 ("redirect_uri", redirect_uri),
142 ]));
143 let response = send(request, cancel).await?;
144 credential(Self::read_token(response, "exchange").await?)
145 }
146
147 fn authorize_url(&self, challenge: &str, state: &str) -> String {
148 let query = form(&[
149 ("response_type", "code"),
150 ("client_id", CLIENT_ID),
151 ("redirect_uri", &self.redirect_uri),
152 ("scope", SCOPE),
153 ("code_challenge", challenge),
154 ("code_challenge_method", "S256"),
155 ("state", state),
156 ("id_token_add_organizations", "true"),
157 ("codex_cli_simplified_flow", "true"),
158 ("originator", ORIGINATOR),
159 ]);
160 format!("{}?{query}", self.url("/oauth/authorize"))
161 }
162
163 async fn login_browser(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
164 let pkce = pkce::generate()?;
165 let state = yapi_types::time::try_random_bytes::<16>()
166 .map(|bytes| yapi_types::time::hex(&bytes))
167 .map_err(AuthError::random)?;
168 let mut server = start_code_server(
171 "OpenAI",
172 &callback_host(),
173 self.callback_port,
174 "/auth/callback",
175 Some(state.clone()),
176 false,
177 interaction.cancel(),
178 )
179 .await
180 .ok();
181 interaction.notify(AuthEvent::AuthUrl {
182 url: self.authorize_url(&pkce.challenge, &state),
183 instructions: Some("A browser window should open. Complete login to finish.".into()),
184 });
185 let received = callback_or_manual(
186 interaction,
187 server.as_mut(),
188 "Complete login in your browser, or paste the authorization code / redirect URL here:",
189 &self.redirect_uri,
190 )
191 .await?;
192 let code = match received {
193 Received::Callback((code, _)) => Some(code),
194 Received::Manual(input) => {
195 let (code, pasted_state) = parse_authorization_input(&input);
196 if pasted_state.is_some_and(|pasted| pasted != state) {
197 return Err(AuthError::failed("State mismatch"));
198 }
199 code
200 }
201 };
202 let code = code.ok_or_else(|| AuthError::failed("Missing authorization code"))?;
203 self.exchange(
204 &code,
205 &pkce.verifier,
206 &self.redirect_uri,
207 interaction.cancel(),
208 )
209 .await
210 }
211
212 async fn login_device_code(
213 &self,
214 interaction: &Interaction,
215 ) -> Result<OAuthCredential, AuthError> {
216 let cancel = interaction.cancel();
217 let request = crate::http::client()
218 .post(self.url("/api/accounts/deviceauth/usercode"))
219 .header("Content-Type", "application/json")
220 .body(yapi_types::json::stringify(&super::object(&[(
221 "client_id",
222 CLIENT_ID,
223 )])));
224 let response = send(request, cancel).await?;
225 if !response.status().is_success() {
226 let status = response.status().as_u16();
227 if status == 404 {
228 return Err(AuthError::failed(
229 "OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.",
230 ));
231 }
232 let body = response.text().await.unwrap_or_default();
233 let suffix = if body.is_empty() {
234 String::new()
235 } else {
236 format!(": {body}")
237 };
238 return Err(AuthError::Failed(format!(
239 "OpenAI Codex device code request failed with status {status}{suffix}"
240 )));
241 }
242 let json: Value = super::json_body(response).await;
243 let interval = match json.get("interval") {
244 Some(Value::String(text)) => text.trim().parse::<f64>().ok(),
245 Some(value) => value.as_f64(),
246 None => None,
247 };
248 let device_auth_id = json
249 .get("device_auth_id")
250 .and_then(Value::as_str)
251 .filter(|s| !s.is_empty());
252 let user_code = json
253 .get("user_code")
254 .and_then(Value::as_str)
255 .filter(|s| !s.is_empty());
256 let (Some(device_auth_id), Some(user_code), Some(interval)) = (
257 device_auth_id,
258 user_code,
259 interval.filter(|value| value.is_finite() && *value >= 0.0),
260 ) else {
261 return Err(AuthError::Failed(format!(
262 "Invalid OpenAI Codex device code response: {}",
263 yapi_types::json::stringify(&json)
264 )));
265 };
266 interaction.notify(AuthEvent::DeviceCode {
267 user_code: user_code.to_owned(),
268 verification_uri: self.url("/codex/device"),
269 interval_seconds: Some(interval),
270 expires_in_seconds: Some(DEVICE_CODE_TIMEOUT_SECONDS),
271 });
272 let poll_body = yapi_types::json::stringify(&super::object(&[
273 ("device_auth_id", device_auth_id),
274 ("user_code", user_code),
275 ]));
276 let token_url = self.url("/api/accounts/deviceauth/token");
277 let (code, verifier) = poll_device_code(
278 Some(interval),
279 Some(DEVICE_CODE_TIMEOUT_SECONDS),
280 false,
281 cancel,
282 || {
283 let request = crate::http::client()
284 .post(&token_url)
285 .header("Content-Type", "application/json")
286 .body(poll_body.clone());
287 async move {
288 let response = send(request, cancel).await?;
289 let status = response.status().as_u16();
290 if response.status().is_success() {
291 let json: Value = super::json_body(response).await;
292 let code = json.get("authorization_code").and_then(Value::as_str);
293 let verifier = json.get("code_verifier").and_then(Value::as_str);
294 return Ok(match (code, verifier) {
295 (Some(code), Some(verifier))
296 if !code.is_empty() && !verifier.is_empty() =>
297 {
298 Poll::Complete((code.to_owned(), verifier.to_owned()))
299 }
300 _ => Poll::Failed(format!(
301 "Invalid OpenAI Codex device auth token response: {}",
302 yapi_types::json::stringify(&json)
303 )),
304 });
305 }
306 if status == 403 || status == 404 {
307 return Ok(Poll::Pending);
308 }
309 let body = response.text().await.unwrap_or_default();
310 let code =
311 serde_json::from_str::<Value>(&body).ok().and_then(|json| {
312 match json.get("error")? {
313 Value::String(code) => Some(code.clone()),
314 Value::Object(error) => {
315 error.get("code")?.as_str().map(str::to_owned)
316 }
317 _ => None,
318 }
319 });
320 Ok(match code.as_deref() {
321 Some("deviceauth_authorization_pending") => Poll::Pending,
322 Some("slow_down") => Poll::SlowDown(None),
323 _ => {
324 let suffix = if body.is_empty() {
325 String::new()
326 } else {
327 format!(": {body}")
328 };
329 Poll::Failed(format!(
330 "OpenAI Codex device auth failed with status {status}{suffix}"
331 ))
332 }
333 })
334 }
335 },
336 )
337 .await?;
338 self.exchange(&code, &verifier, &self.url("/deviceauth/callback"), cancel)
339 .await
340 }
341
342 async fn refresh_token(
343 &self,
344 refresh: &str,
345 cancel: &CancellationToken,
346 ) -> Result<OAuthCredential, AuthError> {
347 let request = crate::http::client()
348 .post(self.url("/oauth/token"))
349 .header("Content-Type", "application/x-www-form-urlencoded")
350 .body(form(&[
351 ("grant_type", "refresh_token"),
352 ("refresh_token", refresh),
353 ("client_id", CLIENT_ID),
354 ]));
355 let response = send(request, cancel).await.map_err(|err| match err {
356 AuthError::Failed(message) => {
357 AuthError::Failed(format!("OpenAI Codex token refresh error: {message}"))
358 }
359 cancelled => cancelled,
360 })?;
361 credential(Self::read_token(response, "refresh").await?)
362 }
363}
364
365impl OAuthProvider for CodexOAuth {
366 fn name(&self) -> &str {
367 "OpenAI (ChatGPT Plus/Pro)"
368 }
369
370 fn login<'a>(
371 &'a self,
372 interaction: &'a Interaction,
373 _options: &'a LoginOptions,
374 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
375 Box::pin(async move {
376 let method = interaction
377 .prompt(AuthPrompt::select(
378 "Select OpenAI Codex login method:",
379 &[
380 (BROWSER, "Browser login (default)"),
381 (DEVICE_CODE, "Device code login (headless)"),
382 ],
383 ))
384 .await?;
385 match method.as_str() {
386 DEVICE_CODE => self.login_device_code(interaction).await,
387 BROWSER => self.login_browser(interaction).await,
388 other => Err(AuthError::Failed(format!(
389 "Unknown OpenAI Codex login method: {other}"
390 ))),
391 }
392 })
393 }
394
395 fn refresh<'a>(
396 &'a self,
397 credential: &'a OAuthCredential,
398 cancel: &'a CancellationToken,
399 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
400 Box::pin(self.refresh_token(&credential.refresh, cancel))
401 }
402}