1use 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 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 #[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 println!("Logging in...");
266 let login = login_with_password(&client, &server_url, ®.email, &password).await?;
267 let cred_path = write_saved_credentials(&server_url, ®.email, &login)?;
268
269 let user_email = login
270 .user
271 .get("email")
272 .and_then(|v| v.as_str())
273 .unwrap_or(®.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 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 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
327fn rpassword_prompt(prompt: &str) -> Result<String> {
329 print!("{}", prompt);
330 io::stdout().flush()?;
331
332 #[cfg(unix)]
334 {
335 use std::io::BufRead;
336 let fd = 0; let orig = unsafe {
339 let mut termios = std::mem::zeroed::<libc::termios>();
340 libc::tcgetattr(fd, &mut termios);
341 termios
342 };
343
344 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 unsafe {
356 libc::tcsetattr(fd, libc::TCSANOW, &orig);
357 }
358 println!(); 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
372fn 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#[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
390pub 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 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}