Skip to main content

lucy/
auth.rs

1//! Bounded browser authentication for the ChatGPT Codex subscription API.
2//!
3//! This module deliberately owns only the OAuth and credential-store boundary. It does not
4//! decide which provider a session uses.
5
6use 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/// OAuth material persisted by Lucy. The JSON names are intentionally short because this file is
60/// user-managed state, while aliases let a future migration read conventional token names.
61#[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
78/// Resolve Lucy's credential path without assuming that either XDG variable is set.
79///
80/// Data storage wins when both XDG locations are available. The config location is retained as a
81/// fallback so installations that deliberately keep all Lucy state under XDG_CONFIG_HOME remain
82/// supported.
83pub 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/// A private, symlink-safe JSON credential store.
126#[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
253/// Perform the browser authorization-code flow and persist the returned credentials.
254pub 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
503/// Refresh a credential set, retaining a rotated refresh token when the authority returns one.
504pub 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}