1use crate::mcp::clock::{Clock, NonceGenerator};
7use crate::mcp::error::McpError;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use base64::Engine;
10use hmac::{Hmac, Mac};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17type HmacSha256 = Hmac<Sha256>;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct McpSession {
22 pub id: String,
23 pub agent_id: String,
24 pub issued_at_secs: u64,
25 pub expires_at_secs: u64,
26 pub token_digest: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct McpIssuedSession {
32 pub token: String,
33 pub session: McpSession,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
37struct SessionTokenPayload {
38 sid: String,
39 aid: String,
40 exp: u64,
41}
42
43pub trait McpSessionStore: Send + Sync {
45 fn get(&self, id: &str) -> Result<Option<McpSession>, McpError>;
46 fn set(&self, session: McpSession) -> Result<(), McpError>;
47 fn delete(&self, id: &str) -> Result<(), McpError>;
48 fn insert_if_below_limit(
49 &self,
50 session: McpSession,
51 concurrent_limit: usize,
52 now_secs: u64,
53 ) -> Result<bool, McpError>;
54}
55
56#[derive(Debug, Default)]
58pub struct InMemorySessionStore {
59 sessions: Mutex<HashMap<String, McpSession>>,
60}
61
62impl McpSessionStore for InMemorySessionStore {
63 fn get(&self, id: &str) -> Result<Option<McpSession>, McpError> {
64 let sessions = self
65 .sessions
66 .lock()
67 .map_err(|_| McpError::store("session", "session store lock poisoned"))?;
68 Ok(sessions.get(id).cloned())
69 }
70
71 fn set(&self, session: McpSession) -> Result<(), McpError> {
72 let mut sessions = self
73 .sessions
74 .lock()
75 .map_err(|_| McpError::store("session", "session store lock poisoned"))?;
76 sessions.insert(session.id.clone(), session);
77 Ok(())
78 }
79
80 fn delete(&self, id: &str) -> Result<(), McpError> {
81 let mut sessions = self
82 .sessions
83 .lock()
84 .map_err(|_| McpError::store("session", "session store lock poisoned"))?;
85 sessions.remove(id);
86 Ok(())
87 }
88
89 fn insert_if_below_limit(
90 &self,
91 session: McpSession,
92 concurrent_limit: usize,
93 now_secs: u64,
94 ) -> Result<bool, McpError> {
95 let mut sessions = self
96 .sessions
97 .lock()
98 .map_err(|_| McpError::store("session", "session store lock poisoned"))?;
99 sessions.retain(|_, existing| existing.expires_at_secs > now_secs);
100 let active_sessions = sessions
101 .values()
102 .filter(|existing| existing.agent_id == session.agent_id)
103 .count();
104 if active_sessions >= concurrent_limit {
105 return Ok(false);
106 }
107 sessions.insert(session.id.clone(), session);
108 Ok(true)
109 }
110}
111
112#[derive(Clone)]
114pub struct McpSessionAuthenticator {
115 secret: Vec<u8>,
116 clock: Arc<dyn Clock>,
117 nonce_generator: Arc<dyn NonceGenerator>,
118 store: Arc<dyn McpSessionStore>,
119 ttl: Duration,
120 concurrent_limit: usize,
121}
122
123impl McpSessionAuthenticator {
124 pub fn new(
125 secret: Vec<u8>,
126 clock: Arc<dyn Clock>,
127 nonce_generator: Arc<dyn NonceGenerator>,
128 store: Arc<dyn McpSessionStore>,
129 ttl: Duration,
130 concurrent_limit: usize,
131 ) -> Result<Self, McpError> {
132 if secret.is_empty() {
133 return Err(McpError::InvalidConfig("session secret must not be empty"));
134 }
135 if ttl.is_zero() {
136 return Err(McpError::InvalidConfig("session ttl must be > 0"));
137 }
138 if concurrent_limit == 0 {
139 return Err(McpError::InvalidConfig(
140 "concurrent session limit must be > 0",
141 ));
142 }
143 Ok(Self {
144 secret,
145 clock,
146 nonce_generator,
147 store,
148 ttl,
149 concurrent_limit,
150 })
151 }
152
153 pub fn issue_session(&self, agent_id: &str) -> Result<McpIssuedSession, McpError> {
154 let now = unix_secs(self.clock.now())?;
155 let session_id = self.nonce_generator.generate()?;
156 let expires_at_secs = now
157 .checked_add(self.ttl.as_secs())
158 .ok_or(McpError::InvalidConfig("session ttl overflow"))?;
159 let payload = SessionTokenPayload {
160 sid: session_id.clone(),
161 aid: agent_id.to_string(),
162 exp: expires_at_secs,
163 };
164 let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload)?);
165 let signature = self.sign(&encoded_payload)?;
166 let token = format!("{encoded_payload}.{signature}");
167 let session = McpSession {
168 id: session_id,
169 agent_id: agent_id.to_string(),
170 issued_at_secs: now,
171 expires_at_secs,
172 token_digest: sha256_hex(&token),
173 };
174 if !self
175 .store
176 .insert_if_below_limit(session.clone(), self.concurrent_limit, now)?
177 {
178 return Err(McpError::SessionLimitExceeded {
179 agent_id: agent_id.to_string(),
180 limit: self.concurrent_limit,
181 });
182 }
183 Ok(McpIssuedSession { token, session })
184 }
185
186 pub fn authenticate(&self, token: &str, agent_id: &str) -> Result<McpSession, McpError> {
187 let (encoded_payload, signature) =
188 token.split_once('.').ok_or(McpError::InvalidTokenFormat)?;
189 self.verify_signature(encoded_payload, signature)?;
190 let payload: SessionTokenPayload = serde_json::from_slice(
191 &URL_SAFE_NO_PAD
192 .decode(encoded_payload)
193 .map_err(|_| McpError::InvalidTokenFormat)?,
194 )?;
195 if payload.aid != agent_id {
196 return Err(McpError::AccessDenied {
197 reason: "session token does not match requested agent".to_string(),
198 });
199 }
200 let now = unix_secs(self.clock.now())?;
201 if payload.exp <= now {
202 self.store.delete(&payload.sid)?;
203 return Err(McpError::SessionExpired);
204 }
205 let session = self
206 .store
207 .get(&payload.sid)?
208 .ok_or_else(|| McpError::AccessDenied {
209 reason: "unknown session".to_string(),
210 })?;
211 if session.agent_id != payload.aid || session.expires_at_secs != payload.exp {
212 return Err(McpError::AccessDenied {
213 reason: "session metadata mismatch".to_string(),
214 });
215 }
216 if !constant_time_eq(
221 sha256_hex(token).as_bytes(),
222 session.token_digest.as_bytes(),
223 ) {
224 return Err(McpError::AccessDenied {
225 reason: "session token digest mismatch".to_string(),
226 });
227 }
228 Ok(session)
229 }
230
231 pub fn revoke(&self, session_id: &str) -> Result<(), McpError> {
232 self.store.delete(session_id)
233 }
234 fn sign(&self, payload: &str) -> Result<String, McpError> {
235 let mut mac =
236 HmacSha256::new_from_slice(&self.secret).map_err(|_| McpError::InvalidHmacKey)?;
237 mac.update(payload.as_bytes());
238 Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
239 }
240
241 fn verify_signature(&self, payload: &str, signature: &str) -> Result<(), McpError> {
242 let provided = URL_SAFE_NO_PAD
243 .decode(signature)
244 .map_err(|_| McpError::InvalidSignature)?;
245 let mut mac =
246 HmacSha256::new_from_slice(&self.secret).map_err(|_| McpError::InvalidHmacKey)?;
247 mac.update(payload.as_bytes());
248 mac.verify_slice(&provided)
249 .map_err(|_| McpError::InvalidSignature)
250 }
251}
252
253fn sha256_hex(input: &str) -> String {
254 let mut hasher = Sha256::new();
255 hasher.update(input.as_bytes());
256 hasher
257 .finalize()
258 .iter()
259 .map(|byte| format!("{byte:02x}"))
260 .collect()
261}
262
263fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
270 if a.len() != b.len() {
271 return false;
272 }
273 let mut diff: u8 = 0;
274 for (x, y) in a.iter().zip(b.iter()) {
275 diff |= x ^ y;
276 }
277 diff == 0
278}
279
280fn unix_secs(time: SystemTime) -> Result<u64, McpError> {
281 Ok(time
282 .duration_since(UNIX_EPOCH)
283 .map_err(|_| McpError::AccessDenied {
284 reason: "system clock before unix epoch".to_string(),
285 })?
286 .as_secs())
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::mcp::clock::{DeterministicNonceGenerator, FixedClock};
293 use std::time::SystemTime;
294
295 #[test]
296 fn issues_and_authenticates_sessions() {
297 let auth = McpSessionAuthenticator::new(
298 b"session-secret".to_vec(),
299 Arc::new(FixedClock::new(SystemTime::UNIX_EPOCH)),
300 Arc::new(DeterministicNonceGenerator::from_values(vec![
301 "session-1".into()
302 ])),
303 Arc::new(InMemorySessionStore::default()),
304 Duration::from_secs(60),
305 1,
306 )
307 .unwrap();
308 let issued = auth.issue_session("did:agentmesh:test").unwrap();
309 let session = auth
310 .authenticate(&issued.token, "did:agentmesh:test")
311 .unwrap();
312 assert_eq!(session.id, "session-1");
313 }
314
315 #[test]
316 fn rejects_session_with_mismatched_token_digest() {
317 let store = Arc::new(InMemorySessionStore::default());
322 let auth = McpSessionAuthenticator::new(
323 b"session-secret".to_vec(),
324 Arc::new(FixedClock::new(SystemTime::UNIX_EPOCH)),
325 Arc::new(DeterministicNonceGenerator::from_values(vec![
326 "session-1".into()
327 ])),
328 Arc::clone(&store) as Arc<dyn McpSessionStore>,
329 Duration::from_secs(60),
330 1,
331 )
332 .unwrap();
333 let issued = auth.issue_session("did:agentmesh:test").unwrap();
334
335 let mut tampered = issued.session.clone();
337 tampered.token_digest =
338 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
339 store.set(tampered).unwrap();
340
341 let result = auth.authenticate(&issued.token, "did:agentmesh:test");
342 assert!(matches!(
343 result,
344 Err(McpError::AccessDenied { ref reason }) if reason == "session token digest mismatch"
345 ));
346 }
347
348 #[test]
349 fn constant_time_eq_matches_regular_eq() {
350 assert!(constant_time_eq(b"", b""));
351 assert!(constant_time_eq(b"abc", b"abc"));
352 assert!(!constant_time_eq(b"abc", b"abd"));
353 assert!(!constant_time_eq(b"abc", b"abcd"));
354 assert!(!constant_time_eq(b"abcd", b"abc"));
355 assert!(!constant_time_eq(b"", b"a"));
356 }
357
358 #[test]
359 fn enforces_concurrent_limit() {
360 let auth = McpSessionAuthenticator::new(
361 b"session-secret".to_vec(),
362 Arc::new(FixedClock::new(SystemTime::UNIX_EPOCH)),
363 Arc::new(DeterministicNonceGenerator::from_values(vec![
364 "s1".into(),
365 "s2".into(),
366 ])),
367 Arc::new(InMemorySessionStore::default()),
368 Duration::from_secs(60),
369 1,
370 )
371 .unwrap();
372 auth.issue_session("did:agentmesh:test").unwrap();
373 assert!(matches!(
374 auth.issue_session("did:agentmesh:test"),
375 Err(McpError::SessionLimitExceeded { .. })
376 ));
377 }
378}