Skip to main content

agentd/mcp/
auth.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Auth material resolution for remote MCP endpoints (RFC 0012 §3.7).
3//!
4//! An [`crate::config::McpServerSpec`] carries secret-**free** header templates
5//! (e.g. `Authorization: Bearer {{secret:MCP_TOKEN}}`); this materializes them to
6//! real request headers at connect time — so the manifest/spawn-payload never
7//! holds a credential and a rotated `{{secret-file:…}}` is picked up on the next
8//! (re)connect. Bearer / API-key auth rides here; mutual-TLS (a client cert) and
9//! OAuth 2.1 client-credentials are separate axes threaded in alongside.
10
11use crate::sec::secret;
12
13/// Resolve every `{{secret:NAME}}` / `{{secret-file:PATH}}` ref in each header
14/// VALUE against the process environment + filesystem, returning materialized
15/// `(name, value)` headers ready for the wire. Header names pass through as-is.
16/// An unresolved ref is an `Err` that names the ref but never the resolved value
17/// (RFC 0012 §3.7 secret-freedom).
18pub fn resolve_headers(templates: &[(String, String)]) -> Result<Vec<(String, String)>, String> {
19    let env = |k: &str| std::env::var(k).ok();
20    templates
21        .iter()
22        .map(|(name, value)| Ok((name.clone(), secret::resolve(value, &env)?)))
23        .collect()
24}
25
26/// Pre-flight (for `--validate-config` / startup): every header template must
27/// resolve, without retaining the bytes. Same diagnostics as [`resolve_headers`].
28pub fn headers_resolvable(templates: &[(String, String)]) -> Result<(), String> {
29    resolve_headers(templates).map(|_| ())
30}
31
32#[cfg(test)]
33mod tests {
34    use super::*;
35    use std::io::Write;
36
37    #[test]
38    fn resolves_env_secret_in_header_value() {
39        // SAFETY: single-threaded test; unique var name avoids cross-test races.
40        unsafe { std::env::set_var("MCP_AUTH_TEST_TOKEN", "s3cr3t") };
41        let headers = resolve_headers(&[(
42            "Authorization".into(),
43            "Bearer {{secret:MCP_AUTH_TEST_TOKEN}}".into(),
44        )])
45        .unwrap();
46        assert_eq!(headers[0].0, "Authorization");
47        assert_eq!(headers[0].1, "Bearer s3cr3t");
48        unsafe { std::env::remove_var("MCP_AUTH_TEST_TOKEN") };
49    }
50
51    #[test]
52    fn resolves_secret_file() {
53        let mut f = tempfile::NamedTempFile::new().unwrap();
54        writeln!(f, "file-token").unwrap();
55        let tmpl = format!("Bearer {{{{secret-file:{}}}}}", f.path().to_str().unwrap());
56        let headers = resolve_headers(&[("x-api-key".into(), tmpl)]).unwrap();
57        assert_eq!(headers[0].1, "Bearer file-token");
58    }
59
60    #[test]
61    fn plain_header_passes_through() {
62        let headers = resolve_headers(&[("Accept".into(), "application/json".into())]).unwrap();
63        assert_eq!(headers[0].1, "application/json");
64    }
65
66    #[test]
67    fn missing_secret_is_an_error_without_the_value() {
68        let err = resolve_headers(&[(
69            "Authorization".into(),
70            "Bearer {{secret:DEFINITELY_UNSET_MCP_VAR}}".into(),
71        )])
72        .unwrap_err();
73        assert!(err.contains("DEFINITELY_UNSET_MCP_VAR"));
74        assert!(!err.contains("Bearer"), "the value must not leak: {err}");
75    }
76}