Skip to main content

artifactize_core/auth/
codex.rs

1//! Codex (ChatGPT) sign-in: artifactize's own OAuth tokens in `$STATE/auth/codex.json`,
2//! or a Codex auth file that artifactize only reads.
3//!
4//! The protocol follows Pi's `openai-codex` provider (pi-mono
5//! `packages/ai/src/auth/oauth/openai-codex.ts`): the Codex public OAuth client, PKCE
6//! with S256, the `localhost:1455` callback or a pasted redirect URL, the
7//! authorization-code and refresh-token grants, and the ChatGPT account ID from the
8//! access token's `https://api.openai.com/auth` claim. Revocation on logout follows the
9//! Codex CLI (`codex-rs/login/src/auth/revoke.rs`). Callers must never print or log a
10//! token.
11
12use std::{
13    collections::BTreeMap,
14    io::{IsTerminal, Read, Write},
15    net::Ipv4Addr,
16    path::{Path, PathBuf},
17    time::Duration,
18};
19
20use base64::{
21    Engine,
22    engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
23};
24use reqwest::Url;
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27use sha2::{Digest, Sha256};
28use tokio::{
29    io::{AsyncReadExt, AsyncWriteExt},
30    net::TcpListener,
31    sync::oneshot,
32};
33
34mod timestamp;
35pub use timestamp::Timestamp;
36
37use super::storage::{Location, MAX_CREDENTIAL_BYTES, Storage, Tokens};
38
39/// The Codex CLI's public OAuth client, which Pi's provider uses too.
40pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
41const AUTH_ROOT: &str = "https://auth.openai.com";
42/// A loopback test endpoint replacing the sign-in root, beside the backends' own.
43pub const AUTH_URL_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_URL";
44/// A Codex auth file (for example `~/.codex/auth.json`) to read instead of
45/// artifactize's own tokens. It is never written, copied or refreshed.
46pub const AUTH_FILE_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_FILE";
47/// Match the localhost callback convention of the Codex public-client flow that Pi
48/// also uses; listener and OAuth redirect must agree for browser/pasted-URL interoperability.
49const CALLBACK_PORT: u16 = 1455;
50const CALLBACK_PATH: &str = "/auth/callback";
51const SCOPE: &str = "openid profile email offline_access";
52/// The `originator` that sign-in and every Codex request name.
53pub const ORIGINATOR: &str = "artifactize";
54#[cfg(test)]
55const CLAIM: &str = "https://api.openai.com/auth";
56const CREDENTIALS: &str = "codex.json";
57const LOCK: &str = "codex";
58/// 32 random bytes give 256 bits and 43 unpadded base64url characters, within RFC 7636's
59/// permitted 43–128-character code_verifier length; the S256 challenge hashes that text.
60const PKCE_VERIFIER_BYTES: usize = 32;
61/// Choose 128 unpredictable bits for OAuth state/CSRF correlation, encoded as 32 hex
62/// characters. This is this client's chosen size, not an OAuth-mandated exact length.
63const OAUTH_STATE_BYTES: usize = 16;
64/// Refresh this many seconds before expiry, as the Codex CLI's refresh window does.
65const REFRESH_MARGIN: Duration = Duration::from_secs(300);
66/// A read-only auth file's token must outlive this many seconds.
67const FILE_MARGIN: Duration = Duration::from_secs(60);
68/// Give a person five minutes to finish browser or pasted-redirect sign-in,
69/// without leaving an unattended callback listener alive indefinitely.
70const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
71/// Bound individual token-exchange HTTP calls independently of the interactive login wait.
72const AUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
73/// Release the callback listener from a client that stops sending its request headers.
74const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(5);
75/// A stalled browser must not hold up a completed callback while receiving its short reply.
76const CALLBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
77/// Best-effort logout should finish sooner than an ordinary token exchange.
78const REVOCATION_TIMEOUT: Duration = Duration::from_secs(10);
79/// OAuth error codes are diagnostics, not arbitrary provider text; keep them short.
80const MAX_OAUTH_ERROR_CHARS: usize = 64;
81const LOGIN_REQUIRED: &str =
82    "Codex is not signed in; run `artifactize login codex` or set ARTIFACTIZE_CODEX_AUTH_FILE.";
83
84#[derive(Deserialize, Serialize)]
85struct Credentials {
86    access_token: String,
87    refresh_token: String,
88    account_id: crate::types::CodexAccountId,
89    #[serde(with = "timestamp::seconds")]
90    expires_at: Timestamp,
91    #[serde(with = "timestamp::seconds")]
92    saved_at: Timestamp,
93}
94
95/// Why no token is available. A transient failure (the token endpoint unreachable or
96/// failing with 429 or 5xx) may succeed on a retry; any other means signing in again.
97#[derive(Debug)]
98pub struct TokenError {
99    pub transient: bool,
100    pub message: String,
101}
102
103impl From<String> for TokenError {
104    fn from(message: String) -> Self {
105        Self {
106            transient: false,
107            message,
108        }
109    }
110}
111
112impl From<&str> for TokenError {
113    fn from(message: &str) -> Self {
114        message.to_owned().into()
115    }
116}
117
118/// A bearer token and the ChatGPT account it belongs to. Never printed or logged.
119pub struct Token {
120    pub access_token: String,
121    pub account_id: crate::types::CodexAccountId,
122}
123
124pub(crate) fn now() -> Result<Timestamp, String> {
125    Timestamp::now()
126}
127
128/// The sign-in root: its loopback test endpoint, else `https://auth.openai.com`.
129pub fn auth_root() -> Result<String, String> {
130    Ok(crate::llm::variable_endpoint(AUTH_URL_VARIABLE)?.unwrap_or_else(|| AUTH_ROOT.into()))
131}
132
133/// The configured read-only auth file, if any.
134pub fn auth_file() -> Option<PathBuf> {
135    crate::platform::environment::var(AUTH_FILE_VARIABLE)
136        .filter(|value| !value.is_empty())
137        .map(PathBuf::from)
138}
139
140fn client() -> Result<reqwest::Client, String> {
141    reqwest::Client::builder()
142        .redirect(reqwest::redirect::Policy::none())
143        .retry(reqwest::retry::never())
144        .timeout(AUTH_HTTP_TIMEOUT)
145        .build()
146        .map_err(|_| "Cannot initialize the Codex sign-in HTTP client.".into())
147}
148
149/// The JSON claims of a JWT, read without verifying its signature, as Pi does: the
150/// token came over TLS from the token endpoint or from the user's own auth file.
151fn claims(token: &str) -> Option<JwtClaims> {
152    let mut parts = token.split('.');
153    let (_, payload, _, None) = (parts.next()?, parts.next()?, parts.next()?, parts.next()) else {
154        return None;
155    };
156    let bytes = URL_SAFE_NO_PAD
157        .decode(payload)
158        .or_else(|_| URL_SAFE.decode(payload))
159        .ok()?;
160    serde_json::from_slice(&bytes).ok()
161}
162
163fn account_id(access_token: &str) -> Option<crate::types::CodexAccountId> {
164    claims(access_token)?.auth.0?.0.chatgpt_account_id.0
165}
166
167fn expiry(access_token: &str) -> Option<Timestamp> {
168    claims(access_token)?.exp.0
169}
170
171#[derive(Default, Deserialize)]
172struct JwtClaims {
173    #[serde(default)]
174    exp: crate::json::Optional<Timestamp>,
175    #[serde(default, rename = "https://api.openai.com/auth")]
176    auth: crate::json::Optional<crate::json::Object<AccountClaim>>,
177}
178#[derive(Default, Deserialize)]
179struct AccountClaim {
180    #[serde(default)]
181    chatgpt_account_id: crate::json::Optional<crate::types::CodexAccountId>,
182}
183
184fn random(bytes: usize) -> Result<Vec<u8>, String> {
185    let mut value = vec![0; bytes];
186    getrandom::fill(&mut value).map_err(|_| "Cannot obtain secure randomness.".to_owned())?;
187    Ok(value)
188}
189
190/// One sign-in attempt: PKCE verifier, its S256 challenge and the `state` it expects.
191struct Pending {
192    verifier: String,
193    state: crate::types::OAuthState,
194}
195
196impl Pending {
197    fn new() -> Result<Self, String> {
198        let state: String = random(OAUTH_STATE_BYTES)?
199            .iter()
200            .map(|byte| format!("{byte:02x}"))
201            .collect();
202        Ok(Self {
203            verifier: URL_SAFE_NO_PAD.encode(random(PKCE_VERIFIER_BYTES)?),
204            state: state.parse().expect("generated OAuth state is valid"),
205        })
206    }
207
208    fn challenge(&self) -> String {
209        URL_SAFE_NO_PAD.encode(Sha256::digest(self.verifier.as_bytes()))
210    }
211
212    fn authorize_url(&self, root: &str, redirect_uri: &str) -> Result<Url, String> {
213        let mut url = Url::parse(&format!("{root}/oauth/authorize"))
214            .map_err(|_| "Invalid Codex sign-in URL.".to_owned())?;
215        url.query_pairs_mut().extend_pairs([
216            ("response_type", "code"),
217            ("client_id", CLIENT_ID),
218            ("redirect_uri", redirect_uri),
219            ("scope", SCOPE),
220            ("code_challenge", &self.challenge()),
221            ("code_challenge_method", "S256"),
222            ("state", &self.state),
223            ("id_token_add_organizations", "true"),
224            ("codex_cli_simplified_flow", "true"),
225            ("originator", ORIGINATOR),
226        ]);
227        Ok(url)
228    }
229
230    /// The code from a pasted redirect URL, `code#state`, a query string or a bare code,
231    /// as Pi accepts them. A pasted state must match.
232    fn pasted(&self, input: &str) -> Result<String, String> {
233        let input = input.trim();
234        let (code, state) = if let Ok(url) = Url::parse(input) {
235            let pairs: BTreeMap<_, _> = url.query_pairs().into_owned().collect();
236            (pairs.get("code").cloned(), pairs.get("state").cloned())
237        } else if let Some((code, state)) = input.split_once('#') {
238            (Some(code.to_owned()), Some(state.to_owned()))
239        } else if input.contains("code=") {
240            let pairs: BTreeMap<_, _> = url::form_urlencoded::parse(input.as_bytes())
241                .into_owned()
242                .collect();
243            (pairs.get("code").cloned(), pairs.get("state").cloned())
244        } else {
245            (Some(input.to_owned()), None)
246        };
247        if state.is_some_and(|state| state != self.state) {
248            return Err("Codex sign-in state mismatch.".into());
249        }
250        code.filter(|code| !code.is_empty())
251            .ok_or_else(|| "The pasted input has no authorization code.".into())
252    }
253
254    /// Answer one callback request. `Some` ends the sign-in; `None` keeps waiting.
255    fn callback(
256        &self,
257        target: &str,
258    ) -> (&'static str, &'static str, Option<Result<String, String>>) {
259        let Ok(url) = Url::parse(&format!("http://localhost{target}")) else {
260            return ("400 Bad Request", "Invalid callback.", None);
261        };
262        if url.path() != CALLBACK_PATH {
263            return ("404 Not Found", "Not found.", None);
264        }
265        let mut parameters = BTreeMap::new();
266        for (key, value) in url.query_pairs().into_owned() {
267            if parameters.insert(key, value).is_some() {
268                return ("400 Bad Request", "Duplicate callback parameter.", None);
269            }
270        }
271        if parameters.get("state").map(String::as_str) != Some(self.state.as_str()) {
272            return ("400 Bad Request", "State mismatch.", None);
273        }
274        if let Some(error) = parameters.get("error") {
275            let error: String = error
276                .chars()
277                .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
278                .take(MAX_OAUTH_ERROR_CHARS)
279                .collect();
280            return (
281                "400 Bad Request",
282                "Codex sign-in was not completed. Return to artifactize.",
283                Some(Err(format!("Codex sign-in failed ({error})."))),
284            );
285        }
286        match parameters.get("code").filter(|code| !code.is_empty()) {
287            Some(code) => (
288                "200 OK",
289                "Signed in to Codex. Return to artifactize; you can close this page.",
290                Some(Ok(code.clone())),
291            ),
292            None => ("400 Bad Request", "Missing authorization code.", None),
293        }
294    }
295
296    /// Serve the loopback callback until one completes the sign-in. Requests must be
297    /// `GET` with a `Host` of `localhost:PORT` or `127.0.0.1:PORT`.
298    async fn serve(&self, listener: TcpListener) -> Result<String, String> {
299        // Allow browser callback headers and the authorization URL while bounding
300        // memory consumed by an untrusted loopback client before HTTP validation.
301        const MAX_CALLBACK_HEADER_BYTES: usize = 16 * 1024;
302        let port = listener.local_addr().map_err(|e| e.to_string())?.port();
303        let hosts = [format!("localhost:{port}"), format!("127.0.0.1:{port}")];
304        loop {
305            let (mut stream, _) = listener.accept().await.map_err(|e| e.to_string())?;
306            let head = tokio::time::timeout(CALLBACK_READ_TIMEOUT, async {
307                let mut head = Vec::new();
308                let mut byte = [0];
309                while !head.ends_with(b"\r\n\r\n") && head.len() < MAX_CALLBACK_HEADER_BYTES {
310                    if stream.read(&mut byte).await? == 0 {
311                        break;
312                    }
313                    head.push(byte[0]);
314                }
315                Ok::<_, std::io::Error>(head)
316            })
317            .await;
318            let Ok(Ok(head)) = head else { continue };
319            let head = String::from_utf8_lossy(&head);
320            let line: Vec<_> = head
321                .lines()
322                .next()
323                .unwrap_or("")
324                .split_whitespace()
325                .collect();
326            let host: Vec<_> = head
327                .lines()
328                .skip(1)
329                .filter_map(|line| line.split_once(':'))
330                .filter(|(name, _)| name.eq_ignore_ascii_case("host"))
331                .map(|(_, value)| value.trim())
332                .collect();
333            let valid = head.ends_with("\r\n\r\n")
334                && line.len() == 3
335                && line[0] == "GET"
336                && matches!(line[2], "HTTP/1.0" | "HTTP/1.1")
337                && host.len() == 1
338                && hosts.iter().any(|expected| expected == host[0])
339                && line[1].starts_with('/')
340                && !line[1].starts_with("//");
341            let (status, body, result) = if valid {
342                self.callback(line[1])
343            } else {
344                ("400 Bad Request", "Invalid callback.", None)
345            };
346            let response = format!(
347                "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
348                body.len()
349            );
350            let _ = tokio::time::timeout(
351                CALLBACK_WRITE_TIMEOUT,
352                stream.write_all(response.as_bytes()),
353            )
354            .await;
355            if let Some(result) = result {
356                return result;
357            }
358        }
359    }
360
361    /// The authorization code from the callback or a pasted redirect, whichever is first.
362    async fn wait(
363        &self,
364        listener: Option<TcpListener>,
365        pasted: Option<oneshot::Receiver<String>>,
366    ) -> Result<String, String> {
367        let callback = async {
368            match listener {
369                Some(listener) => self.serve(listener).await,
370                None => std::future::pending().await,
371            }
372        };
373        let pasted = async {
374            match pasted {
375                Some(receiver) => match receiver.await {
376                    Ok(input) => self.pasted(&input),
377                    Err(_) => std::future::pending().await,
378                },
379                None => std::future::pending().await,
380            }
381        };
382        tokio::time::timeout(LOGIN_TIMEOUT, async {
383            tokio::select! {
384                result = callback => result,
385                result = pasted => result,
386            }
387        })
388        .await
389        .map_err(|_| "Codex sign-in timed out; run `artifactize login codex` again.".to_owned())?
390    }
391}
392
393/// What a sign-in asks the person to do: open `url`, and whether the browser can return to
394/// this machine's callback (when the port is taken, only a pasted redirect URL finishes it).
395#[derive(Debug, Clone, Serialize)]
396#[serde(rename_all = "camelCase")]
397pub struct Prompt {
398    pub url: String,
399    pub callback: bool,
400}
401
402/// Sign in interactively and save artifactize's own tokens. The sign-in URL goes to
403/// stderr, also with --json.
404pub async fn login(state: Option<&Path>, repo: Option<&Path>) -> Result<(), String> {
405    // Refuse before reading the terminal: where the tokens go and the sign-in root.
406    Storage::new(state, repo, Tokens::Codex)?;
407    auth_root()?;
408    let terminal = std::io::stdin().is_terminal();
409    let pasted = if terminal {
410        let (send, receive) = oneshot::channel();
411        // Detached: a sign-in finished in the browser leaves this read unanswered.
412        std::thread::Builder::new()
413            .name("codex-login-input".into())
414            .spawn(move || {
415                for line in std::io::stdin().lines() {
416                    match line {
417                        Ok(line) if line.trim().is_empty() => continue,
418                        Ok(line) => {
419                            let _ = send.send(line);
420                        }
421                        Err(_) => {}
422                    }
423                    break;
424                }
425            })
426            .map_err(|error| format!("Cannot start Codex sign-in input: {error}"))?;
427        Some(receive)
428    } else {
429        None
430    };
431    login_with(state, repo, pasted, true, |prompt| {
432        let mut err = std::io::stderr().lock();
433        let _ = writeln!(err, "Open this URL to sign in with Codex:\n{}", prompt.url);
434        if !prompt.callback {
435            let _ = writeln!(
436                err,
437                "Port {CALLBACK_PORT} is in use (another Codex sign-in?), so the browser cannot return here."
438            );
439        }
440        if terminal {
441            let _ = writeln!(
442                err,
443                "When the browser cannot return here, paste the URL it was sent to and press Enter."
444            );
445        }
446    })
447    .await
448}
449
450/// The sign-in behind `login codex` for any interface: `show` receives the prompt once the
451/// callback listener is up, `pasted` may deliver the redirect URL the browser was sent to,
452/// and `browser` hands the URL to the desktop's opener. It ends when the tokens are saved, the
453/// sign-in fails, or after five minutes; dropping the future stops it and frees the port.
454pub async fn login_with(
455    state: Option<&Path>,
456    repo: Option<&Path>,
457    pasted: Option<oneshot::Receiver<String>>,
458    browser: bool,
459    show: impl FnOnce(&Prompt),
460) -> Result<(), String> {
461    // Check where the tokens will go before anyone signs in.
462    let storage = Storage::new(state, repo, Tokens::Codex)?;
463    let root = auth_root()?;
464    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, CALLBACK_PORT))
465        .await
466        .ok();
467    let redirect_uri = format!("http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}");
468    sign_in(
469        &storage,
470        &root,
471        listener,
472        &redirect_uri,
473        pasted,
474        browser,
475        |url, callback| {
476            show(&Prompt {
477                url: url.to_string(),
478                callback,
479            })
480        },
481    )
482    .await
483}
484
485async fn sign_in(
486    storage: &Storage,
487    root: &str,
488    listener: Option<TcpListener>,
489    redirect_uri: &str,
490    pasted: Option<oneshot::Receiver<String>>,
491    browser: bool,
492    show: impl FnOnce(&Url, bool),
493) -> Result<(), String> {
494    if listener.is_none() && pasted.is_none() {
495        return Err(format!(
496            "Port {CALLBACK_PORT} is in use and there is no terminal to paste the redirect URL into; finish the other sign-in and try again."
497        ));
498    }
499    let pending = Pending::new()?;
500    let url = pending.authorize_url(root, redirect_uri)?;
501    show(&url, listener.is_some());
502    if browser {
503        open_browser(&url).await;
504    }
505    let code = pending.wait(listener, pasted).await?;
506    let tokens = token_request(
507        root,
508        &[
509            ("grant_type", "authorization_code"),
510            ("client_id", CLIENT_ID),
511            ("code", &code),
512            ("code_verifier", &pending.verifier),
513            ("redirect_uri", redirect_uri),
514        ],
515        None,
516    )
517    .await
518    .map_err(|error| error.message)?;
519    let credentials = credentials(tokens, now()?)?;
520    let _lock = storage.lock(LOCK).await?;
521    storage.save(CREDENTIALS, &credentials)
522}
523
524async fn open_browser(url: &Url) {
525    // The printed authorization URL remains available if desktop handoff fails.
526    let _ = crate::platform::program::open_desktop(std::ffi::OsStr::new(url.as_str())).await;
527}
528
529#[derive(Deserialize)]
530struct TokenResponse {
531    access_token: Option<String>,
532    refresh_token: Option<String>,
533    #[serde(
534        default,
535        deserialize_with = "timestamp::optional_duration::deserialize"
536    )]
537    expires_in: Option<Duration>,
538}
539
540fn credentials(tokens: TokenResponse, saved_at: Timestamp) -> Result<Credentials, String> {
541    let invalid = || "Invalid Codex token response; nothing was saved.".to_owned();
542    let access_token = tokens.access_token.filter(|token| !token.is_empty());
543    let refresh_token = tokens.refresh_token.filter(|token| !token.is_empty());
544    let (Some(access_token), Some(refresh_token), Some(expires_in)) = (
545        access_token,
546        refresh_token,
547        tokens.expires_in.filter(|duration| !duration.is_zero()),
548    ) else {
549        return Err(invalid());
550    };
551    let account_id = account_id(&access_token)
552        .ok_or("The Codex access token names no ChatGPT account; nothing was saved.")?;
553    Ok(Credentials {
554        access_token,
555        refresh_token,
556        account_id,
557        expires_at: saved_at.checked_add(expires_in).ok_or_else(invalid)?,
558        saved_at,
559    })
560}
561
562/// Refresh errors after which the refresh token can never work again.
563fn terminal(code: &str) -> bool {
564    matches!(
565        code,
566        "invalid_grant"
567            | "refresh_token_expired"
568            | "refresh_token_reused"
569            | "refresh_token_invalidated"
570    )
571}
572
573/// POST a form to the token endpoint. Error bodies are never echoed: they may carry
574/// codes or tokens. A terminal refresh error removes the stored tokens.
575async fn token_request(
576    root: &str,
577    form: &[(&str, &str)],
578    refreshing: Option<&Storage>,
579) -> Result<TokenResponse, TokenError> {
580    let response = client()?
581        .post(format!("{root}/oauth/token"))
582        .form(form)
583        .send()
584        .await
585        .map_err(|_| TokenError {
586            transient: true,
587            message:
588                "Codex token request failed; check your connection. The stored sign-in was kept."
589                    .into(),
590        })?;
591    let status = response.status();
592    if !status.is_success() {
593        let body: Value = response.json().await.unwrap_or_default();
594        let code = body["error"]
595            .as_str()
596            .or_else(|| body["error"]["code"].as_str())
597            .or_else(|| body["code"].as_str())
598            .unwrap_or_default();
599        // Rate limiting and server failures pass; a client error does not.
600        let transient =
601            status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
602        let status = status.as_u16();
603        let message = match refreshing {
604            Some(storage) if terminal(code) => {
605                storage.remove(CREDENTIALS)?;
606                format!(
607                    "The Codex refresh token is no longer valid ({code}); run `artifactize login codex`."
608                )
609            }
610            Some(_) => {
611                format!("Codex token refresh failed (HTTP {status}); the stored sign-in was kept.")
612            }
613            None if code == "invalid_grant" => {
614                "The Codex authorization code was rejected (invalid_grant); run `artifactize login codex` again."
615                    .into()
616            }
617            None => format!("Codex sign-in failed (HTTP {status})."),
618        };
619        return Err(TokenError { transient, message });
620    }
621    response
622        .json()
623        .await
624        .map_err(|_| "Invalid Codex token response; nothing was saved.".into())
625}
626
627/// A usable token: from the read-only auth file when one is set, else artifactize's own,
628/// refreshed under a cross-process lock when it is about to expire.
629pub async fn access_token(state: Option<&Path>, repo: Option<&Path>) -> Result<Token, TokenError> {
630    if let Some(path) = auth_file() {
631        return Ok(read_auth_file(&path)?);
632    }
633    stored_token(&Storage::new(state, repo, Tokens::Codex)?, &auth_root()?).await
634}
635
636async fn stored_token(storage: &Storage, root: &str) -> Result<Token, TokenError> {
637    stored_token_at(storage, root, now).await
638}
639
640async fn stored_token_at(
641    storage: &Storage,
642    root: &str,
643    clock: impl Fn() -> Result<Timestamp, String>,
644) -> Result<Token, TokenError> {
645    let _lock = storage.lock(LOCK).await?;
646    let stored = storage
647        .read::<Credentials>(CREDENTIALS)?
648        .ok_or(LOGIN_REQUIRED)?;
649    if stored.expires_at > clock()?.saturating_add(REFRESH_MARGIN) {
650        return Ok(Token {
651            access_token: stored.access_token,
652            account_id: stored.account_id,
653        });
654    }
655    let tokens = token_request(
656        root,
657        &[
658            ("grant_type", "refresh_token"),
659            ("refresh_token", &stored.refresh_token),
660            ("client_id", CLIENT_ID),
661        ],
662        Some(storage),
663    )
664    .await?;
665    let refreshed = credentials(tokens, clock()?)?;
666    if refreshed.account_id != stored.account_id {
667        return Err(
668            "The refreshed Codex token is for another ChatGPT account; run `artifactize login codex`."
669                .into(),
670        );
671    }
672    storage.save(CREDENTIALS, &refreshed)?;
673    Ok(Token {
674        access_token: refreshed.access_token,
675        account_id: refreshed.account_id,
676    })
677}
678
679/// Known read-only import fields. Wrong-typed optional fields retain the old missing/null
680/// behavior; extra fields are ignored and no credential data is echoed in parse errors.
681#[derive(Default)]
682struct AuthFile {
683    tokens: crate::json::Optional<crate::json::Object<AuthFileTokens>>,
684}
685#[derive(Default)]
686struct AuthFileTokens {
687    access_token: crate::json::Optional<String>,
688    account_id: crate::json::Optional<String>,
689}
690impl<'de> Deserialize<'de> for AuthFile {
691    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
692        struct AuthVisitor;
693        impl<'de> serde::de::Visitor<'de> for AuthVisitor {
694            type Value = AuthFile;
695            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
696                formatter.write_str("an auth-file object")
697            }
698            fn visit_map<M: serde::de::MapAccess<'de>>(
699                self,
700                mut map: M,
701            ) -> Result<Self::Value, M::Error> {
702                let mut auth = AuthFile::default();
703                while let Some(field) = map.next_key::<String>()? {
704                    if field == "tokens" {
705                        auth.tokens = map.next_value()?;
706                    } else {
707                        map.next_value::<crate::json::Ignored>()?;
708                    }
709                }
710                Ok(auth)
711            }
712        }
713        deserializer.deserialize_map(AuthVisitor)
714    }
715}
716impl<'de> Deserialize<'de> for AuthFileTokens {
717    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
718        struct TokensVisitor;
719        impl<'de> serde::de::Visitor<'de> for TokensVisitor {
720            type Value = AuthFileTokens;
721            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
722                formatter.write_str("an auth-file token object")
723            }
724            fn visit_map<M: serde::de::MapAccess<'de>>(
725                self,
726                mut map: M,
727            ) -> Result<Self::Value, M::Error> {
728                let mut tokens = AuthFileTokens::default();
729                while let Some(field) = map.next_key::<String>()? {
730                    match field.as_str() {
731                        "access_token" => tokens.access_token = map.next_value()?,
732                        "account_id" => tokens.account_id = map.next_value()?,
733                        _ => {
734                            map.next_value::<crate::json::Ignored>()?;
735                        }
736                    }
737                }
738                Ok(tokens)
739            }
740        }
741        deserializer.deserialize_map(TokensVisitor)
742    }
743}
744
745/// The access token of a Codex auth file (`{"tokens":{"access_token",...}}`), read
746/// once per use and never written. An expiring token is an error, never a refresh.
747/// Why a Codex auth file could not be read as a usable token: the expired case is a state
748/// callers may want to report on its own, so it is a variant rather than a message prefix.
749#[derive(Debug)]
750enum AuthFileError {
751    /// The token in the file has expired; the message explains how to fix it.
752    Expired(String),
753    /// The file itself, or what it holds, could not be used; the message explains why.
754    Unreadable(String),
755}
756
757impl std::fmt::Display for AuthFileError {
758    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759        match self {
760            Self::Expired(message) | Self::Unreadable(message) => write!(f, "{message}"),
761        }
762    }
763}
764
765impl From<AuthFileError> for String {
766    fn from(error: AuthFileError) -> Self {
767        error.to_string()
768    }
769}
770
771impl From<AuthFileError> for TokenError {
772    fn from(error: AuthFileError) -> Self {
773        error.to_string().into()
774    }
775}
776
777fn read_auth_file(path: &Path) -> Result<Token, AuthFileError> {
778    read_auth_file_at(path, now().unwrap_or(Timestamp::from_seconds(u64::MAX)))
779}
780
781fn read_auth_file_at(path: &Path, now: Timestamp) -> Result<Token, AuthFileError> {
782    let name = crate::platform::path_text(path);
783    let unreadable = |reason: &str| {
784        AuthFileError::Unreadable(format!(
785            "{AUTH_FILE_VARIABLE}: cannot read {name}: {reason}."
786        ))
787    };
788    let file =
789        crate::platform::open_regular(path).map_err(|error| unreadable(&error.to_string()))?;
790    if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
791        return Err(unreadable("it is not a regular file"));
792    }
793    let mut data = Vec::new();
794    // Match owned credential storage: one extra byte detects oversized imports.
795    file.take(MAX_CREDENTIAL_BYTES as u64 + 1)
796        .read_to_end(&mut data)
797        .map_err(|error| unreadable(&error.to_string()))?;
798    if data.len() > MAX_CREDENTIAL_BYTES {
799        return Err(unreadable("it is larger than 1 MiB"));
800    }
801    let auth: crate::json::Optional<crate::json::Object<AuthFile>> =
802        serde_json::from_slice(&data).map_err(|_| unreadable("it is not a Codex auth file"))?;
803    let tokens = auth
804        .0
805        .and_then(|crate::json::Object(auth)| auth.tokens.0)
806        .map(|crate::json::Object(tokens)| tokens);
807    let access_token = tokens
808        .as_ref()
809        .and_then(|tokens| tokens.access_token.0.as_deref())
810        .filter(|token| !token.is_empty())
811        .ok_or_else(|| unreadable("it holds no ChatGPT sign-in tokens"))?;
812    if expiry(access_token).is_some_and(|exp| exp <= now.saturating_add(FILE_MARGIN)) {
813        return Err(AuthFileError::Expired(format!(
814            "The Codex access token in {name} has expired; sign in with Codex again (for example `codex login`). artifactize never refreshes that file."
815        )));
816    }
817    let account_id = tokens
818        .as_ref()
819        .and_then(|tokens| tokens.account_id.0.as_deref())
820        .filter(|id| !id.is_empty())
821        .and_then(|id| id.parse().ok())
822        .or_else(|| account_id(access_token))
823        .ok_or_else(|| unreadable("its token names no ChatGPT account"))?;
824    Ok(Token {
825        access_token: access_token.to_owned(),
826        account_id,
827    })
828}
829
830/// A read-only file can carry a usable token without an expiry claim. An expired
831/// file reports no timestamp because the existing reader refuses it before returning a token.
832#[derive(Debug)]
833pub enum FileExpiry {
834    Usable { expires_at: Option<Timestamp> },
835    Expired,
836}
837
838/// Owned credentials always carry the expiry saved by the token exchange.
839#[derive(Debug)]
840pub enum StoredExpiry {
841    Usable { expires_at: Timestamp },
842    Expired { expires_at: Timestamp },
843}
844
845/// Offline sign-in states: only file states have a path, only stored states have
846/// a mandatory expiry, and a storage refusal can only accompany an absent sign-in.
847#[derive(Debug)]
848pub enum Status {
849    Absent,
850    Refused { reason: String },
851    File { path: PathBuf, expiry: FileExpiry },
852    Stored { expiry: StoredExpiry },
853}
854
855impl Serialize for Status {
856    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
857        use serde::ser::SerializeStruct;
858        // Keep the pre-enum JSON contract exactly at the serialization boundary.
859        let (source, file, expires_at, expired, refused) = match self {
860            Self::Absent => ("none", None, None, false, None),
861            Self::Refused { reason } => ("none", None, None, false, Some(reason)),
862            Self::File {
863                path,
864                expiry: FileExpiry::Usable { expires_at },
865            } => ("file", Some(path), *expires_at, false, None),
866            Self::File {
867                path,
868                expiry: FileExpiry::Expired,
869            } => ("file", Some(path), None, true, None),
870            Self::Stored {
871                expiry: StoredExpiry::Usable { expires_at },
872            } => ("stored", None, Some(*expires_at), false, None),
873            Self::Stored {
874                expiry: StoredExpiry::Expired { expires_at },
875            } => ("stored", None, Some(*expires_at), true, None),
876        };
877        let mut wire = serializer.serialize_struct(
878            "Status",
879            3 + usize::from(file.is_some()) + usize::from(refused.is_some()),
880        )?;
881        wire.serialize_field("source", source)?;
882        if let Some(file) = file {
883            wire.serialize_field("authFile", &crate::platform::path_text(file))?;
884        }
885        wire.serialize_field("expiresAt", &expires_at)?;
886        wire.serialize_field("expired", &expired)?;
887        if let Some(reason) = refused {
888            wire.serialize_field("refused", reason)?;
889        }
890        wire.end()
891    }
892}
893
894/// Inspect the sign-in offline: no lock, refresh, network or writes.
895pub fn status(state: Option<&Path>, repo: Option<&Path>) -> Result<Status, String> {
896    let now = now()?;
897    if let Some(path) = auth_file() {
898        // Reading fails on an expiring token; that is a state to report, not an error.
899        let expiry = match read_auth_file(&path) {
900            Ok(token) => FileExpiry::Usable {
901                expires_at: expiry(&token.access_token),
902            },
903            Err(AuthFileError::Expired(_)) => FileExpiry::Expired,
904            Err(error) => return Err(error.into()),
905        };
906        return Ok(Status::File { path, expiry });
907    }
908    let location = Location::find(state, repo)?;
909    if let Some(refusal) = location.refusal(Tokens::Codex) {
910        // Only a sign-in already stored there is at risk; without one this is advice.
911        return match crate::platform::entry_exists(&location.directory.join(CREDENTIALS)) {
912            Ok(false) => Ok(Status::Refused { reason: refusal }),
913            _ => Err(refusal),
914        };
915    }
916    let stored = Storage::inspect(state, repo, Tokens::Codex)?.read::<Credentials>(CREDENTIALS)?;
917    Ok(match stored {
918        None => Status::Absent,
919        Some(stored) => Status::Stored {
920            expiry: if stored.expires_at <= now {
921                StoredExpiry::Expired {
922                    expires_at: stored.expires_at,
923                }
924            } else {
925                StoredExpiry::Usable {
926                    expires_at: stored.expires_at,
927                }
928            },
929        },
930    })
931}
932
933/// Revoke and remove artifactize's own tokens; a read-only auth file is left alone.
934/// Returns whether the revocation was confirmed.
935pub async fn logout(state: Option<&Path>, repo: Option<&Path>) -> Result<bool, String> {
936    sign_out(&Storage::new(state, repo, Tokens::Codex)?, &auth_root()?).await
937}
938
939async fn sign_out(storage: &Storage, root: &str) -> Result<bool, String> {
940    let _lock = storage.lock(LOCK).await?;
941    let Some(stored) = storage.read::<Credentials>(CREDENTIALS)? else {
942        return Ok(true);
943    };
944    let revoked = revoke(root, &stored.refresh_token).await;
945    storage.remove(CREDENTIALS)?;
946    Ok(revoked)
947}
948
949async fn revoke(root: &str, refresh_token: &str) -> bool {
950    let Ok(client) = client() else {
951        return false;
952    };
953    client
954        .post(format!("{root}/oauth/revoke"))
955        .timeout(REVOCATION_TIMEOUT)
956        .json(
957            &json!({"token":refresh_token,"token_type_hint":"refresh_token","client_id":CLIENT_ID}),
958        )
959        .send()
960        .await
961        .is_ok_and(|response| response.status().is_success())
962}
963
964#[cfg(test)]
965mod redirect_tests;
966#[cfg(test)]
967mod tests;