Skip to main content

yapi_ai/auth/
codex.rs

1//! OpenAI Codex (ChatGPT Plus/Pro) sign-in: Proof Key for Code Exchange
2//! (PKCE) with a loopback callback, or a device code. Port of
3//! `oauth/openai-codex.ts`.
4
5use 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";
23/// The originator OpenAI expects from this client id. pi sends its own name.
24const ORIGINATOR: &str = "pi";
25
26/// The Codex sign-in and its endpoints.
27#[derive(Clone, Debug)]
28pub struct CodexOAuth {
29    /// `https://auth.openai.com`. Every endpoint is relative to it.
30    pub auth_base_url: String,
31    /// Loopback callback port, shared with the Codex CLI.
32    pub callback_port: u16,
33    /// Redirect URI for the browser flow, which must reach the callback port.
34    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
53/// The JSON payload of a JWT, or `None`.
54fn 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
67/// The ChatGPT account id in a Codex access token.
68pub(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        // Port 1455 is shared with the Codex CLI. When it is taken, the pasted
169        // redirect URL is used.
170        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}