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