1use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5
6use clap::{Args, Subcommand, ValueEnum};
7use crossterm::event::{Event, KeyCode, KeyEventKind, read};
8use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
9
10use super::setup::{ProviderAuthKind, SetupProviderArg};
11use crate::cli::Cli;
12use crate::context;
13use crate::thndrs_core::auth;
14
15#[derive(Clone, Debug, Eq, PartialEq, Args)]
17pub struct LoginCommand {
18 pub provider: SetupProviderArg,
20 #[arg(long, value_enum, default_value = "browser")]
22 pub oauth_method: ChatGptOAuthMethod,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
27#[value(rename_all = "kebab-case")]
28pub enum ChatGptOAuthMethod {
29 Browser,
31 DeviceCode,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq, Args)]
37pub struct LogoutCommand {
38 pub provider: SetupProviderArg,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
44pub enum AuthCommand {
45 Status,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum CredentialScope {
52 Global,
54 Project,
56}
57
58impl CredentialScope {
59 pub fn label(self) -> &'static str {
61 match self {
62 CredentialScope::Global => "global",
63 CredentialScope::Project => "project",
64 }
65 }
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum ProviderCredentialHealth {
71 Verified,
73 Rejected,
75 Unavailable,
77}
78
79pub fn run_login(cli: &Cli, command: &LoginCommand) -> io::Result<()> {
81 let stdout = io::stdout();
82 let mut writer = stdout.lock();
83 require_interactive("login")?;
84
85 if command.provider == SetupProviderArg::ChatgptCodex {
86 return run_chatgpt_codex_login_with_method(&mut writer, command.oauth_method);
87 }
88
89 let workspace = context::discover_workspace_root(&cli.cwd);
90 let env_var = command
91 .provider
92 .api_key_env_var()
93 .expect("non-ChatGPT login providers use API keys");
94 if matches!(
95 auth::credential_source(env_var, &workspace),
96 Some(auth::CredentialSource::Environment)
97 ) {
98 writeln!(
99 writer,
100 "{env_var} is set in the environment and takes precedence over stored credentials."
101 )?;
102 if !confirm(&mut writer, "Store a credential anyway?")? {
103 writeln!(writer, "login cancelled")?;
104 return Ok(());
105 }
106 }
107
108 let scope = prompt_scope(&mut writer)?;
109 let api_key = read_hidden_api_key(&mut writer, command.provider)?;
110 let api_key = api_key.trim();
111 if api_key.is_empty() {
112 return Err(io::Error::new(io::ErrorKind::InvalidInput, "API key cannot be empty"));
113 }
114 if !confirm(
115 &mut writer,
116 &format!(
117 "Store {} credential in {} store?",
118 command.provider.label(),
119 scope.label()
120 ),
121 )? {
122 writeln!(writer, "login cancelled")?;
123 return Ok(());
124 }
125
126 let health = check_provider_key(command.provider, api_key);
127 store_api_key_credential(scope, &workspace, command.provider, env_var, api_key, health)?;
128 writeln!(
129 writer,
130 "{} credential stored in {}",
131 command.provider.label(),
132 scope.label()
133 )?;
134 match health {
135 ProviderCredentialHealth::Verified => writeln!(writer, "validation: ok")?,
136 ProviderCredentialHealth::Unavailable => writeln!(
137 writer,
138 "validation: unavailable; credential stored but not verified. Retry `thndrs setup --provider {}` before coding.",
139 command.provider.label()
140 )?,
141 ProviderCredentialHealth::Rejected => return Err(credential_rejected_error(command.provider)),
142 }
143 Ok(())
144}
145
146pub fn run_logout(cli: &Cli, command: &LogoutCommand) -> io::Result<()> {
148 let stdout = io::stdout();
149 let mut writer = stdout.lock();
150 require_interactive("logout")?;
151
152 if command.provider == SetupProviderArg::ChatgptCodex {
153 if !confirm(
154 &mut writer,
155 "Remove ChatGPT Codex credentials from ~/.thndrs/auth.json?",
156 )? {
157 writeln!(writer, "logout cancelled")?;
158 return Ok(());
159 }
160 auth::remove_chatgpt_codex_credentials().map_err(io::Error::other)?;
161 writeln!(writer, "chatgpt-codex credential removed")?;
162 if std::env::var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV).is_ok_and(|value| !value.is_empty()) {
163 writeln!(
164 writer,
165 "{} is still set in the environment, so chatgpt-codex remains authenticated through the environment.",
166 auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV
167 )?;
168 }
169 return Ok(());
170 }
171
172 let workspace = context::discover_workspace_root(&cli.cwd);
173 let scope = prompt_scope(&mut writer)?;
174 if !confirm(
175 &mut writer,
176 &format!(
177 "Remove {} credential from {} store?",
178 command.provider.label(),
179 scope.label()
180 ),
181 )? {
182 writeln!(writer, "logout cancelled")?;
183 return Ok(());
184 }
185
186 let path = credential_path(scope, &workspace)?;
187 let env_var = command
188 .provider
189 .api_key_env_var()
190 .expect("non-ChatGPT logout providers use API keys");
191 auth::remove_credential(&path, env_var).map_err(io::Error::other)?;
192 writeln!(
193 writer,
194 "{} credential removed from {}",
195 command.provider.label(),
196 scope.label()
197 )?;
198 if matches!(
199 auth::credential_source(env_var, &workspace),
200 Some(auth::CredentialSource::Environment)
201 ) {
202 writeln!(
203 writer,
204 "{} is still set in the environment, so {} remains authenticated through the environment.",
205 env_var,
206 command.provider.label()
207 )?;
208 }
209 Ok(())
210}
211
212pub fn run_auth(cli: &Cli, command: &AuthCommand) -> io::Result<()> {
214 match command {
215 AuthCommand::Status => {
216 let stdout = io::stdout();
217 let mut writer = stdout.lock();
218 let workspace = context::discover_workspace_root(&cli.cwd);
219 write_auth_status(&workspace, &mut writer)
220 }
221 }
222}
223
224pub fn write_auth_status<W: Write>(workspace: &Path, writer: &mut W) -> io::Result<()> {
226 for provider in SetupProviderArg::ALL {
227 let source = match provider.metadata().auth_kind {
228 ProviderAuthKind::ApiKey { env_var } => auth::credential_source(env_var, workspace)
229 .map(|source| source.label().to_string())
230 .unwrap_or_else(|| String::from("missing")),
231 ProviderAuthKind::ChatGptOAuth { .. } => chatgpt_codex_status(),
232 };
233 writeln!(writer, "{}\t{}", provider.label(), source)?;
234 }
235 Ok(())
236}
237
238pub fn credential_path(scope: CredentialScope, workspace: &Path) -> io::Result<PathBuf> {
240 match scope {
241 CredentialScope::Global => auth::global_credentials_path().map_err(io::Error::other),
242 CredentialScope::Project => Ok(auth::project_credentials_path(workspace)),
243 }
244}
245
246fn store_api_key_credential(
247 scope: CredentialScope, workspace: &Path, provider: SetupProviderArg, env_var: &str, api_key: &str,
248 health: ProviderCredentialHealth,
249) -> io::Result<()> {
250 if health == ProviderCredentialHealth::Rejected {
251 return Err(credential_rejected_error(provider));
252 }
253
254 let path = credential_path(scope, workspace)?;
255 auth::set_credential(&path, env_var, api_key).map_err(io::Error::other)?;
256 if scope == CredentialScope::Project {
257 auth::ensure_git_exclude(workspace).map_err(io::Error::other)?;
258 }
259 Ok(())
260}
261
262pub fn check_provider_key(provider: SetupProviderArg, api_key: &str) -> ProviderCredentialHealth {
267 let result = match provider {
268 SetupProviderArg::Umans => crate::providers::umans::probe_api_key(api_key),
269 SetupProviderArg::OpencodeGo => crate::providers::opencode::probe_go_api_key(api_key),
270 SetupProviderArg::OpencodeZen => crate::providers::opencode::probe_zen_api_key(api_key),
271 SetupProviderArg::ChatgptCodex => return ProviderCredentialHealth::Rejected,
272 };
273 classify_provider_key_result(result)
274}
275
276pub fn check_chatgpt_codex_auth(workspace: &Path) -> ProviderCredentialHealth {
278 classify_provider_key_result(crate::providers::chatgpt_codex::probe_env_or_dotenv_authentication(
279 workspace,
280 ))
281}
282
283pub fn validate_provider_key(provider: SetupProviderArg, api_key: &str) -> Result<(), String> {
285 match provider {
286 SetupProviderArg::Umans => crate::providers::umans::validate_api_key(api_key),
287 SetupProviderArg::OpencodeGo => crate::providers::opencode::validate_go_api_key(api_key),
288 SetupProviderArg::OpencodeZen => crate::providers::opencode::validate_zen_api_key(api_key),
289 SetupProviderArg::ChatgptCodex => Err("ChatGPT Codex uses OAuth login, not API-key validation".to_string()),
290 }
291}
292
293fn classify_provider_key_result(result: crate::providers::Result<()>) -> ProviderCredentialHealth {
294 match result {
295 Ok(()) => ProviderCredentialHealth::Verified,
296 Err(error) if error.is_credential_rejected() => ProviderCredentialHealth::Rejected,
297 Err(_) => ProviderCredentialHealth::Unavailable,
298 }
299}
300
301pub fn credential_rejected_error(provider: SetupProviderArg) -> io::Error {
303 io::Error::new(
304 io::ErrorKind::PermissionDenied,
305 format!(
306 "{} rejected the credential; run `thndrs login {}` to replace it",
307 provider.label(),
308 provider.label()
309 ),
310 )
311}
312
313pub fn credential_rejected_error_for_source(provider: SetupProviderArg, source: auth::CredentialSource) -> io::Error {
315 if source == auth::CredentialSource::Environment {
316 let env_var = match provider.metadata().auth_kind {
317 ProviderAuthKind::ApiKey { env_var } => env_var,
318 ProviderAuthKind::ChatGptOAuth { env_override } => env_override,
319 };
320 return io::Error::new(
321 io::ErrorKind::PermissionDenied,
322 format!(
323 "{} rejected the credential from {env_var}; replace or unset {env_var} before using `thndrs login {}`",
324 provider.label(),
325 provider.label()
326 ),
327 );
328 }
329
330 credential_rejected_error(provider)
331}
332
333pub fn confirm<W: Write>(writer: &mut W, prompt: &str) -> io::Result<bool> {
335 write!(writer, "{prompt} [y/N]: ")?;
336 writer.flush()?;
337 let mut answer = String::new();
338 io::stdin().read_line(&mut answer)?;
339 let answer = answer.trim().to_ascii_lowercase();
340 Ok(answer == "y" || answer == "yes")
341}
342
343pub fn prompt_scope<W: Write>(writer: &mut W) -> io::Result<CredentialScope> {
345 writeln!(writer, "Choose credential store:")?;
346 writeln!(writer, " 1) global (~/.thndrs/credentials.env)")?;
347 writeln!(writer, " 2) project (.thndrs/credentials.env)")?;
348 write!(writer, "Store [global/project]: ")?;
349 writer.flush()?;
350
351 let mut answer = String::new();
352 io::stdin().read_line(&mut answer)?;
353 match answer.trim().to_ascii_lowercase().as_str() {
354 "" | "g" | "global" | "1" => Ok(CredentialScope::Global),
355 "p" | "project" | "2" => Ok(CredentialScope::Project),
356 _ => Err(io::Error::new(
357 io::ErrorKind::InvalidInput,
358 "expected `global` or `project`",
359 )),
360 }
361}
362
363pub fn read_hidden_api_key<W: Write>(writer: &mut W, provider: SetupProviderArg) -> io::Result<String> {
365 let label = match provider {
366 SetupProviderArg::Umans => "Umans Code API key".to_string(),
367 _ => format!("{} API key", provider.label()),
368 };
369 write!(writer, "Enter {label} (input hidden): ")?;
370 writer.flush()?;
371 enable_raw_mode()?;
372 let result = read_hidden_line();
373 let _ = disable_raw_mode();
374 writeln!(writer)?;
375 result
376}
377
378fn require_interactive(command: &str) -> io::Result<()> {
379 if io::stdin().is_terminal() {
380 Ok(())
381 } else {
382 Err(io::Error::new(
383 io::ErrorKind::InvalidInput,
384 format!("{command} requires an interactive terminal; use provider env vars for non-interactive setup"),
385 ))
386 }
387}
388
389fn read_hidden_line() -> io::Result<String> {
390 let mut value = String::new();
391 loop {
392 match read()? {
393 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
394 KeyCode::Enter => return Ok(value),
395 KeyCode::Esc => return Err(io::Error::new(io::ErrorKind::Interrupted, "API key entry cancelled")),
396 KeyCode::Backspace => {
397 value.pop();
398 }
399 KeyCode::Char(ch) => value.push(ch),
400 _ => {}
401 },
402 _ => {}
403 }
404 }
405}
406
407pub fn run_chatgpt_codex_login<W: Write>(writer: &mut W) -> io::Result<()> {
409 run_chatgpt_codex_login_with_method(writer, ChatGptOAuthMethod::Browser)
410}
411
412pub fn run_chatgpt_codex_login_with_method<W: Write>(writer: &mut W, method: ChatGptOAuthMethod) -> io::Result<()> {
414 run_chatgpt_codex_login_with(
415 writer,
416 method,
417 auth::request_chatgpt_codex_device_code,
418 auth::poll_chatgpt_codex_device_code,
419 auth::login_chatgpt_codex_with_browser_pkce,
420 auth::write_chatgpt_codex_credentials,
421 )
422}
423
424fn run_chatgpt_codex_login_with<W, Request, Poll, Browser, Store>(
425 writer: &mut W, method: ChatGptOAuthMethod, request_device_code: Request, poll_device_code: Poll,
426 browser_pkce: Browser, store_credentials: Store,
427) -> io::Result<()>
428where
429 W: Write,
430 Request: FnOnce() -> Result<auth::ChatGptCodexDeviceCode, auth::AuthError>,
431 Poll: FnOnce(&auth::ChatGptCodexDeviceCode) -> Result<auth::ChatGptCodexCredentials, auth::AuthError>,
432 Browser: FnOnce() -> Result<auth::ChatGptCodexCredentials, auth::AuthError>,
433 Store: FnOnce(&auth::ChatGptCodexCredentials) -> Result<(), auth::AuthError>,
434{
435 writeln!(
436 writer,
437 "ChatGPT Codex login uses ChatGPT OAuth and stores credentials in ~/.thndrs/auth.json"
438 )?;
439 let credentials = match method {
440 ChatGptOAuthMethod::Browser => {
441 writeln!(
442 writer,
443 "Browser PKCE is selected. The authorization URL will open or be shown for copying."
444 )?;
445 browser_pkce().map_err(io::Error::other)?
446 }
447 ChatGptOAuthMethod::DeviceCode => {
448 let code = request_device_code().map_err(io::Error::other)?;
449 writeln!(
450 writer,
451 "Headless device-code login selected. Open {} and enter code {}",
452 code.verification_uri
453 .as_deref()
454 .unwrap_or("https://auth.openai.com/codex/device"),
455 code.user_code
456 )?;
457 writer.flush()?;
458 poll_device_code(&code).map_err(io::Error::other)?
459 }
460 };
461 store_credentials(&credentials).map_err(io::Error::other)?;
462 writeln!(writer, "chatgpt-codex credential stored in global auth store")?;
463 Ok(())
464}
465
466fn chatgpt_codex_status() -> String {
467 if std::env::var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV).is_ok_and(|value| !value.is_empty()) {
468 return "environment".to_string();
469 }
470 match auth::read_chatgpt_codex_credentials() {
471 Ok(Some(_)) => "global auth".to_string(),
472 Ok(None) => "missing".to_string(),
473 Err(_) => "invalid auth store".to_string(),
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn provider_key_check_distinguishes_rejection_from_unavailability() {
483 assert_eq!(
484 classify_provider_key_result(Err(crate::providers::ProviderError::Status {
485 code: 401,
486 body: "unauthorized".to_string(),
487 })),
488 ProviderCredentialHealth::Rejected
489 );
490 assert_eq!(
491 classify_provider_key_result(Err(crate::providers::ProviderError::Status {
492 code: 503,
493 body: "unavailable".to_string(),
494 })),
495 ProviderCredentialHealth::Unavailable
496 );
497 assert_eq!(
498 classify_provider_key_result(Err(crate::providers::ProviderError::Http("offline".to_string()))),
499 ProviderCredentialHealth::Unavailable
500 );
501 assert_eq!(
502 classify_provider_key_result(Err(crate::providers::ProviderError::Json(
503 "invalid catalog".to_string()
504 ))),
505 ProviderCredentialHealth::Unavailable
506 );
507 assert_eq!(
508 classify_provider_key_result(Err(crate::providers::ProviderError::AuthUnavailable(
509 "refresh endpoint unavailable".to_string(),
510 ))),
511 ProviderCredentialHealth::Unavailable
512 );
513 }
514
515 #[test]
516 fn environment_credential_rejection_explains_precedence() {
517 let error = credential_rejected_error_for_source(SetupProviderArg::Umans, auth::CredentialSource::Environment);
518
519 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
520 assert!(error.to_string().contains("replace or unset UMANS_API_KEY"));
521 assert!(error.to_string().contains("thndrs login umans"));
522 }
523
524 #[test]
525 fn rejected_login_credential_is_not_persisted() {
526 let tmp = tempfile::tempdir().expect("tempdir");
527 let workspace = tmp.path().join("workspace");
528 std::fs::create_dir_all(&workspace).expect("workspace");
529 let path = auth::project_credentials_path(&workspace);
530
531 let error = store_api_key_credential(
532 CredentialScope::Project,
533 &workspace,
534 SetupProviderArg::Umans,
535 auth::UMANS_API_KEY_ENV,
536 "rejected-key",
537 ProviderCredentialHealth::Rejected,
538 )
539 .expect_err("rejected credential");
540
541 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
542 assert!(!path.exists());
543 }
544
545 #[test]
546 fn auth_status_does_not_print_values() {
547 let tmp = tempfile::tempdir().expect("tempdir");
548 let workspace = tmp.path();
549 auth::set_credential(
550 &auth::project_credentials_path(workspace),
551 auth::UMANS_API_KEY_ENV,
552 "sk-secret-value",
553 )
554 .expect("store credential");
555
556 let mut output = Vec::new();
557 write_auth_status(workspace, &mut output).expect("status");
558 let output = String::from_utf8(output).expect("utf8");
559
560 assert!(output.contains("umans\tproject credentials"));
561 assert!(output.contains("opencode-go\tmissing"));
562 assert!(output.contains("opencode-zen\tmissing"));
563 assert!(!output.contains("sk-secret-value"));
564 }
565
566 #[test]
567 fn credential_scope_labels_are_stable() {
568 assert_eq!(CredentialScope::Global.label(), "global");
569 assert_eq!(CredentialScope::Project.label(), "project");
570 }
571
572 #[test]
573 fn chatgpt_oauth_login_output_does_not_print_tokens() {
574 let code = auth::ChatGptCodexDeviceCode {
575 device_auth_id: "device-auth-secret-from-test".to_string(),
576 user_code: "USER-CODE".to_string(),
577 verification_uri: Some("https://auth.example.test/device".to_string()),
578 verification_uri_complete: None,
579 expires_in: Some(900),
580 interval: Some(1),
581 };
582 let credentials = auth::ChatGptCodexCredentials {
583 access_token: "access-token-secret-from-test".to_string(),
584 refresh_token: "refresh-token-secret-from-test".to_string(),
585 expires_at_ms: 123,
586 account_id: "acct_test".to_string(),
587 };
588 let mut output = Vec::new();
589
590 run_chatgpt_codex_login_with(
591 &mut output,
592 ChatGptOAuthMethod::DeviceCode,
593 || Ok(code),
594 |_| Ok(credentials),
595 || panic!("browser PKCE should not be used for explicit device-code login"),
596 |_| Ok(()),
597 )
598 .expect("login");
599
600 let output = String::from_utf8(output).expect("utf8");
601 assert!(output.contains("USER-CODE"));
602 assert!(output.contains("Headless device-code login selected"));
603 assert!(!output.contains("device-token-secret-from-test"));
604 assert!(!output.contains("access-token-secret-from-test"));
605 assert!(!output.contains("refresh-token-secret-from-test"));
606 }
607
608 #[test]
609 fn chatgpt_oauth_methods_never_fall_back_to_each_other() {
610 let mut output = Vec::new();
611 let error = run_chatgpt_codex_login_with(
612 &mut output,
613 ChatGptOAuthMethod::DeviceCode,
614 || Err(auth::AuthError::ChatGptCodex("device unavailable".to_string())),
615 |_| unreachable!("poll should not run after request failure"),
616 || panic!("device-code failure must not fall back to browser"),
617 |_| Ok(()),
618 )
619 .expect_err("explicit device failure should be returned");
620
621 assert!(error.to_string().contains("device unavailable"));
622 assert!(!String::from_utf8(output).expect("utf8").contains("falling back"));
623 }
624}