1use std::collections::HashMap;
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15use thiserror::Error;
16
17use crate::core::keyring::Keyring;
18use crate::core::manifest::{AuthGenType, AuthGenerator, AuthOutputFormat, Provider};
19
20#[derive(Error, Debug)]
25pub enum AuthGenError {
26 #[error("Auth generator config error: {0}")]
27 Config(String),
28 #[error("Failed to spawn generator process: {0}")]
29 Spawn(String),
30 #[error("Generator timed out after {0}s")]
31 Timeout(u64),
32 #[error("Generator exited with code {code}: {stderr}")]
33 NonZeroExit { code: i32, stderr: String },
34 #[error("Failed to parse generator output: {0}")]
35 OutputParse(String),
36 #[error("Keyring key '{0}' not found (required by auth_generator)")]
37 KeyringMissing(String),
38 #[error("IO error: {0}")]
39 Io(#[from] std::io::Error),
40}
41
42pub struct GenContext {
48 pub jwt_sub: String,
49 pub jwt_scope: String,
50 pub tool_name: String,
51 pub timestamp: u64,
52 pub jwt_token: String,
62}
63
64impl Default for GenContext {
65 fn default() -> Self {
66 GenContext {
67 jwt_sub: "dev".into(),
68 jwt_scope: "*".into(),
69 tool_name: String::new(),
70 timestamp: std::time::SystemTime::now()
71 .duration_since(std::time::UNIX_EPOCH)
72 .unwrap_or_default()
73 .as_secs(),
74 jwt_token: String::new(),
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
85pub struct GeneratedCredential {
86 pub value: String,
88 pub extra_headers: HashMap<String, String>,
90 pub extra_env: HashMap<String, String>,
92}
93
94struct CachedCredential {
99 cred: GeneratedCredential,
100 expires_at: Instant,
101}
102
103pub struct AuthCache {
121 entries: Mutex<HashMap<(String, String, String), CachedCredential>>,
122}
123
124impl Default for AuthCache {
125 fn default() -> Self {
126 AuthCache {
127 entries: Mutex::new(HashMap::new()),
128 }
129 }
130}
131
132pub fn token_fingerprint(token: &str) -> String {
136 if token.is_empty() {
137 return String::new();
138 }
139 use sha2::{Digest, Sha256};
140 let mut hasher = Sha256::new();
141 hasher.update(token.as_bytes());
142 let digest = hasher.finalize();
143 hex::encode(digest)[..16].to_string()
144}
145
146impl AuthCache {
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 pub fn get(&self, provider: &str, sub: &str, token: &str) -> Option<GeneratedCredential> {
152 let cache = self.entries.lock().unwrap();
153 let key = (
154 provider.to_string(),
155 sub.to_string(),
156 token_fingerprint(token),
157 );
158 match cache.get(&key) {
159 Some(entry) if Instant::now() < entry.expires_at => Some(entry.cred.clone()),
160 _ => None,
161 }
162 }
163
164 pub fn insert(
165 &self,
166 provider: &str,
167 sub: &str,
168 token: &str,
169 cred: GeneratedCredential,
170 ttl_secs: u64,
171 ) {
172 if ttl_secs == 0 {
173 return; }
175 let mut cache = self.entries.lock().unwrap();
176 let now = Instant::now();
186 cache.retain(|_, v| now < v.expires_at);
187 let key = (
188 provider.to_string(),
189 sub.to_string(),
190 token_fingerprint(token),
191 );
192 cache.insert(
193 key,
194 CachedCredential {
195 cred,
196 expires_at: now + Duration::from_secs(ttl_secs),
197 },
198 );
199 }
200
201 #[cfg(test)]
206 pub fn entry_count(&self) -> usize {
207 self.entries.lock().unwrap().len()
208 }
209}
210
211pub async fn generate(
223 provider: &Provider,
224 gen: &AuthGenerator,
225 ctx: &GenContext,
226 keyring: &Keyring,
227 cache: &AuthCache,
228) -> Result<GeneratedCredential, AuthGenError> {
229 if gen.cache_ttl_secs > 0 {
233 if let Some(cached) = cache.get(&provider.name, &ctx.jwt_sub, &ctx.jwt_token) {
234 return Ok(cached);
235 }
236 }
237
238 let expanded_args: Vec<String> = gen
240 .args
241 .iter()
242 .map(|a| expand_variables(a, ctx, keyring))
243 .collect::<Result<Vec<_>, _>>()?;
244
245 let mut expanded_env: HashMap<String, String> = HashMap::new();
246 for (k, v) in &gen.env {
247 expanded_env.insert(k.clone(), expand_variables(v, ctx, keyring)?);
248 }
249
250 let mut final_env: HashMap<String, String> = HashMap::new();
252 for var in &["PATH", "HOME", "TMPDIR"] {
253 if let Ok(val) = std::env::var(var) {
254 final_env.insert(var.to_string(), val);
255 }
256 }
257 final_env.extend(expanded_env);
258
259 let output =
261 match gen.gen_type {
262 AuthGenType::Command => {
263 let command = gen.command.as_deref().ok_or_else(|| {
264 AuthGenError::Config("command required for type=command".into())
265 })?;
266
267 let child = tokio::process::Command::new(command)
268 .args(&expanded_args)
269 .env_clear()
270 .envs(&final_env)
271 .stdout(std::process::Stdio::piped())
272 .stderr(std::process::Stdio::piped())
273 .kill_on_drop(true)
274 .spawn()
275 .map_err(|e| AuthGenError::Spawn(format!("{command}: {e}")))?;
276
277 let timeout = Duration::from_secs(gen.timeout_secs);
278 tokio::time::timeout(timeout, child.wait_with_output())
279 .await
280 .map_err(|_| AuthGenError::Timeout(gen.timeout_secs))?
281 .map_err(AuthGenError::Io)?
282 }
283 AuthGenType::Script => {
284 let interpreter = gen.interpreter.as_deref().ok_or_else(|| {
285 AuthGenError::Config("interpreter required for type=script".into())
286 })?;
287 let script = gen.script.as_deref().ok_or_else(|| {
288 AuthGenError::Config("script required for type=script".into())
289 })?;
290
291 let suffix: u32 = rand::random();
293 let tmp_path = std::env::temp_dir().join(format!("ati_gen_{suffix}.tmp"));
294 std::fs::write(&tmp_path, script).map_err(AuthGenError::Io)?;
295
296 let child = tokio::process::Command::new(interpreter)
297 .arg(&tmp_path)
298 .env_clear()
299 .envs(&final_env)
300 .stdout(std::process::Stdio::piped())
301 .stderr(std::process::Stdio::piped())
302 .kill_on_drop(true)
303 .spawn()
304 .map_err(|e| AuthGenError::Spawn(format!("{interpreter}: {e}")))?;
305
306 let timeout = Duration::from_secs(gen.timeout_secs);
307 let result = tokio::time::timeout(timeout, child.wait_with_output())
308 .await
309 .map_err(|_| AuthGenError::Timeout(gen.timeout_secs))?
310 .map_err(AuthGenError::Io)?;
311
312 let _ = std::fs::remove_file(&tmp_path);
314 result
315 }
316 };
317
318 if !output.status.success() {
319 let code = output.status.code().unwrap_or(-1);
320 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
321 return Err(AuthGenError::NonZeroExit { code, stderr });
322 }
323
324 let stdout = String::from_utf8_lossy(&output.stdout);
325
326 let cred = match gen.output_format {
328 AuthOutputFormat::Text => GeneratedCredential {
329 value: stdout.trim().to_string(),
330 extra_headers: HashMap::new(),
331 extra_env: HashMap::new(),
332 },
333 AuthOutputFormat::Json => {
334 let json: serde_json::Value = serde_json::from_str(stdout.trim())
335 .map_err(|e| AuthGenError::OutputParse(format!("invalid JSON: {e}")))?;
336
337 let mut extra_headers = HashMap::new();
338 let mut extra_env = HashMap::new();
339 let mut primary_value = stdout.trim().to_string();
340
341 if gen.inject.is_empty() {
343 if let Some(tok) = json.get("token").or(json.get("access_token")) {
345 if let Some(s) = tok.as_str() {
346 primary_value = s.to_string();
347 }
348 }
349 } else {
350 let mut found_primary = false;
352 for (json_path, target) in &gen.inject {
353 let extracted = extract_json_path(&json, json_path).ok_or_else(|| {
354 AuthGenError::OutputParse(format!(
355 "JSON path '{}' not found in output",
356 json_path
357 ))
358 })?;
359
360 match target.inject_type.as_str() {
361 "header" => {
362 extra_headers.insert(target.name.clone(), extracted);
363 }
364 "env" => {
365 extra_env.insert(target.name.clone(), extracted);
366 }
367 "query" => {
368 if !found_primary {
370 primary_value = extracted;
371 found_primary = true;
372 }
373 }
374 _ => {
375 if !found_primary {
377 primary_value = extracted;
378 found_primary = true;
379 }
380 }
381 }
382 }
383 }
384
385 GeneratedCredential {
386 value: primary_value,
387 extra_headers,
388 extra_env,
389 }
390 }
391 };
392
393 cache.insert(
396 &provider.name,
397 &ctx.jwt_sub,
398 &ctx.jwt_token,
399 cred.clone(),
400 gen.cache_ttl_secs,
401 );
402
403 Ok(cred)
404}
405
406fn expand_variables(
418 input: &str,
419 ctx: &GenContext,
420 keyring: &Keyring,
421) -> Result<String, AuthGenError> {
422 let mut result = input.to_string();
423 while let Some(start) = result.find("${") {
425 let rest = &result[start + 2..];
426 let end = match rest.find('}') {
427 Some(e) => e,
428 None => break,
429 };
430 let var_name = &rest[..end];
431
432 let replacement = match var_name {
433 "JWT_SUB" => ctx.jwt_sub.clone(),
434 "JWT_SCOPE" => ctx.jwt_scope.clone(),
435 "TOOL_NAME" => ctx.tool_name.clone(),
436 "TIMESTAMP" => ctx.timestamp.to_string(),
437 "JWT_TOKEN" => ctx.jwt_token.clone(),
445 _ => {
446 match keyring.get(var_name) {
448 Some(val) => val.to_string(),
449 None => return Err(AuthGenError::KeyringMissing(var_name.to_string())),
450 }
451 }
452 };
453
454 result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
455 }
456 Ok(result)
457}
458
459fn extract_json_path(value: &serde_json::Value, path: &str) -> Option<String> {
468 let mut current = value;
469 for segment in path.split('.') {
470 current = current.get(segment)?;
471 }
472 match current {
473 serde_json::Value::String(s) => Some(s.clone()),
474 serde_json::Value::Number(n) => Some(n.to_string()),
475 serde_json::Value::Bool(b) => Some(b.to_string()),
476 other => Some(other.to_string()),
477 }
478}
479
480#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn test_expand_variables_context() {
490 let ctx = GenContext {
491 jwt_sub: "agent-7".into(),
492 jwt_scope: "tool:brain:*".into(),
493 tool_name: "brain:query".into(),
494 timestamp: 1773096459,
495 jwt_token: "eyJhbGciOiJIUzI1NiJ9.payload.sig".into(),
496 };
497 let keyring = Keyring::empty();
498
499 assert_eq!(
500 expand_variables("${JWT_SUB}", &ctx, &keyring).unwrap(),
501 "agent-7"
502 );
503 assert_eq!(
504 expand_variables("${TOOL_NAME}", &ctx, &keyring).unwrap(),
505 "brain:query"
506 );
507 assert_eq!(
508 expand_variables("${TIMESTAMP}", &ctx, &keyring).unwrap(),
509 "1773096459"
510 );
511 assert_eq!(
512 expand_variables("${JWT_TOKEN}", &ctx, &keyring).unwrap(),
513 "eyJhbGciOiJIUzI1NiJ9.payload.sig"
514 );
515 assert_eq!(
516 expand_variables("sub=${JWT_SUB}&tool=${TOOL_NAME}", &ctx, &keyring).unwrap(),
517 "sub=agent-7&tool=brain:query"
518 );
519 assert_eq!(
521 expand_variables("Bearer ${JWT_TOKEN}", &ctx, &keyring).unwrap(),
522 "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"
523 );
524 }
525
526 #[test]
527 fn test_expand_variables_jwt_token_empty_when_unset() {
528 let ctx = GenContext::default();
535 let keyring = Keyring::empty();
536 assert_eq!(
537 expand_variables("${JWT_TOKEN}", &ctx, &keyring).unwrap(),
538 ""
539 );
540 }
541
542 #[test]
543 fn test_expand_variables_keyring() {
544 let dir = tempfile::TempDir::new().unwrap();
545 let path = dir.path().join("creds");
546 std::fs::write(&path, r#"{"my_secret":"s3cr3t"}"#).unwrap();
547 let keyring = Keyring::load_credentials(&path).unwrap();
548
549 let ctx = GenContext::default();
550 assert_eq!(
551 expand_variables("${my_secret}", &ctx, &keyring).unwrap(),
552 "s3cr3t"
553 );
554 }
555
556 #[test]
557 fn test_expand_variables_missing_key() {
558 let keyring = Keyring::empty();
559 let ctx = GenContext::default();
560 let err = expand_variables("${nonexistent}", &ctx, &keyring).unwrap_err();
561 assert!(matches!(err, AuthGenError::KeyringMissing(_)));
562 }
563
564 #[test]
565 fn test_expand_variables_no_placeholder() {
566 let keyring = Keyring::empty();
567 let ctx = GenContext::default();
568 assert_eq!(
569 expand_variables("plain text", &ctx, &keyring).unwrap(),
570 "plain text"
571 );
572 }
573
574 #[test]
575 fn test_extract_json_path_simple() {
576 let json: serde_json::Value = serde_json::json!({"token": "abc123", "expires_in": 3600});
577 assert_eq!(extract_json_path(&json, "token"), Some("abc123".into()));
578 assert_eq!(extract_json_path(&json, "expires_in"), Some("3600".into()));
579 }
580
581 #[test]
582 fn test_extract_json_path_nested() {
583 let json: serde_json::Value = serde_json::json!({
584 "Credentials": {
585 "AccessKeyId": "AKIA...",
586 "SecretAccessKey": "wJalrX...",
587 "SessionToken": "FwoGZ..."
588 }
589 });
590 assert_eq!(
591 extract_json_path(&json, "Credentials.AccessKeyId"),
592 Some("AKIA...".into())
593 );
594 assert_eq!(
595 extract_json_path(&json, "Credentials.SessionToken"),
596 Some("FwoGZ...".into())
597 );
598 }
599
600 #[test]
601 fn test_extract_json_path_missing() {
602 let json: serde_json::Value = serde_json::json!({"a": "b"});
603 assert_eq!(extract_json_path(&json, "nonexistent"), None);
604 assert_eq!(extract_json_path(&json, "a.b.c"), None);
605 }
606
607 #[test]
608 fn test_auth_cache_basic() {
609 let cache = AuthCache::new();
610 assert!(cache.get("provider", "sub", "").is_none());
611
612 let cred = GeneratedCredential {
613 value: "token123".into(),
614 extra_headers: HashMap::new(),
615 extra_env: HashMap::new(),
616 };
617 cache.insert("provider", "sub", "", cred.clone(), 300);
618
619 let cached = cache.get("provider", "sub", "").unwrap();
620 assert_eq!(cached.value, "token123");
621 }
622
623 #[test]
624 fn test_auth_cache_zero_ttl_no_cache() {
625 let cache = AuthCache::new();
626 let cred = GeneratedCredential {
627 value: "token".into(),
628 extra_headers: HashMap::new(),
629 extra_env: HashMap::new(),
630 };
631 cache.insert("provider", "sub", "", cred, 0);
632 assert!(cache.get("provider", "sub", "").is_none());
633 }
634
635 #[test]
636 fn test_auth_cache_different_keys() {
637 let cache = AuthCache::new();
638 let cred1 = GeneratedCredential {
639 value: "token-a".into(),
640 extra_headers: HashMap::new(),
641 extra_env: HashMap::new(),
642 };
643 let cred2 = GeneratedCredential {
644 value: "token-b".into(),
645 extra_headers: HashMap::new(),
646 extra_env: HashMap::new(),
647 };
648 cache.insert("provider", "agent-1", "", cred1, 300);
649 cache.insert("provider", "agent-2", "", cred2, 300);
650
651 assert_eq!(
652 cache.get("provider", "agent-1", "").unwrap().value,
653 "token-a"
654 );
655 assert_eq!(
656 cache.get("provider", "agent-2", "").unwrap().value,
657 "token-b"
658 );
659 }
660
661 #[test]
662 fn test_auth_cache_per_token_isolation() {
663 let cache = AuthCache::new();
667 let cred_a = GeneratedCredential {
668 value: "for-sandbox-a".into(),
669 extra_headers: HashMap::new(),
670 extra_env: HashMap::new(),
671 };
672 let cred_b = GeneratedCredential {
673 value: "for-sandbox-b".into(),
674 extra_headers: HashMap::new(),
675 extra_env: HashMap::new(),
676 };
677 cache.insert("provider", "sandbox-svc", "jwt-A", cred_a, 300);
678 cache.insert("provider", "sandbox-svc", "jwt-B", cred_b, 300);
679
680 assert_eq!(
682 cache.get("provider", "sandbox-svc", "jwt-A").unwrap().value,
683 "for-sandbox-a"
684 );
685 assert_eq!(
686 cache.get("provider", "sandbox-svc", "jwt-B").unwrap().value,
687 "for-sandbox-b"
688 );
689 assert!(cache.get("provider", "sandbox-svc", "jwt-C").is_none());
691 }
692
693 #[test]
694 fn test_token_fingerprint_stable_and_distinct() {
695 let f1 = token_fingerprint("token-one");
696 let f2 = token_fingerprint("token-two");
697 assert_eq!(token_fingerprint("token-one"), f1, "fingerprint is stable");
698 assert_ne!(f1, f2, "different tokens hash to different fingerprints");
699 assert_eq!(f1.len(), 16, "fingerprint is 16 hex chars");
700 assert!(f1.chars().all(|c| c.is_ascii_hexdigit()));
701 assert_eq!(token_fingerprint(""), "");
704 }
705
706 #[test]
707 fn test_auth_cache_insert_evicts_expired_entries() {
708 let cache = AuthCache::new();
715 let cred = GeneratedCredential {
716 value: "ephemeral".into(),
717 extra_headers: HashMap::new(),
718 extra_env: HashMap::new(),
719 };
720 cache.insert("p", "s", "old-jwt", cred.clone(), 1);
721 assert_eq!(cache.entry_count(), 1);
722
723 std::thread::sleep(Duration::from_millis(1100));
726
727 cache.insert("p", "s", "new-jwt", cred, 60);
729 assert_eq!(
730 cache.entry_count(),
731 1,
732 "sweep on insert should have dropped the expired entry; \
733 only the new one should remain"
734 );
735
736 assert!(cache.get("p", "s", "new-jwt").is_some());
738 assert!(cache.get("p", "s", "old-jwt").is_none());
739 }
740
741 #[tokio::test]
742 async fn test_generate_command_text() {
743 let provider = Provider {
744 name: "test".into(),
745 description: "test provider".into(),
746 base_url: String::new(),
747 auth_type: crate::core::manifest::AuthType::Bearer,
748 auth_key_name: None,
749 auth_header_name: None,
750 auth_query_name: None,
751 auth_value_prefix: None,
752 extra_headers: HashMap::new(),
753 oauth2_token_url: None,
754 auth_secret_name: None,
755 oauth2_basic_auth: false,
756 internal: false,
757 handler: "http".into(),
758 mcp_transport: None,
759 mcp_command: None,
760 mcp_args: vec![],
761 mcp_url: None,
762 mcp_env: HashMap::new(),
763 cli_command: None,
764 cli_default_args: vec![],
765 cli_env: HashMap::new(),
766 cli_timeout_secs: None,
767 cli_output_args: Vec::new(),
768 cli_output_positional: HashMap::new(),
769 upload_destinations: HashMap::new(),
770 upload_default_destination: None,
771 openapi_spec: None,
772 openapi_include_tags: vec![],
773 openapi_exclude_tags: vec![],
774 openapi_include_operations: vec![],
775 openapi_exclude_operations: vec![],
776 openapi_max_operations: None,
777 openapi_overrides: HashMap::new(),
778 auth_generator: None,
779 category: None,
780 skills: vec![],
781 };
782
783 let gen = AuthGenerator {
784 gen_type: AuthGenType::Command,
785 command: Some("echo".into()),
786 args: vec!["hello-token".into()],
787 interpreter: None,
788 script: None,
789 cache_ttl_secs: 0,
790 output_format: AuthOutputFormat::Text,
791 env: HashMap::new(),
792 inject: HashMap::new(),
793 timeout_secs: 5,
794 };
795
796 let ctx = GenContext::default();
797 let keyring = Keyring::empty();
798 let cache = AuthCache::new();
799
800 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
801 .await
802 .unwrap();
803 assert_eq!(cred.value, "hello-token");
804 assert!(cred.extra_headers.is_empty());
805 }
806
807 #[tokio::test]
808 async fn test_generate_command_json() {
809 let provider = Provider {
810 name: "test".into(),
811 description: "test".into(),
812 base_url: String::new(),
813 auth_type: crate::core::manifest::AuthType::Bearer,
814 auth_key_name: None,
815 auth_header_name: None,
816 auth_query_name: None,
817 auth_value_prefix: None,
818 extra_headers: HashMap::new(),
819 oauth2_token_url: None,
820 auth_secret_name: None,
821 oauth2_basic_auth: false,
822 internal: false,
823 handler: "http".into(),
824 mcp_transport: None,
825 mcp_command: None,
826 mcp_args: vec![],
827 mcp_url: None,
828 mcp_env: HashMap::new(),
829 cli_command: None,
830 cli_default_args: vec![],
831 cli_env: HashMap::new(),
832 cli_timeout_secs: None,
833 cli_output_args: Vec::new(),
834 cli_output_positional: HashMap::new(),
835 upload_destinations: HashMap::new(),
836 upload_default_destination: None,
837 openapi_spec: None,
838 openapi_include_tags: vec![],
839 openapi_exclude_tags: vec![],
840 openapi_include_operations: vec![],
841 openapi_exclude_operations: vec![],
842 openapi_max_operations: None,
843 openapi_overrides: HashMap::new(),
844 auth_generator: None,
845 category: None,
846 skills: vec![],
847 };
848
849 let mut inject = HashMap::new();
850 inject.insert(
851 "Credentials.AccessKeyId".into(),
852 crate::core::manifest::InjectTarget {
853 inject_type: "header".into(),
854 name: "X-Access-Key".into(),
855 },
856 );
857 inject.insert(
858 "Credentials.Secret".into(),
859 crate::core::manifest::InjectTarget {
860 inject_type: "env".into(),
861 name: "AWS_SECRET".into(),
862 },
863 );
864
865 let gen = AuthGenerator {
866 gen_type: AuthGenType::Command,
867 command: Some("echo".into()),
868 args: vec![
869 r#"{"Credentials":{"AccessKeyId":"AKIA123","Secret":"wJalr","SessionToken":"FwoG"}}"#.into(),
870 ],
871 interpreter: None,
872 script: None,
873 cache_ttl_secs: 0,
874 output_format: AuthOutputFormat::Json,
875 env: HashMap::new(),
876 inject,
877 timeout_secs: 5,
878 };
879
880 let ctx = GenContext::default();
881 let keyring = Keyring::empty();
882 let cache = AuthCache::new();
883
884 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
885 .await
886 .unwrap();
887 assert_eq!(cred.extra_headers.get("X-Access-Key").unwrap(), "AKIA123");
888 assert_eq!(cred.extra_env.get("AWS_SECRET").unwrap(), "wJalr");
889 }
890
891 #[tokio::test]
892 async fn test_generate_script() {
893 let provider = Provider {
894 name: "test".into(),
895 description: "test".into(),
896 base_url: String::new(),
897 auth_type: crate::core::manifest::AuthType::Bearer,
898 auth_key_name: None,
899 auth_header_name: None,
900 auth_query_name: None,
901 auth_value_prefix: None,
902 extra_headers: HashMap::new(),
903 oauth2_token_url: None,
904 auth_secret_name: None,
905 oauth2_basic_auth: false,
906 internal: false,
907 handler: "http".into(),
908 mcp_transport: None,
909 mcp_command: None,
910 mcp_args: vec![],
911 mcp_url: None,
912 mcp_env: HashMap::new(),
913 cli_command: None,
914 cli_default_args: vec![],
915 cli_env: HashMap::new(),
916 cli_timeout_secs: None,
917 cli_output_args: Vec::new(),
918 cli_output_positional: HashMap::new(),
919 upload_destinations: HashMap::new(),
920 upload_default_destination: None,
921 openapi_spec: None,
922 openapi_include_tags: vec![],
923 openapi_exclude_tags: vec![],
924 openapi_include_operations: vec![],
925 openapi_exclude_operations: vec![],
926 openapi_max_operations: None,
927 openapi_overrides: HashMap::new(),
928 auth_generator: None,
929 category: None,
930 skills: vec![],
931 };
932
933 let gen = AuthGenerator {
934 gen_type: AuthGenType::Script,
935 command: None,
936 args: vec![],
937 interpreter: Some("bash".into()),
938 script: Some("echo script-token-42".into()),
939 cache_ttl_secs: 0,
940 output_format: AuthOutputFormat::Text,
941 env: HashMap::new(),
942 inject: HashMap::new(),
943 timeout_secs: 5,
944 };
945
946 let ctx = GenContext::default();
947 let keyring = Keyring::empty();
948 let cache = AuthCache::new();
949
950 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
951 .await
952 .unwrap();
953 assert_eq!(cred.value, "script-token-42");
954 }
955
956 #[tokio::test]
957 async fn test_generate_caches_result() {
958 let provider = Provider {
959 name: "cached_provider".into(),
960 description: "test".into(),
961 base_url: String::new(),
962 auth_type: crate::core::manifest::AuthType::Bearer,
963 auth_key_name: None,
964 auth_header_name: None,
965 auth_query_name: None,
966 auth_value_prefix: None,
967 extra_headers: HashMap::new(),
968 oauth2_token_url: None,
969 auth_secret_name: None,
970 oauth2_basic_auth: false,
971 internal: false,
972 handler: "http".into(),
973 mcp_transport: None,
974 mcp_command: None,
975 mcp_args: vec![],
976 mcp_url: None,
977 mcp_env: HashMap::new(),
978 cli_command: None,
979 cli_default_args: vec![],
980 cli_env: HashMap::new(),
981 cli_timeout_secs: None,
982 cli_output_args: Vec::new(),
983 cli_output_positional: HashMap::new(),
984 upload_destinations: HashMap::new(),
985 upload_default_destination: None,
986 openapi_spec: None,
987 openapi_include_tags: vec![],
988 openapi_exclude_tags: vec![],
989 openapi_include_operations: vec![],
990 openapi_exclude_operations: vec![],
991 openapi_max_operations: None,
992 openapi_overrides: HashMap::new(),
993 auth_generator: None,
994 category: None,
995 skills: vec![],
996 };
997
998 let gen = AuthGenerator {
999 gen_type: AuthGenType::Command,
1000 command: Some("date".into()),
1001 args: vec!["+%s%N".into()],
1002 interpreter: None,
1003 script: None,
1004 cache_ttl_secs: 300,
1005 output_format: AuthOutputFormat::Text,
1006 env: HashMap::new(),
1007 inject: HashMap::new(),
1008 timeout_secs: 5,
1009 };
1010
1011 let ctx = GenContext {
1012 jwt_sub: "test-agent".into(),
1013 ..GenContext::default()
1014 };
1015 let keyring = Keyring::empty();
1016 let cache = AuthCache::new();
1017
1018 let cred1 = generate(&provider, &gen, &ctx, &keyring, &cache)
1019 .await
1020 .unwrap();
1021 let cred2 = generate(&provider, &gen, &ctx, &keyring, &cache)
1022 .await
1023 .unwrap();
1024 assert_eq!(cred1.value, cred2.value);
1026 }
1027
1028 #[tokio::test]
1029 async fn test_generate_with_variable_expansion() {
1030 let provider = Provider {
1031 name: "test".into(),
1032 description: "test".into(),
1033 base_url: String::new(),
1034 auth_type: crate::core::manifest::AuthType::Bearer,
1035 auth_key_name: None,
1036 auth_header_name: None,
1037 auth_query_name: None,
1038 auth_value_prefix: None,
1039 extra_headers: HashMap::new(),
1040 oauth2_token_url: None,
1041 auth_secret_name: None,
1042 oauth2_basic_auth: false,
1043 internal: false,
1044 handler: "http".into(),
1045 mcp_transport: None,
1046 mcp_command: None,
1047 mcp_args: vec![],
1048 mcp_url: None,
1049 mcp_env: HashMap::new(),
1050 cli_command: None,
1051 cli_default_args: vec![],
1052 cli_env: HashMap::new(),
1053 cli_timeout_secs: None,
1054 cli_output_args: Vec::new(),
1055 cli_output_positional: HashMap::new(),
1056 upload_destinations: HashMap::new(),
1057 upload_default_destination: None,
1058 openapi_spec: None,
1059 openapi_include_tags: vec![],
1060 openapi_exclude_tags: vec![],
1061 openapi_include_operations: vec![],
1062 openapi_exclude_operations: vec![],
1063 openapi_max_operations: None,
1064 openapi_overrides: HashMap::new(),
1065 auth_generator: None,
1066 category: None,
1067 skills: vec![],
1068 };
1069
1070 let gen = AuthGenerator {
1071 gen_type: AuthGenType::Command,
1072 command: Some("echo".into()),
1073 args: vec!["${JWT_SUB}".into()],
1074 interpreter: None,
1075 script: None,
1076 cache_ttl_secs: 0,
1077 output_format: AuthOutputFormat::Text,
1078 env: HashMap::new(),
1079 inject: HashMap::new(),
1080 timeout_secs: 5,
1081 };
1082
1083 let ctx = GenContext {
1084 jwt_sub: "agent-42".into(),
1085 jwt_scope: "*".into(),
1086 tool_name: "brain:query".into(),
1087 timestamp: 1234567890,
1088 jwt_token: String::new(),
1089 };
1090 let keyring = Keyring::empty();
1091 let cache = AuthCache::new();
1092
1093 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
1094 .await
1095 .unwrap();
1096 assert_eq!(cred.value, "agent-42");
1097 }
1098
1099 #[tokio::test]
1100 async fn test_generate_timeout() {
1101 let provider = Provider {
1102 name: "test".into(),
1103 description: "test".into(),
1104 base_url: String::new(),
1105 auth_type: crate::core::manifest::AuthType::Bearer,
1106 auth_key_name: None,
1107 auth_header_name: None,
1108 auth_query_name: None,
1109 auth_value_prefix: None,
1110 extra_headers: HashMap::new(),
1111 oauth2_token_url: None,
1112 auth_secret_name: None,
1113 oauth2_basic_auth: false,
1114 internal: false,
1115 handler: "http".into(),
1116 mcp_transport: None,
1117 mcp_command: None,
1118 mcp_args: vec![],
1119 mcp_url: None,
1120 mcp_env: HashMap::new(),
1121 cli_command: None,
1122 cli_default_args: vec![],
1123 cli_env: HashMap::new(),
1124 cli_timeout_secs: None,
1125 cli_output_args: Vec::new(),
1126 cli_output_positional: HashMap::new(),
1127 upload_destinations: HashMap::new(),
1128 upload_default_destination: None,
1129 openapi_spec: None,
1130 openapi_include_tags: vec![],
1131 openapi_exclude_tags: vec![],
1132 openapi_include_operations: vec![],
1133 openapi_exclude_operations: vec![],
1134 openapi_max_operations: None,
1135 openapi_overrides: HashMap::new(),
1136 auth_generator: None,
1137 category: None,
1138 skills: vec![],
1139 };
1140
1141 let gen = AuthGenerator {
1142 gen_type: AuthGenType::Command,
1143 command: Some("sleep".into()),
1144 args: vec!["10".into()],
1145 interpreter: None,
1146 script: None,
1147 cache_ttl_secs: 0,
1148 output_format: AuthOutputFormat::Text,
1149 env: HashMap::new(),
1150 inject: HashMap::new(),
1151 timeout_secs: 1,
1152 };
1153
1154 let ctx = GenContext::default();
1155 let keyring = Keyring::empty();
1156 let cache = AuthCache::new();
1157
1158 let err = generate(&provider, &gen, &ctx, &keyring, &cache)
1159 .await
1160 .unwrap_err();
1161 assert!(matches!(err, AuthGenError::Timeout(1)));
1162 }
1163}