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 = match gen.gen_type {
183 AuthGenType::Command => {
184 let command = gen
185 .command
186 .as_deref()
187 .ok_or_else(|| AuthGenError::Config("command required for type=command".into()))?;
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
207 .interpreter
208 .as_deref()
209 .ok_or_else(|| AuthGenError::Config("interpreter required for type=script".into()))?;
210 let script = gen
211 .script
212 .as_deref()
213 .ok_or_else(|| AuthGenError::Config("script required for type=script".into()))?;
214
215 let suffix: u32 = rand::random();
217 let tmp_path = std::env::temp_dir().join(format!("ati_gen_{suffix}.tmp"));
218 std::fs::write(&tmp_path, script).map_err(AuthGenError::Io)?;
219
220 let child = tokio::process::Command::new(interpreter)
221 .arg(&tmp_path)
222 .env_clear()
223 .envs(&final_env)
224 .stdout(std::process::Stdio::piped())
225 .stderr(std::process::Stdio::piped())
226 .kill_on_drop(true)
227 .spawn()
228 .map_err(|e| AuthGenError::Spawn(format!("{interpreter}: {e}")))?;
229
230 let timeout = Duration::from_secs(gen.timeout_secs);
231 let result = tokio::time::timeout(timeout, child.wait_with_output())
232 .await
233 .map_err(|_| AuthGenError::Timeout(gen.timeout_secs))?
234 .map_err(AuthGenError::Io)?;
235
236 let _ = std::fs::remove_file(&tmp_path);
238 result
239 }
240 };
241
242 if !output.status.success() {
243 let code = output.status.code().unwrap_or(-1);
244 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
245 return Err(AuthGenError::NonZeroExit { code, stderr });
246 }
247
248 let stdout = String::from_utf8_lossy(&output.stdout);
249
250 let cred = match gen.output_format {
252 AuthOutputFormat::Text => GeneratedCredential {
253 value: stdout.trim().to_string(),
254 extra_headers: HashMap::new(),
255 extra_env: HashMap::new(),
256 },
257 AuthOutputFormat::Json => {
258 let json: serde_json::Value = serde_json::from_str(stdout.trim())
259 .map_err(|e| AuthGenError::OutputParse(format!("invalid JSON: {e}")))?;
260
261 let mut extra_headers = HashMap::new();
262 let mut extra_env = HashMap::new();
263 let mut primary_value = stdout.trim().to_string();
264
265 if gen.inject.is_empty() {
267 if let Some(tok) = json.get("token").or(json.get("access_token")) {
269 if let Some(s) = tok.as_str() {
270 primary_value = s.to_string();
271 }
272 }
273 } else {
274 let mut found_primary = false;
276 for (json_path, target) in &gen.inject {
277 let extracted = extract_json_path(&json, json_path)
278 .ok_or_else(|| {
279 AuthGenError::OutputParse(format!(
280 "JSON path '{}' not found in output",
281 json_path
282 ))
283 })?;
284
285 match target.inject_type.as_str() {
286 "header" => {
287 extra_headers.insert(target.name.clone(), extracted);
288 }
289 "env" => {
290 extra_env.insert(target.name.clone(), extracted);
291 }
292 "query" => {
293 if !found_primary {
295 primary_value = extracted;
296 found_primary = true;
297 }
298 }
299 _ => {
300 if !found_primary {
302 primary_value = extracted;
303 found_primary = true;
304 }
305 }
306 }
307 }
308 }
309
310 GeneratedCredential {
311 value: primary_value,
312 extra_headers,
313 extra_env,
314 }
315 }
316 };
317
318 cache.insert(&provider.name, &ctx.jwt_sub, cred.clone(), gen.cache_ttl_secs);
320
321 Ok(cred)
322}
323
324fn expand_variables(
334 input: &str,
335 ctx: &GenContext,
336 keyring: &Keyring,
337) -> Result<String, AuthGenError> {
338 let mut result = input.to_string();
339 while let Some(start) = result.find("${") {
341 let rest = &result[start + 2..];
342 let end = match rest.find('}') {
343 Some(e) => e,
344 None => break,
345 };
346 let var_name = &rest[..end];
347
348 let replacement = match var_name {
349 "JWT_SUB" => ctx.jwt_sub.clone(),
350 "JWT_SCOPE" => ctx.jwt_scope.clone(),
351 "TOOL_NAME" => ctx.tool_name.clone(),
352 "TIMESTAMP" => ctx.timestamp.to_string(),
353 _ => {
354 match keyring.get(var_name) {
356 Some(val) => val.to_string(),
357 None => return Err(AuthGenError::KeyringMissing(var_name.to_string())),
358 }
359 }
360 };
361
362 result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
363 }
364 Ok(result)
365}
366
367fn extract_json_path(value: &serde_json::Value, path: &str) -> Option<String> {
376 let mut current = value;
377 for segment in path.split('.') {
378 current = current.get(segment)?;
379 }
380 match current {
381 serde_json::Value::String(s) => Some(s.clone()),
382 serde_json::Value::Number(n) => Some(n.to_string()),
383 serde_json::Value::Bool(b) => Some(b.to_string()),
384 other => Some(other.to_string()),
385 }
386}
387
388#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_expand_variables_context() {
398 let ctx = GenContext {
399 jwt_sub: "agent-7".into(),
400 jwt_scope: "tool:brain__*".into(),
401 tool_name: "brain__query".into(),
402 timestamp: 1773096459,
403 };
404 let keyring = Keyring::empty();
405
406 assert_eq!(
407 expand_variables("${JWT_SUB}", &ctx, &keyring).unwrap(),
408 "agent-7"
409 );
410 assert_eq!(
411 expand_variables("${TOOL_NAME}", &ctx, &keyring).unwrap(),
412 "brain__query"
413 );
414 assert_eq!(
415 expand_variables("${TIMESTAMP}", &ctx, &keyring).unwrap(),
416 "1773096459"
417 );
418 assert_eq!(
419 expand_variables("sub=${JWT_SUB}&tool=${TOOL_NAME}", &ctx, &keyring).unwrap(),
420 "sub=agent-7&tool=brain__query"
421 );
422 }
423
424 #[test]
425 fn test_expand_variables_keyring() {
426 let dir = tempfile::TempDir::new().unwrap();
427 let path = dir.path().join("creds");
428 std::fs::write(&path, r#"{"my_secret":"s3cr3t"}"#).unwrap();
429 let keyring = Keyring::load_credentials(&path).unwrap();
430
431 let ctx = GenContext::default();
432 assert_eq!(
433 expand_variables("${my_secret}", &ctx, &keyring).unwrap(),
434 "s3cr3t"
435 );
436 }
437
438 #[test]
439 fn test_expand_variables_missing_key() {
440 let keyring = Keyring::empty();
441 let ctx = GenContext::default();
442 let err = expand_variables("${nonexistent}", &ctx, &keyring).unwrap_err();
443 assert!(matches!(err, AuthGenError::KeyringMissing(_)));
444 }
445
446 #[test]
447 fn test_expand_variables_no_placeholder() {
448 let keyring = Keyring::empty();
449 let ctx = GenContext::default();
450 assert_eq!(
451 expand_variables("plain text", &ctx, &keyring).unwrap(),
452 "plain text"
453 );
454 }
455
456 #[test]
457 fn test_extract_json_path_simple() {
458 let json: serde_json::Value =
459 serde_json::json!({"token": "abc123", "expires_in": 3600});
460 assert_eq!(extract_json_path(&json, "token"), Some("abc123".into()));
461 assert_eq!(extract_json_path(&json, "expires_in"), Some("3600".into()));
462 }
463
464 #[test]
465 fn test_extract_json_path_nested() {
466 let json: serde_json::Value = serde_json::json!({
467 "Credentials": {
468 "AccessKeyId": "AKIA...",
469 "SecretAccessKey": "wJalrX...",
470 "SessionToken": "FwoGZ..."
471 }
472 });
473 assert_eq!(
474 extract_json_path(&json, "Credentials.AccessKeyId"),
475 Some("AKIA...".into())
476 );
477 assert_eq!(
478 extract_json_path(&json, "Credentials.SessionToken"),
479 Some("FwoGZ...".into())
480 );
481 }
482
483 #[test]
484 fn test_extract_json_path_missing() {
485 let json: serde_json::Value = serde_json::json!({"a": "b"});
486 assert_eq!(extract_json_path(&json, "nonexistent"), None);
487 assert_eq!(extract_json_path(&json, "a.b.c"), None);
488 }
489
490 #[test]
491 fn test_auth_cache_basic() {
492 let cache = AuthCache::new();
493 assert!(cache.get("provider", "sub").is_none());
494
495 let cred = GeneratedCredential {
496 value: "token123".into(),
497 extra_headers: HashMap::new(),
498 extra_env: HashMap::new(),
499 };
500 cache.insert("provider", "sub", cred.clone(), 300);
501
502 let cached = cache.get("provider", "sub").unwrap();
503 assert_eq!(cached.value, "token123");
504 }
505
506 #[test]
507 fn test_auth_cache_zero_ttl_no_cache() {
508 let cache = AuthCache::new();
509 let cred = GeneratedCredential {
510 value: "token".into(),
511 extra_headers: HashMap::new(),
512 extra_env: HashMap::new(),
513 };
514 cache.insert("provider", "sub", cred, 0);
515 assert!(cache.get("provider", "sub").is_none());
516 }
517
518 #[test]
519 fn test_auth_cache_different_keys() {
520 let cache = AuthCache::new();
521 let cred1 = GeneratedCredential {
522 value: "token-a".into(),
523 extra_headers: HashMap::new(),
524 extra_env: HashMap::new(),
525 };
526 let cred2 = GeneratedCredential {
527 value: "token-b".into(),
528 extra_headers: HashMap::new(),
529 extra_env: HashMap::new(),
530 };
531 cache.insert("provider", "agent-1", cred1, 300);
532 cache.insert("provider", "agent-2", cred2, 300);
533
534 assert_eq!(cache.get("provider", "agent-1").unwrap().value, "token-a");
535 assert_eq!(cache.get("provider", "agent-2").unwrap().value, "token-b");
536 }
537
538 #[tokio::test]
539 async fn test_generate_command_text() {
540 let provider = Provider {
541 name: "test".into(),
542 description: "test provider".into(),
543 base_url: String::new(),
544 auth_type: crate::core::manifest::AuthType::Bearer,
545 auth_key_name: None,
546 auth_header_name: None,
547 auth_query_name: None,
548 auth_value_prefix: None,
549 extra_headers: HashMap::new(),
550 oauth2_token_url: None,
551 auth_secret_name: None,
552 oauth2_basic_auth: false,
553 internal: false,
554 handler: "http".into(),
555 mcp_transport: None,
556 mcp_command: None,
557 mcp_args: vec![],
558 mcp_url: None,
559 mcp_env: HashMap::new(),
560 cli_command: None,
561 cli_default_args: vec![],
562 cli_env: HashMap::new(),
563 cli_timeout_secs: None,
564 openapi_spec: None,
565 openapi_include_tags: vec![],
566 openapi_exclude_tags: vec![],
567 openapi_include_operations: vec![],
568 openapi_exclude_operations: vec![],
569 openapi_max_operations: None,
570 openapi_overrides: HashMap::new(),
571 auth_generator: None,
572 category: None,
573 skills: vec![],
574 };
575
576 let gen = AuthGenerator {
577 gen_type: AuthGenType::Command,
578 command: Some("echo".into()),
579 args: vec!["hello-token".into()],
580 interpreter: None,
581 script: None,
582 cache_ttl_secs: 0,
583 output_format: AuthOutputFormat::Text,
584 env: HashMap::new(),
585 inject: HashMap::new(),
586 timeout_secs: 5,
587 };
588
589 let ctx = GenContext::default();
590 let keyring = Keyring::empty();
591 let cache = AuthCache::new();
592
593 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
594 .await
595 .unwrap();
596 assert_eq!(cred.value, "hello-token");
597 assert!(cred.extra_headers.is_empty());
598 }
599
600 #[tokio::test]
601 async fn test_generate_command_json() {
602 let provider = Provider {
603 name: "test".into(),
604 description: "test".into(),
605 base_url: String::new(),
606 auth_type: crate::core::manifest::AuthType::Bearer,
607 auth_key_name: None,
608 auth_header_name: None,
609 auth_query_name: None,
610 auth_value_prefix: None,
611 extra_headers: HashMap::new(),
612 oauth2_token_url: None,
613 auth_secret_name: None,
614 oauth2_basic_auth: false,
615 internal: false,
616 handler: "http".into(),
617 mcp_transport: None,
618 mcp_command: None,
619 mcp_args: vec![],
620 mcp_url: None,
621 mcp_env: HashMap::new(),
622 cli_command: None,
623 cli_default_args: vec![],
624 cli_env: HashMap::new(),
625 cli_timeout_secs: None,
626 openapi_spec: None,
627 openapi_include_tags: vec![],
628 openapi_exclude_tags: vec![],
629 openapi_include_operations: vec![],
630 openapi_exclude_operations: vec![],
631 openapi_max_operations: None,
632 openapi_overrides: HashMap::new(),
633 auth_generator: None,
634 category: None,
635 skills: vec![],
636 };
637
638 let mut inject = HashMap::new();
639 inject.insert(
640 "Credentials.AccessKeyId".into(),
641 crate::core::manifest::InjectTarget {
642 inject_type: "header".into(),
643 name: "X-Access-Key".into(),
644 },
645 );
646 inject.insert(
647 "Credentials.Secret".into(),
648 crate::core::manifest::InjectTarget {
649 inject_type: "env".into(),
650 name: "AWS_SECRET".into(),
651 },
652 );
653
654 let gen = AuthGenerator {
655 gen_type: AuthGenType::Command,
656 command: Some("echo".into()),
657 args: vec![
658 r#"{"Credentials":{"AccessKeyId":"AKIA123","Secret":"wJalr","SessionToken":"FwoG"}}"#.into(),
659 ],
660 interpreter: None,
661 script: None,
662 cache_ttl_secs: 0,
663 output_format: AuthOutputFormat::Json,
664 env: HashMap::new(),
665 inject,
666 timeout_secs: 5,
667 };
668
669 let ctx = GenContext::default();
670 let keyring = Keyring::empty();
671 let cache = AuthCache::new();
672
673 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
674 .await
675 .unwrap();
676 assert_eq!(cred.extra_headers.get("X-Access-Key").unwrap(), "AKIA123");
677 assert_eq!(cred.extra_env.get("AWS_SECRET").unwrap(), "wJalr");
678 }
679
680 #[tokio::test]
681 async fn test_generate_script() {
682 let provider = Provider {
683 name: "test".into(),
684 description: "test".into(),
685 base_url: String::new(),
686 auth_type: crate::core::manifest::AuthType::Bearer,
687 auth_key_name: None,
688 auth_header_name: None,
689 auth_query_name: None,
690 auth_value_prefix: None,
691 extra_headers: HashMap::new(),
692 oauth2_token_url: None,
693 auth_secret_name: None,
694 oauth2_basic_auth: false,
695 internal: false,
696 handler: "http".into(),
697 mcp_transport: None,
698 mcp_command: None,
699 mcp_args: vec![],
700 mcp_url: None,
701 mcp_env: HashMap::new(),
702 cli_command: None,
703 cli_default_args: vec![],
704 cli_env: HashMap::new(),
705 cli_timeout_secs: None,
706 openapi_spec: None,
707 openapi_include_tags: vec![],
708 openapi_exclude_tags: vec![],
709 openapi_include_operations: vec![],
710 openapi_exclude_operations: vec![],
711 openapi_max_operations: None,
712 openapi_overrides: HashMap::new(),
713 auth_generator: None,
714 category: None,
715 skills: vec![],
716 };
717
718 let gen = AuthGenerator {
719 gen_type: AuthGenType::Script,
720 command: None,
721 args: vec![],
722 interpreter: Some("bash".into()),
723 script: Some("echo script-token-42".into()),
724 cache_ttl_secs: 0,
725 output_format: AuthOutputFormat::Text,
726 env: HashMap::new(),
727 inject: HashMap::new(),
728 timeout_secs: 5,
729 };
730
731 let ctx = GenContext::default();
732 let keyring = Keyring::empty();
733 let cache = AuthCache::new();
734
735 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
736 .await
737 .unwrap();
738 assert_eq!(cred.value, "script-token-42");
739 }
740
741 #[tokio::test]
742 async fn test_generate_caches_result() {
743 let provider = Provider {
744 name: "cached_provider".into(),
745 description: "test".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 openapi_spec: None,
768 openapi_include_tags: vec![],
769 openapi_exclude_tags: vec![],
770 openapi_include_operations: vec![],
771 openapi_exclude_operations: vec![],
772 openapi_max_operations: None,
773 openapi_overrides: HashMap::new(),
774 auth_generator: None,
775 category: None,
776 skills: vec![],
777 };
778
779 let gen = AuthGenerator {
780 gen_type: AuthGenType::Command,
781 command: Some("date".into()),
782 args: vec!["+%s%N".into()],
783 interpreter: None,
784 script: None,
785 cache_ttl_secs: 300,
786 output_format: AuthOutputFormat::Text,
787 env: HashMap::new(),
788 inject: HashMap::new(),
789 timeout_secs: 5,
790 };
791
792 let ctx = GenContext {
793 jwt_sub: "test-agent".into(),
794 ..GenContext::default()
795 };
796 let keyring = Keyring::empty();
797 let cache = AuthCache::new();
798
799 let cred1 = generate(&provider, &gen, &ctx, &keyring, &cache)
800 .await
801 .unwrap();
802 let cred2 = generate(&provider, &gen, &ctx, &keyring, &cache)
803 .await
804 .unwrap();
805 assert_eq!(cred1.value, cred2.value);
807 }
808
809 #[tokio::test]
810 async fn test_generate_with_variable_expansion() {
811 let provider = Provider {
812 name: "test".into(),
813 description: "test".into(),
814 base_url: String::new(),
815 auth_type: crate::core::manifest::AuthType::Bearer,
816 auth_key_name: None,
817 auth_header_name: None,
818 auth_query_name: None,
819 auth_value_prefix: None,
820 extra_headers: HashMap::new(),
821 oauth2_token_url: None,
822 auth_secret_name: None,
823 oauth2_basic_auth: false,
824 internal: false,
825 handler: "http".into(),
826 mcp_transport: None,
827 mcp_command: None,
828 mcp_args: vec![],
829 mcp_url: None,
830 mcp_env: HashMap::new(),
831 cli_command: None,
832 cli_default_args: vec![],
833 cli_env: HashMap::new(),
834 cli_timeout_secs: None,
835 openapi_spec: None,
836 openapi_include_tags: vec![],
837 openapi_exclude_tags: vec![],
838 openapi_include_operations: vec![],
839 openapi_exclude_operations: vec![],
840 openapi_max_operations: None,
841 openapi_overrides: HashMap::new(),
842 auth_generator: None,
843 category: None,
844 skills: vec![],
845 };
846
847 let gen = AuthGenerator {
848 gen_type: AuthGenType::Command,
849 command: Some("echo".into()),
850 args: vec!["${JWT_SUB}".into()],
851 interpreter: None,
852 script: None,
853 cache_ttl_secs: 0,
854 output_format: AuthOutputFormat::Text,
855 env: HashMap::new(),
856 inject: HashMap::new(),
857 timeout_secs: 5,
858 };
859
860 let ctx = GenContext {
861 jwt_sub: "agent-42".into(),
862 jwt_scope: "*".into(),
863 tool_name: "brain__query".into(),
864 timestamp: 1234567890,
865 };
866 let keyring = Keyring::empty();
867 let cache = AuthCache::new();
868
869 let cred = generate(&provider, &gen, &ctx, &keyring, &cache)
870 .await
871 .unwrap();
872 assert_eq!(cred.value, "agent-42");
873 }
874
875 #[tokio::test]
876 async fn test_generate_timeout() {
877 let provider = Provider {
878 name: "test".into(),
879 description: "test".into(),
880 base_url: String::new(),
881 auth_type: crate::core::manifest::AuthType::Bearer,
882 auth_key_name: None,
883 auth_header_name: None,
884 auth_query_name: None,
885 auth_value_prefix: None,
886 extra_headers: HashMap::new(),
887 oauth2_token_url: None,
888 auth_secret_name: None,
889 oauth2_basic_auth: false,
890 internal: false,
891 handler: "http".into(),
892 mcp_transport: None,
893 mcp_command: None,
894 mcp_args: vec![],
895 mcp_url: None,
896 mcp_env: HashMap::new(),
897 cli_command: None,
898 cli_default_args: vec![],
899 cli_env: HashMap::new(),
900 cli_timeout_secs: None,
901 openapi_spec: None,
902 openapi_include_tags: vec![],
903 openapi_exclude_tags: vec![],
904 openapi_include_operations: vec![],
905 openapi_exclude_operations: vec![],
906 openapi_max_operations: None,
907 openapi_overrides: HashMap::new(),
908 auth_generator: None,
909 category: None,
910 skills: vec![],
911 };
912
913 let gen = AuthGenerator {
914 gen_type: AuthGenType::Command,
915 command: Some("sleep".into()),
916 args: vec!["10".into()],
917 interpreter: None,
918 script: None,
919 cache_ttl_secs: 0,
920 output_format: AuthOutputFormat::Text,
921 env: HashMap::new(),
922 inject: HashMap::new(),
923 timeout_secs: 1,
924 };
925
926 let ctx = GenContext::default();
927 let keyring = Keyring::empty();
928 let cache = AuthCache::new();
929
930 let err = generate(&provider, &gen, &ctx, &keyring, &cache)
931 .await
932 .unwrap_err();
933 assert!(matches!(err, AuthGenError::Timeout(1)));
934 }
935}