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 manifest = snapshot.and_then(|snapshot| snapshot.provider_manifest(provider));
376 let resolved =
377 crate::credentials::resolve(provider, models.providers.get(provider), &store, manifest);
378 if resolved.source.is_present() {
379 return Ok(resolved.source);
380 }
381 let manifests = snapshot.map_or_else(Vec::new, |snapshot| {
382 snapshot
383 .providers()
384 .iter()
385 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
386 .collect()
387 });
388 let catalog = crate::provider::execution_catalog_with_manifests(&manifests)?;
389 if catalog.iter().any(|model| {
390 model.provider == provider && crate::credentials::has_header_auth(model.headers.as_ref())
391 }) {
392 return Ok(CredentialSource::Headers);
393 }
394 Ok(CredentialSource::Missing)
395}
396
397fn print_auth_help() {
399 println!(
400 "Usage: {name} auth <subcommand> [options]
401
402Manage persisted credentials and inspect provider package authentication.
403
404Subcommands:
405 login Prompt for an API key, or run an OAuth provider login.
406 check Report whether credentials are available (no network call).
407 status Alias for check; never prints token material.
408 refresh Refresh an OAuth token through its provider extension.
409 logout Revoke OAuth credentials, then remove the stored credential.
410
411Options:
412 --provider <id> Provider id (default: anthropic)
413 --extension <path> Load a provider or OAuth extension explicitly.
414 --no-extensions Disable extension discovery and package auth declarations.
415 --json (check only) Emit a {{ready, provider, source}} JSON object
416
417Environment:
418 ANTHROPIC_API_KEY Fallback API key (x-api-key) when no stored credential.
419 ANTHROPIC_AUTH_TOKEN Fallback bearer token (Authorization: Bearer).
420 OPENAI_API_KEY Fallback bearer token for the built-in openai identity.
421 Other providers Environment names come from installed package declarations.
422
423Notes:
424 `login --provider <id>` uses the registered OAuth extension when one exists;
425 otherwise it is a provider-neutral API-key compatibility prompt. The host
426 only handles standard interaction and credential responses for OAuth.
427 `refresh` and `logout` use extension actions. API-key providers may still
428 use ~/.rpi/agent/models.json or environment variables.
429",
430 name = crate::APP_NAME
431 );
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::config::{
438 auth_path, test_support::env_lock, upsert_credential, Credential, DEFAULT_PROVIDER_ID,
439 };
440
441 struct TempConfig {
445 _guard: std::sync::MutexGuard<'static, ()>,
446 _tmp: tempfile::TempDir,
447 prev_dir: Option<std::ffi::OsString>,
448 prev_key: Option<std::ffi::OsString>,
449 prev_tok: Option<std::ffi::OsString>,
450 prev_openai_key: Option<std::ffi::OsString>,
451 }
452 impl TempConfig {
453 fn new() -> Self {
454 let guard = env_lock().lock().unwrap();
455 let prev_dir = std::env::var_os(crate::config::CONFIG_DIR_ENV);
456 let prev_key = std::env::var_os(crate::provider::ANTHROPIC_API_KEY_ENV);
457 let prev_tok = std::env::var_os(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
458 let prev_openai_key = std::env::var_os(crate::provider::OPENAI_API_KEY_ENV);
459 let tmp = tempfile::TempDir::new().unwrap();
460 std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
461 std::env::remove_var(crate::provider::ANTHROPIC_API_KEY_ENV);
462 std::env::remove_var(crate::provider::ANTHROPIC_AUTH_TOKEN_ENV);
463 std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
464 Self {
465 _guard: guard,
466 _tmp: tmp,
467 prev_dir,
468 prev_key,
469 prev_tok,
470 prev_openai_key,
471 }
472 }
473 }
474 impl Drop for TempConfig {
475 fn drop(&mut self) {
476 restore(crate::config::CONFIG_DIR_ENV, self.prev_dir.take());
477 restore(crate::provider::ANTHROPIC_API_KEY_ENV, self.prev_key.take());
478 restore(
479 crate::provider::ANTHROPIC_AUTH_TOKEN_ENV,
480 self.prev_tok.take(),
481 );
482 restore(
483 crate::provider::OPENAI_API_KEY_ENV,
484 self.prev_openai_key.take(),
485 );
486 }
487 }
488 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
489 match prev {
490 Some(v) => std::env::set_var(name, v),
491 None => std::env::remove_var(name),
492 }
493 }
494
495 #[test]
496 fn standard_oauth_credential_response_is_parsed() {
497 let response = rpi_extensions::OAuthActionResponse::parse(serde_json::json!({
498 "version": 1,
499 "kind": "credential",
500 "payload": {
501 "access": "access",
502 "refresh": "refresh",
503 "expires_at": 123
504 }
505 }))
506 .unwrap();
507 let credential = response.credential().unwrap().unwrap();
508 assert_eq!(credential.access, "access");
509 assert_eq!(credential.refresh, "refresh");
510 assert_eq!(credential.expires_at, 123);
511 }
512
513 #[tokio::test]
514 async fn check_not_ready_with_no_credentials() {
515 let _cfg = TempConfig::new();
516 let code = run_check(&[], None).await;
517 assert_eq!(code, EXIT_NOT_READY);
518 }
519
520 #[tokio::test]
521 async fn check_ready_with_stored_credential() {
522 let _cfg = TempConfig::new();
523 upsert_credential(
524 DEFAULT_PROVIDER_ID,
525 Credential::ApiKey {
526 key: Some("sk-stored".into()),
527 env: None,
528 },
529 )
530 .unwrap();
531 let code = run_check(&[], None).await;
532 assert_eq!(code, 0);
533 }
534
535 #[tokio::test]
536 async fn check_ready_with_oauth_credential() {
537 let _cfg = TempConfig::new();
538 crate::config::upsert_oauth_token(
539 DEFAULT_PROVIDER_ID,
540 "access".into(),
541 "refresh".into(),
542 0,
543 )
544 .unwrap();
545 assert_eq!(run_check(&[], None).await, 0);
546 }
547
548 #[tokio::test]
549 async fn check_rejects_expired_oauth_credential() {
550 let _cfg = TempConfig::new();
551 crate::config::upsert_oauth_token(
552 DEFAULT_PROVIDER_ID,
553 "access".into(),
554 "refresh".into(),
555 1,
556 )
557 .unwrap();
558 assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
559 }
560
561 #[tokio::test]
562 async fn check_ready_with_env_api_key() {
563 let _cfg = TempConfig::new();
564 std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "sk-env");
565 let code = run_check(&[], None).await;
566 assert_eq!(code, 0);
567 }
568
569 #[test]
570 fn check_does_not_borrow_credentials_from_another_provider_or_protocol() {
571 let _cfg = TempConfig::new();
572 std::env::set_var(crate::provider::ANTHROPIC_API_KEY_ENV, "anthropic-key");
573 std::env::set_var(crate::provider::OPENAI_API_KEY_ENV, "openai-key");
574 std::fs::write(
575 config::models_path().unwrap(),
576 r#"{"providers":{
577 "gateway":{"api":"openai-completions","models":[{"id":"model"}]},
578 "another":{"api":"openai-completions","apiKey":"another-key","models":[{"id":"other"}]}
579 }}"#,
580 )
581 .unwrap();
582 for provider in ["gateway", "unknown", "openai-completions", "OpenAI"] {
583 assert_eq!(
584 detect_credential(provider, None).unwrap(),
585 CredentialSource::Missing,
586 "{provider}"
587 );
588 }
589 std::env::remove_var(crate::provider::OPENAI_API_KEY_ENV);
591 assert_eq!(
592 detect_credential("openai", None).unwrap(),
593 CredentialSource::Missing
594 );
595 }
596
597 #[test]
598 fn check_resolves_stored_references_and_rejects_empty_or_unresolved_values() {
599 let _cfg = TempConfig::new();
600 upsert_credential(
601 "gateway",
602 Credential::ApiKey {
603 key: Some("$RPI_TEST_STORED_KEY".into()),
604 env: Some(std::collections::BTreeMap::from([(
605 "RPI_TEST_STORED_KEY".into(),
606 "stored-key".into(),
607 )])),
608 },
609 )
610 .unwrap();
611 assert_eq!(
612 detect_credential("gateway", None).unwrap(),
613 CredentialSource::StoredApiKey
614 );
615 for key in [Some("$RPI_TEST_AUTH_MISSING_VARIABLE"), Some(" "), None] {
616 upsert_credential(
617 "gateway",
618 Credential::ApiKey {
619 key: key.map(str::to_string),
620 env: None,
621 },
622 )
623 .unwrap();
624 assert_eq!(
625 detect_credential("gateway", None).unwrap(),
626 CredentialSource::Missing
627 );
628 }
629 }
630
631 #[tokio::test]
632 async fn check_reports_config_errors_and_respects_resolved_header_auth() {
633 let _cfg = TempConfig::new();
634 std::fs::write(
635 config::models_path().unwrap(),
636 r#"{"providers":{"gateway":{
637 "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":""}}]
638 }}}"#,
639 )
640 .unwrap();
641 assert_eq!(
642 detect_credential("gateway", None).unwrap(),
643 CredentialSource::Missing
644 );
645 std::fs::write(config::models_path().unwrap(), r#"{"providers":{"gateway":{
646 "api":"openai-completions","models":[{"id":"model","headers":{"Authorization":"Bearer key"}}]
647 }}}"#).unwrap();
648 assert_eq!(
649 detect_credential("gateway", None).unwrap(),
650 CredentialSource::Headers
651 );
652 std::fs::write(config::auth_path().unwrap(), "{invalid").unwrap();
653 assert_eq!(run_check(&[], None).await, EXIT_ERROR);
654 }
655
656 #[tokio::test]
657 async fn check_ready_with_openai_models_json_key() {
658 let _cfg = TempConfig::new();
659 std::fs::write(
660 crate::config::models_path().unwrap(),
661 r#"{"providers":{"gateway":{"api":"openai-completions","apiKey":"key","models":[{"id":"gpt-test"}]}}}"#,
662 )
663 .unwrap();
664 let code = run_check(&["--provider".to_string(), "gateway".to_string()], None).await;
665 assert_eq!(code, 0);
666 }
667
668 #[tokio::test]
669 async fn check_json_outputs_object() {
670 let _cfg = TempConfig::new();
671 upsert_credential(
674 DEFAULT_PROVIDER_ID,
675 Credential::ApiKey {
676 key: Some("sk-x".into()),
677 env: None,
678 },
679 )
680 .unwrap();
681 let code = run_check(&["--json".to_string()], None).await;
682 assert_eq!(code, 0);
683 }
684
685 #[tokio::test]
686 async fn logout_removes_stored_credential() {
687 let _cfg = TempConfig::new();
688 upsert_credential(
689 DEFAULT_PROVIDER_ID,
690 Credential::ApiKey {
691 key: Some("sk".into()),
692 env: None,
693 },
694 )
695 .unwrap();
696 assert!(auth_path().unwrap().exists());
697 let code = run_logout(&[], None).await;
698 assert_eq!(code, 0);
699 assert_eq!(run_check(&[], None).await, EXIT_NOT_READY);
701 }
702
703 #[tokio::test]
704 async fn logout_when_empty_is_noop() {
705 let _cfg = TempConfig::new();
706 let code = run_logout(&[], None).await;
707 assert_eq!(code, 0);
708 }
709
710 #[tokio::test]
711 async fn unknown_auth_subcommand_errors() {
712 let code = run(&["bogus".to_string()]).await;
713 assert_eq!(code, EXIT_ERROR);
714 }
715
716 #[tokio::test]
717 async fn auth_help_exits_zero() {
718 let code = run(&[]).await;
719 assert_eq!(code, 0);
720 let code = run(&["--help".to_string()]).await;
721 assert_eq!(code, 0);
722 }
723
724 #[test]
725 fn parse_provider_handles_both_forms() {
726 assert_eq!(parse_provider(&[]), None);
727 assert_eq!(
728 parse_provider(&["--provider".to_string(), "anthropic".to_string()]),
729 Some("anthropic")
730 );
731 assert_eq!(
732 parse_provider(&["--provider=custom".to_string()]),
733 Some("custom")
734 );
735 assert_eq!(parse_provider(&["--provider".to_string()]), None);
737 }
738}