Skip to main content

thndrs_lib/cli/commands/
auth.rs

1//! `thndrs login`, `thndrs logout`, and `thndrs auth` command definitions.
2
3use 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/// Store one provider credential.
16#[derive(Clone, Debug, Eq, PartialEq, Args)]
17pub struct LoginCommand {
18    /// Provider whose credential should be stored.
19    pub provider: SetupProviderArg,
20    /// ChatGPT OAuth method. Browser PKCE is the default; device code is explicit.
21    #[arg(long, value_enum, default_value = "browser")]
22    pub oauth_method: ChatGptOAuthMethod,
23}
24
25/// ChatGPT Codex OAuth method for the headless login command.
26#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
27#[value(rename_all = "kebab-case")]
28pub enum ChatGptOAuthMethod {
29    /// Browser PKCE with a short-lived loopback callback.
30    Browser,
31    /// Device code for an explicitly selected headless or remote login.
32    DeviceCode,
33}
34
35/// Remove one stored provider credential.
36#[derive(Clone, Debug, Eq, PartialEq, Args)]
37pub struct LogoutCommand {
38    /// Provider whose stored credential should be removed.
39    pub provider: SetupProviderArg,
40}
41
42/// Authentication inspection commands.
43#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
44pub enum AuthCommand {
45    /// Show provider credential sources without values.
46    Status,
47}
48
49/// Scope for a stored credential.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum CredentialScope {
52    /// Store in `~/.thndrs/credentials.env`.
53    Global,
54    /// Store in `<workspace>/.thndrs/credentials.env`.
55    Project,
56}
57
58impl CredentialScope {
59    /// Human-readable storage label.
60    pub fn label(self) -> &'static str {
61        match self {
62            CredentialScope::Global => "global",
63            CredentialScope::Project => "project",
64        }
65    }
66}
67
68/// Result of a lightweight provider credential check.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum ProviderCredentialHealth {
71    /// The provider accepted the credential.
72    Verified,
73    /// The provider rejected the credential or its permissions.
74    Rejected,
75    /// A transport or service failure prevented verification.
76    Unavailable,
77}
78
79/// Run `thndrs login`.
80pub 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
146/// Run `thndrs logout`.
147pub 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
212/// Run `thndrs auth`.
213pub 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
224/// Write redacted provider credential status.
225pub 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
238/// Resolve the credential file path for a storage scope.
239pub 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
262/// Check a provider key with a lightweight provider request.
263///
264/// Rejections are distinct from transient verification failures so setup does
265/// not send a user to replace a credential during an outage.
266pub 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
276/// Check the resolved ChatGPT Codex OAuth credential with the model catalog.
277pub 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
283/// Validate a provider key without persisting provider payloads.
284pub 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
301/// Build the safe recovery error for a rejected stored credential.
302pub 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
313/// Build the safe recovery error for a rejected credential with known precedence.
314pub 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
333/// Ask for yes/no confirmation.
334pub 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
343/// Prompt for the credential storage scope.
344pub 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
363/// Read an API key from the terminal without echoing typed characters.
364pub 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
407/// Run the shared ChatGPT Codex OAuth login flow.
408pub fn run_chatgpt_codex_login<W: Write>(writer: &mut W) -> io::Result<()> {
409    run_chatgpt_codex_login_with_method(writer, ChatGptOAuthMethod::Browser)
410}
411
412/// Run ChatGPT Codex login with an explicit OAuth method.
413pub 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}