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