1use 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
39pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
41const AUTH_ROOT: &str = "https://auth.openai.com";
42pub const AUTH_URL_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_URL";
44pub const AUTH_FILE_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_FILE";
47const CALLBACK_PORT: u16 = 1455;
50const CALLBACK_PATH: &str = "/auth/callback";
51const SCOPE: &str = "openid profile email offline_access";
52pub const ORIGINATOR: &str = "artifactize";
54#[cfg(test)]
55const CLAIM: &str = "https://api.openai.com/auth";
56const CREDENTIALS: &str = "codex.json";
57const LOCK: &str = "codex";
58const PKCE_VERIFIER_BYTES: usize = 32;
61const OAUTH_STATE_BYTES: usize = 16;
64const REFRESH_MARGIN: Duration = Duration::from_secs(300);
66const FILE_MARGIN: Duration = Duration::from_secs(60);
68const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
71const AUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
73const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(5);
75const CALLBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
77const REVOCATION_TIMEOUT: Duration = Duration::from_secs(10);
79const 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#[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
118pub 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
128pub fn auth_root() -> Result<String, String> {
130 Ok(crate::llm::variable_endpoint(AUTH_URL_VARIABLE)?.unwrap_or_else(|| AUTH_ROOT.into()))
131}
132
133pub 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
149fn 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
190struct 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 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 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 async fn serve(&self, listener: TcpListener) -> Result<String, String> {
299 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 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#[derive(Debug, Clone, Serialize)]
396#[serde(rename_all = "camelCase")]
397pub struct Prompt {
398 pub url: String,
399 pub callback: bool,
400}
401
402pub async fn login(state: Option<&Path>, repo: Option<&Path>) -> Result<(), String> {
405 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 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
450pub 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 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 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
562fn 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
573async 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 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
627pub 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#[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#[derive(Debug)]
750enum AuthFileError {
751 Expired(String),
753 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 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#[derive(Debug)]
833pub enum FileExpiry {
834 Usable { expires_at: Option<Timestamp> },
835 Expired,
836}
837
838#[derive(Debug)]
840pub enum StoredExpiry {
841 Usable { expires_at: Timestamp },
842 Expired { expires_at: Timestamp },
843}
844
845#[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 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
894pub fn status(state: Option<&Path>, repo: Option<&Path>) -> Result<Status, String> {
896 let now = now()?;
897 if let Some(path) = auth_file() {
898 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 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
933pub 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;