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;
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/// OAuth material persisted by Lucy. The JSON names are intentionally short because this file is
58/// user-managed state, while aliases let a future migration read conventional token names.
59#[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
76/// Resolve Lucy's credential path without assuming that either XDG variable is set.
77///
78/// Data storage wins when both XDG locations are available. The config location is retained as a
79/// fallback so installations that deliberately keep all Lucy state under XDG_CONFIG_HOME remain
80/// supported.
81pub 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/// A private, symlink-safe JSON credential store.
124#[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
251/// Perform the browser authorization-code flow and persist the returned credentials.
252pub fn login(home: &Path) -> Result<CodexCredentials, AuthError> {
253    login_with_endpoints(home, &OAuthEndpoints::default())
254}
255
256pub fn login_with_endpoints(
257    home: &Path,
258    endpoints: &OAuthEndpoints,
259) -> Result<CodexCredentials, AuthError> {
260    let pkce = generate_pkce()?;
261    let state = random_url_value()?;
262    let listener = TcpListener::bind((CALLBACK_HOST, CALLBACK_PORT))
263        .map_err(|_| AuthError::new("unable to bind OAuth callback on 127.0.0.1:1455"))?;
264    let redirect_uri = format!("http://{CALLBACK_REDIRECT_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}");
265    let authorize_url = build_authorize_url(endpoints, &redirect_uri, &pkce, &state)?;
266    if !open_browser(&authorize_url) {
267        eprintln!("Open this URL in your browser to sign in with Codex:\n{authorize_url}");
268    }
269
270    let (code, callback_error) = receive_callback(&listener, &state)?;
271    if let Some(error) = callback_error {
272        return Err(error);
273    }
274    let code = code.ok_or_else(|| AuthError::new("OAuth callback did not contain a code"))?;
275    let credentials = exchange_code(endpoints, &redirect_uri, &pkce.verifier, &code)?;
276    AuthStore::for_home(home).save(&credentials)?;
277    Ok(credentials)
278}
279
280fn build_authorize_url(
281    endpoints: &OAuthEndpoints,
282    redirect_uri: &str,
283    pkce: &PkceChallenge,
284    state: &str,
285) -> Result<String, AuthError> {
286    let mut url = reqwest::Url::parse(&endpoints.authorize)
287        .map_err(|_| AuthError::new("invalid OAuth authorize endpoint"))?;
288    url.query_pairs_mut()
289        .append_pair("response_type", "code")
290        .append_pair("client_id", &endpoints.client_id)
291        .append_pair("redirect_uri", redirect_uri)
292        .append_pair(
293            "scope",
294            "openid profile email offline_access api.connectors.read api.connectors.invoke",
295        )
296        .append_pair("code_challenge", &pkce.challenge)
297        .append_pair("code_challenge_method", "S256")
298        .append_pair("state", state)
299        .append_pair("id_token_add_organizations", "true")
300        .append_pair("codex_cli_simplified_flow", "true")
301        .append_pair("originator", "lucy");
302    Ok(url.to_string())
303}
304
305fn receive_callback(
306    listener: &TcpListener,
307    expected_state: &str,
308) -> Result<(Option<String>, Option<AuthError>), AuthError> {
309    for stream in listener.incoming() {
310        let mut stream = match stream {
311            Ok(stream) => stream,
312            Err(_) => return Err(AuthError::new("OAuth callback server failed")),
313        };
314        let request = read_http_request(&mut stream)?;
315        let target = request
316            .strip_prefix("GET ")
317            .and_then(|request| request.split_whitespace().next())
318            .ok_or_else(|| AuthError::new("OAuth callback request was invalid"))?;
319        let url = reqwest::Url::parse(&format!("http://localhost{target}"))
320            .map_err(|_| AuthError::new("OAuth callback request was invalid"))?;
321        if url.path() != CALLBACK_PATH {
322            write_callback(&mut stream, 404, "Not found")?;
323            continue;
324        }
325        let query: std::collections::HashMap<String, String> =
326            url.query_pairs().into_owned().collect();
327        let state_valid = query.get("state").map(String::as_str) == Some(expected_state);
328        if !state_valid {
329            write_callback(&mut stream, 400, "Authentication state was rejected.")?;
330            continue;
331        }
332        if query.contains_key("error") {
333            write_callback(&mut stream, 400, "Authentication was not completed.")?;
334            return Ok((None, Some(AuthError::new("OAuth authorization was denied"))));
335        }
336        let code = query
337            .get("code")
338            .filter(|code| !code.is_empty())
339            .cloned()
340            .ok_or_else(|| AuthError::new("OAuth callback did not contain a code"))?;
341        write_callback(
342            &mut stream,
343            200,
344            "Authentication complete. You may close this window.",
345        )?;
346        return Ok((Some(code), None));
347    }
348    Err(AuthError::new("OAuth callback server stopped"))
349}
350
351fn read_http_request(stream: &mut TcpStream) -> Result<String, AuthError> {
352    stream
353        .set_read_timeout(Some(Duration::from_secs(120)))
354        .map_err(|_| AuthError::new("OAuth callback server failed"))?;
355    let mut bytes = Vec::new();
356    let mut chunk = [0u8; 1024];
357    while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
358        let count = stream
359            .read(&mut chunk)
360            .map_err(|_| AuthError::new("OAuth callback request could not be read"))?;
361        if count == 0 {
362            break;
363        }
364        bytes.extend_from_slice(&chunk[..count]);
365        if bytes.len() > MAX_CALLBACK_REQUEST_BYTES {
366            return Err(AuthError::new("OAuth callback request was too large"));
367        }
368    }
369    String::from_utf8(bytes).map_err(|_| AuthError::new("OAuth callback request was invalid"))
370}
371
372fn write_callback(stream: &mut TcpStream, status: u16, body: &str) -> Result<(), AuthError> {
373    let response = format!(
374        "HTTP/1.1 {status} OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
375        body.len()
376    );
377    stream
378        .write_all(response.as_bytes())
379        .map_err(|_| AuthError::new("OAuth callback response failed"))
380}
381
382fn exchange_code(
383    endpoints: &OAuthEndpoints,
384    redirect_uri: &str,
385    verifier: &str,
386    code: &str,
387) -> Result<CodexCredentials, AuthError> {
388    let response = Client::builder()
389        .timeout(Duration::from_secs(30))
390        .build()
391        .map_err(|_| AuthError::new("unable to initialize OAuth HTTP client"))?
392        .post(&endpoints.token)
393        .form(&[
394            ("grant_type", "authorization_code"),
395            ("client_id", endpoints.client_id.as_str()),
396            ("code", code),
397            ("redirect_uri", redirect_uri),
398            ("code_verifier", verifier),
399        ])
400        .send()
401        .map_err(oauth_transport_error)?;
402    parse_token_response(response)
403}
404
405fn oauth_transport_error(error: reqwest::Error) -> AuthError {
406    let kind = if error.is_timeout() {
407        "timeout"
408    } else if error.is_connect() {
409        "connection"
410    } else if error.is_request() {
411        "request"
412    } else {
413        "transport"
414    };
415    let mut details = error.to_string();
416    let mut source = std::error::Error::source(&error);
417    while let Some(error) = source {
418        details.push_str(": ");
419        details.push_str(&error.to_string());
420        source = error.source();
421    }
422    AuthError::new(format!("OAuth token exchange {kind} error: {details}"))
423}
424
425fn parse_token_response(
426    response: reqwest::blocking::Response,
427) -> Result<CodexCredentials, AuthError> {
428    if !response.status().is_success() {
429        return Err(AuthError::new(format!(
430            "OAuth token endpoint returned HTTP status {}",
431            response.status().as_u16()
432        )));
433    }
434    let mut bytes = Vec::new();
435    response
436        .take((MAX_TOKEN_RESPONSE_BYTES + 1) as u64)
437        .read_to_end(&mut bytes)
438        .map_err(|_| AuthError::new("OAuth token response could not be read"))?;
439    if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
440        return Err(AuthError::new(
441            "OAuth token response exceeded the response limit",
442        ));
443    }
444    let payload: TokenResponse = serde_json::from_slice(&bytes)
445        .map_err(|_| AuthError::new("OAuth token response was invalid"))?;
446    let access = non_empty(payload.access_token)
447        .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
448    let refresh = non_empty(payload.refresh_token)
449        .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
450    let account_id = payload
451        .account_id
452        .or(payload.chatgpt_account_id)
453        .or_else(|| payload.id_token.as_deref().and_then(account_id_from_jwt))
454        .and_then(|value| non_empty(Some(value)))
455        .ok_or_else(|| AuthError::new("OAuth token response did not contain an account"))?;
456    let expires_at = payload
457        .expires_in
458        .map(|seconds| now_seconds().saturating_add(seconds));
459    Ok(CodexCredentials {
460        access,
461        refresh,
462        expires_at,
463        account_id,
464    })
465}
466
467#[derive(Debug, Deserialize)]
468struct TokenResponse {
469    access_token: Option<String>,
470    refresh_token: Option<String>,
471    expires_in: Option<i64>,
472    account_id: Option<String>,
473    chatgpt_account_id: Option<String>,
474    id_token: Option<String>,
475}
476
477fn account_id_from_jwt(jwt: &str) -> Option<String> {
478    let payload = jwt.split('.').nth(1)?;
479    let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
480    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
481    value
482        .get("https://api.openai.com/auth")
483        .and_then(|auth| auth.get("chatgpt_account_id"))
484        .and_then(serde_json::Value::as_str)
485        .or_else(|| {
486            value
487                .get("chatgpt_account_id")
488                .and_then(serde_json::Value::as_str)
489        })
490        .or_else(|| {
491            value
492                .get("organizations")
493                .and_then(serde_json::Value::as_array)
494                .and_then(|organizations| organizations.first())
495                .and_then(|organization| organization.get("id"))
496                .and_then(serde_json::Value::as_str)
497        })
498        .map(str::to_owned)
499}
500
501/// Refresh a credential set, retaining a rotated refresh token when the authority returns one.
502pub fn refresh_credentials(
503    credentials: &CodexCredentials,
504    token_endpoint: &str,
505    client_id: &str,
506) -> Result<CodexCredentials, AuthError> {
507    let response = Client::builder()
508        .timeout(Duration::from_secs(30))
509        .build()
510        .map_err(|_| AuthError::new("unable to initialize OAuth HTTP client"))?
511        .post(token_endpoint)
512        .form(&[
513            ("grant_type", "refresh_token"),
514            ("client_id", client_id),
515            ("refresh_token", credentials.refresh.as_str()),
516        ])
517        .send()
518        .map_err(|_| AuthError::new("OAuth token refresh failed"))?;
519    if !response.status().is_success() {
520        return Err(AuthError::new("OAuth token refresh failed"));
521    }
522    let mut bytes = Vec::new();
523    response
524        .take((MAX_TOKEN_RESPONSE_BYTES + 1) as u64)
525        .read_to_end(&mut bytes)
526        .map_err(|_| AuthError::new("OAuth token response could not be read"))?;
527    if bytes.len() > MAX_TOKEN_RESPONSE_BYTES {
528        return Err(AuthError::new(
529            "OAuth token response exceeded the response limit",
530        ));
531    }
532    let payload: RefreshResponse = serde_json::from_slice(&bytes)
533        .map_err(|_| AuthError::new("OAuth token response was invalid"))?;
534    let access = non_empty(payload.access_token)
535        .ok_or_else(|| AuthError::new("OAuth token response was incomplete"))?;
536    Ok(CodexCredentials {
537        access,
538        refresh: non_empty(payload.refresh_token).unwrap_or_else(|| credentials.refresh.clone()),
539        expires_at: payload
540            .expires_in
541            .map(|seconds| now_seconds().saturating_add(seconds))
542            .or(credentials.expires_at),
543        account_id: non_empty(payload.account_id).unwrap_or_else(|| credentials.account_id.clone()),
544    })
545}
546
547#[derive(Debug, Deserialize)]
548struct RefreshResponse {
549    access_token: Option<String>,
550    refresh_token: Option<String>,
551    expires_in: Option<i64>,
552    account_id: Option<String>,
553}
554
555fn non_empty(value: Option<String>) -> Option<String> {
556    value.filter(|value| !value.trim().is_empty())
557}
558
559fn random_url_value() -> Result<String, AuthError> {
560    let mut bytes = [0u8; 32];
561    getrandom::fill(&mut bytes).map_err(|_| AuthError::new("unable to initialize OAuth"))?;
562    Ok(URL_SAFE_NO_PAD.encode(bytes))
563}
564
565fn open_browser(url: &str) -> bool {
566    #[cfg(target_os = "macos")]
567    let command = ("open", vec![url]);
568    #[cfg(target_os = "linux")]
569    let command = ("xdg-open", vec![url]);
570    #[cfg(target_os = "windows")]
571    let command = ("cmd", vec!["/C", "start", "", url]);
572    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
573    let command: (&str, Vec<&str>) = ("", Vec::new());
574
575    !command.0.is_empty() && Command::new(command.0).args(command.1).spawn().is_ok()
576}
577
578fn now_seconds() -> i64 {
579    SystemTime::now()
580        .duration_since(UNIX_EPOCH)
581        .map(|duration| duration.as_secs() as i64)
582        .unwrap_or(0)
583}
584
585fn reject_symlink(path: &Path) -> io::Result<()> {
586    match fs::symlink_metadata(path) {
587        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::other("symlink")),
588        Ok(_) => Ok(()),
589        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
590        Err(error) => Err(error),
591    }
592}
593
594fn ensure_private_directory(path: &Path) -> Result<(), AuthError> {
595    ensure_directory(path).map_err(|_| AuthError::new("unable to secure credentials directory"))?;
596    #[cfg(unix)]
597    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
598        .map_err(|_| AuthError::new("unable to secure credentials directory"))?;
599    Ok(())
600}
601
602fn ensure_directory(path: &Path) -> io::Result<()> {
603    reject_symlink(path)?;
604    if !path.exists() {
605        fs::create_dir_all(path)?;
606    }
607    let metadata = fs::symlink_metadata(path)?;
608    if !metadata.is_dir() || metadata.file_type().is_symlink() {
609        return Err(io::Error::other("not a directory"));
610    }
611    Ok(())
612}
613
614fn ensure_mode(path: &Path) -> io::Result<()> {
615    reject_symlink(path)?;
616    let metadata = fs::symlink_metadata(path)?;
617    if !metadata.is_file() {
618        return Err(io::Error::other("not a file"));
619    }
620    #[cfg(unix)]
621    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
622    Ok(())
623}
624
625#[cfg(unix)]
626use std::os::unix::fs::PermissionsExt;
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use std::ffi::OsStr;
632    use std::io::{Read, Write};
633    use std::net::TcpListener;
634    use std::thread;
635
636    #[test]
637    fn pkce_uses_s256_without_padding() {
638        let pkce = generate_pkce().expect("pkce");
639        assert!((43..=128).contains(&pkce.verifier.len()));
640        assert!(!pkce.challenge.contains('='));
641        let digest = Sha256::digest(pkce.verifier.as_bytes());
642        assert_eq!(pkce.challenge, URL_SAFE_NO_PAD.encode(digest));
643    }
644
645    #[test]
646    fn authorize_url_matches_the_codex_loopback_contract() {
647        let pkce = PkceChallenge {
648            verifier: "verifier".to_owned(),
649            challenge: "challenge".to_owned(),
650        };
651        let url = build_authorize_url(
652            &OAuthEndpoints::default(),
653            "http://localhost:1455/auth/callback",
654            &pkce,
655            "state",
656        )
657        .expect("authorize URL");
658        let parsed = reqwest::Url::parse(&url).expect("URL");
659        assert_eq!(
660            parsed
661                .query_pairs()
662                .find(|(key, _)| key == "redirect_uri")
663                .map(|(_, value)| value.into_owned()),
664            Some("http://localhost:1455/auth/callback".to_owned())
665        );
666        assert_eq!(
667            parsed
668                .query_pairs()
669                .find(|(key, _)| key == "originator")
670                .map(|(_, value)| value.into_owned()),
671            Some("lucy".to_owned())
672        );
673    }
674
675    #[test]
676    fn credential_path_prefers_data_then_config_and_rejects_relative_xdg() {
677        assert_eq!(
678            credential_path_from_xdg(
679                Path::new("/home/test"),
680                Some(OsStr::new("/tmp/data")),
681                Some(OsStr::new("/tmp/config"))
682            ),
683            PathBuf::from("/tmp/data/lucy/codex-credentials.json")
684        );
685        assert_eq!(
686            credential_path_from_xdg(Path::new("/home/test"), None, Some(OsStr::new("relative"))),
687            PathBuf::from("/home/test/.config/lucy/codex-credentials.json")
688        );
689    }
690
691    #[test]
692    fn store_is_private_and_round_trips_without_secret_in_error() {
693        let directory = std::env::temp_dir().join(format!(
694            "lucy-auth-{}",
695            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
696        ));
697        let path = directory.join("credentials.json");
698        let store = AuthStore::new(path.clone());
699        let credentials = CodexCredentials {
700            access: "access-secret".to_owned(),
701            refresh: "refresh-secret".to_owned(),
702            expires_at: Some(10),
703            account_id: "account".to_owned(),
704        };
705        store.save(&credentials).expect("save");
706        assert_eq!(store.load().expect("load"), Some(credentials));
707        #[cfg(unix)]
708        assert_eq!(
709            fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
710            0o600
711        );
712        store.logout().expect("logout");
713        assert_eq!(store.load().expect("missing"), None);
714        let _ = fs::remove_dir_all(directory);
715    }
716
717    #[test]
718    fn token_exchange_transport_error_preserves_the_cause() {
719        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
720        let address = listener.local_addr().expect("address");
721        drop(listener);
722        let endpoints = OAuthEndpoints {
723            token: format!("http://{address}"),
724            ..OAuthEndpoints::default()
725        };
726
727        let error = exchange_code(&endpoints, "http://localhost/callback", "verifier", "code")
728            .expect_err("connection failure");
729        let message = error.to_string();
730        assert!(message.starts_with("OAuth token exchange connection error:"));
731        assert!(message.contains("error sending request"));
732    }
733
734    #[test]
735    fn token_exchange_http_error_includes_status() {
736        let address = serve_token_response(403, r#"{"error":"access_denied"}"#);
737        let endpoints = OAuthEndpoints {
738            token: format!("http://{address}"),
739            ..OAuthEndpoints::default()
740        };
741
742        let error = exchange_code(&endpoints, "http://localhost/callback", "verifier", "code")
743            .expect_err("HTTP failure");
744        assert_eq!(
745            error.to_string(),
746            "OAuth token endpoint returned HTTP status 403"
747        );
748    }
749
750    #[test]
751    fn token_exchange_parse_error_is_distinct_from_transport_and_http_errors() {
752        let address = serve_token_response(200, "not JSON");
753        let endpoints = OAuthEndpoints {
754            token: format!("http://{address}"),
755            ..OAuthEndpoints::default()
756        };
757
758        let error = exchange_code(&endpoints, "http://localhost/callback", "verifier", "code")
759            .expect_err("parse failure");
760        assert_eq!(error.to_string(), "OAuth token response was invalid");
761    }
762
763    fn serve_token_response(status: u16, body: &'static str) -> std::net::SocketAddr {
764        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
765        let address = listener.local_addr().expect("address");
766        thread::spawn(move || {
767            let (mut stream, _) = listener.accept().expect("accept");
768            let mut request = [0u8; 4096];
769            let _ = stream.read(&mut request);
770            write!(
771                stream,
772                "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
773                body.len()
774            )
775            .expect("response");
776        });
777        address
778    }
779
780    #[test]
781    fn refresh_keeps_rotated_tokens_and_account_metadata() {
782        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
783        let address = listener.local_addr().expect("address");
784        let thread = thread::spawn(move || {
785            let (mut stream, _) = listener.accept().expect("accept");
786            let mut request = [0u8; 4096];
787            let _ = stream.read(&mut request);
788            let body = r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"account_id":"account-2"}"#;
789            write!(
790                stream,
791                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
792                body.len(), body
793            )
794            .expect("response");
795        });
796        let credentials = CodexCredentials {
797            access: "old-access".to_owned(),
798            refresh: "old-refresh".to_owned(),
799            expires_at: Some(1),
800            account_id: "account-1".to_owned(),
801        };
802        let refreshed = refresh_credentials(&credentials, &format!("http://{address}"), "client")
803            .expect("refresh");
804        thread.join().expect("server");
805        assert_eq!(refreshed.access, "new-access");
806        assert_eq!(refreshed.refresh, "new-refresh");
807        assert_eq!(refreshed.account_id, "account-2");
808        assert!(refreshed.expires_at.unwrap_or_default() > credentials.expires_at.unwrap());
809    }
810
811    #[test]
812    fn store_rejects_unsafe_access_or_refresh_tokens_before_writing() {
813        let directory = std::env::temp_dir().join(format!(
814            "lucy-auth-unsafe-{}",
815            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
816        ));
817        let path = directory.join("credentials.json");
818        let store = AuthStore::new(path.clone());
819        for (access, refresh) in [("123", "refresh"), ("access", "refresh\"token")] {
820            let credentials = CodexCredentials {
821                access: access.to_owned(),
822                refresh: refresh.to_owned(),
823                expires_at: Some(10),
824                account_id: "account".to_owned(),
825            };
826            assert!(store.save(&credentials).is_err());
827            assert!(!path.exists());
828        }
829        let _ = fs::remove_dir_all(directory);
830    }
831
832    #[test]
833    fn expiry_window_is_five_minutes() {
834        let credentials = CodexCredentials {
835            access: "a".to_owned(),
836            refresh: "r".to_owned(),
837            expires_at: Some(1_000),
838            account_id: "id".to_owned(),
839        };
840        assert!(credentials.near_expiry(700));
841        assert!(!credentials.near_expiry(699));
842    }
843}