Skip to main content

codetether_agent/cli/
auth.rs

1//! Provider authentication commands.
2
3use super::{
4    AuthArgs, AuthCommand, CodexAuthArgs, CookieAuthArgs, CopilotAuthArgs, LoginAuthArgs,
5    RegisterAuthArgs,
6};
7use crate::provider::copilot::normalize_enterprise_domain;
8use crate::provider::openai_codex::{OAuthCredentials, OpenAiCodexProvider};
9use crate::secrets::{self, ProviderSecrets};
10use anyhow::{Context, Result};
11use reqwest::Client;
12use serde::Deserialize;
13use serde::de::{self, Deserializer};
14use serde_json::json;
15use std::collections::HashMap;
16use std::io::{self, Write};
17use std::path::PathBuf;
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::TcpListener;
20use tokio::time::{Duration, Instant, sleep};
21
22const DEFAULT_GITHUB_DOMAIN: &str = "github.com";
23const OAUTH_POLLING_SAFETY_MARGIN_MS: u64 = 3000;
24const CODEX_CALLBACK_ADDR_V4: &str = "127.0.0.1:1455";
25const CODEX_CALLBACK_ADDR_V6: &str = "[::1]:1455";
26const CODEX_CALLBACK_DISPLAY_ADDR: &str = "localhost:1455";
27const CODEX_CALLBACK_TIMEOUT_SECS: u64 = 300;
28const CODEX_CALLBACK_TIMEOUT_SSH_SECS: u64 = 15;
29const CODEX_DEVICE_AUTH_TIMEOUT_SECS: u64 = 15 * 60;
30
31#[derive(Debug, Deserialize)]
32struct DeviceCodeResponse {
33    device_code: String,
34    user_code: String,
35    verification_uri: String,
36    #[serde(default)]
37    verification_uri_complete: Option<String>,
38    #[serde(default)]
39    interval: Option<u64>,
40}
41
42#[derive(Debug, Deserialize)]
43struct AccessTokenResponse {
44    #[serde(default)]
45    access_token: Option<String>,
46    #[serde(default)]
47    error: Option<String>,
48    #[serde(default)]
49    error_description: Option<String>,
50    #[serde(default)]
51    interval: Option<u64>,
52}
53
54#[derive(Debug, Deserialize)]
55struct CodexDeviceCodeResponse {
56    device_auth_id: String,
57    #[serde(alias = "usercode")]
58    user_code: String,
59    #[serde(default, deserialize_with = "deserialize_interval_seconds")]
60    interval: u64,
61}
62
63#[derive(Debug, Deserialize)]
64struct CodexDeviceCodeTokenResponse {
65    authorization_code: String,
66    code_verifier: String,
67}
68
69#[derive(Debug, Deserialize)]
70struct CodexDeviceErrorResponse {
71    #[serde(default)]
72    error: Option<String>,
73    #[serde(default)]
74    error_description: Option<String>,
75}
76
77pub async fn execute(args: AuthArgs) -> Result<()> {
78    match args.command {
79        AuthCommand::Copilot(copilot_args) => authenticate_copilot(copilot_args).await,
80        AuthCommand::Codex(codex_args) => authenticate_codex(codex_args).await,
81        AuthCommand::Cookies(cookie_args) => authenticate_cookie_import(cookie_args).await,
82        AuthCommand::Register(register_args) => authenticate_register(register_args).await,
83        AuthCommand::Login(login_args) => authenticate_login(login_args).await,
84    }
85}
86
87#[derive(Debug, Deserialize)]
88struct LoginResponsePayload {
89    access_token: String,
90    expires_at: String,
91    user: serde_json::Value,
92}
93
94async fn login_with_password(
95    client: &Client,
96    server_url: &str,
97    email: &str,
98    password: &str,
99) -> Result<LoginResponsePayload> {
100    let user_agent = format!("codetether-agent/{}", env!("CARGO_PKG_VERSION"));
101
102    let resp = client
103        .post(format!("{}/v1/users/login", server_url))
104        .header("User-Agent", &user_agent)
105        .header("Content-Type", "application/json")
106        .json(&json!({
107            "email": email,
108            "password": password,
109        }))
110        .send()
111        .await
112        .context("Failed to connect to CodeTether server")?;
113
114    if !resp.status().is_success() {
115        let status = resp.status();
116        let body: serde_json::Value = resp.json().await.unwrap_or_default();
117        let detail = body
118            .get("detail")
119            .and_then(|v| v.as_str())
120            .unwrap_or("Authentication failed");
121        anyhow::bail!("Login failed ({}): {}", status, detail);
122    }
123
124    let login: LoginResponsePayload = resp
125        .json()
126        .await
127        .context("Failed to parse login response")?;
128
129    Ok(login)
130}
131
132fn write_saved_credentials(
133    server_url: &str,
134    email: &str,
135    login: &LoginResponsePayload,
136) -> Result<PathBuf> {
137    // Store token to ~/.config/codetether-agent/credentials.json
138    let cred_path = credential_file_path()?;
139    if let Some(parent) = cred_path.parent() {
140        std::fs::create_dir_all(parent)
141            .with_context(|| format!("Failed to create config dir: {}", parent.display()))?;
142    }
143
144    let creds = json!({
145        "server": server_url,
146        "access_token": login.access_token,
147        "expires_at": login.expires_at,
148        "email": email,
149    });
150
151    // Write with restrictive permissions (owner-only read/write)
152    #[cfg(unix)]
153    {
154        use std::os::unix::fs::OpenOptionsExt;
155        let file = std::fs::OpenOptions::new()
156            .write(true)
157            .create(true)
158            .truncate(true)
159            .mode(0o600)
160            .open(&cred_path)
161            .with_context(|| format!("Failed to write credentials to {}", cred_path.display()))?;
162        serde_json::to_writer_pretty(file, &creds)?;
163    }
164    #[cfg(not(unix))]
165    {
166        let file = std::fs::File::create(&cred_path)
167            .with_context(|| format!("Failed to write credentials to {}", cred_path.display()))?;
168        serde_json::to_writer_pretty(file, &creds)?;
169    }
170
171    Ok(cred_path)
172}
173
174async fn authenticate_register(args: RegisterAuthArgs) -> Result<()> {
175    #[derive(Debug, Deserialize)]
176    struct RegisterResponse {
177        user_id: String,
178        email: String,
179        message: String,
180        #[serde(default)]
181        instance_url: Option<String>,
182        #[serde(default)]
183        instance_namespace: Option<String>,
184        #[serde(default)]
185        provisioning_status: Option<String>,
186    }
187
188    let server_url = args.server.trim_end_matches('/').to_string();
189
190    let email = match args.email {
191        Some(e) => e,
192        None => {
193            print!("Email: ");
194            io::stdout().flush()?;
195            let mut email = String::new();
196            io::stdin().read_line(&mut email)?;
197            email.trim().to_string()
198        }
199    };
200
201    if email.is_empty() {
202        anyhow::bail!("Email is required");
203    }
204
205    let password = rpassword_prompt("Password (min 8 chars): ")?;
206    if password.trim().len() < 8 {
207        anyhow::bail!("Password must be at least 8 characters");
208    }
209    let confirm = rpassword_prompt("Confirm password: ")?;
210    if password != confirm {
211        anyhow::bail!("Passwords do not match");
212    }
213
214    println!("Registering with {}...", server_url);
215
216    let client = Client::new();
217    let user_agent = format!("codetether-agent/{}", env!("CARGO_PKG_VERSION"));
218
219    let resp = client
220        .post(format!("{}/v1/users/register", server_url))
221        .header("User-Agent", &user_agent)
222        .header("Content-Type", "application/json")
223        .json(&json!({
224            "email": email,
225            "password": password,
226            "first_name": args.first_name,
227            "last_name": args.last_name,
228            "referral_source": args.referral_source,
229        }))
230        .send()
231        .await
232        .context("Failed to connect to CodeTether server")?;
233
234    if !resp.status().is_success() {
235        let status = resp.status();
236        let body: serde_json::Value = resp.json().await.unwrap_or_default();
237        let detail = body
238            .get("detail")
239            .and_then(|v| v.as_str())
240            .unwrap_or("Registration failed");
241        anyhow::bail!("Registration failed ({}): {}", status, detail);
242    }
243
244    let reg: RegisterResponse = resp
245        .json()
246        .await
247        .context("Failed to parse registration response")?;
248
249    println!(
250        "Account created for {} (user_id={})",
251        reg.email, reg.user_id
252    );
253    println!("{}", reg.message);
254    if let Some(status) = reg.provisioning_status.as_deref() {
255        println!("Provisioning status: {}", status);
256    }
257    if let Some(url) = reg.instance_url.as_deref() {
258        println!("Instance URL: {}", url);
259    }
260    if let Some(ns) = reg.instance_namespace.as_deref() {
261        println!("Instance namespace: {}", ns);
262    }
263
264    // Auto-login and save credentials for the worker.
265    println!("Logging in...");
266    let login = login_with_password(&client, &server_url, &reg.email, &password).await?;
267    let cred_path = write_saved_credentials(&server_url, &reg.email, &login)?;
268
269    let user_email = login
270        .user
271        .get("email")
272        .and_then(|v| v.as_str())
273        .unwrap_or(&reg.email);
274
275    println!("Logged in as {} (expires {})", user_email, login.expires_at);
276    println!("Credentials saved to {}", cred_path.display());
277    println!("\nThe CLI will automatically use these credentials for `codetether worker`.");
278
279    Ok(())
280}
281
282async fn authenticate_login(args: LoginAuthArgs) -> Result<()> {
283    let server_url = args.server.trim_end_matches('/').to_string();
284
285    // Prompt for email if not provided
286    let email = match args.email {
287        Some(e) => e,
288        None => {
289            print!("Email: ");
290            io::stdout().flush()?;
291            let mut email = String::new();
292            io::stdin().read_line(&mut email)?;
293            email.trim().to_string()
294        }
295    };
296
297    if email.is_empty() {
298        anyhow::bail!("Email is required");
299    }
300
301    // Prompt for password (no echo)
302    let password = rpassword_prompt("Password: ")?;
303    if password.is_empty() {
304        anyhow::bail!("Password is required");
305    }
306
307    println!("Authenticating with {}...", server_url);
308
309    let client = Client::new();
310
311    let login = login_with_password(&client, &server_url, &email, &password).await?;
312    let cred_path = write_saved_credentials(&server_url, &email, &login)?;
313
314    let user_email = login
315        .user
316        .get("email")
317        .and_then(|v| v.as_str())
318        .unwrap_or(&email);
319
320    println!("Logged in as {} (expires {})", user_email, login.expires_at);
321    println!("Credentials saved to {}", cred_path.display());
322    println!("\nThe CLI will automatically use these credentials for `codetether worker`.");
323
324    Ok(())
325}
326
327/// Read password from terminal without echo.
328fn rpassword_prompt(prompt: &str) -> Result<String> {
329    print!("{}", prompt);
330    io::stdout().flush()?;
331
332    // Disable echo on Unix
333    #[cfg(unix)]
334    {
335        use std::io::BufRead;
336        // Save terminal state
337        let fd = 0; // stdin
338        let orig = unsafe {
339            let mut termios = std::mem::zeroed::<libc::termios>();
340            libc::tcgetattr(fd, &mut termios);
341            termios
342        };
343
344        // Disable echo
345        unsafe {
346            let mut termios = orig;
347            termios.c_lflag &= !libc::ECHO;
348            libc::tcsetattr(fd, libc::TCSANOW, &termios);
349        }
350
351        let mut password = String::new();
352        let result = io::stdin().lock().read_line(&mut password);
353
354        // Restore terminal state
355        unsafe {
356            libc::tcsetattr(fd, libc::TCSANOW, &orig);
357        }
358        println!(); // newline after password entry
359
360        result?;
361        Ok(password.trim().to_string())
362    }
363
364    #[cfg(not(unix))]
365    {
366        let mut password = String::new();
367        io::stdin().read_line(&mut password)?;
368        Ok(password.trim().to_string())
369    }
370}
371
372/// Get the path to the credential storage file.
373fn credential_file_path() -> Result<std::path::PathBuf> {
374    use directories::ProjectDirs;
375    let dirs = ProjectDirs::from("ai", "codetether", "codetether-agent")
376        .ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?;
377    Ok(dirs.config_dir().join("credentials.json"))
378}
379
380/// Stored credentials from `codetether auth login`.
381#[derive(Debug, Deserialize)]
382pub struct SavedCredentials {
383    pub server: String,
384    pub access_token: String,
385    pub expires_at: String,
386    #[serde(default)]
387    pub email: String,
388}
389
390/// Load saved credentials from disk, returning `None` if the file doesn't exist,
391/// is malformed, or the token has expired.
392pub fn load_saved_credentials() -> Option<SavedCredentials> {
393    let path = credential_file_path().ok()?;
394    let data = std::fs::read_to_string(&path).ok()?;
395    let creds: SavedCredentials = serde_json::from_str(&data).ok()?;
396
397    // Check expiry if parseable
398    if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&creds.expires_at)
399        && expires < chrono::Utc::now()
400    {
401        tracing::warn!("Saved credentials have expired — run `codetether auth login` to refresh");
402        return None;
403    }
404
405    Some(creds)
406}
407
408async fn authenticate_codex(args: CodexAuthArgs) -> Result<()> {
409    if secrets::secrets_manager().is_none() {
410        anyhow::bail!(
411            "HashiCorp Vault is not configured. Set VAULT_ADDR and VAULT_TOKEN before running `codetether auth codex`."
412        );
413    }
414
415    if args.device_code {
416        let credentials = authenticate_codex_device_code().await?;
417        return store_codex_credentials(credentials).await;
418    }
419
420    let (authorization_url, code_verifier, expected_state) =
421        OpenAiCodexProvider::get_authorization_url();
422
423    println!("OpenAI Codex OAuth authentication");
424    println!(
425        "Sign in with your ChatGPT subscription account (Plus/Pro/Team/Enterprise) to use Codex models without API credits."
426    );
427
428    let is_ssh_session =
429        std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some();
430    if is_ssh_session {
431        println!("Detected SSH session.");
432        println!(
433            "If your browser runs on your local machine, port-forward callback traffic first:"
434        );
435        println!("  ssh -L 1455:127.0.0.1:1455 <remote-host>");
436        println!("Without forwarding, manual callback paste is still supported.");
437    }
438
439    println!("Open this URL: {}", authorization_url);
440    println!(
441        "After approving access, copy the browser callback URL and paste it below (it starts with http://localhost:1455/auth/callback)."
442    );
443
444    let callback_timeout = if is_ssh_session {
445        Duration::from_secs(CODEX_CALLBACK_TIMEOUT_SSH_SECS)
446    } else {
447        Duration::from_secs(CODEX_CALLBACK_TIMEOUT_SECS)
448    };
449    let auto_callback = capture_oauth_callback_auto(callback_timeout).await?;
450    let (authorization_code, returned_state) = if let Some(callback) = auto_callback {
451        println!("Captured callback automatically.");
452        callback
453    } else {
454        if is_ssh_session {
455            println!(
456                "Press Enter to switch to device-code auth, or paste callback URL from your browser."
457            );
458        }
459        let callback_input = if is_ssh_session {
460            prompt_optional_line("Callback URL: ")?
461        } else {
462            prompt_line("Callback URL: ")?
463        };
464
465        if callback_input.trim().is_empty() {
466            let credentials = authenticate_codex_device_code().await?;
467            return store_codex_credentials(credentials).await;
468        }
469
470        extract_oauth_code_and_state(&callback_input)?
471    };
472
473    if returned_state != expected_state {
474        anyhow::bail!(
475            "OAuth state mismatch. Retry `codetether auth codex` and paste the callback URL from the same login attempt."
476        );
477    }
478
479    let credentials = OpenAiCodexProvider::exchange_code(&authorization_code, &code_verifier)
480        .await
481        .context("Failed to exchange ChatGPT OAuth code for Codex tokens")?;
482
483    store_codex_credentials(credentials).await
484}
485
486async fn store_codex_credentials(credentials: OAuthCredentials) -> Result<()> {
487    let chatgpt_account_id = credentials
488        .chatgpt_account_id
489        .clone()
490        .or_else(|| {
491            credentials
492                .id_token
493                .as_deref()
494                .and_then(OpenAiCodexProvider::extract_chatgpt_account_id)
495        })
496        .or_else(|| OpenAiCodexProvider::extract_chatgpt_account_id(&credentials.access_token));
497
498    let mut expected_token_exchange_fallback = false;
499    let api_key = if let Some(id_token) = credentials.id_token.as_deref() {
500        match OpenAiCodexProvider::exchange_id_token_for_api_key(id_token).await {
501            Ok(key) => Some(key),
502            Err(error) => {
503                if is_expected_codex_id_token_exchange_fallback(&error) {
504                    expected_token_exchange_fallback = true;
505                    tracing::info!(
506                        error = %error,
507                        "Expected id_token exchange fallback; using OAuth access token for Codex backend"
508                    );
509                } else {
510                    tracing::warn!(
511                        error = %error,
512                        "Failed to exchange id_token for OpenAI API key; falling back to OAuth access token"
513                    );
514                }
515                None
516            }
517        }
518    } else {
519        tracing::warn!(
520            "OAuth token exchange did not return an id_token; cannot derive OpenAI API key"
521        );
522        None
523    };
524
525    let mut extra = HashMap::new();
526    extra.insert(
527        "access_token".to_string(),
528        serde_json::Value::String(credentials.access_token.clone()),
529    );
530    extra.insert(
531        "refresh_token".to_string(),
532        serde_json::Value::String(credentials.refresh_token.clone()),
533    );
534    extra.insert(
535        "expires_at".to_string(),
536        serde_json::Value::Number(credentials.expires_at.into()),
537    );
538    if let Some(id_token) = credentials.id_token.as_ref() {
539        extra.insert(
540            "id_token".to_string(),
541            serde_json::Value::String(id_token.clone()),
542        );
543    }
544    if let Some(account_id) = chatgpt_account_id.as_ref() {
545        extra.insert(
546            "chatgpt_account_id".to_string(),
547            serde_json::Value::String(account_id.clone()),
548        );
549    }
550
551    let provider_secrets = ProviderSecrets {
552        api_key: api_key.clone(),
553        base_url: None,
554        organization: chatgpt_account_id.clone(),
555        headers: None,
556        extra,
557    };
558
559    secrets::set_provider_secrets("openai-codex", &provider_secrets)
560        .await
561        .context("Failed to store openai-codex OAuth credentials in Vault")?;
562
563    let expires_display = chrono::DateTime::from_timestamp(credentials.expires_at as i64, 0)
564        .map(|ts| ts.to_rfc3339())
565        .unwrap_or_else(|| credentials.expires_at.to_string());
566
567    println!("Saved openai-codex credentials to HashiCorp Vault.");
568    if api_key.is_some() {
569        println!("Stored exchanged OpenAI API key for Codex model requests.");
570    } else {
571        println!(
572            "Could not exchange an OpenAI API key; Codex requests will use ChatGPT OAuth backend tokens."
573        );
574        if expected_token_exchange_fallback {
575            println!(
576                "Note: this fallback is expected when your id_token does not include organization context."
577            );
578        }
579    }
580    if let Some(account_id) = chatgpt_account_id {
581        println!("Using ChatGPT workspace/account ID: {}", account_id);
582    }
583    println!("Access token expires at {}", expires_display);
584    println!("You can now select models like `openai-codex/gpt-5-codex`.");
585    Ok(())
586}
587
588fn is_expected_codex_id_token_exchange_fallback(error: &anyhow::Error) -> bool {
589    let msg = error.to_string().to_ascii_lowercase();
590    msg.contains("missing organization_id")
591        || (msg.contains("invalid_subject_token") && msg.contains("organization"))
592}
593
594async fn authenticate_codex_device_code() -> Result<OAuthCredentials> {
595    let client = Client::new();
596    let issuer = OpenAiCodexProvider::oauth_issuer().trim_end_matches('/');
597    let user_agent = format!("codetether-agent/{}", env!("CARGO_PKG_VERSION"));
598    let device_code = request_codex_device_code(&client, issuer, &user_agent).await?;
599
600    println!("OpenAI Codex device authentication");
601    println!("Open this URL: {issuer}/codex/device");
602    println!("Enter code: {}", device_code.user_code);
603    println!("Waiting for authorization...");
604
605    let code = poll_for_codex_authorization_code(&client, issuer, &user_agent, &device_code)
606        .await
607        .context("Timed out waiting for device authorization")?;
608
609    let redirect_uri = format!("{issuer}/deviceauth/callback");
610    OpenAiCodexProvider::exchange_code_with_redirect_uri(
611        &code.authorization_code,
612        &code.code_verifier,
613        &redirect_uri,
614    )
615    .await
616    .context("Failed to exchange device authorization code for Codex tokens")
617}
618
619#[derive(Debug, Clone)]
620struct CookieRow {
621    domain: String,
622    include_subdomains: bool,
623    path: String,
624    secure: bool,
625    expires_epoch: i64,
626    name: String,
627    value: String,
628    http_only: bool,
629}
630
631async fn authenticate_cookie_import(args: CookieAuthArgs) -> Result<()> {
632    if secrets::secrets_manager().is_none() {
633        anyhow::bail!(
634            "HashiCorp Vault is not configured. Set VAULT_ADDR and VAULT_TOKEN before running `codetether auth cookies`."
635        );
636    }
637
638    let provider_id = args.provider.trim().to_string();
639    if provider_id.is_empty() {
640        anyhow::bail!("--provider cannot be empty");
641    }
642
643    let raw = tokio::fs::read_to_string(&args.file)
644        .await
645        .with_context(|| format!("Failed to read cookie file {}", args.file.display()))?;
646    let rows = parse_netscape_cookie_file(&raw);
647    if rows.is_empty() {
648        anyhow::bail!(
649            "No valid cookie rows found in {} (expected Netscape format)",
650            args.file.display()
651        );
652    }
653
654    let (selected, dropped_expired, dropped_non_auth) =
655        select_cookie_rows(&rows, &provider_id, args.keep_all);
656    if selected.is_empty() {
657        anyhow::bail!("No usable cookies remained after filtering");
658    }
659
660    let rendered = render_netscape_cookie_file(&selected);
661    let now = chrono::Utc::now();
662    let (earliest_expiry, latest_expiry) = cookie_expiry_bounds(&selected);
663    let cookie_names: Vec<String> = selected.iter().map(|row| row.name.clone()).collect();
664    let mut extra = HashMap::new();
665    extra.insert("cookies".to_string(), json!(rendered));
666    extra.insert("cookie_format".to_string(), json!("netscape"));
667    extra.insert("imported_at".to_string(), json!(now.to_rfc3339()));
668    extra.insert("cookie_count".to_string(), json!(selected.len()));
669    extra.insert("cookie_names".to_string(), json!(cookie_names));
670    extra.insert("dropped_expired".to_string(), json!(dropped_expired));
671    extra.insert("dropped_non_auth".to_string(), json!(dropped_non_auth));
672    extra.insert("keep_all".to_string(), json!(args.keep_all));
673    extra.insert(
674        "strategy".to_string(),
675        json!(if args.keep_all {
676            "cookies_all_v1"
677        } else {
678            "cookies_auth_subset_v1"
679        }),
680    );
681
682    if let Some(epoch) = earliest_expiry {
683        extra.insert("earliest_expiry_epoch".to_string(), json!(epoch));
684        if let Some(ts) = chrono::DateTime::from_timestamp(epoch, 0) {
685            extra.insert(
686                "earliest_expiry_rfc3339".to_string(),
687                json!(ts.to_rfc3339()),
688            );
689        }
690        extra.insert(
691            "rotate_before_epoch".to_string(),
692            json!(epoch.saturating_sub(24 * 60 * 60)),
693        );
694    }
695    if let Some(epoch) = latest_expiry {
696        extra.insert("latest_expiry_epoch".to_string(), json!(epoch));
697    }
698
699    let provider_secrets = ProviderSecrets {
700        api_key: None,
701        base_url: None,
702        organization: None,
703        headers: None,
704        extra,
705    };
706
707    secrets::set_provider_secrets(&provider_id, &provider_secrets)
708        .await
709        .with_context(|| format!("Failed to store {} cookies in Vault", provider_id))?;
710    let can_read_back = secrets::get_provider_secrets(&provider_id)
711        .await
712        .map(|saved| saved.extra.contains_key("cookies"))
713        .unwrap_or(false);
714
715    println!(
716        "Saved {} cookies to HashiCorp Vault provider '{}'.",
717        selected.len(),
718        provider_id
719    );
720    println!(
721        "Dropped {} expired and {} non-auth cookies.",
722        dropped_expired, dropped_non_auth
723    );
724    if let Some(epoch) = earliest_expiry
725        && let Some(ts) = chrono::DateTime::from_timestamp(epoch, 0)
726    {
727        println!(
728            "Earliest cookie expiry: {} (rotate at least 24h before this).",
729            ts.to_rfc3339()
730        );
731    }
732    println!(
733        "Vault path: {}/{}",
734        std::env::var("VAULT_SECRETS_PATH").unwrap_or_else(|_| "codetether/providers".to_string()),
735        provider_id
736    );
737    println!(
738        "Read-back verification: {}",
739        if can_read_back { "ok" } else { "failed" }
740    );
741    Ok(())
742}
743
744fn parse_netscape_cookie_file(raw: &str) -> Vec<CookieRow> {
745    raw.lines().filter_map(parse_netscape_cookie_line).collect()
746}
747
748fn parse_netscape_cookie_line(line: &str) -> Option<CookieRow> {
749    let trimmed = line.trim();
750    if trimmed.is_empty() || (trimmed.starts_with('#') && !trimmed.starts_with("#HttpOnly_")) {
751        return None;
752    }
753
754    let (http_only, normalized) = if let Some(rest) = trimmed.strip_prefix("#HttpOnly_") {
755        (true, rest)
756    } else {
757        (false, trimmed)
758    };
759    let parts: Vec<&str> = normalized.split('\t').collect();
760    if parts.len() < 7 {
761        return None;
762    }
763
764    Some(CookieRow {
765        domain: parts[0].trim().to_string(),
766        include_subdomains: parts[1].trim().eq_ignore_ascii_case("TRUE"),
767        path: parts[2].trim().to_string(),
768        secure: parts[3].trim().eq_ignore_ascii_case("TRUE"),
769        expires_epoch: parts[4].trim().parse::<i64>().unwrap_or(0),
770        name: parts[5].trim().to_string(),
771        value: parts[6].trim().to_string(),
772        http_only,
773    })
774}
775
776fn select_cookie_rows(
777    rows: &[CookieRow],
778    provider_id: &str,
779    keep_all: bool,
780) -> (Vec<CookieRow>, usize, usize) {
781    let now_epoch = chrono::Utc::now().timestamp();
782    let allowed = preferred_cookie_names(provider_id);
783    let mut selected_by_name: HashMap<String, CookieRow> = HashMap::new();
784    let mut dropped_expired = 0usize;
785    let mut dropped_non_auth = 0usize;
786
787    for row in rows {
788        if row.name.is_empty() {
789            continue;
790        }
791        if row.expires_epoch > 0 && row.expires_epoch <= now_epoch {
792            dropped_expired += 1;
793            continue;
794        }
795        if !keep_all && !allowed.is_empty() && !allowed.iter().any(|name| *name == row.name) {
796            dropped_non_auth += 1;
797            continue;
798        }
799        match selected_by_name.get(&row.name) {
800            Some(existing) if existing.expires_epoch >= row.expires_epoch => {}
801            _ => {
802                selected_by_name.insert(row.name.clone(), row.clone());
803            }
804        }
805    }
806
807    let mut selected: Vec<CookieRow> = selected_by_name.into_values().collect();
808    selected.sort_by(|left, right| left.name.cmp(&right.name));
809    (selected, dropped_expired, dropped_non_auth)
810}
811
812fn preferred_cookie_names(provider_id: &str) -> &'static [&'static str] {
813    match provider_id {
814        "nextdoor-web" => &[
815            "ndbr_at",
816            "ndbr_idt",
817            "ndbr_adt",
818            "csrftoken",
819            "ndp_session_id",
820            "WE",
821            "WE3P",
822            "DAID",
823        ],
824        "gemini-web" => &[
825            "__Secure-1PSID",
826            "__Secure-1PSIDTS",
827            "__Secure-1PSIDCC",
828            "SID",
829            "HSID",
830            "SSID",
831            "APISID",
832            "SAPISID",
833        ],
834        _ => &[],
835    }
836}
837
838fn render_netscape_cookie_file(rows: &[CookieRow]) -> String {
839    let mut lines = vec![
840        "# Netscape HTTP Cookie File".to_string(),
841        "# Generated by codetether auth cookies".to_string(),
842    ];
843    lines.extend(rows.iter().map(|row| {
844        let domain = if row.http_only {
845            format!("#HttpOnly_{}", row.domain)
846        } else {
847            row.domain.clone()
848        };
849        format!(
850            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
851            domain,
852            bool_flag(row.include_subdomains),
853            row.path,
854            bool_flag(row.secure),
855            row.expires_epoch,
856            row.name,
857            row.value
858        )
859    }));
860    format!("{}\n", lines.join("\n"))
861}
862
863fn cookie_expiry_bounds(rows: &[CookieRow]) -> (Option<i64>, Option<i64>) {
864    let mut expiries = rows.iter().map(|row| row.expires_epoch).filter(|e| *e > 0);
865    let first = expiries.next();
866    let Some(mut min_epoch) = first else {
867        return (None, None);
868    };
869    let mut max_epoch = min_epoch;
870    for epoch in expiries {
871        if epoch < min_epoch {
872            min_epoch = epoch;
873        }
874        if epoch > max_epoch {
875            max_epoch = epoch;
876        }
877    }
878    (Some(min_epoch), Some(max_epoch))
879}
880
881fn bool_flag(value: bool) -> &'static str {
882    if value { "TRUE" } else { "FALSE" }
883}
884
885async fn capture_oauth_callback_auto(timeout: Duration) -> Result<Option<(String, String)>> {
886    let mut listeners = Vec::new();
887    for address in [CODEX_CALLBACK_ADDR_V4, CODEX_CALLBACK_ADDR_V6] {
888        match TcpListener::bind(address).await {
889            Ok(listener) => listeners.push(listener),
890            Err(error) => {
891                tracing::debug!(
892                    address,
893                    error = %error,
894                    "Failed to bind one OAuth callback listener address"
895                );
896            }
897        }
898    }
899
900    if listeners.is_empty() {
901        tracing::warn!(
902            ipv4 = CODEX_CALLBACK_ADDR_V4,
903            ipv6 = CODEX_CALLBACK_ADDR_V6,
904            "Failed to bind OAuth callback listener on localhost addresses; falling back to manual paste"
905        );
906        return Ok(None);
907    }
908
909    println!(
910        "Waiting up to {}s for automatic callback capture on http://{}/auth/callback ...",
911        timeout.as_secs(),
912        CODEX_CALLBACK_DISPLAY_ADDR
913    );
914
915    match wait_for_oauth_callback_any(listeners, timeout).await {
916        Ok(callback) => Ok(Some(callback)),
917        Err(error) => {
918            tracing::warn!(
919                error = %error,
920                "Automatic OAuth callback capture did not complete; falling back to manual paste"
921            );
922            Ok(None)
923        }
924    }
925}
926
927async fn wait_for_oauth_callback_any(
928    mut listeners: Vec<TcpListener>,
929    timeout: Duration,
930) -> Result<(String, String)> {
931    match listeners.len() {
932        0 => anyhow::bail!("No OAuth callback listeners were available"),
933        1 => {
934            let listener = listeners.pop().expect("length checked");
935            wait_for_oauth_callback(listener, timeout).await
936        }
937        _ => {
938            let listener2 = listeners.pop().expect("length checked");
939            let listener1 = listeners.pop().expect("length checked");
940
941            let mut waiter1 = Box::pin(wait_for_oauth_callback(listener1, timeout));
942            let mut waiter2 = Box::pin(wait_for_oauth_callback(listener2, timeout));
943
944            tokio::select! {
945                result1 = &mut waiter1 => {
946                    match result1 {
947                        Ok(callback) => Ok(callback),
948                        Err(err1) => match waiter2.await {
949                            Ok(callback) => Ok(callback),
950                            Err(err2) => anyhow::bail!("{}; {}", err1, err2),
951                        },
952                    }
953                }
954                result2 = &mut waiter2 => {
955                    match result2 {
956                        Ok(callback) => Ok(callback),
957                        Err(err2) => match waiter1.await {
958                            Ok(callback) => Ok(callback),
959                            Err(err1) => anyhow::bail!("{}; {}", err2, err1),
960                        },
961                    }
962                }
963            }
964        }
965    }
966}
967
968async fn wait_for_oauth_callback(
969    listener: TcpListener,
970    timeout: Duration,
971) -> Result<(String, String)> {
972    let deadline = Instant::now() + timeout;
973
974    loop {
975        let now = Instant::now();
976        if now >= deadline {
977            anyhow::bail!("Timed out waiting for OAuth callback");
978        }
979        let remaining = deadline - now;
980
981        let (mut stream, peer_addr) = tokio::time::timeout(remaining, listener.accept())
982            .await
983            .context("Timed out waiting for callback connection")?
984            .context("Failed to accept callback connection")?;
985
986        let request = read_http_request(&mut stream).await?;
987        match parse_oauth_callback_request(&request) {
988            Ok((code, state)) => {
989                write_http_response(
990                    &mut stream,
991                    200,
992                    "OK",
993                    "<html><body><h1>CodeTether login complete</h1><p>You can close this tab.</p></body></html>",
994                )
995                .await?;
996                return Ok((code, state));
997            }
998            Err(error) => {
999                tracing::warn!(
1000                    peer = %peer_addr,
1001                    error = %error,
1002                    "Ignoring non-callback HTTP request while waiting for OAuth callback"
1003                );
1004                write_http_response(
1005                    &mut stream,
1006                    400,
1007                    "Bad Request",
1008                    "<html><body><h1>Invalid callback request</h1><p>Retry authorization from CodeTether.</p></body></html>",
1009                )
1010                .await?;
1011            }
1012        }
1013    }
1014}
1015
1016async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Result<String> {
1017    let mut buffer = [0u8; 8192];
1018    let read = stream
1019        .read(&mut buffer)
1020        .await
1021        .context("Failed to read callback request")?;
1022    if read == 0 {
1023        anyhow::bail!("Callback request stream closed before data was received");
1024    }
1025    Ok(String::from_utf8_lossy(&buffer[..read]).to_string())
1026}
1027
1028fn parse_oauth_callback_request(request: &str) -> Result<(String, String)> {
1029    let first_line = request
1030        .lines()
1031        .next()
1032        .ok_or_else(|| anyhow::anyhow!("Missing HTTP request line"))?;
1033    let mut parts = first_line.split_whitespace();
1034
1035    let method = parts.next().unwrap_or_default();
1036    let method = method.to_ascii_uppercase();
1037
1038    let target = parts
1039        .next()
1040        .ok_or_else(|| anyhow::anyhow!("Missing callback target"))?;
1041    let target_query = target.split_once('?').map(|(_, query)| query.trim());
1042    let body = request
1043        .split_once("\r\n\r\n")
1044        .map(|(_, body)| body)
1045        .or_else(|| request.split_once("\n\n").map(|(_, body)| body))
1046        .map(str::trim)
1047        .filter(|body| !body.is_empty());
1048
1049    let callback_payload = match method.as_str() {
1050        "GET" => target_query
1051            .or(body)
1052            .ok_or_else(|| anyhow::anyhow!("Callback target missing query string"))?,
1053        "POST" => body
1054            .or(target_query)
1055            .ok_or_else(|| anyhow::anyhow!("Callback POST body missing OAuth payload"))?,
1056        _ => anyhow::bail!("Unsupported callback method: {}", method),
1057    };
1058
1059    extract_oauth_code_and_state(callback_payload)
1060}
1061
1062async fn write_http_response(
1063    stream: &mut tokio::net::TcpStream,
1064    status_code: u16,
1065    status_text: &str,
1066    body: &str,
1067) -> Result<()> {
1068    let response = format!(
1069        "HTTP/1.1 {} {}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1070        status_code,
1071        status_text,
1072        body.len(),
1073        body
1074    );
1075    stream
1076        .write_all(response.as_bytes())
1077        .await
1078        .context("Failed to write callback response")?;
1079    Ok(())
1080}
1081
1082async fn authenticate_copilot(args: CopilotAuthArgs) -> Result<()> {
1083    if secrets::secrets_manager().is_none() {
1084        anyhow::bail!(
1085            "HashiCorp Vault is not configured. Set VAULT_ADDR and VAULT_TOKEN before running `codetether auth copilot`."
1086        );
1087    }
1088
1089    let (provider_id, domain, enterprise_domain) = match args.enterprise_url {
1090        Some(raw) => {
1091            let domain = normalize_enterprise_domain(&raw);
1092            if domain.is_empty() {
1093                anyhow::bail!("--enterprise-url cannot be empty");
1094            }
1095            ("github-copilot-enterprise", domain.clone(), Some(domain))
1096        }
1097        None => ("github-copilot", DEFAULT_GITHUB_DOMAIN.to_string(), None),
1098    };
1099
1100    let client = Client::new();
1101    let client_id = resolve_client_id(args.client_id)?;
1102    let user_agent = format!("codetether-agent/{}", env!("CARGO_PKG_VERSION"));
1103    let device = request_device_code(&client, &domain, &user_agent, &client_id).await?;
1104
1105    println!("GitHub Copilot device authentication");
1106    println!(
1107        "Open this URL: {}",
1108        device
1109            .verification_uri_complete
1110            .as_deref()
1111            .unwrap_or(&device.verification_uri)
1112    );
1113    println!("Enter code: {}", device.user_code);
1114    println!("Waiting for authorization...");
1115
1116    let token = poll_for_access_token(&client, &domain, &user_agent, &client_id, &device).await?;
1117
1118    let mut extra = HashMap::new();
1119    if let Some(enterprise_url) = enterprise_domain {
1120        extra.insert(
1121            "enterpriseUrl".to_string(),
1122            serde_json::Value::String(enterprise_url),
1123        );
1124    }
1125
1126    let provider_secrets = ProviderSecrets {
1127        api_key: Some(token),
1128        base_url: None,
1129        organization: None,
1130        headers: None,
1131        extra,
1132    };
1133
1134    secrets::set_provider_secrets(provider_id, &provider_secrets)
1135        .await
1136        .with_context(|| format!("Failed to store {} auth token in Vault", provider_id))?;
1137
1138    println!("Saved {} credentials to HashiCorp Vault.", provider_id);
1139    Ok(())
1140}
1141
1142async fn request_codex_device_code(
1143    client: &Client,
1144    issuer: &str,
1145    user_agent: &str,
1146) -> Result<CodexDeviceCodeResponse> {
1147    let url = format!("{issuer}/api/accounts/deviceauth/usercode");
1148    let response = client
1149        .post(&url)
1150        .header("Accept", "application/json")
1151        .header("Content-Type", "application/json")
1152        .header("User-Agent", user_agent)
1153        .json(&json!({
1154            "client_id": OpenAiCodexProvider::oauth_client_id(),
1155        }))
1156        .send()
1157        .await
1158        .with_context(|| format!("Failed to reach device authorization endpoint: {url}"))?;
1159
1160    let status = response.status();
1161    if !status.is_success() {
1162        let body = response.text().await.unwrap_or_default();
1163        if status == reqwest::StatusCode::NOT_FOUND {
1164            anyhow::bail!(
1165                "Device code login is not enabled for this Codex server. Use browser OAuth flow instead."
1166            );
1167        }
1168        anyhow::bail!(
1169            "Failed to initiate Codex device authorization ({}): {}",
1170            status,
1171            truncate_body(&body)
1172        );
1173    }
1174
1175    let mut device: CodexDeviceCodeResponse = response
1176        .json()
1177        .await
1178        .context("Failed to parse Codex device authorization response")?;
1179    if device.interval == 0 {
1180        device.interval = 5;
1181    }
1182    Ok(device)
1183}
1184
1185async fn poll_for_codex_authorization_code(
1186    client: &Client,
1187    issuer: &str,
1188    user_agent: &str,
1189    device: &CodexDeviceCodeResponse,
1190) -> Result<CodexDeviceCodeTokenResponse> {
1191    let url = format!("{issuer}/api/accounts/deviceauth/token");
1192    let interval_secs = device.interval.max(1);
1193    let timeout = Duration::from_secs(CODEX_DEVICE_AUTH_TIMEOUT_SECS);
1194    let start = Instant::now();
1195
1196    loop {
1197        let response = client
1198            .post(&url)
1199            .header("Accept", "application/json")
1200            .header("Content-Type", "application/json")
1201            .header("User-Agent", user_agent)
1202            .json(&json!({
1203                "device_auth_id": device.device_auth_id,
1204                "user_code": device.user_code,
1205            }))
1206            .send()
1207            .await
1208            .with_context(|| format!("Failed to poll device authorization endpoint: {url}"))?;
1209
1210        let status = response.status();
1211        if status.is_success() {
1212            return response
1213                .json()
1214                .await
1215                .context("Failed to parse Codex device authorization response");
1216        }
1217
1218        let body = response.text().await.unwrap_or_default();
1219        if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::NOT_FOUND {
1220            if start.elapsed() >= timeout {
1221                anyhow::bail!(
1222                    "Device authorization timed out after {} seconds",
1223                    CODEX_DEVICE_AUTH_TIMEOUT_SECS
1224                );
1225            }
1226            sleep_with_margin(interval_secs).await;
1227            continue;
1228        }
1229
1230        if let Ok(payload) = serde_json::from_str::<CodexDeviceErrorResponse>(&body)
1231            && let Some(error) = payload.error.as_deref()
1232        {
1233            let description = payload
1234                .error_description
1235                .as_deref()
1236                .unwrap_or("No error description provided");
1237            anyhow::bail!("Codex device authorization failed: {error} ({description})");
1238        }
1239
1240        anyhow::bail!(
1241            "Codex device authorization failed ({}): {}",
1242            status,
1243            truncate_body(&body)
1244        );
1245    }
1246}
1247
1248async fn request_device_code(
1249    client: &Client,
1250    domain: &str,
1251    user_agent: &str,
1252    client_id: &str,
1253) -> Result<DeviceCodeResponse> {
1254    let url = format!("https://{domain}/login/device/code");
1255    let response = client
1256        .post(&url)
1257        .header("Accept", "application/json")
1258        .header("Content-Type", "application/json")
1259        .header("User-Agent", user_agent)
1260        .json(&json!({
1261            "client_id": client_id,
1262            "scope": "read:user",
1263        }))
1264        .send()
1265        .await
1266        .with_context(|| format!("Failed to reach device authorization endpoint: {url}"))?;
1267
1268    let status = response.status();
1269    if !status.is_success() {
1270        let body = response.text().await.unwrap_or_default();
1271        anyhow::bail!(
1272            "Failed to initiate device authorization ({}): {}",
1273            status,
1274            truncate_body(&body)
1275        );
1276    }
1277
1278    let mut device: DeviceCodeResponse = response
1279        .json()
1280        .await
1281        .context("Failed to parse device authorization response")?;
1282    if device.interval.unwrap_or(0) == 0 {
1283        device.interval = Some(5);
1284    }
1285    Ok(device)
1286}
1287
1288async fn poll_for_access_token(
1289    client: &Client,
1290    domain: &str,
1291    user_agent: &str,
1292    client_id: &str,
1293    device: &DeviceCodeResponse,
1294) -> Result<String> {
1295    let url = format!("https://{domain}/login/oauth/access_token");
1296    let mut interval_secs = device.interval.unwrap_or(5).max(1);
1297
1298    loop {
1299        let response = client
1300            .post(&url)
1301            .header("Accept", "application/json")
1302            .header("Content-Type", "application/json")
1303            .header("User-Agent", user_agent)
1304            .json(&json!({
1305                "client_id": client_id,
1306                "device_code": device.device_code,
1307                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
1308            }))
1309            .send()
1310            .await
1311            .with_context(|| format!("Failed to poll token endpoint: {url}"))?;
1312
1313        let status = response.status();
1314        if !status.is_success() {
1315            let body = response.text().await.unwrap_or_default();
1316            anyhow::bail!(
1317                "Failed to exchange device code for access token ({}): {}",
1318                status,
1319                truncate_body(&body)
1320            );
1321        }
1322
1323        let payload: AccessTokenResponse = response
1324            .json()
1325            .await
1326            .context("Failed to parse OAuth token response")?;
1327
1328        if let Some(token) = payload.access_token
1329            && !token.trim().is_empty()
1330        {
1331            return Ok(token);
1332        }
1333
1334        match payload.error.as_deref() {
1335            Some("authorization_pending") => sleep_with_margin(interval_secs).await,
1336            Some("slow_down") => {
1337                interval_secs = payload
1338                    .interval
1339                    .filter(|value| *value > 0)
1340                    .unwrap_or(interval_secs + 5);
1341                sleep_with_margin(interval_secs).await;
1342            }
1343            Some(error) => {
1344                let description = payload
1345                    .error_description
1346                    .unwrap_or_else(|| "No error description provided".to_string());
1347                anyhow::bail!("Copilot OAuth failed: {} ({})", error, description);
1348            }
1349            None => sleep_with_margin(interval_secs).await,
1350        }
1351    }
1352}
1353
1354fn resolve_client_id(client_id: Option<String>) -> Result<String> {
1355    let id = client_id
1356        .map(|value| value.trim().to_string())
1357        .filter(|value| !value.is_empty())
1358        .ok_or_else(|| {
1359            anyhow::anyhow!(
1360                "GitHub OAuth client ID is required. Pass `--client-id <id>` or set `CODETETHER_COPILOT_OAUTH_CLIENT_ID`."
1361            )
1362        })?;
1363
1364    Ok(id)
1365}
1366
1367async fn sleep_with_margin(interval_secs: u64) {
1368    sleep(Duration::from_millis(
1369        interval_secs.saturating_mul(1000) + OAUTH_POLLING_SAFETY_MARGIN_MS,
1370    ))
1371    .await;
1372}
1373
1374fn truncate_body(body: &str) -> String {
1375    const MAX_LEN: usize = 300;
1376    if body.len() <= MAX_LEN {
1377        body.to_string()
1378    } else {
1379        format!("{}...", &body[..MAX_LEN])
1380    }
1381}
1382
1383fn deserialize_interval_seconds<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
1384where
1385    D: Deserializer<'de>,
1386{
1387    #[derive(Deserialize)]
1388    #[serde(untagged)]
1389    enum IntervalValue {
1390        Number(u64),
1391        String(String),
1392    }
1393
1394    let value = Option::<IntervalValue>::deserialize(deserializer)?;
1395    match value {
1396        Some(IntervalValue::Number(value)) => Ok(value),
1397        Some(IntervalValue::String(value)) => value
1398            .trim()
1399            .parse::<u64>()
1400            .map_err(|error| de::Error::custom(format!("invalid interval value: {error}"))),
1401        None => Ok(0),
1402    }
1403}
1404
1405fn prompt_line(prompt: &str) -> Result<String> {
1406    print!("{prompt}");
1407    io::stdout().flush()?;
1408
1409    let mut input = String::new();
1410    io::stdin().read_line(&mut input)?;
1411    let trimmed = input.trim().to_string();
1412    if trimmed.is_empty() {
1413        anyhow::bail!("Input is required");
1414    }
1415    Ok(trimmed)
1416}
1417
1418fn prompt_optional_line(prompt: &str) -> Result<String> {
1419    print!("{prompt}");
1420    io::stdout().flush()?;
1421
1422    let mut input = String::new();
1423    io::stdin().read_line(&mut input)?;
1424    Ok(input.trim().to_string())
1425}
1426
1427fn extract_oauth_code_and_state(callback_input: &str) -> Result<(String, String)> {
1428    let input = callback_input.trim();
1429    if input.is_empty() {
1430        anyhow::bail!("Callback URL is required");
1431    }
1432
1433    let query = if input.contains("://") {
1434        let url =
1435            reqwest::Url::parse(input).with_context(|| format!("Invalid callback URL: {input}"))?;
1436        url.query()
1437            .map(str::to_string)
1438            .ok_or_else(|| anyhow::anyhow!("Callback URL is missing query parameters"))?
1439    } else if let Some((_, params)) = input.split_once('?') {
1440        params.to_string()
1441    } else {
1442        input.to_string()
1443    };
1444
1445    let params = parse_query_pairs(&query);
1446    if let Some(error) = params.get("error") {
1447        let error_description = params
1448            .get("error_description")
1449            .map(String::as_str)
1450            .unwrap_or("No error description provided");
1451        anyhow::bail!(
1452            "OAuth authorization failed: {} ({})",
1453            error,
1454            error_description
1455        );
1456    }
1457
1458    let code = params
1459        .get("code")
1460        .cloned()
1461        .filter(|value| !value.is_empty())
1462        .ok_or_else(|| anyhow::anyhow!("Callback URL does not include an OAuth code"))?;
1463    let state = params
1464        .get("state")
1465        .cloned()
1466        .filter(|value| !value.is_empty())
1467        .ok_or_else(|| anyhow::anyhow!("Callback URL does not include OAuth state"))?;
1468
1469    Ok((code, state))
1470}
1471
1472fn parse_query_pairs(query: &str) -> HashMap<String, String> {
1473    let mut params = HashMap::new();
1474
1475    for pair in query.split('&') {
1476        if pair.trim().is_empty() {
1477            continue;
1478        }
1479
1480        let (raw_key, raw_value) = match pair.split_once('=') {
1481            Some((key, value)) => (key, value),
1482            None => (pair, ""),
1483        };
1484        let key = decode_query_component(raw_key);
1485        let value = decode_query_component(raw_value);
1486        params.insert(key, value);
1487    }
1488
1489    params
1490}
1491
1492fn decode_query_component(component: &str) -> String {
1493    match urlencoding::decode(component) {
1494        Ok(value) => value.into_owned(),
1495        Err(_) => component.to_string(),
1496    }
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501    use super::{
1502        CodexDeviceCodeResponse, extract_oauth_code_and_state, parse_netscape_cookie_line,
1503        parse_oauth_callback_request, select_cookie_rows,
1504    };
1505
1506    #[test]
1507    fn extracts_code_and_state_from_full_callback_url() {
1508        let input = "http://localhost:1455/auth/callback?code=abc123&state=xyz789";
1509        let (code, state) = extract_oauth_code_and_state(input).expect("expected callback parse");
1510        assert_eq!(code, "abc123");
1511        assert_eq!(state, "xyz789");
1512    }
1513
1514    #[test]
1515    fn extracts_code_and_state_from_raw_query_string() {
1516        let input = "code=abc123&state=xyz789";
1517        let (code, state) = extract_oauth_code_and_state(input).expect("expected callback parse");
1518        assert_eq!(code, "abc123");
1519        assert_eq!(state, "xyz789");
1520    }
1521
1522    #[test]
1523    fn returns_error_when_state_is_missing() {
1524        let input = "http://localhost:1455/auth/callback?code=abc123";
1525        let err = extract_oauth_code_and_state(input).expect_err("expected missing state");
1526        assert!(err.to_string().contains("OAuth state"));
1527    }
1528
1529    #[test]
1530    fn parses_oauth_callback_http_request() {
1531        let request =
1532            "GET /auth/callback?code=abc123&state=xyz789 HTTP/1.1\r\nHost: localhost:1455\r\n\r\n";
1533        let (code, state) =
1534            parse_oauth_callback_request(request).expect("expected valid callback request");
1535        assert_eq!(code, "abc123");
1536        assert_eq!(state, "xyz789");
1537    }
1538
1539    #[test]
1540    fn parses_post_callback_with_query_params() {
1541        let request =
1542            "POST /auth/callback?code=abc123&state=xyz789 HTTP/1.1\r\nHost: localhost:1455\r\n\r\n";
1543        let (code, state) =
1544            parse_oauth_callback_request(request).expect("expected POST callback parse");
1545        assert_eq!(code, "abc123");
1546        assert_eq!(state, "xyz789");
1547    }
1548
1549    #[test]
1550    fn parses_post_form_encoded_callback_request() {
1551        let request = "POST /auth/callback HTTP/1.1\r\nHost: localhost:1455\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 25\r\n\r\ncode=abc123&state=xyz789";
1552        let (code, state) =
1553            parse_oauth_callback_request(request).expect("expected form POST callback parse");
1554        assert_eq!(code, "abc123");
1555        assert_eq!(state, "xyz789");
1556    }
1557
1558    #[test]
1559    fn rejects_unsupported_callback_method() {
1560        let request = "OPTIONS /auth/callback HTTP/1.1\r\nHost: localhost:1455\r\n\r\n";
1561        let err = parse_oauth_callback_request(request)
1562            .expect_err("expected unsupported callback method");
1563        assert!(err.to_string().contains("Unsupported callback method"));
1564    }
1565
1566    #[test]
1567    fn parses_codex_device_interval_from_string() {
1568        let parsed: CodexDeviceCodeResponse = serde_json::from_str(
1569            r#"{"device_auth_id":"id-1","user_code":"ABCD-EFGH","interval":"7"}"#,
1570        )
1571        .expect("expected valid device-code payload");
1572        assert_eq!(parsed.interval, 7);
1573    }
1574
1575    #[test]
1576    fn parses_codex_device_interval_from_number() {
1577        let parsed: CodexDeviceCodeResponse = serde_json::from_str(
1578            r#"{"device_auth_id":"id-1","user_code":"ABCD-EFGH","interval":9}"#,
1579        )
1580        .expect("expected valid numeric interval payload");
1581        assert_eq!(parsed.interval, 9);
1582    }
1583
1584    #[test]
1585    fn parses_netscape_cookie_with_httponly_prefix() {
1586        let line = "#HttpOnly_.nextdoor.com\tTRUE\t/\tTRUE\t1803495701\tndbr_at\ttoken123";
1587        let parsed = parse_netscape_cookie_line(line).expect("expected cookie parse");
1588        assert_eq!(parsed.domain, ".nextdoor.com");
1589        assert!(parsed.http_only);
1590        assert_eq!(parsed.name, "ndbr_at");
1591    }
1592
1593    #[test]
1594    fn nextdoor_filter_keeps_auth_cookies_only() {
1595        let rows = vec![
1596            parse_netscape_cookie_line(
1597                ".nextdoor.com\tTRUE\t/\tTRUE\t4803495701\tndbr_at\tauth-token",
1598            )
1599            .expect("auth cookie"),
1600            parse_netscape_cookie_line(".nextdoor.com\tTRUE\t/\tFALSE\t4803495701\t_ga\ttracking")
1601                .expect("tracking cookie"),
1602        ];
1603        let (selected, dropped_expired, dropped_non_auth) =
1604            select_cookie_rows(&rows, "nextdoor-web", false);
1605        assert_eq!(selected.len(), 1);
1606        assert_eq!(selected[0].name, "ndbr_at");
1607        assert_eq!(dropped_expired, 0);
1608        assert_eq!(dropped_non_auth, 1);
1609    }
1610}