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)?
132 .map_or_else(|| AUTH_ROOT.into(), |endpoint| endpoint.root().to_owned()))
133}
134
135pub 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
151fn 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
192struct 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 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 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 async fn serve(&self, listener: TcpListener) -> Result<String, String> {
301 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 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#[derive(Debug, Clone, Serialize)]
402#[serde(rename_all = "camelCase")]
403pub struct Prompt {
404 pub url: String,
405 pub callback: bool,
406}
407
408pub async fn login(state: Option<&Path>, repo: Option<&Path>) -> Result<(), String> {
411 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 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
456pub 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 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 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
567fn 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
578async 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 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
632pub 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#[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#[derive(Debug)]
755enum AuthFileError {
756 Expired(String),
758 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 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#[derive(Debug)]
838pub enum FileExpiry {
839 Usable { expires_at: Option<Timestamp> },
840 Expired,
841}
842
843#[derive(Debug)]
845pub enum StoredExpiry {
846 Usable { expires_at: Timestamp },
847 Expired { expires_at: Timestamp },
848}
849
850#[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 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
899pub fn status(state: Option<&Path>, repo: Option<&Path>) -> Result<Status, String> {
901 let now = now()?;
902 if let Some(path) = auth_file() {
903 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 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
938pub 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;