Skip to main content

rpi_cli/
auth.rs

1//! `rpi auth` subcommand — persistent credential management. Mirrors the
2//! Rust-relevant slice of the TS `packages/coding-agent/src/cli/auth-command.ts`
3//! + `main.ts:runAuthCommand` + `core/auth-check.ts`.
4//!
5//! The host owns credential storage and standard login/check/logout/refresh
6//! commands. Packages own provider-specific policy and OAuth implementations.
7//!
8//! - `rpi auth login`  — legacy API-key prompt (no echo, via `rpassword`) for
9//!   the default provider and persist it to `~/.rpi/auth.json`.
10//!   Mirrors the upstream `/login` TUI's `type:"secret"` prompt + `modify`.
11//! - `rpi auth check`  — local-only probe of `auth.json`, models.json and the
12//!   provider's declared policy; reports `ready`/`not_ready` (no network call — the upstream
13//!   `--no-refresh` equivalent). `--json` emits a structured result.
14//! - `rpi auth logout` — drop the selected provider entry from `auth.json`
15//!   (env vars are left untouched).
16//!
17//! # Not ported (deferred — see the initial port notes (retired))
18//!
19//! Provider-specific OAuth implementations belong in extensions. The host only
20//! understands the standard OAuth action response and scoped credential format.
21
22use std::io::{self, Write};
23use std::sync::Arc;
24
25use crate::config::{
26    self, delete_credential, read_auth, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
27};
28use crate::credentials::CredentialSource;
29use rpi_runtime::credentials::{oauth_needs_refresh, OAuthRefresher, RefreshedOAuth};
30/// Exit code for a missing-credential `auth check` (mirrors upstream's
31/// non-zero `auth check` when not ready).
32const EXIT_NOT_READY: i32 = 1;
33/// Exit code for an operational error (IO failure, bad args).
34const EXIT_ERROR: i32 = 2;
35
36/// `rpi auth <sub> [args]` entry. `args` is the slice *after* `auth` (i.e. the
37/// subcommand + its flags). Returns the process exit code. Async to match the
38/// `app::run` shape, though v1 does no async work here.
39pub async fn run(args: &[String]) -> i32 {
40    run_with_extensions(args, None).await
41}
42
43/// Run auth with an optional extension snapshot. The caller must keep the
44/// matching extension session alive until this future completes.
45pub async fn run_with_extensions(
46    args: &[String],
47    snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
48) -> i32 {
49    let sub = args.first().map(|s| s.as_str()).unwrap_or("");
50    match sub {
51        "login" => run_login(&args[1..], snapshot.as_ref()).await,
52        "check" | "status" => run_check(&args[1..], snapshot.as_ref()).await,
53        "refresh" => run_refresh(&args[1..], snapshot.as_ref()).await,
54        "logout" => run_logout(&args[1..], snapshot.as_ref()).await,
55        "--help" | "-h" | "help" | "" => {
56            print_auth_help();
57            0
58        }
59        other => {
60            eprintln!("error: unknown auth subcommand \"{other}\"");
61            eprintln!();
62            print_auth_help();
63            EXIT_ERROR
64        }
65    }
66}
67
68/// `rpi auth login [--provider <id>]` — prompt for a key and persist it.
69async fn run_login(
70    args: &[String],
71    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
72) -> i32 {
73    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
74    if let Some(snapshot) = snapshot {
75        if snapshot.oauth_provider(provider).is_some() {
76            return run_oauth_login(provider, snapshot).await;
77        }
78    }
79    // Compatibility API-key login. This path is provider-neutral; OAuth
80    // behavior is supplied by the matching extension above.
81    eprint!("Enter API key for {provider}: ");
82    let key = match rpassword::read_password() {
83        Ok(k) => k,
84        Err(e) => {
85            eprintln!();
86            eprintln!("error: could not read the key from the terminal: {e}");
87            return EXIT_ERROR;
88        }
89    };
90    eprintln!();
91    let key = key.trim();
92    if key.is_empty() {
93        eprintln!("error: an empty key was entered; nothing saved.");
94        return EXIT_ERROR;
95    }
96    let cred = Credential::ApiKey {
97        key: Some(key.to_string()),
98        env: None,
99    };
100    if let Err(e) = upsert_credential(provider, cred) {
101        eprintln!("error: could not save credentials: {e}");
102        return EXIT_ERROR;
103    }
104    let path = match config::auth_path() {
105        Ok(p) => p,
106        Err(e) => {
107            eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
108            return 0;
109        }
110    };
111    println!("Credentials saved to {}", path.display());
112    println!("Run `rpi auth check --provider {provider}` to verify.");
113    0
114}
115
116/// `rpi auth check [--provider <id>] [--json]` — local readiness probe.
117async fn run_check(
118    args: &[String],
119    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
120) -> i32 {
121    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
122    let want_json = args.iter().any(|a| a == "--json");
123
124    let source = match detect_credential(provider, snapshot) {
125        Ok(source) => source,
126        Err(error) => {
127            eprintln!("error: could not read authentication configuration: {error}");
128            return EXIT_ERROR;
129        }
130    };
131    let ready = source.is_present();
132    if want_json {
133        let json = serde_json::json!({
134            "ready": ready,
135            "provider": provider,
136            "source": source.as_json_str(),
137        });
138        println!("{json}");
139    } else if ready {
140        println!("ready ({})", source.as_json_str());
141    } else {
142        println!("not_ready — no credentials found.");
143        eprintln!();
144        eprintln!("Run `rpi auth login --provider {provider}`, configure this provider's apiKey in models.json, or pass --api-key when starting rpi.");
145        let env = match snapshot.and_then(|snapshot| snapshot.provider_manifest(provider)) {
146            Some(manifest) => manifest.api_key_env.as_deref(),
147            None => crate::credentials::builtin_api_key_env(provider),
148        };
149        if let Some(env) = env {
150            eprintln!("You can also set {env}.");
151        }
152    }
153    if ready {
154        0
155    } else {
156        EXIT_NOT_READY
157    }
158}
159
160/// `rpi auth refresh [--provider <id>]` — refresh a stored OAuth credential.
161async fn run_refresh(
162    args: &[String],
163    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
164) -> i32 {
165    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
166    let Some(snapshot) = snapshot else {
167        eprintln!("error: no OAuth extensions are loaded");
168        return EXIT_ERROR;
169    };
170    match refresh_stored_oauth(snapshot, provider, false) {
171        Ok(_) => {
172            println!("OAuth credentials refreshed for \"{provider}\".");
173            0
174        }
175        Err(RefreshError::NotStored) => {
176            eprintln!("error: no stored OAuth credentials for \"{provider}\"");
177            EXIT_NOT_READY
178        }
179        Err(error) => {
180            eprintln!("error: {error}");
181            EXIT_ERROR
182        }
183    }
184}
185
186/// Refresh errors must never put provider output or credential JSON in a transcript.
187enum RefreshError {
188    NotStored,
189    Read,
190    Plugin(rpi_extensions::OAuthExtensionError),
191    Invalid,
192    Save,
193}
194
195impl std::fmt::Display for RefreshError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::NotStored => f.write_str("no stored OAuth credentials"),
199            Self::Read => f.write_str("could not read stored OAuth credentials"),
200            Self::Plugin(rpi_extensions::OAuthExtensionError::PluginStatus(status, _)) => {
201                write!(f, "the provider package returned status {status}")
202            }
203            Self::Plugin(_) => f.write_str("the provider package could not refresh the login"),
204            Self::Invalid => f.write_str("OAuth refresh returned an invalid or expired credential"),
205            Self::Save => f.write_str("could not save refreshed OAuth credentials"),
206        }
207    }
208}
209
210/// Renewal lock for one provider id, created on first use. Renewals of the same
211/// provider serialize on it, so the second waits and then re-reads the store
212/// the first one saved. Renewals of different providers never share a lock.
213fn renewal_lock(provider: &str) -> Arc<std::sync::Mutex<()>> {
214    static LOCKS: std::sync::OnceLock<
215        std::sync::Mutex<std::collections::HashMap<String, Arc<std::sync::Mutex<()>>>>,
216    > = std::sync::OnceLock::new();
217    let locks = LOCKS.get_or_init(Default::default);
218    let mut locks = locks
219        .lock()
220        .unwrap_or_else(std::sync::PoisonError::into_inner);
221    Arc::clone(locks.entry(provider.to_owned()).or_default())
222}
223
224/// Refresh `provider`'s stored OAuth credential through its package and save
225/// the result. Shared by `rpi auth refresh` and request-time renewal.
226fn refresh_stored_oauth(
227    snapshot: &Arc<rpi_extensions::RegistrySnapshot>,
228    provider: &str,
229    only_if_needed: bool,
230) -> Result<RefreshedOAuth, RefreshError> {
231    // Serialize renewal of this provider across independently resolved sessions
232    // and manual refresh. The lock is held across the package callback, which may
233    // hang, so it is per provider and never shared with another one. It serializes
234    // one process only: auth.json has no cross-process lock, so two processes may
235    // still both refresh, as two `rpi auth refresh` runs can.
236    let renewal = renewal_lock(provider);
237    let _renewing = renewal
238        .lock()
239        .unwrap_or_else(std::sync::PoisonError::into_inner);
240    let Some(Credential::Oauth {
241        access,
242        refresh,
243        expires,
244    }) = config::read_oauth_token(provider).map_err(|_| RefreshError::Read)?
245    else {
246        return Err(RefreshError::NotStored);
247    };
248    let now = || {
249        std::time::SystemTime::now()
250            .duration_since(std::time::UNIX_EPOCH)
251            .map_or(i64::MAX, |duration| duration.as_secs() as i64)
252    };
253    // A different binding or process may have renewed the credential already.
254    if only_if_needed && !access.trim().is_empty() && !oauth_needs_refresh(expires, now()) {
255        return Ok(RefreshedOAuth { access, expires });
256    }
257    if refresh.trim().is_empty() {
258        return Err(RefreshError::Invalid);
259    }
260    let started_from = (access.clone(), refresh.clone(), expires);
261    let response = rpi_extensions::request_oauth(
262        snapshot,
263        provider,
264        "refresh",
265        serde_json::json!({"credential": {"access": access, "refresh": refresh, "expires_at": expires}}),
266    )
267    .map_err(RefreshError::Plugin)?;
268    let response =
269        rpi_extensions::OAuthActionResponse::parse(response).map_err(|_| RefreshError::Invalid)?;
270    if response
271        .payload
272        .get("expires_at")
273        .is_some_and(|value| value.as_i64().is_none())
274        || response
275            .payload
276            .get("refresh")
277            .is_some_and(|value| !value.is_string())
278    {
279        return Err(RefreshError::Invalid);
280    }
281    let token = response
282        .credential()
283        .map_err(|_| RefreshError::Invalid)?
284        .ok_or(RefreshError::Invalid)?;
285    if token.access.trim().is_empty() || (token.expires_at != 0 && token.expires_at <= now()) {
286        return Err(RefreshError::Invalid);
287    }
288    // Providers need not rotate the refresh token on every renewal.
289    let refresh = if token.refresh.trim().is_empty() {
290        refresh
291    } else {
292        token.refresh
293    };
294    let committed = config::replace_oauth_token_if_unchanged(
295        provider,
296        (&started_from.0, &started_from.1, started_from.2),
297        token.access.clone(),
298        refresh,
299        token.expires_at,
300    )
301    .map_err(|_| RefreshError::Save)?;
302    if !committed {
303        // Login and logout don't take the renewal lock, so they can land while
304        // the package call runs. Keep what the user did and drop this result.
305        return match config::read_oauth_token(provider).map_err(|_| RefreshError::Read)? {
306            Some(Credential::Oauth {
307                access, expires, ..
308            }) => Ok(RefreshedOAuth { access, expires }),
309            _ => Err(RefreshError::NotStored),
310        };
311    }
312    Ok(RefreshedOAuth {
313        access: token.access,
314        expires: token.expires_at,
315    })
316}
317
318/// Renews stored OAuth credentials through the loaded provider packages before
319/// a request sends them.
320pub(crate) struct PackageOAuthRefresher {
321    snapshot: Arc<rpi_extensions::RegistrySnapshot>,
322    _keepalive: Arc<rpi_extensions::PluginKeepalive>,
323}
324
325impl PackageOAuthRefresher {
326    pub(crate) fn new(session: &rpi_extensions::ExtensionSession) -> Option<Self> {
327        Some(Self {
328            snapshot: session.snapshot_arc()?,
329            _keepalive: session.keepalive(),
330        })
331    }
332}
333
334impl OAuthRefresher for PackageOAuthRefresher {
335    fn supports(&self, provider: &str) -> bool {
336        self.snapshot.oauth_provider(provider).is_some()
337    }
338
339    fn refresh(&self, provider: &str) -> Result<RefreshedOAuth, String> {
340        refresh_stored_oauth(&self.snapshot, provider, true).map_err(|error| error.to_string())
341    }
342}
343
344async fn run_logout(
345    args: &[String],
346    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
347) -> i32 {
348    let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
349    if let Ok(Some(Credential::Oauth {
350        access, refresh, ..
351    })) = config::read_oauth_token(provider)
352    {
353        let Some(snapshot) = snapshot else {
354            eprintln!("error: cannot revoke OAuth credentials: no OAuth extensions are loaded");
355            return EXIT_ERROR;
356        };
357        let response = match rpi_extensions::request_oauth(
358            snapshot,
359            provider,
360            "revoke",
361            serde_json::json!({"credential": {"access": access, "refresh": refresh}}),
362        ) {
363            Ok(response) => response,
364            Err(error) => {
365                eprintln!("error: OAuth revoke failed; local credentials were kept: {error}");
366                return EXIT_ERROR;
367            }
368        };
369        match rpi_extensions::OAuthActionResponse::parse(response) {
370            Ok(response) if response.kind == rpi_extensions::OAuthResponseKind::Success => {}
371            Ok(_) => {
372                eprintln!(
373                    "error: OAuth revoke did not return success; local credentials were kept"
374                );
375                return EXIT_ERROR;
376            }
377            Err(error) => {
378                eprintln!(
379                    "error: invalid OAuth revoke response; local credentials were kept: {error}"
380                );
381                return EXIT_ERROR;
382            }
383        }
384        /*
385         */
386    }
387    match delete_credential(provider) {
388        Ok(true) => {
389            println!("Removed stored credentials for \"{provider}\".");
390            0
391        }
392        Ok(false) => {
393            println!("No stored credential for \"{provider}\" (nothing to do).");
394            0
395        }
396        Err(e) => {
397            eprintln!("error: could not remove credentials: {e}");
398            EXIT_ERROR
399        }
400    }
401}
402
403/// Pull the `--provider <id>` value from a subcommand's args (defaults to
404/// `None`). Mirrors the TS `--provider` handshake before it falls back to the
405/// default.
406async fn run_oauth_login(provider: &str, snapshot: &Arc<rpi_extensions::RegistrySnapshot>) -> i32 {
407    let mut input = serde_json::json!({});
408    let mut action = "begin";
409    loop {
410        let raw = match rpi_extensions::request_oauth(snapshot, provider, action, input) {
411            Ok(value) => value,
412            Err(error) => {
413                eprintln!("error: OAuth {action} failed: {error}");
414                return EXIT_ERROR;
415            }
416        };
417        let response = match rpi_extensions::OAuthActionResponse::parse(raw) {
418            Ok(response) => response,
419            Err(error) => {
420                eprintln!("error: invalid OAuth response: {error}");
421                return EXIT_ERROR;
422            }
423        };
424        match response.kind {
425            rpi_extensions::OAuthResponseKind::Credential => {
426                let Some(token) = response.credential().ok().flatten() else {
427                    eprintln!("error: OAuth response contained no valid credential");
428                    return EXIT_ERROR;
429                };
430                if let Err(error) = config::upsert_oauth_token(
431                    provider,
432                    token.access,
433                    token.refresh,
434                    token.expires_at,
435                ) {
436                    eprintln!("error: could not save OAuth credentials: {error}");
437                    return EXIT_ERROR;
438                }
439                println!("OAuth credentials saved for \"{provider}\".");
440                return 0;
441            }
442            rpi_extensions::OAuthResponseKind::Error => {
443                eprintln!("error: OAuth provider rejected the request");
444                return EXIT_ERROR;
445            }
446            rpi_extensions::OAuthResponseKind::Success => {
447                eprintln!("error: OAuth login completed without credentials");
448                return EXIT_ERROR;
449            }
450            rpi_extensions::OAuthResponseKind::Interaction => {
451                let display = response.payload.get("display").unwrap_or(&response.payload);
452                if let Some(title) = display.get("title").and_then(serde_json::Value::as_str) {
453                    println!("{title}");
454                }
455                if let Some(url) = display.get("url").and_then(serde_json::Value::as_str) {
456                    println!("{url}");
457                }
458                if let Some(instructions) = display
459                    .get("instructions")
460                    .and_then(serde_json::Value::as_array)
461                {
462                    for instruction in instructions.iter().filter_map(serde_json::Value::as_str) {
463                        println!("{instruction}");
464                    }
465                }
466                eprint!("OAuth input: ");
467                let _ = io::stderr().flush();
468                let mut line = String::new();
469                if io::stdin().read_line(&mut line).is_err() || line.trim().is_empty() {
470                    eprintln!("error: empty OAuth input; nothing saved.");
471                    return EXIT_ERROR;
472                }
473                input = serde_json::json!({"value": line.trim()});
474                action = "exchange";
475            }
476        }
477    }
478}
479
480fn parse_provider(args: &[String]) -> Option<&str> {
481    let mut iter = args.iter();
482    while let Some(a) = iter.next() {
483        if a == "--provider" {
484            if let Some(v) = iter.next() {
485                return Some(v.as_str());
486            }
487        } else if let Some(rest) = a.strip_prefix("--provider=") {
488            return Some(rest);
489        }
490    }
491    None
492}
493
494/// Local readiness uses the same provider identity, precedence and package
495/// policy as requests. It never exposes the resolved credential material.
496fn detect_credential(
497    provider: &str,
498    snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
499) -> Result<CredentialSource, config::ConfigError> {
500    let store = read_auth()?;
501    let models = config::load_models_config()?;
502    let manifests = snapshot.map_or_else(Vec::new, |snapshot| {
503        snapshot
504            .providers()
505            .iter()
506            .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
507            .collect()
508    });
509    Ok(crate::credentials::status(provider, &models, &store, &manifests).source)
510}
511
512/// `rpi auth --help`. Mirrors the TS auth-command usage but scoped to v1.
513fn print_auth_help() {
514    println!(
515        "Usage: {name} auth <subcommand> [options]
516
517Manage persisted credentials and inspect provider package authentication.
518
519Subcommands:
520  login   Prompt for an API key, or run an OAuth provider login.
521  check   Report whether credentials are available (no network call).
522  status  Alias for check; never prints token material.
523  refresh Refresh an OAuth token through its provider extension.
524  logout  Revoke OAuth credentials, then remove the stored credential.
525
526Options:
527  --provider <id>   Provider id (default: anthropic)
528  --extension <path>  Load a provider or OAuth extension explicitly.
529  --no-extensions     Disable extension discovery and package auth declarations.
530  --json            (check only) Emit a {{ready, provider, source}} JSON object
531
532Environment:
533  ANTHROPIC_API_KEY      Fallback API key (x-api-key) when no stored credential.
534  ANTHROPIC_AUTH_TOKEN   Fallback bearer token (Authorization: Bearer).
535  OPENAI_API_KEY         Fallback bearer token for the built-in openai identity.
536  Other providers       Environment names come from installed package declarations.
537
538Notes:
539  `login --provider <id>` uses the registered OAuth extension when one exists;
540  otherwise it is a provider-neutral API-key compatibility prompt. The host
541  only handles standard interaction and credential responses for OAuth.
542  `refresh` and `logout` use extension actions. API-key providers may still
543  use ~/.rpi/agent/models.json or environment variables.
544",
545        name = crate::APP_NAME
546    );
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::config::{
553        auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
554    };
555
556    /// Scope `RPI_CODING_AGENT_DIR` + the `ANTHROPIC_*` env vars to a temp dir
557    /// for the duration of a test. Holds the shared env lock for its whole
558    /// lifetime so it can't race with config/provider env-mutating tests.
559    struct TempConfig {
560        _guard: std::sync::MutexGuard<'static, ()>,
561        _tmp: tempfile::TempDir,
562        prev_dir: Option<std::ffi::OsString>,
563        prev_key: Option<std::ffi::OsString>,
564        prev_tok: Option<std::ffi::OsString>,
565        prev_openai_key: Option<std::ffi::OsString>,
566    }
567    impl TempConfig {
568        fn new() -> Self {
569            let guard = env_lock().lock().unwrap();
570            let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
571            let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
572            let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
573            let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
574            let tmp = tempfile::TempDir::new().unwrap();
575            std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
576            std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
577            std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
578            std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
579            Self {
580                _guard: guard,
581                _tmp: tmp,
582                prev_dir,
583                prev_key,
584                prev_tok,
585                prev_openai_key,
586            }
587        }
588    }
589    impl Drop for TempConfig {
590        fn drop(&mut self) {
591            restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
592            restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
593            restore(
594                crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
595                self.prev_tok.take(),
596            );
597            restore(
598                crate::provider::OPENAI_API_KEY_ENV,
599                self.prev_openai_key.take(),
600            );
601        }
602    }
603    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
604        match prev {
605            Some(v) => std::env::set_var(name, v),
606            None => std::env::remove_var(name),
607        }
608    }
609
610    #[test]
611    fn standard_oauth_credential_response_is_parsed() {
612        let response = rpi_extensions::OAuthActionResponse::parse(serde_json::json!({
613            "version": 1,
614            "kind": "credential",
615            "payload": {
616                "access": "access",
617                "refresh": "refresh",
618                "expires_at": 123
619            }
620        }))
621        .unwrap();
622        let credential = response.credential().unwrap().unwrap();
623        assert_eq!(credential.access, "access");
624        assert_eq!(credential.refresh, "refresh");
625        assert_eq!(credential.expires_at, 123);
626    }
627
628    #[tokio::test]
629    async fn check_not_ready_with_no_credentials() {
630        let _cfg = TempConfig::new();
631        let code = run_check(&[], None).await;
632        assert_eq!(code, EXIT_NOT_READY);
633    }
634
635    #[tokio::test]
636    async fn check_ready_with_stored_credential() {
637        let _cfg = TempConfig::new();
638        upsert_credential(
639            DEFAULT_PROVIDER_ID,
640            Credential::ApiKey {
641                key: Some("sk-stored".into()),
642                env: None,
643            },
644        )
645        .unwrap();
646        let code = run_check(&[], None).await;
647        assert_eq!(code, 0);
648    }
649
650    #[tokio::test]
651    async fn check_ready_with_oauth_credential() {
652        let _cfg = TempConfig::new();
653        crate::config::upsert_oauth_token(
654            DEFAULT_PROVIDER_ID,
655            "access".into(),
656            "refresh".into(),
657            0,
658        )
659        .unwrap();
660        assert_eq!(run_check(&[], None).await, 0);
661    }
662
663    #[tokio::test]
664    async fn check_rejects_expired_oauth_credential() {
665        let _cfg = TempConfig::new();
666        crate::config::upsert_oauth_token(
667            DEFAULT_PROVIDER_ID,
668            "access".into(),
669            "refresh".into(),
670            1,
671        )
672        .unwrap();
673        assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
674    }
675
676    #[tokio::test]
677    async fn check_ready_with_env_api_key() {
678        let _cfg = TempConfig::new();
679        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
680        let code = run_check(&[], None).await;
681        assert_eq!(code, 0);
682    }
683
684    #[test]
685    fn check_does_not_borrow_credentials_from_another_provider_or_protocol() {
686        let _cfg = TempConfig::new();
687        std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "anthropic-key");
688        std::env::set_var(crate::provider::OPENAI_API_KEY_ENV, "openai-key");
689        std::fs::write(
690            config::models_path().unwrap(),
691            r#"{"providers":{
692            "gateway":{"api":"openai-completions","models":[{"id":"model"}]},
693            "another":{"api":"openai-completions","apiKey":"another-key","models":[{"id":"other"}]}
694        }}"#,
695        )
696        .unwrap();
697        for provider in ["gateway", "unknown", "openai-completions", "OpenAI"] {
698            assert_eq!(
699                detect_credential(provider, None).unwrap(),
700                CredentialSource::Missing,
701                "{provider}"
702            );
703        }
704        // A gateway key also must not make the built-in OpenAI identity ready.
705        std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
706        assert_eq!(
707            detect_credential("openai", None).unwrap(),
708            CredentialSource::Missing
709        );
710    }
711
712    #[test]
713    fn check_resolves_stored_references_and_rejects_empty_or_unresolved_values() {
714        let _cfg = TempConfig::new();
715        upsert_credential(
716            "gateway",
717            Credential::ApiKey {
718                key: Some("$RPI_TEST_STORED_KEY".into()),
719                env: Some(std::collections::BTreeMap::from([(
720                    "RPI_TEST_STORED_KEY".into(),
721                    "stored-key".into(),
722                )])),
723            },
724        )
725        .unwrap();
726        assert_eq!(
727            detect_credential("gateway", None).unwrap(),
728            CredentialSource::StoredApiKey
729        );
730        for key in [Some("$RPI_TEST_AUTH_MISSING_VARIABLE"), Some("   "), None] {
731            upsert_credential(
732                "gateway",
733                Credential::ApiKey {
734                    key: key.map(str::to_string),
735                    env: None,
736                },
737            )
738            .unwrap();
739            assert_eq!(
740                detect_credential("gateway", None).unwrap(),
741                CredentialSource::Missing
742            );
743        }
744    }
745
746    #[tokio::test]
747    async fn check_reports_config_errors_and_respects_resolved_header_auth() {
748        let _cfg = TempConfig::new();
749        std::fs::write(
750            config::models_path().unwrap(),
751            r#"{"providers":{"gateway":{
752            "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":""}}]
753        }}}"#,
754        )
755        .unwrap();
756        assert_eq!(
757            detect_credential("gateway", None).unwrap(),
758            CredentialSource::Missing
759        );
760        std::fs::write(config::models_path().unwrap(), r#"{"providers":{"gateway":{
761            "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":"Bearer key"}}]
762        }}}"#).unwrap();
763        assert_eq!(
764            detect_credential("gateway", None).unwrap(),
765            CredentialSource::Headers
766        );
767        std::fs::write(config::auth_path().unwrap(), "{invalid").unwrap();
768        assert_eq!(run_check(&[], None).await, EXIT_ERROR);
769    }
770
771    #[tokio::test]
772    async fn check_ready_with_openai_models_json_key() {
773        let _cfg = TempConfig::new();
774        std::fs::write(
775            crate::config::models_path().unwrap(),
776            r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
777        )
778        .unwrap();
779        let code = run_check(&["--provider".to_string(), "gateway".to_string()], None).await;
780        assert_eq!(code, 0);
781    }
782
783    #[tokio::test]
784    async fn check_json_outputs_object() {
785        let _cfg = TempConfig::new();
786        // Capture stdout is awkward in unit tests; just assert the exit code +
787        // that a stored cred flips `ready`.
788        upsert_credential(
789            DEFAULT_PROVIDER_ID,
790            Credential::ApiKey {
791                key: Some("sk-x".into()),
792                env: None,
793            },
794        )
795        .unwrap();
796        let code = run_check(&["--json".to_string()], None).await;
797        assert_eq!(code, 0);
798    }
799
800    #[tokio::test]
801    async fn logout_removes_stored_credential() {
802        let _cfg = TempConfig::new();
803        upsert_credential(
804            DEFAULT_PROVIDER_ID,
805            Credential::ApiKey {
806                key: Some("sk".into()),
807                env: None,
808            },
809        )
810        .unwrap();
811        assert!(auth_path().unwrap().exists());
812        let code = run_logout(&[], None).await;
813        assert_eq!(code, 0);
814        // The entry should be gone → check is now not_ready.
815        assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
816    }
817
818    #[tokio::test]
819    async fn logout_when_empty_is_noop() {
820        let _cfg = TempConfig::new();
821        let code = run_logout(&[], None).await;
822        assert_eq!(code, 0);
823    }
824
825    #[tokio::test]
826    async fn unknown_auth_subcommand_errors() {
827        let code = run(&["bogus".to_string()]).await;
828        assert_eq!(code, EXIT_ERROR);
829    }
830
831    #[tokio::test]
832    async fn auth_help_exits_zero() {
833        let code = run(&[]).await;
834        assert_eq!(code, 0);
835        let code = run(&["--help".to_string()]).await;
836        assert_eq!(code, 0);
837    }
838
839    #[test]
840    fn parse_provider_handles_both_forms() {
841        assert_eq!(parse_provider(&[]), None);
842        assert_eq!(
843            parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
844            Some("anthropic")
845        );
846        assert_eq!(
847            parse_provider(&["--provider=custom".to_string()]),
848            Some("custom")
849        );
850        // A trailing flag with no value doesn't panic.
851        assert_eq!(parse_provider(&["--provider".to_string()]), None);
852    }
853}