1use std::collections::BTreeMap;
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};
28const EXIT_NOT_READY: i32 = 1;
31const EXIT_ERROR: i32 = 2;
33
34pub async fn run(args: &[String]) -> i32 {
38 run_with_extensions(args, None).await
39}
40
41pub async fn run_with_extensions(
44 args: &[String],
45 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
46) -> i32 {
47 let sub = args.first().map(|s| s.as_str()).unwrap_or("");
48 match sub {
49 "login" => run_login(&args[1..], snapshot.as_ref()).await,
50 "check" | "status" => run_check(&args[1..]).await,
51 "refresh" => run_refresh(&args[1..], snapshot.as_ref()).await,
52 "logout" => run_logout(&args[1..], snapshot.as_ref()).await,
53 "--help" | "-h" | "help" | "" => {
54 print_auth_help();
55 0
56 }
57 other => {
58 eprintln!("error: unknown auth subcommand \"{other}\"");
59 eprintln!();
60 print_auth_help();
61 EXIT_ERROR
62 }
63 }
64}
65
66async fn run_login(
68 args: &[String],
69 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
70) -> i32 {
71 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
72 if let Some(snapshot) = snapshot {
73 if snapshot.oauth_provider(provider).is_some() {
74 return run_oauth_login(provider, snapshot).await;
75 }
76 }
77 eprint!("Enter API key for {provider}: ");
80 let key = match rpassword::read_password() {
81 Ok(k) => k,
82 Err(e) => {
83 eprintln!();
84 eprintln!("error: could not read the key from the terminal: {e}");
85 return EXIT_ERROR;
86 }
87 };
88 eprintln!();
89 let key = key.trim();
90 if key.is_empty() {
91 eprintln!("error: an empty key was entered; nothing saved.");
92 return EXIT_ERROR;
93 }
94 let cred = Credential::ApiKey {
95 key: Some(key.to_string()),
96 env: None,
97 };
98 if let Err(e) = upsert_credential(provider, cred) {
99 eprintln!("error: could not save credentials: {e}");
100 return EXIT_ERROR;
101 }
102 let path = match config::auth_path() {
103 Ok(p) => p,
104 Err(e) => {
105 eprintln!("warn: credentials saved, but could not resolve the config path: {e}");
106 return 0;
107 }
108 };
109 println!("Credentials saved to {}", path.display());
110 println!("Run `rpi auth check` to verify.");
111 0
112}
113
114async fn run_check(args: &[String]) -> i32 {
116 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
117 let want_json = args.iter().any(|a| a == "--json");
118
119 let source = detect_credential(provider);
120 let ready = source.is_present();
121 if want_json {
122 let json = serde_json::json!({
123 "ready": ready,
124 "provider": provider,
125 "source": source.as_json_str(),
126 });
127 println!("{json}");
128 } else if ready {
129 let display = source.as_display().unwrap_or_default();
130 println!("ready ({display})");
131 } else {
132 println!("not_ready — no credentials found.");
133 eprintln!();
134 eprintln!(
135 "Set up credentials with one of:\n \
136 - `rpi auth login`\n \
137 - export ANTHROPIC_API_KEY=<key>\n \
138 - export ANTHROPIC_AUTH_TOKEN=<bearer>\n \
139 - pass --api-key <key>"
140 );
141 }
142 if ready {
143 0
144 } else {
145 EXIT_NOT_READY
146 }
147}
148
149async fn run_refresh(
151 args: &[String],
152 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
153) -> i32 {
154 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
155 let Some(snapshot) = snapshot else {
156 eprintln!("error: no OAuth extensions are loaded");
157 return EXIT_ERROR;
158 };
159 let Some(Credential::Oauth {
160 access,
161 refresh,
162 expires,
163 }) = config::read_oauth_token(provider).ok().flatten()
164 else {
165 eprintln!("error: no stored OAuth credentials for \"{provider}\"");
166 return EXIT_NOT_READY;
167 };
168 let response = match rpi_extensions::request_oauth(
169 snapshot,
170 provider,
171 "refresh",
172 serde_json::json!({"credential": {"access": access, "refresh": refresh, "expires_at": expires}}),
173 ) {
174 Ok(value) => value,
175 Err(error) => {
176 eprintln!("error: OAuth refresh failed: {error}");
177 return EXIT_ERROR;
178 }
179 };
180 let response = match rpi_extensions::OAuthActionResponse::parse(response) {
181 Ok(response) => response,
182 Err(error) => {
183 eprintln!("error: invalid OAuth refresh response: {error}");
184 return EXIT_ERROR;
185 }
186 };
187 let Some(token) = (match response.credential() {
188 Ok(token) => token,
189 Err(error) => {
190 eprintln!("error: invalid OAuth refresh credential: {error}");
191 return EXIT_ERROR;
192 }
193 }) else {
194 eprintln!("error: OAuth refresh returned no credential");
195 return EXIT_ERROR;
196 };
197 if let Err(error) =
198 config::upsert_oauth_token(provider, token.access, token.refresh, token.expires_at)
199 {
200 eprintln!("error: could not save refreshed OAuth credentials: {error}");
201 return EXIT_ERROR;
202 }
203 println!("OAuth credentials refreshed for \"{provider}\".");
204 0
205}
206
207async fn run_logout(
208 args: &[String],
209 snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
210) -> i32 {
211 let provider = parse_provider(args).unwrap_or(DEFAULT_PROVIDER_ID);
212 if let Ok(Some(Credential::Oauth {
213 access, refresh, ..
214 })) = config::read_oauth_token(provider)
215 {
216 let Some(snapshot) = snapshot else {
217 eprintln!("error: cannot revoke OAuth credentials: no OAuth extensions are loaded");
218 return EXIT_ERROR;
219 };
220 let response = match rpi_extensions::request_oauth(
221 snapshot,
222 provider,
223 "revoke",
224 serde_json::json!({"credential": {"access": access, "refresh": refresh}}),
225 ) {
226 Ok(response) => response,
227 Err(error) => {
228 eprintln!("error: OAuth revoke failed; local credentials were kept: {error}");
229 return EXIT_ERROR;
230 }
231 };
232 match rpi_extensions::OAuthActionResponse::parse(response) {
233 Ok(response) if response.kind == rpi_extensions::OAuthResponseKind::Success => {}
234 Ok(_) => {
235 eprintln!(
236 "error: OAuth revoke did not return success; local credentials were kept"
237 );
238 return EXIT_ERROR;
239 }
240 Err(error) => {
241 eprintln!(
242 "error: invalid OAuth revoke response; local credentials were kept: {error}"
243 );
244 return EXIT_ERROR;
245 }
246 }
247 }
250 match delete_credential(provider) {
251 Ok(true) => {
252 println!("Removed stored credentials for \"{provider}\".");
253 0
254 }
255 Ok(false) => {
256 println!("No stored credential for \"{provider}\" (nothing to do).");
257 0
258 }
259 Err(e) => {
260 eprintln!("error: could not remove credentials: {e}");
261 EXIT_ERROR
262 }
263 }
264}
265
266async fn run_oauth_login(provider: &str, snapshot: &Arc<rpi_extensions::RegistrySnapshot>) -> i32 {
270 let mut input = serde_json::json!({});
271 let mut action = "begin";
272 loop {
273 let raw = match rpi_extensions::request_oauth(snapshot, provider, action, input) {
274 Ok(value) => value,
275 Err(error) => {
276 eprintln!("error: OAuth {action} failed: {error}");
277 return EXIT_ERROR;
278 }
279 };
280 let response = match rpi_extensions::OAuthActionResponse::parse(raw) {
281 Ok(response) => response,
282 Err(error) => {
283 eprintln!("error: invalid OAuth response: {error}");
284 return EXIT_ERROR;
285 }
286 };
287 match response.kind {
288 rpi_extensions::OAuthResponseKind::Credential => {
289 let Some(token) = response.credential().ok().flatten() else {
290 eprintln!("error: OAuth response contained no valid credential");
291 return EXIT_ERROR;
292 };
293 if let Err(error) = config::upsert_oauth_token(
294 provider,
295 token.access,
296 token.refresh,
297 token.expires_at,
298 ) {
299 eprintln!("error: could not save OAuth credentials: {error}");
300 return EXIT_ERROR;
301 }
302 println!("OAuth credentials saved for \"{provider}\".");
303 return 0;
304 }
305 rpi_extensions::OAuthResponseKind::Error => {
306 eprintln!("error: OAuth provider rejected the request");
307 return EXIT_ERROR;
308 }
309 rpi_extensions::OAuthResponseKind::Success => {
310 eprintln!("error: OAuth login completed without credentials");
311 return EXIT_ERROR;
312 }
313 rpi_extensions::OAuthResponseKind::Interaction => {
314 let display = response.payload.get("display").unwrap_or(&response.payload);
315 if let Some(title) = display.get("title").and_then(serde_json::Value::as_str) {
316 println!("{title}");
317 }
318 if let Some(url) = display.get("url").and_then(serde_json::Value::as_str) {
319 println!("{url}");
320 }
321 if let Some(instructions) = display
322 .get("instructions")
323 .and_then(serde_json::Value::as_array)
324 {
325 for instruction in instructions.iter().filter_map(serde_json::Value::as_str) {
326 println!("{instruction}");
327 }
328 }
329 eprint!("OAuth input: ");
330 let _ = io::stderr().flush();
331 let mut line = String::new();
332 if io::stdin().read_line(&mut line).is_err() || line.trim().is_empty() {
333 eprintln!("error: empty OAuth input; nothing saved.");
334 return EXIT_ERROR;
335 }
336 input = serde_json::json!({"value": line.trim()});
337 action = "exchange";
338 }
339 }
340 }
341}
342
343fn parse_provider(args: &[String]) -> Option<&str> {
344 let mut iter = args.iter();
345 while let Some(a) = iter.next() {
346 if a == "--provider" {
347 if let Some(v) = iter.next() {
348 return Some(v.as_str());
349 }
350 } else if let Some(rest) = a.strip_prefix("--provider=") {
351 return Some(rest);
352 }
353 }
354 None
355}
356
357enum CredentialSource {
359 StoredApiKey,
360 StoredOauth,
361 ModelsJson,
362 EnvApiKey,
363 EnvAuthTToken,
364 EnvOpenAiApiKey,
365 CliFlagUnset, }
367
368impl CredentialSource {
369 fn is_present(&self) -> bool {
370 !matches!(self, CredentialSource::CliFlagUnset)
371 }
372 fn as_json_str(&self) -> &'static str {
373 match self {
374 CredentialSource::StoredApiKey => "auth.json",
375 CredentialSource::StoredOauth => "oauth",
376 CredentialSource::ModelsJson => "models.json",
377 CredentialSource::EnvApiKey => "ANTHROPIC_API_KEY",
378 CredentialSource::EnvAuthTToken => "ANTHROPIC_AUTH_TOKEN",
379 CredentialSource::EnvOpenAiApiKey => "OPENAI_API_KEY",
380 CredentialSource::CliFlagUnset => "none",
381 }
382 }
383 fn as_display(&self) -> Option<&'static str> {
384 match self {
385 CredentialSource::StoredApiKey => Some("key in ~/.rpi/auth.json"),
386 CredentialSource::StoredOauth => Some("OAuth token in ~/.rpi/auth.json"),
387 CredentialSource::ModelsJson => Some("apiKey in ~/.rpi/agent/models.json"),
388 CredentialSource::EnvApiKey => Some("ANTHROPIC_API_KEY env var"),
389 CredentialSource::EnvAuthTToken => Some("ANTHROPIC_AUTH_TOKEN env var"),
390 CredentialSource::EnvOpenAiApiKey => Some("OPENAI_API_KEY env var"),
391 CredentialSource::CliFlagUnset => None,
392 }
393 }
394}
395
396fn detect_credential(provider: &str) -> CredentialSource {
400 if let Ok(store) = read_auth() {
401 match store.get(provider) {
402 Some(Credential::ApiKey { key: Some(k), .. }) if !k.is_empty() => {
403 return CredentialSource::StoredApiKey;
404 }
405 Some(Credential::ApiKey {
406 key: None,
407 env: Some(_),
408 ..
409 }) => {
410 return CredentialSource::StoredApiKey;
411 }
412 Some(Credential::Oauth {
413 access, expires, ..
414 }) if !access.is_empty()
415 && (*expires == 0
416 || *expires
417 > std::time::SystemTime::now()
418 .duration_since(std::time::UNIX_EPOCH)
419 .map(|duration| duration.as_secs() as i64)
420 .unwrap_or(i64::MAX)) =>
421 {
422 return CredentialSource::StoredOauth;
423 }
424 _ => {}
425 }
426 }
427 let configured = config::load_models_config().ok().and_then(|models| {
428 models
429 .providers
430 .into_iter()
431 .find(|(id, cfg)| {
432 id.eq_ignore_ascii_case(provider)
433 || ((provider.eq_ignore_ascii_case("openai")
434 || provider.eq_ignore_ascii_case("openai-completions"))
435 && config::provider_is_openai_completions(cfg))
436 })
437 .map(|(_, cfg)| cfg)
438 });
439 if configured.as_ref().is_some_and(|cfg| {
440 cfg.api_key
441 .as_deref()
442 .filter(|key| !key.is_empty())
443 .and_then(|key| config::resolve_config_value(key, None))
444 .is_some()
445 }) {
446 return CredentialSource::ModelsJson;
447 }
448 let is_openai = provider.eq_ignore_ascii_case("openai")
449 || provider.eq_ignore_ascii_case("openai-completions")
450 || configured
451 .as_ref()
452 .is_some_and(config::provider_is_openai_completions);
453 if is_openai
454 && std::env::var(crate::provider::OPENAI_API_KEY_ENV)
455 .map(|v| !v.is_empty())
456 .unwrap_or(false)
457 {
458 return CredentialSource::EnvOpenAiApiKey;
459 }
460 if !is_openai
461 && std::env::var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV)
462 .map(|v| !v.is_empty())
463 .unwrap_or(false)
464 {
465 return CredentialSource::EnvAuthTToken;
466 }
467 if !is_openai
468 && std::env::var(crate::provider::ANTHROPIC_API_KEY_ENV)
469 .map(|v| !v.is_empty())
470 .unwrap_or(false)
471 {
472 return CredentialSource::EnvApiKey;
473 }
474 CredentialSource::CliFlagUnset
475}
476
477fn print_auth_help() {
479 println!(
480 "Usage: {name} auth <subcommand> [options]
481
482Manage persisted credentials and inspect models.json provider authentication.
483
484Subcommands:
485 login Prompt for an API key, or run an OAuth provider login.
486 check Report whether credentials are available (no network call).
487 status Alias for check; never prints token material.
488 refresh Refresh an OAuth token through its provider extension.
489 logout Revoke OAuth credentials, then remove the stored credential.
490
491Options:
492 --provider <id> Provider id (default: anthropic)
493 --json (check only) Emit a {{ready, provider, source}} JSON object
494
495Environment:
496 ANTHROPIC_API_KEY Fallback API key (x-api-key) when no stored credential.
497 ANTHROPIC_AUTH_TOKEN Fallback bearer token (Authorization: Bearer).
498 OPENAI_API_KEY Fallback bearer token for openai-completions.
499
500Notes:
501 `login --provider <id>` uses the registered OAuth extension when one exists;
502 otherwise it is a provider-neutral API-key compatibility prompt. The host
503 only handles standard interaction and credential responses for OAuth.
504 `refresh` and `logout` use extension actions. API-key providers may still
505 use ~/.rpi/agent/models.json or environment variables.
506",
507 name = crate::APP_NAME
508 );
509}
510
511#[allow(dead_code)]
514fn _keep_btreemap() -> Option<BTreeMap<String, String>> {
515 None
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::config::{
522 auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
523 };
524
525 struct TempConfig {
529 _guard: std::sync::MutexGuard<'static, ()>,
530 _tmp: tempfile::TempDir,
531 prev_dir: Option<std::ffi::OsString>,
532 prev_key: Option<std::ffi::OsString>,
533 prev_tok: Option<std::ffi::OsString>,
534 prev_openai_key: Option<std::ffi::OsString>,
535 }
536 impl TempConfig {
537 fn new() -> Self {
538 let guard = env_lock().lock().unwrap();
539 let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
540 let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
541 let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
542 let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
543 let tmp = tempfile::TempDir::new().unwrap();
544 std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
545 std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
546 std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
547 std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
548 Self {
549 _guard: guard,
550 _tmp: tmp,
551 prev_dir,
552 prev_key,
553 prev_tok,
554 prev_openai_key,
555 }
556 }
557 }
558 impl Drop for TempConfig {
559 fn drop(&mut self) {
560 restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
561 restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
562 restore(
563 crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
564 self.prev_tok.take(),
565 );
566 restore(
567 crate::provider::OPENAI_API_KEY_ENV,
568 self.prev_openai_key.take(),
569 );
570 }
571 }
572 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
573 match prev {
574 Some(v) => std::env::set_var(name, v),
575 None => std::env::remove_var(name),
576 }
577 }
578
579 #[test]
580 fn standard_oauth_credential_response_is_parsed() {
581 let response = rpi_extensions::OAuthActionResponse::parse(serde_json::json!({
582 "version": 1,
583 "kind": "credential",
584 "payload": {
585 "access": "access",
586 "refresh": "refresh",
587 "expires_at": 123
588 }
589 }))
590 .unwrap();
591 let credential = response.credential().unwrap().unwrap();
592 assert_eq!(credential.access, "access");
593 assert_eq!(credential.refresh, "refresh");
594 assert_eq!(credential.expires_at, 123);
595 }
596
597 #[tokio::test]
598 async fn check_not_ready_with_no_credentials() {
599 let _cfg = TempConfig::new();
600 let code = run_check(&[]).await;
601 assert_eq!(code, EXIT_NOT_READY);
602 }
603
604 #[tokio::test]
605 async fn check_ready_with_stored_credential() {
606 let _cfg = TempConfig::new();
607 upsert_credential(
608 DEFAULT_PROVIDER_ID,
609 Credential::ApiKey {
610 key: Some("sk-stored".into()),
611 env: None,
612 },
613 )
614 .unwrap();
615 let code = run_check(&[]).await;
616 assert_eq!(code, 0);
617 }
618
619 #[tokio::test]
620 async fn check_ready_with_oauth_credential() {
621 let _cfg = TempConfig::new();
622 crate::config::upsert_oauth_token(
623 DEFAULT_PROVIDER_ID,
624 "access".into(),
625 "refresh".into(),
626 0,
627 )
628 .unwrap();
629 assert_eq!(run_check(&[]).await, 0);
630 }
631
632 #[tokio::test]
633 async fn check_rejects_expired_oauth_credential() {
634 let _cfg = TempConfig::new();
635 crate::config::upsert_oauth_token(
636 DEFAULT_PROVIDER_ID,
637 "access".into(),
638 "refresh".into(),
639 1,
640 )
641 .unwrap();
642 assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
643 }
644
645 #[tokio::test]
646 async fn check_ready_with_env_api_key() {
647 let _cfg = TempConfig::new();
648 std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
649 let code = run_check(&[]).await;
650 assert_eq!(code, 0);
651 }
652
653 #[tokio::test]
654 async fn check_ready_with_openai_models_json_key() {
655 let _cfg = TempConfig::new();
656 std::fs::write(
657 crate::config::models_path().unwrap(),
658 r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
659 )
660 .unwrap();
661 let code = run_check(&["--provider".to_string(), "gateway".to_string()]).await;
662 assert_eq!(code, 0);
663 }
664
665 #[tokio::test]
666 async fn check_json_outputs_object() {
667 let _cfg = TempConfig::new();
668 upsert_credential(
671 DEFAULT_PROVIDER_ID,
672 Credential::ApiKey {
673 key: Some("sk-x".into()),
674 env: None,
675 },
676 )
677 .unwrap();
678 let code = run_check(&["--json".to_string()]).await;
679 assert_eq!(code, 0);
680 }
681
682 #[tokio::test]
683 async fn logout_removes_stored_credential() {
684 let _cfg = TempConfig::new();
685 upsert_credential(
686 DEFAULT_PROVIDER_ID,
687 Credential::ApiKey {
688 key: Some("sk".into()),
689 env: None,
690 },
691 )
692 .unwrap();
693 assert!(auth_path().unwrap().exists());
694 let code = run_logout(&[], None).await;
695 assert_eq!(code, 0);
696 assert_eq!(run_check(&[]).await, EXIT_NOT_READY);
698 }
699
700 #[tokio::test]
701 async fn logout_when_empty_is_noop() {
702 let _cfg = TempConfig::new();
703 let code = run_logout(&[], None).await;
704 assert_eq!(code, 0);
705 }
706
707 #[tokio::test]
708 async fn unknown_auth_subcommand_errors() {
709 let code = run(&["bogus".to_string()]).await;
710 assert_eq!(code, EXIT_ERROR);
711 }
712
713 #[tokio::test]
714 async fn auth_help_exits_zero() {
715 let code = run(&[]).await;
716 assert_eq!(code, 0);
717 let code = run(&["--help".to_string()]).await;
718 assert_eq!(code, 0);
719 }
720
721 #[test]
722 fn parse_provider_handles_both_forms() {
723 assert_eq!(parse_provider(&[]), None);
724 assert_eq!(
725 parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
726 Some("anthropic")
727 );
728 assert_eq!(
729 parse_provider(&["--provider=custom".to_string()]),
730 Some("custom")
731 );
732 assert_eq!(parse_provider(&["--provider".to_string()]), None);
734 }
735}