1use 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
40pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
42const AUTH_ROOT: &str = "https://auth.openai.com";
43pub const AUTH_URL_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_URL";
45pub const AUTH_FILE_VARIABLE: &str = "ARTIFACTIZE_CODEX_AUTH_FILE";
48const CALLBACK_PORT: u16 = 1455;
51const CALLBACK_PATH: &str = "/auth/callback";
52const SCOPE: &str = "openid profile email offline_access";
53pub const ORIGINATOR: &str = "artifactize";
55#[cfg(test)]
56const CLAIM: &str = "https://api.openai.com/auth";
57const CREDENTIALS: &str = "codex.json";
58const LOCK: &str = "codex";
59const PKCE_VERIFIER_BYTES: usize = 32;
62const OAUTH_STATE_BYTES: usize = 16;
65const REFRESH_MARGIN: Duration = Duration::from_secs(300);
67const FILE_MARGIN: Duration = Duration::from_secs(60);
69const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
72const AUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
74const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(5);
76const CALLBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
78const REVOCATION_TIMEOUT: Duration = Duration::from_secs(10);
80const 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#[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
119pub 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
129pub fn auth_root() -> Result<String, String> {
131 Ok(crate::llm::variable_endpoint(AUTH_URL_VARIABLE)?.unwrap_or_else(|| AUTH_ROOT.into()))
132}
133
134pub 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
150fn 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
191struct 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 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 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 async fn serve(&self, listener: TcpListener) -> Result<String, String> {
300 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 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#[derive(Debug, Clone, Serialize)]
401#[serde(rename_all = "camelCase")]
402pub struct Prompt {
403 pub url: String,
404 pub callback: bool,
405}
406
407pub async fn login(state: Option<&Path>, repo: Option<&Path>) -> Result<(), String> {
410 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 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
455pub 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 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 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
566fn 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
577async 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 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
631pub 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#[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#[derive(Debug)]
754enum AuthFileError {
755 Expired(String),
757 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 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#[derive(Debug)]
837pub enum FileExpiry {
838 Usable { expires_at: Option<Timestamp> },
839 Expired,
840}
841
842#[derive(Debug)]
844pub enum StoredExpiry {
845 Usable { expires_at: Timestamp },
846 Expired { expires_at: Timestamp },
847}
848
849#[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 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
898pub fn status(state: Option<&Path>, repo: Option<&Path>) -> Result<Status, String> {
900 let now = now()?;
901 if let Some(path) = auth_file() {
902 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 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
937pub 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;