Skip to main content

agentic_planning/
auth.rs

1//! Server-mode authentication with constant-time token comparison.
2//!
3//! Reads `AGENTIC_AUTH_TOKEN` and `AGENTIC_AUTH_MODE` from environment.
4
5use std::env;
6
7/// Authentication mode for the MCP/HTTP server.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum AuthMode {
10    /// No authentication required.
11    None,
12    /// A valid token must be provided.
13    Required,
14    /// Token is checked if provided, but not mandatory.
15    Optional,
16}
17
18/// Token-based authenticator.
19#[derive(Debug, Clone)]
20pub struct TokenAuth {
21    mode: AuthMode,
22    token: Option<String>,
23}
24
25impl TokenAuth {
26    /// Create from environment variables.
27    ///
28    /// - `AGENTIC_AUTH_MODE`: "none" | "required" | "optional" (default: "none")
29    /// - `AGENTIC_AUTH_TOKEN`: the secret token
30    pub fn from_env() -> Self {
31        let mode = match env::var("AGENTIC_AUTH_MODE")
32            .unwrap_or_default()
33            .to_lowercase()
34            .as_str()
35        {
36            "required" => AuthMode::Required,
37            "optional" => AuthMode::Optional,
38            _ => AuthMode::None,
39        };
40
41        let token = env::var("AGENTIC_AUTH_TOKEN")
42            .ok()
43            .filter(|t| !t.is_empty());
44
45        if mode == AuthMode::Required && token.is_none() {
46            eprintln!(
47                "WARNING: AGENTIC_AUTH_MODE=required but AGENTIC_AUTH_TOKEN is not set. \
48                 All requests will be rejected."
49            );
50        }
51
52        Self { mode, token }
53    }
54
55    /// Create with explicit values (for testing).
56    pub fn new(mode: AuthMode, token: Option<String>) -> Self {
57        Self { mode, token }
58    }
59
60    /// Validate a provided token against the configured secret.
61    pub fn validate(&self, provided: Option<&str>) -> Result<(), AuthError> {
62        match self.mode {
63            AuthMode::None => Ok(()),
64            AuthMode::Required => {
65                let provided = provided.ok_or(AuthError::TokenMissing)?;
66                let expected = self
67                    .token
68                    .as_deref()
69                    .ok_or(AuthError::ServerMisconfigured)?;
70                if constant_time_eq(provided.as_bytes(), expected.as_bytes()) {
71                    Ok(())
72                } else {
73                    Err(AuthError::TokenInvalid)
74                }
75            }
76            AuthMode::Optional => {
77                if let Some(provided) = provided {
78                    let expected = self
79                        .token
80                        .as_deref()
81                        .ok_or(AuthError::ServerMisconfigured)?;
82                    if constant_time_eq(provided.as_bytes(), expected.as_bytes()) {
83                        Ok(())
84                    } else {
85                        Err(AuthError::TokenInvalid)
86                    }
87                } else {
88                    // Optional mode: no token provided is OK
89                    Ok(())
90                }
91            }
92        }
93    }
94
95    pub fn mode(&self) -> &AuthMode {
96        &self.mode
97    }
98}
99
100#[derive(Debug, PartialEq, Eq)]
101pub enum AuthError {
102    /// Token was required but not provided.
103    TokenMissing,
104    /// Provided token does not match.
105    TokenInvalid,
106    /// Auth mode is Required but no server token is configured.
107    ServerMisconfigured,
108}
109
110impl std::fmt::Display for AuthError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            AuthError::TokenMissing => write!(f, "authentication token required"),
114            AuthError::TokenInvalid => write!(f, "invalid authentication token"),
115            AuthError::ServerMisconfigured => {
116                write!(f, "server requires auth but no token is configured")
117            }
118        }
119    }
120}
121
122impl std::error::Error for AuthError {}
123
124/// Constant-time byte comparison to prevent timing attacks.
125///
126/// Always compares the full length of `a`, even if `b` differs in length.
127fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
128    if a.len() != b.len() {
129        // Still do some work to avoid leaking length via timing,
130        // but result is always false.
131        let mut _acc: u8 = 1;
132        for byte in a {
133            _acc |= byte;
134        }
135        return false;
136    }
137
138    let mut diff: u8 = 0;
139    for (x, y) in a.iter().zip(b.iter()) {
140        diff |= x ^ y;
141    }
142    diff == 0
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn none_mode_always_passes() {
151        let auth = TokenAuth::new(AuthMode::None, None);
152        assert!(auth.validate(None).is_ok());
153        assert!(auth.validate(Some("anything")).is_ok());
154    }
155
156    #[test]
157    fn required_mode_needs_token() {
158        let auth = TokenAuth::new(AuthMode::Required, Some("secret123".to_string()));
159        assert_eq!(auth.validate(None), Err(AuthError::TokenMissing));
160        assert_eq!(auth.validate(Some("wrong")), Err(AuthError::TokenInvalid));
161        assert!(auth.validate(Some("secret123")).is_ok());
162    }
163
164    #[test]
165    fn required_mode_no_server_token() {
166        let auth = TokenAuth::new(AuthMode::Required, None);
167        assert_eq!(
168            auth.validate(Some("anything")),
169            Err(AuthError::ServerMisconfigured)
170        );
171    }
172
173    #[test]
174    fn optional_mode_works() {
175        let auth = TokenAuth::new(AuthMode::Optional, Some("secret".to_string()));
176        assert!(auth.validate(None).is_ok()); // no token is fine
177        assert!(auth.validate(Some("secret")).is_ok());
178        assert_eq!(auth.validate(Some("wrong")), Err(AuthError::TokenInvalid));
179    }
180
181    #[test]
182    fn constant_time_eq_works() {
183        assert!(constant_time_eq(b"hello", b"hello"));
184        assert!(!constant_time_eq(b"hello", b"world"));
185        assert!(!constant_time_eq(b"hello", b"hell"));
186        assert!(!constant_time_eq(b"", b"x"));
187        assert!(constant_time_eq(b"", b""));
188    }
189
190    #[test]
191    fn constant_time_eq_different_lengths() {
192        assert!(!constant_time_eq(b"short", b"longer-string"));
193        assert!(!constant_time_eq(b"longer-string", b"short"));
194    }
195}