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(|_| AuthError::new("OAuth token exchange failed"))?;
402 parse_token_response(response)
403}
404
405fn parse_token_response(
406 response: reqwest::blocking::Response,
407) -> Result<CodexCredentials, AuthError> {
408 if !response.status().is_success() {
409 return Err(AuthError::new("OAuth token exchange failed"));
410 }
411 let mut bytes = Vec::new();
412 response
413 .take((MAX_TOKEN_RESPONSE_BYTES + 1) as u64)
414 .read_to_end(&mut bytes)
415 .map_err(|_| AuthError::new("OAuth token response could not be read"))?;
416 if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
417 return Err(AuthError::new(
418 "OAuth token response exceeded the response limit",
419 ));
420 }
421 let payload: TokenResponse = serde_json::from_slice(&bytes)
422 .map_err(|_| AuthError::new("OAuth token response was invalid"))?;
423 let access = non_empty(payload.access_token)
424 .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
425 let refresh = non_empty(payload.refresh_token)
426 .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
427 let account_id = payload
428 .account_id
429 .or(payload.chatgpt_account_id)
430 .or_else(|| payload.id_token.as_deref().and_then(account_id_from_jwt))
431 .and_then(|value| non_empty(Some(value)))
432 .ok_or_else(|| AuthError::new("OAuth token response did not contain an account"))?;
433 let expires_at = payload
434 .expires_in
435 .map(|seconds| now_seconds().saturating_add(seconds));
436 Ok(CodexCredentials {
437 access,
438 refresh,
439 expires_at,
440 account_id,
441 })
442}
443
444#[derive(Debug, Deserialize)]
445struct TokenResponse {
446 access_token: Option<String>,
447 refresh_token: Option<String>,
448 expires_in: Option<i64>,
449 account_id: Option<String>,
450 chatgpt_account_id: Option<String>,
451 id_token: Option<String>,
452}
453
454fn account_id_from_jwt(jwt: &str) -> Option<String> {
455 let payload = jwt.split('.').nth(1)?;
456 let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
457 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
458 value
459 .get("https://api.openai.com/auth")
460 .and_then(|auth| auth.get("chatgpt_account_id"))
461 .and_then(serde_json::Value::as_str)
462 .or_else(|| {
463 value
464 .get("chatgpt_account_id")
465 .and_then(serde_json::Value::as_str)
466 })
467 .or_else(|| {
468 value
469 .get("organizations")
470 .and_then(serde_json::Value::as_array)
471 .and_then(|organizations| organizations.first())
472 .and_then(|organization| organization.get("id"))
473 .and_then(serde_json::Value::as_str)
474 })
475 .map(str::to_owned)
476}
477
478pub fn refresh_credentials(
480 credentials: &CodexCredentials,
481 token_endpoint: &str,
482 client_id: &str,
483) -> Result<CodexCredentials, AuthError> {
484 let response = Client::builder()
485 .timeout(Duration::from_secs(30))
486 .build()
487 .map_err(|_| AuthError::new("unable to initialize OAuth HTTP client"))?
488 .post(token_endpoint)
489 .form(&[
490 ("grant_type", "refresh_token"),
491 ("client_id", client_id),
492 ("refresh_token", credentials.refresh.as_str()),
493 ])
494 .send()
495 .map_err(|_| AuthError::new("OAuth token refresh failed"))?;
496 if !response.status().is_success() {
497 return Err(AuthError::new("OAuth token refresh failed"));
498 }
499 let mut bytes = Vec::new();
500 response
501 .take((MAX_TOKEN_RESPONSE_BYTES + 1) as u64)
502 .read_to_end(&mut bytes)
503 .map_err(|_| AuthError::new("OAuth token response could not be read"))?;
504 if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
505 return Err(AuthError::new(
506 "OAuth token response exceeded the response limit",
507 ));
508 }
509 let payload: RefreshResponse = serde_json::from_slice(&bytes)
510 .map_err(|_| AuthError::new("OAuth token response was invalid"))?;
511 let access = non_empty(payload.access_token)
512 .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
513 Ok(CodexCredentials {
514 access,
515 refresh: non_empty(payload.refresh_token).unwrap_or_else(|| credentials.refresh.clone()),
516 expires_at: payload
517 .expires_in
518 .map(|seconds| now_seconds().saturating_add(seconds))
519 .or(credentials.expires_at),
520 account_id: non_empty(payload.account_id).unwrap_or_else(|| credentials.account_id.clone()),
521 })
522}
523
524#[derive(Debug, Deserialize)]
525struct RefreshResponse {
526 access_token: Option<String>,
527 refresh_token: Option<String>,
528 expires_in: Option<i64>,
529 account_id: Option<String>,
530}
531
532fn non_empty(value: Option<String>) -> Option<String> {
533 value.filter(|value| !value.trim().is_empty())
534}
535
536fn random_url_value() -> Result<String, AuthError> {
537 let mut bytes = [0u8; 32];
538 getrandom::fill(&mut bytes).map_err(|_| AuthError::new("unable to initialize OAuth"))?;
539 Ok(URL_SAFE_NO_PAD.encode(bytes))
540}
541
542fn open_browser(url: &str) -> bool {
543 #[cfg(target_os = "macos")]
544 let command = ("open", vec![url]);
545 #[cfg(target_os = "linux")]
546 let command = ("xdg-open", vec![url]);
547 #[cfg(target_os = "windows")]
548 let command = ("cmd", vec!["/C", "start", "", url]);
549 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
550 let command: (&str, Vec<&str>) = ("", Vec::new());
551
552 !command.0.is_empty() && Command::new(command.0).args(command.1).spawn().is_ok()
553}
554
555fn now_seconds() -> i64 {
556 SystemTime::now()
557 .duration_since(UNIX_EPOCH)
558 .map(|duration| duration.as_secs() as i64)
559 .unwrap_or(0)
560}
561
562fn reject_symlink(path: &Path) -> io::Result<()> {
563 match fs::symlink_metadata(path) {
564 Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::other("symlink")),
565 Ok(_) => Ok(()),
566 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
567 Err(error) => Err(error),
568 }
569}
570
571fn ensure_private_directory(path: &Path) -> Result<(), AuthError> {
572 ensure_directory(path).map_err(|_| AuthError::new("unable to secure credentials directory"))?;
573 #[cfg(unix)]
574 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
575 .map_err(|_| AuthError::new("unable to secure credentials directory"))?;
576 Ok(())
577}
578
579fn ensure_directory(path: &Path) -> io::Result<()> {
580 reject_symlink(path)?;
581 if !path.exists() {
582 fs::create_dir_all(path)?;
583 }
584 let metadata = fs::symlink_metadata(path)?;
585 if !metadata.is_dir() || metadata.file_type().is_symlink() {
586 return Err(io::Error::other("not a directory"));
587 }
588 Ok(())
589}
590
591fn ensure_mode(path: &Path) -> io::Result<()> {
592 reject_symlink(path)?;
593 let metadata = fs::symlink_metadata(path)?;
594 if !metadata.is_file() {
595 return Err(io::Error::other("not a file"));
596 }
597 #[cfg(unix)]
598 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
599 Ok(())
600}
601
602#[cfg(unix)]
603use std::os::unix::fs::PermissionsExt;
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use std::ffi::OsStr;
609 use std::io::{Read, Write};
610 use std::net::TcpListener;
611 use std::thread;
612
613 #[test]
614 fn pkce_uses_s256_without_padding() {
615 let pkce = generate_pkce().expect("pkce");
616 assert!((43..=128).contains(&pkce.verifier.len()));
617 assert!(!pkce.challenge.contains('='));
618 let digest = Sha256::digest(pkce.verifier.as_bytes());
619 assert_eq!(pkce.challenge, URL_SAFE_NO_PAD.encode(digest));
620 }
621
622 #[test]
623 fn authorize_url_matches_the_codex_loopback_contract() {
624 let pkce = PkceChallenge {
625 verifier: "verifier".to_owned(),
626 challenge: "challenge".to_owned(),
627 };
628 let url = build_authorize_url(
629 &OAuthEndpoints::default(),
630 "http://localhost:1455/auth/callback",
631 &pkce,
632 "state",
633 )
634 .expect("authorize URL");
635 let parsed = reqwest::Url::parse(&url).expect("URL");
636 assert_eq!(
637 parsed
638 .query_pairs()
639 .find(|(key, _)| key == "redirect_uri")
640 .map(|(_, value)| value.into_owned()),
641 Some("http://localhost:1455/auth/callback".to_owned())
642 );
643 assert_eq!(
644 parsed
645 .query_pairs()
646 .find(|(key, _)| key == "originator")
647 .map(|(_, value)| value.into_owned()),
648 Some("lucy".to_owned())
649 );
650 }
651
652 #[test]
653 fn credential_path_prefers_data_then_config_and_rejects_relative_xdg() {
654 assert_eq!(
655 credential_path_from_xdg(
656 Path::new("/home/test"),
657 Some(OsStr::new("/tmp/data")),
658 Some(OsStr::new("/tmp/config"))
659 ),
660 PathBuf::from("/tmp/data/lucy/codex-credentials.json")
661 );
662 assert_eq!(
663 credential_path_from_xdg(Path::new("/home/test"), None, Some(OsStr::new("relative"))),
664 PathBuf::from("/home/test/.config/lucy/codex-credentials.json")
665 );
666 }
667
668 #[test]
669 fn store_is_private_and_round_trips_without_secret_in_error() {
670 let directory = std::env::temp_dir().join(format!(
671 "lucy-auth-{}",
672 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
673 ));
674 let path = directory.join("credentials.json");
675 let store = AuthStore::new(path.clone());
676 let credentials = CodexCredentials {
677 access: "access-secret".to_owned(),
678 refresh: "refresh-secret".to_owned(),
679 expires_at: Some(10),
680 account_id: "account".to_owned(),
681 };
682 store.save(&credentials).expect("save");
683 assert_eq!(store.load().expect("load"), Some(credentials));
684 #[cfg(unix)]
685 assert_eq!(
686 fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
687 0o600
688 );
689 store.logout().expect("logout");
690 assert_eq!(store.load().expect("missing"), None);
691 let _ = fs::remove_dir_all(directory);
692 }
693
694 #[test]
695 fn refresh_keeps_rotated_tokens_and_account_metadata() {
696 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
697 let address = listener.local_addr().expect("address");
698 let thread = thread::spawn(move || {
699 let (mut stream, _) = listener.accept().expect("accept");
700 let mut request = [0u8; 4096];
701 let _ = stream.read(&mut request);
702 let body = r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"account_id":"account-2"}"#;
703 write!(
704 stream,
705 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
706 body.len(), body
707 )
708 .expect("response");
709 });
710 let credentials = CodexCredentials {
711 access: "old-access".to_owned(),
712 refresh: "old-refresh".to_owned(),
713 expires_at: Some(1),
714 account_id: "account-1".to_owned(),
715 };
716 let refreshed = refresh_credentials(&credentials, &format!("http://{address}"), "client")
717 .expect("refresh");
718 thread.join().expect("server");
719 assert_eq!(refreshed.access, "new-access");
720 assert_eq!(refreshed.refresh, "new-refresh");
721 assert_eq!(refreshed.account_id, "account-2");
722 assert!(refreshed.expires_at.unwrap_or_default() > credentials.expires_at.unwrap());
723 }
724
725 #[test]
726 fn store_rejects_unsafe_access_or_refresh_tokens_before_writing() {
727 let directory = std::env::temp_dir().join(format!(
728 "lucy-auth-unsafe-{}",
729 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
730 ));
731 let path = directory.join("credentials.json");
732 let store = AuthStore::new(path.clone());
733 for (access, refresh) in [("123", "refresh"), ("access", "refresh\"token")] {
734 let credentials = CodexCredentials {
735 access: access.to_owned(),
736 refresh: refresh.to_owned(),
737 expires_at: Some(10),
738 account_id: "account".to_owned(),
739 };
740 assert!(store.save(&credentials).is_err());
741 assert!(!path.exists());
742 }
743 let _ = fs::remove_dir_all(directory);
744 }
745
746 #[test]
747 fn expiry_window_is_five_minutes() {
748 let credentials = CodexCredentials {
749 access: "a".to_owned(),
750 refresh: "r".to_owned(),
751 expires_at: Some(1_000),
752 account_id: "id".to_owned(),
753 };
754 assert!(credentials.near_expiry(700));
755 assert!(!credentials.near_expiry(699));
756 }
757}