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