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