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