1use std::fs::{self, OpenOptions};
7use std::io::{self, Read, Write};
8use std::net::{TcpListener, TcpStream};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use base64::Engine;
16use reqwest::blocking::Client;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use crate::redaction::{conflicts_with_protected_literal, redaction_marker};
21
22pub const DEFAULT_AUTH_ISSUER: &str = "https://auth.openai.com";
23pub const DEFAULT_TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token";
24pub const DEFAULT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
25pub const CALLBACK_HOST: &str = "127.0.0.1";
26pub const CALLBACK_REDIRECT_HOST: &str = "localhost";
27pub const CALLBACK_PORT: u16 = 1455;
28pub const CALLBACK_PATH: &str = "/auth/callback";
29pub const REFRESH_WINDOW_SECONDS: i64 = 300;
30const MAX_CALLBACK_REQUEST_BYTES: usize = 16 * 1024;
31const MAX_TOKEN_RESPONSE_BYTES: usize = 256 * 1024;
32static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AuthError(String);
36
37impl AuthError {
38 fn new(message: impl Into<String>) -> Self {
39 Self(message.into())
40 }
41}
42
43impl std::fmt::Display for AuthError {
44 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 formatter.write_str(&self.0)
46 }
47}
48
49impl std::error::Error for AuthError {}
50
51impl From<io::Error> for AuthError {
52 fn from(_: io::Error) -> Self {
53 Self::new("authentication storage error")
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct CodexCredentials {
61 #[serde(rename = "access", alias = "access_token")]
62 pub access: String,
63 #[serde(rename = "refresh", alias = "refresh_token")]
64 pub refresh: String,
65 pub expires_at: Option<i64>,
66 pub account_id: String,
67}
68
69impl CodexCredentials {
70 pub fn near_expiry(&self, now: i64) -> bool {
71 self.expires_at
72 .is_some_and(|expires_at| expires_at <= now.saturating_add(REFRESH_WINDOW_SECONDS))
73 }
74}
75
76pub fn credential_path(home: &Path) -> PathBuf {
82 credential_path_from_xdg(
83 home,
84 std::env::var_os("XDG_DATA_HOME").as_deref(),
85 std::env::var_os("XDG_CONFIG_HOME").as_deref(),
86 )
87}
88
89pub fn credential_path_from_xdg(
90 home: &Path,
91 xdg_data_home: Option<&std::ffi::OsStr>,
92 xdg_config_home: Option<&std::ffi::OsStr>,
93) -> PathBuf {
94 let root = xdg_data_home
95 .filter(|value| !value.is_empty())
96 .map(PathBuf::from)
97 .filter(|path| path.is_absolute())
98 .or_else(|| {
99 xdg_config_home
100 .filter(|value| !value.is_empty())
101 .map(PathBuf::from)
102 .filter(|path| path.is_absolute())
103 })
104 .unwrap_or_else(|| home.join(".config"));
105 root.join("lucy").join("codex-credentials.json")
106}
107
108fn validate_credentials(credentials: &CodexCredentials) -> Result<(), AuthError> {
109 if credentials.access.is_empty()
110 || credentials.refresh.is_empty()
111 || credentials.account_id.is_empty()
112 {
113 return Err(AuthError::new("credentials are incomplete"));
114 }
115 for token in [&credentials.access, &credentials.refresh] {
116 if conflicts_with_protected_literal(token) || redaction_marker(token).is_none() {
117 return Err(AuthError::new("credentials cannot be safely stored"));
118 }
119 }
120 Ok(())
121}
122
123#[derive(Debug, Clone)]
125pub struct AuthStore {
126 path: PathBuf,
127}
128
129impl AuthStore {
130 pub fn new(path: PathBuf) -> Self {
131 Self { path }
132 }
133
134 pub fn for_home(home: &Path) -> Self {
135 Self::new(credential_path(home))
136 }
137
138 pub fn path(&self) -> &Path {
139 &self.path
140 }
141
142 pub fn load(&self) -> Result<Option<CodexCredentials>, AuthError> {
143 reject_symlink(&self.path).map_err(|_| AuthError::new("unable to secure credentials"))?;
144 let mut file = match OpenOptions::new().read(true).open(&self.path) {
145 Ok(file) => file,
146 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
147 Err(_) => return Err(AuthError::new("unable to read credentials")),
148 };
149 ensure_mode(&self.path).map_err(|_| AuthError::new("unable to secure credentials"))?;
150 let mut bytes = Vec::new();
151 file.read_to_end(&mut bytes)
152 .map_err(|_| AuthError::new("unable to read credentials"))?;
153 if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
154 return Err(AuthError::new("credentials exceeded the storage limit"));
155 }
156 let credentials: CodexCredentials = serde_json::from_slice(&bytes)
157 .map_err(|_| AuthError::new("credentials are invalid"))?;
158 validate_credentials(&credentials)?;
159 Ok(Some(credentials))
160 }
161
162 pub fn save(&self, credentials: &CodexCredentials) -> Result<(), AuthError> {
163 validate_credentials(credentials)?;
164 let directory = self
165 .path
166 .parent()
167 .ok_or_else(|| AuthError::new("unable to secure credentials"))?;
168 ensure_private_directory(directory)?;
169 reject_symlink(&self.path).map_err(|_| AuthError::new("unable to secure credentials"))?;
170
171 let bytes = serde_json::to_vec_pretty(credentials)
172 .map_err(|_| AuthError::new("unable to encode credentials"))?;
173 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
174 let temporary = directory.join(format!(
175 ".{}.{}.tmp",
176 self.path
177 .file_name()
178 .and_then(|name| name.to_str())
179 .unwrap_or("credentials"),
180 counter
181 ));
182 reject_symlink(&temporary).map_err(|_| AuthError::new("unable to secure credentials"))?;
183 let mut options = OpenOptions::new();
184 options.write(true).create_new(true);
185 #[cfg(unix)]
186 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
187 let result = (|| {
188 let mut file = options
189 .open(&temporary)
190 .map_err(|_| AuthError::new("unable to write credentials"))?;
191 file.write_all(&bytes)
192 .and_then(|_| file.sync_all())
193 .map_err(|_| AuthError::new("unable to write credentials"))?;
194 ensure_mode(&temporary).map_err(|_| AuthError::new("unable to secure credentials"))?;
195 fs::rename(&temporary, &self.path)
196 .map_err(|_| AuthError::new("unable to replace credentials"))?;
197 ensure_mode(&self.path).map_err(|_| AuthError::new("unable to secure credentials"))
198 })();
199 if result.is_err() {
200 let _ = fs::remove_file(&temporary);
201 }
202 result
203 }
204
205 pub fn logout(&self) -> Result<bool, AuthError> {
206 reject_symlink(&self.path).map_err(|_| AuthError::new("unable to secure credentials"))?;
207 match fs::remove_file(&self.path) {
208 Ok(()) => Ok(true),
209 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
210 Err(_) => Err(AuthError::new("unable to remove credentials")),
211 }
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct PkceChallenge {
217 pub verifier: String,
218 pub challenge: String,
219}
220
221pub fn generate_pkce() -> Result<PkceChallenge, AuthError> {
222 let mut random = [0u8; 32];
223 getrandom::fill(&mut random).map_err(|_| AuthError::new("unable to initialize OAuth"))?;
224 let verifier = URL_SAFE_NO_PAD.encode(random);
225 let digest = Sha256::digest(verifier.as_bytes());
226 Ok(PkceChallenge {
227 verifier,
228 challenge: URL_SAFE_NO_PAD.encode(digest),
229 })
230}
231
232#[derive(Debug, Clone)]
233pub struct OAuthEndpoints {
234 pub authorize: String,
235 pub token: String,
236 pub client_id: String,
237 pub issuer: String,
238}
239
240impl Default for OAuthEndpoints {
241 fn default() -> Self {
242 Self {
243 authorize: format!("{DEFAULT_AUTH_ISSUER}/oauth/authorize"),
244 token: DEFAULT_TOKEN_ENDPOINT.to_owned(),
245 client_id: DEFAULT_CLIENT_ID.to_owned(),
246 issuer: DEFAULT_AUTH_ISSUER.to_owned(),
247 }
248 }
249}
250
251pub fn login(home: &Path) -> Result<CodexCredentials, AuthError> {
253 login_with_endpoints(home, &OAuthEndpoints::default())
254}
255
256pub fn login_with_endpoints(
257 home: &Path,
258 endpoints: &OAuthEndpoints,
259) -> Result<CodexCredentials, AuthError> {
260 let pkce = generate_pkce()?;
261 let state = random_url_value()?;
262 let listener = TcpListener::bind((CALLBACK_HOST, CALLBACK_PORT))
263 .map_err(|_| AuthError::new("unable to bind OAuth callback on 127.0.0.1:1455"))?;
264 let redirect_uri = format!("http://{CALLBACK_REDIRECT_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}");
265 let authorize_url = build_authorize_url(endpoints, &redirect_uri, &pkce, &state)?;
266 if !open_browser(&authorize_url) {
267 eprintln!("Open this URL in your browser to sign in with Codex:\n{authorize_url}");
268 }
269
270 let (code, callback_error) = receive_callback(&listener, &state)?;
271 if let Some(error) = callback_error {
272 return Err(error);
273 }
274 let code = code.ok_or_else(|| AuthError::new("OAuth callback did not contain a code"))?;
275 let credentials = exchange_code(endpoints, &redirect_uri, &pkce.verifier, &code)?;
276 AuthStore::for_home(home).save(&credentials)?;
277 Ok(credentials)
278}
279
280fn build_authorize_url(
281 endpoints: &OAuthEndpoints,
282 redirect_uri: &str,
283 pkce: &PkceChallenge,
284 state: &str,
285) -> Result<String, AuthError> {
286 let mut url = reqwest::Url::parse(&endpoints.authorize)
287 .map_err(|_| AuthError::new("invalid OAuth authorize endpoint"))?;
288 url.query_pairs_mut()
289 .append_pair("response_type", "code")
290 .append_pair("client_id", &endpoints.client_id)
291 .append_pair("redirect_uri", redirect_uri)
292 .append_pair(
293 "scope",
294 "openid profile email offline_access api.connectors.read api.connectors.invoke",
295 )
296 .append_pair("code_challenge", &pkce.challenge)
297 .append_pair("code_challenge_method", "S256")
298 .append_pair("state", state)
299 .append_pair("id_token_add_organizations", "true")
300 .append_pair("codex_cli_simplified_flow", "true")
301 .append_pair("originator", "lucy");
302 Ok(url.to_string())
303}
304
305fn receive_callback(
306 listener: &TcpListener,
307 expected_state: &str,
308) -> Result<(Option<String>, Option<AuthError>), AuthError> {
309 for stream in listener.incoming() {
310 let mut stream = match stream {
311 Ok(stream) => stream,
312 Err(_) => return Err(AuthError::new("OAuth callback server failed")),
313 };
314 let request = read_http_request(&mut stream)?;
315 let target = request
316 .strip_prefix("GET ")
317 .and_then(|request| request.split_whitespace().next())
318 .ok_or_else(|| AuthError::new("OAuth callback request was invalid"))?;
319 let url = reqwest::Url::parse(&format!("http://localhost{target}"))
320 .map_err(|_| AuthError::new("OAuth callback request was invalid"))?;
321 if url.path() != CALLBACK_PATH {
322 write_callback(&mut stream, 404, "Not found")?;
323 continue;
324 }
325 let query: std::collections::HashMap<String, String> =
326 url.query_pairs().into_owned().collect();
327 let state_valid = query.get("state").map(String::as_str) == Some(expected_state);
328 if !state_valid {
329 write_callback(&mut stream, 400, "Authentication state was rejected.")?;
330 continue;
331 }
332 if query.contains_key("error") {
333 write_callback(&mut stream, 400, "Authentication was not completed.")?;
334 return Ok((None, Some(AuthError::new("OAuth authorization was denied"))));
335 }
336 let code = query
337 .get("code")
338 .filter(|code| !code.is_empty())
339 .cloned()
340 .ok_or_else(|| AuthError::new("OAuth callback did not contain a code"))?;
341 write_callback(
342 &mut stream,
343 200,
344 "Authentication complete. You may close this window.",
345 )?;
346 return Ok((Some(code), None));
347 }
348 Err(AuthError::new("OAuth callback server stopped"))
349}
350
351fn read_http_request(stream: &mut TcpStream) -> Result<String, AuthError> {
352 stream
353 .set_read_timeout(Some(Duration::from_secs(120)))
354 .map_err(|_| AuthError::new("OAuth callback server failed"))?;
355 let mut bytes = Vec::new();
356 let mut chunk = [0u8; 1024];
357 while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
358 let count = stream
359 .read(&mut chunk)
360 .map_err(|_| AuthError::new("OAuth callback request could not be read"))?;
361 if count == 0 {
362 break;
363 }
364 bytes.extend_from_slice(&chunk[..count]);
365 if bytes.len() > MAX_CALLBACK_REQUEST_BYTES {
366 return Err(AuthError::new("OAuth callback request was too large"));
367 }
368 }
369 String::from_utf8(bytes).map_err(|_| AuthError::new("OAuth callback request was invalid"))
370}
371
372fn write_callback(stream: &mut TcpStream, status: u16, body: &str) -> Result<(), AuthError> {
373 let response = format!(
374 "HTTP/1.1 {status} OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
375 body.len()
376 );
377 stream
378 .write_all(response.as_bytes())
379 .map_err(|_| AuthError::new("OAuth callback response failed"))
380}
381
382fn exchange_code(
383 endpoints: &OAuthEndpoints,
384 redirect_uri: &str,
385 verifier: &str,
386 code: &str,
387) -> Result<CodexCredentials, AuthError> {
388 let response = Client::builder()
389 .timeout(Duration::from_secs(30))
390 .build()
391 .map_err(|_| AuthError::new("unable to initialize OAuth HTTP client"))?
392 .post(&endpoints.token)
393 .form(&[
394 ("grant_type", "authorization_code"),
395 ("client_id", endpoints.client_id.as_str()),
396 ("code", code),
397 ("redirect_uri", redirect_uri),
398 ("code_verifier", verifier),
399 ])
400 .send()
401 .map_err(oauth_transport_error)?;
402 parse_token_response(response)
403}
404
405fn oauth_transport_error(error: reqwest::Error) -> AuthError {
406 let kind = if error.is_timeout() {
407 "timeout"
408 } else if error.is_connect() {
409 "connection"
410 } else if error.is_request() {
411 "request"
412 } else {
413 "transport"
414 };
415 let mut details = error.to_string();
416 let mut source = std::error::Error::source(&error);
417 while let Some(error) = source {
418 details.push_str(": ");
419 details.push_str(&error.to_string());
420 source = error.source();
421 }
422 AuthError::new(format!("OAuth token exchange {kind} error: {details}"))
423}
424
425fn parse_token_response(
426 response: reqwest::blocking::Response,
427) -> Result<CodexCredentials, AuthError> {
428 if !response.status().is_success() {
429 return Err(AuthError::new(format!(
430 "OAuth token endpoint returned HTTP status {}",
431 response.status().as_u16()
432 )));
433 }
434 let mut bytes = Vec::new();
435 response
436 .take((MAX_TOKEN_RESPONSE_BYTES + 1) as u64)
437 .read_to_end(&mut bytes)
438 .map_err(|_| AuthError::new("OAuth token response could not be read"))?;
439 if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
440 return Err(AuthError::new(
441 "OAuth token response exceeded the response limit",
442 ));
443 }
444 let payload: TokenResponse = serde_json::from_slice(&bytes)
445 .map_err(|_| AuthError::new("OAuth token response was invalid"))?;
446 let access = non_empty(payload.access_token)
447 .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
448 let refresh = non_empty(payload.refresh_token)
449 .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
450 let account_id = payload
451 .account_id
452 .or(payload.chatgpt_account_id)
453 .or_else(|| payload.id_token.as_deref().and_then(account_id_from_jwt))
454 .and_then(|value| non_empty(Some(value)))
455 .ok_or_else(|| AuthError::new("OAuth token response did not contain an account"))?;
456 let expires_at = payload
457 .expires_in
458 .map(|seconds| now_seconds().saturating_add(seconds));
459 Ok(CodexCredentials {
460 access,
461 refresh,
462 expires_at,
463 account_id,
464 })
465}
466
467#[derive(Debug, Deserialize)]
468struct TokenResponse {
469 access_token: Option<String>,
470 refresh_token: Option<String>,
471 expires_in: Option<i64>,
472 account_id: Option<String>,
473 chatgpt_account_id: Option<String>,
474 id_token: Option<String>,
475}
476
477fn account_id_from_jwt(jwt: &str) -> Option<String> {
478 let payload = jwt.split('.').nth(1)?;
479 let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
480 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
481 value
482 .get("https://api.openai.com/auth")
483 .and_then(|auth| auth.get("chatgpt_account_id"))
484 .and_then(serde_json::Value::as_str)
485 .or_else(|| {
486 value
487 .get("chatgpt_account_id")
488 .and_then(serde_json::Value::as_str)
489 })
490 .or_else(|| {
491 value
492 .get("organizations")
493 .and_then(serde_json::Value::as_array)
494 .and_then(|organizations| organizations.first())
495 .and_then(|organization| organization.get("id"))
496 .and_then(serde_json::Value::as_str)
497 })
498 .map(str::to_owned)
499}
500
501pub fn refresh_credentials(
503 credentials: &CodexCredentials,
504 token_endpoint: &str,
505 client_id: &str,
506) -> Result<CodexCredentials, AuthError> {
507 let response = Client::builder()
508 .timeout(Duration::from_secs(30))
509 .build()
510 .map_err(|_| AuthError::new("unable to initialize OAuth HTTP client"))?
511 .post(token_endpoint)
512 .form(&[
513 ("grant_type", "refresh_token"),
514 ("client_id", client_id),
515 ("refresh_token", credentials.refresh.as_str()),
516 ])
517 .send()
518 .map_err(|_| AuthError::new("OAuth token refresh failed"))?;
519 if !response.status().is_success() {
520 return Err(AuthError::new("OAuth token refresh failed"));
521 }
522 let mut bytes = Vec::new();
523 response
524 .take((MAX_TOKEN_RESPONSE_BYTES + 1) as u64)
525 .read_to_end(&mut bytes)
526 .map_err(|_| AuthError::new("OAuth token response could not be read"))?;
527 if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
528 return Err(AuthError::new(
529 "OAuth token response exceeded the response limit",
530 ));
531 }
532 let payload: RefreshResponse = serde_json::from_slice(&bytes)
533 .map_err(|_| AuthError::new("OAuth token response was invalid"))?;
534 let access = non_empty(payload.access_token)
535 .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
536 Ok(CodexCredentials {
537 access,
538 refresh: non_empty(payload.refresh_token).unwrap_or_else(|| credentials.refresh.clone()),
539 expires_at: payload
540 .expires_in
541 .map(|seconds| now_seconds().saturating_add(seconds))
542 .or(credentials.expires_at),
543 account_id: non_empty(payload.account_id).unwrap_or_else(|| credentials.account_id.clone()),
544 })
545}
546
547#[derive(Debug, Deserialize)]
548struct RefreshResponse {
549 access_token: Option<String>,
550 refresh_token: Option<String>,
551 expires_in: Option<i64>,
552 account_id: Option<String>,
553}
554
555fn non_empty(value: Option<String>) -> Option<String> {
556 value.filter(|value| !value.trim().is_empty())
557}
558
559fn random_url_value() -> Result<String, AuthError> {
560 let mut bytes = [0u8; 32];
561 getrandom::fill(&mut bytes).map_err(|_| AuthError::new("unable to initialize OAuth"))?;
562 Ok(URL_SAFE_NO_PAD.encode(bytes))
563}
564
565fn open_browser(url: &str) -> bool {
566 #[cfg(target_os = "macos")]
567 let command = ("open", vec![url]);
568 #[cfg(target_os = "linux")]
569 let command = ("xdg-open", vec![url]);
570 #[cfg(target_os = "windows")]
571 let command = ("cmd", vec!["/C", "start", "", url]);
572 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
573 let command: (&str, Vec<&str>) = ("", Vec::new());
574
575 !command.0.is_empty() && Command::new(command.0).args(command.1).spawn().is_ok()
576}
577
578fn now_seconds() -> i64 {
579 SystemTime::now()
580 .duration_since(UNIX_EPOCH)
581 .map(|duration| duration.as_secs() as i64)
582 .unwrap_or(0)
583}
584
585fn reject_symlink(path: &Path) -> io::Result<()> {
586 match fs::symlink_metadata(path) {
587 Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::other("symlink")),
588 Ok(_) => Ok(()),
589 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
590 Err(error) => Err(error),
591 }
592}
593
594fn ensure_private_directory(path: &Path) -> Result<(), AuthError> {
595 ensure_directory(path).map_err(|_| AuthError::new("unable to secure credentials directory"))?;
596 #[cfg(unix)]
597 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
598 .map_err(|_| AuthError::new("unable to secure credentials directory"))?;
599 Ok(())
600}
601
602fn ensure_directory(path: &Path) -> io::Result<()> {
603 reject_symlink(path)?;
604 if !path.exists() {
605 fs::create_dir_all(path)?;
606 }
607 let metadata = fs::symlink_metadata(path)?;
608 if !metadata.is_dir() || metadata.file_type().is_symlink() {
609 return Err(io::Error::other("not a directory"));
610 }
611 Ok(())
612}
613
614fn ensure_mode(path: &Path) -> io::Result<()> {
615 reject_symlink(path)?;
616 let metadata = fs::symlink_metadata(path)?;
617 if !metadata.is_file() {
618 return Err(io::Error::other("not a file"));
619 }
620 #[cfg(unix)]
621 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
622 Ok(())
623}
624
625#[cfg(unix)]
626use std::os::unix::fs::PermissionsExt;
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use std::ffi::OsStr;
632 use std::io::{Read, Write};
633 use std::net::TcpListener;
634 use std::thread;
635
636 #[test]
637 fn pkce_uses_s256_without_padding() {
638 let pkce = generate_pkce().expect("pkce");
639 assert!((43..=128).contains(&pkce.verifier.len()));
640 assert!(!pkce.challenge.contains('='));
641 let digest = Sha256::digest(pkce.verifier.as_bytes());
642 assert_eq!(pkce.challenge, URL_SAFE_NO_PAD.encode(digest));
643 }
644
645 #[test]
646 fn authorize_url_matches_the_codex_loopback_contract() {
647 let pkce = PkceChallenge {
648 verifier: "verifier".to_owned(),
649 challenge: "challenge".to_owned(),
650 };
651 let url = build_authorize_url(
652 &OAuthEndpoints::default(),
653 "http://localhost:1455/auth/callback",
654 &pkce,
655 "state",
656 )
657 .expect("authorize URL");
658 let parsed = reqwest::Url::parse(&url).expect("URL");
659 assert_eq!(
660 parsed
661 .query_pairs()
662 .find(|(key, _)| key == "redirect_uri")
663 .map(|(_, value)| value.into_owned()),
664 Some("http://localhost:1455/auth/callback".to_owned())
665 );
666 assert_eq!(
667 parsed
668 .query_pairs()
669 .find(|(key, _)| key == "originator")
670 .map(|(_, value)| value.into_owned()),
671 Some("lucy".to_owned())
672 );
673 }
674
675 #[test]
676 fn credential_path_prefers_data_then_config_and_rejects_relative_xdg() {
677 assert_eq!(
678 credential_path_from_xdg(
679 Path::new("/home/test"),
680 Some(OsStr::new("/tmp/data")),
681 Some(OsStr::new("/tmp/config"))
682 ),
683 PathBuf::from("/tmp/data/lucy/codex-credentials.json")
684 );
685 assert_eq!(
686 credential_path_from_xdg(Path::new("/home/test"), None, Some(OsStr::new("relative"))),
687 PathBuf::from("/home/test/.config/lucy/codex-credentials.json")
688 );
689 }
690
691 #[test]
692 fn store_is_private_and_round_trips_without_secret_in_error() {
693 let directory = std::env::temp_dir().join(format!(
694 "lucy-auth-{}",
695 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
696 ));
697 let path = directory.join("credentials.json");
698 let store = AuthStore::new(path.clone());
699 let credentials = CodexCredentials {
700 access: "access-secret".to_owned(),
701 refresh: "refresh-secret".to_owned(),
702 expires_at: Some(10),
703 account_id: "account".to_owned(),
704 };
705 store.save(&credentials).expect("save");
706 assert_eq!(store.load().expect("load"), Some(credentials));
707 #[cfg(unix)]
708 assert_eq!(
709 fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
710 0o600
711 );
712 store.logout().expect("logout");
713 assert_eq!(store.load().expect("missing"), None);
714 let _ = fs::remove_dir_all(directory);
715 }
716
717 #[test]
718 fn token_exchange_transport_error_preserves_the_cause() {
719 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
720 let address = listener.local_addr().expect("address");
721 drop(listener);
722 let endpoints = OAuthEndpoints {
723 token: format!("http://{address}"),
724 ..OAuthEndpoints::default()
725 };
726
727 let error = exchange_code(&endpoints, "http://localhost/callback", "verifier", "code")
728 .expect_err("connection failure");
729 let message = error.to_string();
730 assert!(message.starts_with("OAuth token exchange connection error:"));
731 assert!(message.contains("error sending request"));
732 }
733
734 #[test]
735 fn token_exchange_http_error_includes_status() {
736 let address = serve_token_response(403, r#"{"error":"access_denied"}"#);
737 let endpoints = OAuthEndpoints {
738 token: format!("http://{address}"),
739 ..OAuthEndpoints::default()
740 };
741
742 let error = exchange_code(&endpoints, "http://localhost/callback", "verifier", "code")
743 .expect_err("HTTP failure");
744 assert_eq!(
745 error.to_string(),
746 "OAuth token endpoint returned HTTP status 403"
747 );
748 }
749
750 #[test]
751 fn token_exchange_parse_error_is_distinct_from_transport_and_http_errors() {
752 let address = serve_token_response(200, "not JSON");
753 let endpoints = OAuthEndpoints {
754 token: format!("http://{address}"),
755 ..OAuthEndpoints::default()
756 };
757
758 let error = exchange_code(&endpoints, "http://localhost/callback", "verifier", "code")
759 .expect_err("parse failure");
760 assert_eq!(error.to_string(), "OAuth token response was invalid");
761 }
762
763 fn serve_token_response(status: u16, body: &'static str) -> std::net::SocketAddr {
764 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
765 let address = listener.local_addr().expect("address");
766 thread::spawn(move || {
767 let (mut stream, _) = listener.accept().expect("accept");
768 let mut request = [0u8; 4096];
769 let _ = stream.read(&mut request);
770 write!(
771 stream,
772 "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
773 body.len()
774 )
775 .expect("response");
776 });
777 address
778 }
779
780 #[test]
781 fn refresh_keeps_rotated_tokens_and_account_metadata() {
782 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
783 let address = listener.local_addr().expect("address");
784 let thread = thread::spawn(move || {
785 let (mut stream, _) = listener.accept().expect("accept");
786 let mut request = [0u8; 4096];
787 let _ = stream.read(&mut request);
788 let body = r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"account_id":"account-2"}"#;
789 write!(
790 stream,
791 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
792 body.len(), body
793 )
794 .expect("response");
795 });
796 let credentials = CodexCredentials {
797 access: "old-access".to_owned(),
798 refresh: "old-refresh".to_owned(),
799 expires_at: Some(1),
800 account_id: "account-1".to_owned(),
801 };
802 let refreshed = refresh_credentials(&credentials, &format!("http://{address}"), "client")
803 .expect("refresh");
804 thread.join().expect("server");
805 assert_eq!(refreshed.access, "new-access");
806 assert_eq!(refreshed.refresh, "new-refresh");
807 assert_eq!(refreshed.account_id, "account-2");
808 assert!(refreshed.expires_at.unwrap_or_default() > credentials.expires_at.unwrap());
809 }
810
811 #[test]
812 fn store_rejects_unsafe_access_or_refresh_tokens_before_writing() {
813 let directory = std::env::temp_dir().join(format!(
814 "lucy-auth-unsafe-{}",
815 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
816 ));
817 let path = directory.join("credentials.json");
818 let store = AuthStore::new(path.clone());
819 for (access, refresh) in [("123", "refresh"), ("access", "refresh\"token")] {
820 let credentials = CodexCredentials {
821 access: access.to_owned(),
822 refresh: refresh.to_owned(),
823 expires_at: Some(10),
824 account_id: "account".to_owned(),
825 };
826 assert!(store.save(&credentials).is_err());
827 assert!(!path.exists());
828 }
829 let _ = fs::remove_dir_all(directory);
830 }
831
832 #[test]
833 fn expiry_window_is_five_minutes() {
834 let credentials = CodexCredentials {
835 access: "a".to_owned(),
836 refresh: "r".to_owned(),
837 expires_at: Some(1_000),
838 account_id: "id".to_owned(),
839 };
840 assert!(credentials.near_expiry(700));
841 assert!(!credentials.near_expiry(699));
842 }
843}