Skip to main content

agentd/mcp/
auth.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Auth material resolution for remote MCP endpoints.
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 any resolved
17/// value: diagnostics travel to logs and operators, so a credential must never
18/// ride along in one.
19pub fn resolve_headers(templates: &[(String, String)]) -> Result<Vec<(String, String)>, String> {
20    let env = |k: &str| std::env::var(k).ok();
21    templates
22        .iter()
23        .map(|(name, value)| Ok((name.clone(), secret::resolve(value, &env)?)))
24        .collect()
25}
26
27/// Pre-flight (for `--validate-config` / startup): every header template must
28/// resolve, without retaining the bytes. Same diagnostics as [`resolve_headers`].
29pub fn headers_resolvable(templates: &[(String, String)]) -> Result<(), String> {
30    resolve_headers(templates).map(|_| ())
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36    use std::io::Write;
37
38    #[test]
39    fn resolves_env_secret_in_header_value() {
40        // SAFETY: single-threaded test; unique var name avoids cross-test races.
41        unsafe { std::env::set_var("MCP_AUTH_TEST_TOKEN", "s3cr3t") };
42        let headers = resolve_headers(&[(
43            "Authorization".into(),
44            "Bearer {{secret:MCP_AUTH_TEST_TOKEN}}".into(),
45        )])
46        .unwrap();
47        assert_eq!(headers[0].0, "Authorization");
48        assert_eq!(headers[0].1, "Bearer s3cr3t");
49        unsafe { std::env::remove_var("MCP_AUTH_TEST_TOKEN") };
50    }
51
52    #[test]
53    fn resolves_secret_file() {
54        let mut f = tempfile::NamedTempFile::new().unwrap();
55        writeln!(f, "file-token").unwrap();
56        let tmpl = format!("Bearer {{{{secret-file:{}}}}}", f.path().to_str().unwrap());
57        let headers = resolve_headers(&[("x-api-key".into(), tmpl)]).unwrap();
58        assert_eq!(headers[0].1, "Bearer file-token");
59    }
60
61    #[test]
62    fn plain_header_passes_through() {
63        let headers = resolve_headers(&[("Accept".into(), "application/json".into())]).unwrap();
64        assert_eq!(headers[0].1, "application/json");
65    }
66
67    #[test]
68    fn missing_secret_is_an_error_without_the_value() {
69        let err = resolve_headers(&[(
70            "Authorization".into(),
71            "Bearer {{secret:DEFINITELY_UNSET_MCP_VAR}}".into(),
72        )])
73        .unwrap_err();
74        assert!(err.contains("DEFINITELY_UNSET_MCP_VAR"));
75        assert!(!err.contains("Bearer"), "the value must not leak: {err}");
76    }
77}