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}
53
54impl Default for GenContext {
55 fn default() -> Self {
56 GenContext {
57 jwt_sub: "dev".into(),
58 jwt_scope: "*".into(),
59 tool_name: String::new(),
60 timestamp: std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .unwrap_or_default()
63 .as_secs(),
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
74pub struct GeneratedCredential {
75 pub value: String,
77 pub extra_headers: HashMap<String, String>,
79 pub extra_env: HashMap<String, String>,
81}
82
83struct CachedCredential {
88 cred: GeneratedCredential,
89 expires_at: Instant,
90}
91
92pub struct AuthCache {
94 entries: Mutex<HashMap<(String, String), CachedCredential>>,
95}
96
97impl Default for AuthCache {
98 fn default() -> Self {
99 AuthCache {
100 entries: Mutex::new(HashMap::new()),
101 }
102 }
103}
104
105impl AuthCache {
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 pub fn get(&self, provider: &str, sub: &str) -> Option<GeneratedCredential> {
111 let cache = self.entries.lock().unwrap();
112 let key = (provider.to_string(), sub.to_string());
113 match cache.get(&key) {
114 Some(entry) if Instant::now() < entry.expires_at => Some(entry.cred.clone()),
115 _ => None,
116 }
117 }
118
119 pub fn insert(&self, provider: &str, sub: &str, cred: GeneratedCredential, ttl_secs: u64) {
120 if ttl_secs == 0 {
121 return; }
123 let mut cache = self.entries.lock().unwrap();
124 let key = (provider.to_string(), sub.to_string());
125 cache.insert(
126 key,
127 CachedCredential {
128 cred,
129 expires_at: Instant::now() + Duration::from_secs(ttl_secs),
130 },
131 );
132 }
133}
134
135pub async fn generate(
147 provider: &Provider,
148 gen: &AuthGenerator,
149 ctx: &GenContext,
150 keyring: &Keyring,
151 cache: &AuthCache,
152) -> Result<GeneratedCredential, AuthGenError> {
153 if gen.cache_ttl_secs > 0 {
155 if let Some(cached) = cache.get(&provider.name, &ctx.jwt_sub) {
156 return Ok(cached);
157 }
158 }
159
160 let expanded_args: Vec<String> = gen
162 .args
163 .iter()
164 .map(|a| expand_variables(a, ctx, keyring))
165 .collect::<Result<Vec<_>, _>>()?;
166
167 let mut expanded_env: HashMap<String, String> = HashMap::new();
168 for (k, v) in &gen.env {
169 expanded_env.insert(k.clone(), expand_variables(v, ctx, keyring)?);
170 }
171
172 let mut final_env: HashMap<String, String> = HashMap::new();
174 for var in &["PATH", "HOME", "TMPDIR"] {
175 if let Ok(val) = std::env::var(var) {
176 final_env.insert(var.to_string(), val);
177 }
178 }
179 final_env.extend(expanded_env);
180
181 let output =
183 match gen.gen_type {
184 AuthGenType::Command => {
185 let command = gen.command.as_deref().ok_or_else(|| {
186 AuthGenError::Config("command required for type=command".into())
187 })?;
188
189 let child = tokio::process::Command::new(command)
190 .args(&expanded_args)
191 .env_clear()
192 .envs(&final_env)
193 .stdout(std::process::Stdio::piped())
194 .stderr(std::process::Stdio::piped())
195 .kill_on_drop(true)
196 .spawn()
197 .map_err(|e| AuthGenError::Spawn(format!("{command}: {e}")))?;
198
199 let timeout = Duration::from_secs(gen.timeout_secs);
200 tokio::time::timeout(timeout, child.wait_with_output())
201 .await
202 .map_err(|_| AuthGenError::Timeout(gen.timeout_secs))?
203 .map_err(AuthGenError::Io)?
204 }
205 AuthGenType::Script => {
206 let interpreter = gen.interpreter.as_deref().ok_or_else(|| {
207 AuthGenError::Config("interpreter required for type=script".into())
208 })?;
209 let script = gen.script.as_deref().ok_or_else(|| {
210 AuthGenError::Config("script required for type=script".into())
211 })?;
212
213 let suffix: u32 = rand::random();
215 let tmp_path = std::env::temp_dir().join(format!("ati_gen_{suffix}.tmp"));
216 std::fs::write(&tmp_path, script).map_err(AuthGenError::Io)?;
217
218 let child = tokio::process::Command::new(interpreter)
219 .arg(&tmp_path)
220 .env_clear()
221 .envs(&final_env)
222 .stdout(std::process::Stdio::piped())
223 .stderr(std::process::Stdio::piped())
224 .kill_on_drop(true)
225 .spawn()
226 .map_err(|e| AuthGenError::Spawn(format!("{interpreter}: {e}")))?;
227
228 let timeout = Duration::from_secs(gen.timeout_secs);
229 let result = tokio::time::timeout(timeout, child.wait_with_output())
230 .await
231 .map_err(|_| AuthGenError::Timeout(gen.timeout_secs))?
232 .map_err(AuthGenError::Io)?;
233
234 let _ = std::fs::remove_file(&tmp_path);
236 result
237 }
238 };
239
240 if !output.status.success() {
241 let code = output.status.code().unwrap_or(-1);
242 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
243 return Err(AuthGenError::NonZeroExit { code, stderr });
244 }
245
246 let stdout = String::from_utf8_lossy(&output.stdout);
247
248 let cred = match gen.output_format {
250 AuthOutputFormat::Text => GeneratedCredential {
251 value: stdout.trim().to_string(),
252 extra_headers: HashMap::new(),
253 extra_env: HashMap::new(),
254 },
255 AuthOutputFormat::Json => {
256 let json: serde_json::Value = serde_json::from_str(stdout.trim())
257 .map_err(|e| AuthGenError::OutputParse(format!("invalid JSON: {e}")))?;
258
259 let mut extra_headers = HashMap::new();
260 let mut extra_env = HashMap::new();
261 let mut primary_value = stdout.trim().to_string();
262
263 if gen.inject.is_empty() {
265 if let Some(tok) = json.get("token").or(json.get("access_token")) {
267 if let Some(s) = tok.as_str() {
268 primary_value = s.to_string();
269 }
270 }
271 } else {
272 let mut found_primary = false;
274 for (json_path, target) in &gen.inject {
275 let extracted = extract_json_path(&json, json_path).ok_or_else(|| {
276 AuthGenError::OutputParse(format!(
277 "JSON path '{}' not found in output",
278 json_path
279 ))
280 })?;
281
282 match target.inject_type.as_str() {
283 "header" => {
284 extra_headers.insert(target.name.clone(), extracted);
285 }
286 "env" => {
287 extra_env.insert(target.name.clone(), extracted);
288 }
289 "query" => {
290 if !found_primary {
292 primary_value = extracted;
293 found_primary = true;
294 }
295 }
296 _ => {
297 if !found_primary {
299 primary_value = extracted;
300 found_primary = true;
301 }
302 }
303 }
304 }
305 }
306
307 GeneratedCredential {
308 value: primary_value,
309 extra_headers,
310 extra_env,
311 }
312 }
313 };
314
315 cache.insert(
317 &provider.name,
318 &ctx.jwt_sub,
319 cred.clone(),
320 gen.cache_ttl_secs,
321 );
322
323 Ok(cred)
324}
325
326fn expand_variables(
336 input: &str,
337 ctx: &GenContext,
338 keyring: &Keyring,
339) -> Result<String, AuthGenError> {
340 let mut result = input.to_string();
341 while let Some(start) = result.find("${") {
343 let rest = &result[start + 2..];
344 let end = match rest.find('}') {
345 Some(e) => e,
346 None => break,
347 };
348 let var_name = &rest[..end];
349
350 let replacement = match var_name {
351 "JWT_SUB" => ctx.jwt_sub.clone(),
352 "JWT_SCOPE" => ctx.jwt_scope.clone(),
353 "TOOL_NAME" => ctx.tool_name.clone(),
354 "TIMESTAMP" => ctx.timestamp.to_string(),
355 _ => {
356 match keyring.get(var_name) {
358 Some(val) => val.to_string(),
359 None => return Err(AuthGenError::KeyringMissing(var_name.to_string())),
360 }
361 }
362 };
363
364 result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
365 }
366 Ok(result)
367}
368
369fn extract_json_path(value: &serde_json::Value, path: &str) -> Option<String> {
378 let mut current = value;
379 for segment in path.split('.') {
380 current = current.get(segment)?;
381 }
382 match current {
383 serde_json::Value::String(s) => Some(s.clone()),
384 serde_json::Value::Number(n) => Some(n.to_string()),
385 serde_json::Value::Bool(b) => Some(b.to_string()),
386 other => Some(other.to_string()),
387 }
388}
389
390#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn test_expand_variables_context() {
400 let ctx = GenContext {
401 jwt_sub: "agent-7".into(),
402 jwt_scope: "tool:brain:*".into(),
403 tool_name: "brain:query".into(),
404 timestamp: 1773096459,
405 };
406 let keyring = Keyring::empty();
407
408 assert_eq!(
409 expand_variables("${JWT_SUB}", &ctx, &keyring).unwrap(),
410 "agent-7"
411 );
412 assert_eq!(
413 expand_variables("${TOOL_NAME}", &ctx, &keyring).unwrap(),
414 "brain:query"
415 );
416 assert_eq!(
417 expand_variables("${TIMESTAMP}", &ctx, &keyring).unwrap(),
418 "1773096459"
419 );
420 assert_eq!(
421 expand_variables("sub=${JWT_SUB}&tool=${TOOL_NAME}", &ctx, &keyring).unwrap(),
422 "sub=agent-7&tool=brain:query"
423 );
424 }
425
426 #[test]
427 fn test_expand_variables_keyring() {
428 let dir = tempfile::TempDir::new().unwrap();
429 let path = dir.path().join("creds");
430 std::fs::write(&path, r#"{"my_secret":"s3cr3t"}"#).unwrap();
431 let keyring = Keyring::load_credentials(&path).unwrap();
432
433 let ctx = GenContext::default();
434 assert_eq!(
435 expand_variables("${my_secret}", &ctx, &keyring).unwrap(),
436 "s3cr3t"
437 );
438 }
439
440 #[test]
441 fn test_expand_variables_missing_key() {
442 let keyring = Keyring::empty();
443 let ctx = GenContext::default();
444 let err = expand_variables("${nonexistent}", &ctx, &keyring).unwrap_err();
445 assert!(matches!(err, AuthGenError::KeyringMissing(_)));
446 }
447
448 #[test]
449 fn test_expand_variables_no_placeholder() {
450 let keyring = Keyring::empty();
451 let ctx = GenContext::default();
452 assert_eq!(
453 expand_variables("plain text", &ctx, &keyring).unwrap(),
454 "plain text"
455 );
456 }
457
458 #[test]
459 fn test_extract_json_path_simple() {
460 let json: serde_json::Value = serde_json::json!({"token": "abc123", "expires_in": 3600});
461 assert_eq!(extract_json_path(&json, "token"), Some("abc123".into()));
462 assert_eq!(extract_json_path(&json, "expires_in"), Some("3600".into()));
463 }
464
465 #[test]
466 fn test_extract_json_path_nested() {
467 let json: serde_json::Value = serde_json::json!({
468 "Credentials": {
469 "AccessKeyId": "AKIA...",
470 "SecretAccessKey": "wJalrX...",
471 "SessionToken": "FwoGZ..."
472 }
473 });
474 assert_eq!(
475 extract_json_path(&json, "Credentials.AccessKeyId"),
476 Some("AKIA...".into())
477 );
478 assert_eq!(
479 extract_json_path(&json, "Credentials.SessionToken"),
480 Some("FwoGZ...".into())
481 );
482 }
483
484 #[test]
485 fn test_extract_json_path_missing() {
486 let json: serde_json::Value = serde_json::json!({"a": "b"});
487 assert_eq!(extract_json_path(&json, "nonexistent"), None);
488 assert_eq!(extract_json_path(&json, "a.b.c"), None);
489 }
490
491 #[test]
492 fn test_auth_cache_basic() {
493 let cache = AuthCache::new();
494 assert!(cache.get("provider", "sub").is_none());
495
496 let cred = GeneratedCredential {
497 value: "token123".into(),
498 extra_headers: HashMap::new(),
499 extra_env: HashMap::new(),
500 };
501 cache.insert("provider", "sub", cred.clone(), 300);
502
503 let cached = cache.get("provider", "sub").unwrap();
504 assert_eq!(cached.value, "token123");
505 }
506
507 #[test]
508 fn test_auth_cache_zero_ttl_no_cache() {
509 let cache = AuthCache::new();
510 let cred = GeneratedCredential {
511 value: "token".into(),
512 extra_headers: HashMap::new(),
513 extra_env: HashMap::new(),
514 };
515 cache.insert("provider", "sub", cred, 0);
516 assert!(cache.get("provider", "sub").is_none());
517 }
518
519 #[test]
520 fn test_auth_cache_different_keys() {
521 let cache = AuthCache::new();
522 let cred1 = GeneratedCredential {
523 value: "token-a".into(),
524 extra_headers: HashMap::new(),
525 extra_env: HashMap::new(),
526 };
527 let cred2 = GeneratedCredential {
528 value: "token-b".into(),
529 extra_headers: HashMap::new(),
530 extra_env: HashMap::new(),
531 };
532 cache.insert("provider", "agent-1", cred1, 300);
533 cache.insert("provider", "agent-2", cred2, 300);
534
535 assert_eq!(cache.get("provider", "agent-1").unwrap().value, "token-a");
536 assert_eq!(cache.get("provider", "agent-2").unwrap().value, "token-b");
537 }
538
539 #[tokio::test]
540 async fn test_generate_command_text() {
541 let provider = Provider {
542 name: "test".into(),
543 description: "test provider".into(),
544 base_url: String::new(),
545 auth_type: crate::core::manifest::AuthType::Bearer,
546 auth_key_name: None,
547 auth_header_name: None,
548 auth_query_name: None,
549 auth_value_prefix: None,
550 extra_headers: HashMap::new(),
551 oauth2_token_url: None,
552 auth_secret_name: None,
553 oauth2_basic_auth: false,
554 internal: false,
555 handler: "http".into(),
556 mcp_transport: None,
557 mcp_command: None,
558 mcp_args: vec![],
559 mcp_url: None,
560 mcp_env: HashMap::new(),
561 cli_command: None,
562 cli_default_args: vec![],
563 cli_env: HashMap::new(),
564 cli_timeout_secs: None,
565 cli_output_args: Vec::new(),
566 cli_output_positional: HashMap::new(),
567 upload_destinations: HashMap::new(),
568 upload_default_destination: None,
569 openapi_spec: None,
570 openapi_include_tags: vec![],
571 openapi_exclude_tags: vec![],
572 openapi_include_operations: vec![],
573 openapi_exclude_operations: vec![],
574 openapi_max_operations: None,
575 openapi_overrides: HashMap::new(),
576 auth_generator: None,
577 category: None,
578 skills: vec![],
579 };
580
581 let gen = AuthGenerator {
582 gen_type: AuthGenType::Command,
583 command: Some("echo".into()),
584 args: vec!["hello-token".into()],
585 interpreter: None,
586 script: None,
587 cache_ttl_secs: 0,
588 output_format: AuthOutputFormat::Text,
589 env: HashMap::new(),
590 inject: HashMap::new(),
591 timeout_secs: 5,
592 };
593
594 let ctx = GenContext::default();
595 let keyring = Keyring::empty();
596 let cache = AuthCache::new();
597
598 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
599 .await
600 .unwrap();
601 assert_eq!(cred.value, "hello-token");
602 assert!(cred.extra_headers.is_empty());
603 }
604
605 #[tokio::test]
606 async fn test_generate_command_json() {
607 let provider = Provider {
608 name: "test".into(),
609 description: "test".into(),
610 base_url: String::new(),
611 auth_type: crate::core::manifest::AuthType::Bearer,
612 auth_key_name: None,
613 auth_header_name: None,
614 auth_query_name: None,
615 auth_value_prefix: None,
616 extra_headers: HashMap::new(),
617 oauth2_token_url: None,
618 auth_secret_name: None,
619 oauth2_basic_auth: false,
620 internal: false,
621 handler: "http".into(),
622 mcp_transport: None,
623 mcp_command: None,
624 mcp_args: vec![],
625 mcp_url: None,
626 mcp_env: HashMap::new(),
627 cli_command: None,
628 cli_default_args: vec![],
629 cli_env: HashMap::new(),
630 cli_timeout_secs: None,
631 cli_output_args: Vec::new(),
632 cli_output_positional: HashMap::new(),
633 upload_destinations: HashMap::new(),
634 upload_default_destination: None,
635 openapi_spec: None,
636 openapi_include_tags: vec![],
637 openapi_exclude_tags: vec![],
638 openapi_include_operations: vec![],
639 openapi_exclude_operations: vec![],
640 openapi_max_operations: None,
641 openapi_overrides: HashMap::new(),
642 auth_generator: None,
643 category: None,
644 skills: vec![],
645 };
646
647 let mut inject = HashMap::new();
648 inject.insert(
649 "Credentials.AccessKeyId".into(),
650 crate::core::manifest::InjectTarget {
651 inject_type: "header".into(),
652 name: "X-Access-Key".into(),
653 },
654 );
655 inject.insert(
656 "Credentials.Secret".into(),
657 crate::core::manifest::InjectTarget {
658 inject_type: "env".into(),
659 name: "AWS_SECRET".into(),
660 },
661 );
662
663 let gen = AuthGenerator {
664 gen_type: AuthGenType::Command,
665 command: Some("echo".into()),
666 args: vec![
667 r#"{"Credentials":{"AccessKeyId":"AKIA123","Secret":"wJalr","SessionToken":"FwoG"}}"#.into(),
668 ],
669 interpreter: None,
670 script: None,
671 cache_ttl_secs: 0,
672 output_format: AuthOutputFormat::Json,
673 env: HashMap::new(),
674 inject,
675 timeout_secs: 5,
676 };
677
678 let ctx = GenContext::default();
679 let keyring = Keyring::empty();
680 let cache = AuthCache::new();
681
682 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
683 .await
684 .unwrap();
685 assert_eq!(cred.extra_headers.get("X-Access-Key").unwrap(), "AKIA123");
686 assert_eq!(cred.extra_env.get("AWS_SECRET").unwrap(), "wJalr");
687 }
688
689 #[tokio::test]
690 async fn test_generate_script() {
691 let provider = Provider {
692 name: "test".into(),
693 description: "test".into(),
694 base_url: String::new(),
695 auth_type: crate::core::manifest::AuthType::Bearer,
696 auth_key_name: None,
697 auth_header_name: None,
698 auth_query_name: None,
699 auth_value_prefix: None,
700 extra_headers: HashMap::new(),
701 oauth2_token_url: None,
702 auth_secret_name: None,
703 oauth2_basic_auth: false,
704 internal: false,
705 handler: "http".into(),
706 mcp_transport: None,
707 mcp_command: None,
708 mcp_args: vec![],
709 mcp_url: None,
710 mcp_env: HashMap::new(),
711 cli_command: None,
712 cli_default_args: vec![],
713 cli_env: HashMap::new(),
714 cli_timeout_secs: None,
715 cli_output_args: Vec::new(),
716 cli_output_positional: HashMap::new(),
717 upload_destinations: HashMap::new(),
718 upload_default_destination: None,
719 openapi_spec: None,
720 openapi_include_tags: vec![],
721 openapi_exclude_tags: vec![],
722 openapi_include_operations: vec![],
723 openapi_exclude_operations: vec![],
724 openapi_max_operations: None,
725 openapi_overrides: HashMap::new(),
726 auth_generator: None,
727 category: None,
728 skills: vec![],
729 };
730
731 let gen = AuthGenerator {
732 gen_type: AuthGenType::Script,
733 command: None,
734 args: vec![],
735 interpreter: Some("bash".into()),
736 script: Some("echo script-token-42".into()),
737 cache_ttl_secs: 0,
738 output_format: AuthOutputFormat::Text,
739 env: HashMap::new(),
740 inject: HashMap::new(),
741 timeout_secs: 5,
742 };
743
744 let ctx = GenContext::default();
745 let keyring = Keyring::empty();
746 let cache = AuthCache::new();
747
748 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
749 .await
750 .unwrap();
751 assert_eq!(cred.value, "script-token-42");
752 }
753
754 #[tokio::test]
755 async fn test_generate_caches_result() {
756 let provider = Provider {
757 name: "cached_provider".into(),
758 description: "test".into(),
759 base_url: String::new(),
760 auth_type: crate::core::manifest::AuthType::Bearer,
761 auth_key_name: None,
762 auth_header_name: None,
763 auth_query_name: None,
764 auth_value_prefix: None,
765 extra_headers: HashMap::new(),
766 oauth2_token_url: None,
767 auth_secret_name: None,
768 oauth2_basic_auth: false,
769 internal: false,
770 handler: "http".into(),
771 mcp_transport: None,
772 mcp_command: None,
773 mcp_args: vec![],
774 mcp_url: None,
775 mcp_env: HashMap::new(),
776 cli_command: None,
777 cli_default_args: vec![],
778 cli_env: HashMap::new(),
779 cli_timeout_secs: None,
780 cli_output_args: Vec::new(),
781 cli_output_positional: HashMap::new(),
782 upload_destinations: HashMap::new(),
783 upload_default_destination: None,
784 openapi_spec: None,
785 openapi_include_tags: vec![],
786 openapi_exclude_tags: vec![],
787 openapi_include_operations: vec![],
788 openapi_exclude_operations: vec![],
789 openapi_max_operations: None,
790 openapi_overrides: HashMap::new(),
791 auth_generator: None,
792 category: None,
793 skills: vec![],
794 };
795
796 let gen = AuthGenerator {
797 gen_type: AuthGenType::Command,
798 command: Some("date".into()),
799 args: vec!["+%s%N".into()],
800 interpreter: None,
801 script: None,
802 cache_ttl_secs: 300,
803 output_format: AuthOutputFormat::Text,
804 env: HashMap::new(),
805 inject: HashMap::new(),
806 timeout_secs: 5,
807 };
808
809 let ctx = GenContext {
810 jwt_sub: "test-agent".into(),
811 ..GenContext::default()
812 };
813 let keyring = Keyring::empty();
814 let cache = AuthCache::new();
815
816 let cred1 = generate(&provider, &gen, &ctx, &keyring, &cache)
817 .await
818 .unwrap();
819 let cred2 = generate(&provider, &gen, &ctx, &keyring, &cache)
820 .await
821 .unwrap();
822 assert_eq!(cred1.value, cred2.value);
824 }
825
826 #[tokio::test]
827 async fn test_generate_with_variable_expansion() {
828 let provider = Provider {
829 name: "test".into(),
830 description: "test".into(),
831 base_url: String::new(),
832 auth_type: crate::core::manifest::AuthType::Bearer,
833 auth_key_name: None,
834 auth_header_name: None,
835 auth_query_name: None,
836 auth_value_prefix: None,
837 extra_headers: HashMap::new(),
838 oauth2_token_url: None,
839 auth_secret_name: None,
840 oauth2_basic_auth: false,
841 internal: false,
842 handler: "http".into(),
843 mcp_transport: None,
844 mcp_command: None,
845 mcp_args: vec![],
846 mcp_url: None,
847 mcp_env: HashMap::new(),
848 cli_command: None,
849 cli_default_args: vec![],
850 cli_env: HashMap::new(),
851 cli_timeout_secs: None,
852 cli_output_args: Vec::new(),
853 cli_output_positional: HashMap::new(),
854 upload_destinations: HashMap::new(),
855 upload_default_destination: None,
856 openapi_spec: None,
857 openapi_include_tags: vec![],
858 openapi_exclude_tags: vec![],
859 openapi_include_operations: vec![],
860 openapi_exclude_operations: vec![],
861 openapi_max_operations: None,
862 openapi_overrides: HashMap::new(),
863 auth_generator: None,
864 category: None,
865 skills: vec![],
866 };
867
868 let gen = AuthGenerator {
869 gen_type: AuthGenType::Command,
870 command: Some("echo".into()),
871 args: vec!["${JWT_SUB}".into()],
872 interpreter: None,
873 script: None,
874 cache_ttl_secs: 0,
875 output_format: AuthOutputFormat::Text,
876 env: HashMap::new(),
877 inject: HashMap::new(),
878 timeout_secs: 5,
879 };
880
881 let ctx = GenContext {
882 jwt_sub: "agent-42".into(),
883 jwt_scope: "*".into(),
884 tool_name: "brain:query".into(),
885 timestamp: 1234567890,
886 };
887 let keyring = Keyring::empty();
888 let cache = AuthCache::new();
889
890 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
891 .await
892 .unwrap();
893 assert_eq!(cred.value, "agent-42");
894 }
895
896 #[tokio::test]
897 async fn test_generate_timeout() {
898 let provider = Provider {
899 name: "test".into(),
900 description: "test".into(),
901 base_url: String::new(),
902 auth_type: crate::core::manifest::AuthType::Bearer,
903 auth_key_name: None,
904 auth_header_name: None,
905 auth_query_name: None,
906 auth_value_prefix: None,
907 extra_headers: HashMap::new(),
908 oauth2_token_url: None,
909 auth_secret_name: None,
910 oauth2_basic_auth: false,
911 internal: false,
912 handler: "http".into(),
913 mcp_transport: None,
914 mcp_command: None,
915 mcp_args: vec![],
916 mcp_url: None,
917 mcp_env: HashMap::new(),
918 cli_command: None,
919 cli_default_args: vec![],
920 cli_env: HashMap::new(),
921 cli_timeout_secs: None,
922 cli_output_args: Vec::new(),
923 cli_output_positional: HashMap::new(),
924 upload_destinations: HashMap::new(),
925 upload_default_destination: None,
926 openapi_spec: None,
927 openapi_include_tags: vec![],
928 openapi_exclude_tags: vec![],
929 openapi_include_operations: vec![],
930 openapi_exclude_operations: vec![],
931 openapi_max_operations: None,
932 openapi_overrides: HashMap::new(),
933 auth_generator: None,
934 category: None,
935 skills: vec![],
936 };
937
938 let gen = AuthGenerator {
939 gen_type: AuthGenType::Command,
940 command: Some("sleep".into()),
941 args: vec!["10".into()],
942 interpreter: None,
943 script: None,
944 cache_ttl_secs: 0,
945 output_format: AuthOutputFormat::Text,
946 env: HashMap::new(),
947 inject: HashMap::new(),
948 timeout_secs: 1,
949 };
950
951 let ctx = GenContext::default();
952 let keyring = Keyring::empty();
953 let cache = AuthCache::new();
954
955 let err = generate(&provider, &gen, &ctx, &keyring, &cache)
956 .await
957 .unwrap_err();
958 assert!(matches!(err, AuthGenError::Timeout(1)));
959 }
960}