1use std::collections::HashMap;
30use std::fmt;
31use std::sync::{Arc, RwLock};
32
33use crate::error::ClientResult;
34use crate::interceptor::{CallInterceptor, ClientRequest, ClientResponse};
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct SessionId(String);
44
45impl SessionId {
46 #[must_use]
48 pub fn new(s: impl Into<String>) -> Self {
49 Self(s.into())
50 }
51}
52
53impl fmt::Display for SessionId {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(&self.0)
56 }
57}
58
59impl From<String> for SessionId {
60 fn from(s: String) -> Self {
61 Self(s)
62 }
63}
64
65impl From<&str> for SessionId {
66 fn from(s: &str) -> Self {
67 Self(s.to_owned())
68 }
69}
70
71pub trait CredentialsStore: Send + Sync + 'static {
79 fn get(&self, session: &SessionId, scheme: &str) -> Option<String>;
81
82 fn set(&self, session: SessionId, scheme: &str, credential: String);
84
85 fn remove(&self, session: &SessionId, scheme: &str);
87}
88
89pub struct InMemoryCredentialsStore {
102 inner: RwLock<HashMap<SessionId, HashMap<String, String>>>,
103}
104
105impl InMemoryCredentialsStore {
106 #[must_use]
108 pub fn new() -> Self {
109 Self {
110 inner: RwLock::new(HashMap::new()),
111 }
112 }
113}
114
115impl Default for InMemoryCredentialsStore {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl fmt::Debug for InMemoryCredentialsStore {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 let mut out = f.debug_struct("InMemoryCredentialsStore");
138 match self.inner.read() {
139 Ok(guard) => out.field("sessions", &guard.len()),
140 Err(_) => out.field("sessions", &"<lock poisoned>"),
141 }
142 .finish()
143 }
144}
145
146impl CredentialsStore for InMemoryCredentialsStore {
147 fn get(&self, session: &SessionId, scheme: &str) -> Option<String> {
148 let guard = self.inner.read().expect("credentials store lock poisoned");
150 guard.get(session)?.get(scheme).cloned()
151 }
152
153 fn set(&self, session: SessionId, scheme: &str, credential: String) {
154 let mut guard = self.inner.write().expect("credentials store lock poisoned");
155 guard
156 .entry(session)
157 .or_default()
158 .insert(scheme.to_owned(), credential);
159 }
160
161 fn remove(&self, session: &SessionId, scheme: &str) {
162 let mut guard = self.inner.write().expect("credentials store lock poisoned");
163 if let Some(schemes) = guard.get_mut(session) {
164 schemes.remove(scheme);
165 }
166 }
167}
168
169pub struct AuthInterceptor {
183 store: Arc<dyn CredentialsStore>,
184 session: SessionId,
185 scheme: String,
187}
188
189impl AuthInterceptor {
190 #[must_use]
192 pub fn new(store: Arc<dyn CredentialsStore>, session: SessionId) -> Self {
193 Self {
194 store,
195 session,
196 scheme: "bearer".to_owned(),
197 }
198 }
199
200 #[must_use]
202 pub fn with_scheme(
203 store: Arc<dyn CredentialsStore>,
204 session: SessionId,
205 scheme: impl Into<String>,
206 ) -> Self {
207 Self {
208 store,
209 session,
210 scheme: scheme.into(),
211 }
212 }
213}
214
215#[allow(clippy::missing_fields_in_debug)]
216impl fmt::Debug for AuthInterceptor {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.debug_struct("AuthInterceptor")
220 .field("session", &self.session)
221 .field("scheme", &self.scheme)
222 .finish()
223 }
224}
225
226impl CallInterceptor for AuthInterceptor {
227 #[allow(clippy::manual_async_fn)]
228 fn before<'a>(
229 &'a self,
230 req: &'a mut ClientRequest,
231 ) -> impl std::future::Future<Output = ClientResult<()>> + Send + 'a {
232 async move {
233 if let Some(credential) = self.store.get(&self.session, &self.scheme) {
234 let header_value = if self.scheme.eq_ignore_ascii_case("bearer") {
235 format!("Bearer {credential}")
236 } else if self.scheme.eq_ignore_ascii_case("basic") {
237 format!("Basic {credential}")
238 } else {
239 credential
240 };
241 req.extra_headers
242 .insert("authorization".to_owned(), header_value);
243 }
244 Ok(())
245 }
246 }
247
248 #[allow(clippy::manual_async_fn)]
249 fn after<'a>(
250 &'a self,
251 _resp: &'a ClientResponse,
252 ) -> impl std::future::Future<Output = ClientResult<()>> + Send + 'a {
253 async move { Ok(()) }
254 }
255}
256
257#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
272 fn debug_reports_a_poisoned_lock_instead_of_panicking() {
273 let store = std::sync::Arc::new(InMemoryCredentialsStore::new());
274 store.set(SessionId::new("s1"), "bearer", "tok".into());
275
276 let poisoner = std::sync::Arc::clone(&store);
277 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
285 let _guard = poisoner.inner.write().expect("uncontended");
286 panic!("poison the lock");
287 }));
288 assert!(outcome.is_err(), "the closure must actually have panicked");
289 assert!(store.inner.is_poisoned(), "and poisoned the lock");
290
291 let rendered =
292 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| format!("{store:?}")))
293 .expect("Debug must not panic on a poisoned lock");
294 assert!(
295 rendered.contains("poisoned"),
296 "and must say so rather than printing a count read out of a map a \
297 writer panicked inside: {rendered}"
298 );
299 }
300
301 #[test]
302 fn credentials_store_set_get_remove() {
303 let store = InMemoryCredentialsStore::new();
304 let session = SessionId::new("sess-1");
305
306 assert!(store.get(&session, "bearer").is_none());
307
308 store.set(session.clone(), "bearer", "my-token".into());
309 assert_eq!(store.get(&session, "bearer").as_deref(), Some("my-token"));
310
311 store.remove(&session, "bearer");
312 assert!(store.get(&session, "bearer").is_none());
313 }
314
315 #[tokio::test]
316 async fn auth_interceptor_injects_bearer() {
317 let store = Arc::new(InMemoryCredentialsStore::new());
318 let session = SessionId::new("test");
319 store.set(session.clone(), "bearer", "my-secret-token".into());
320
321 let interceptor = AuthInterceptor::new(store, session);
322 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
323
324 interceptor.before(&mut req).await.unwrap();
325
326 assert_eq!(
327 req.extra_headers.get("authorization").map(String::as_str),
328 Some("Bearer my-secret-token")
329 );
330 }
331
332 #[tokio::test]
333 async fn auth_interceptor_no_credential_no_header() {
334 let store = Arc::new(InMemoryCredentialsStore::new());
335 let session = SessionId::new("empty");
336 let interceptor = AuthInterceptor::new(store, session);
337
338 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
339 interceptor.before(&mut req).await.unwrap();
340
341 assert!(!req.extra_headers.contains_key("authorization"));
342 }
343
344 #[test]
345 fn credentials_store_multiple_sessions() {
346 let store = InMemoryCredentialsStore::new();
347 let s1 = SessionId::new("session-1");
348 let s2 = SessionId::new("session-2");
349
350 store.set(s1.clone(), "bearer", "token-1".into());
351 store.set(s2.clone(), "bearer", "token-2".into());
352
353 assert_eq!(store.get(&s1, "bearer").as_deref(), Some("token-1"));
354 assert_eq!(store.get(&s2, "bearer").as_deref(), Some("token-2"));
355
356 store.remove(&s1, "bearer");
358 assert!(store.get(&s1, "bearer").is_none());
359 assert_eq!(store.get(&s2, "bearer").as_deref(), Some("token-2"));
360 }
361
362 #[test]
363 fn credentials_store_multiple_schemes() {
364 let store = InMemoryCredentialsStore::new();
365 let session = SessionId::new("multi-scheme");
366
367 store.set(session.clone(), "bearer", "bearer-tok".into());
368 store.set(session.clone(), "api-key", "key-123".into());
369
370 assert_eq!(store.get(&session, "bearer").as_deref(), Some("bearer-tok"));
371 assert_eq!(store.get(&session, "api-key").as_deref(), Some("key-123"));
372 }
373
374 #[test]
375 fn credentials_store_overwrite() {
376 let store = InMemoryCredentialsStore::new();
377 let session = SessionId::new("overwrite");
378
379 store.set(session.clone(), "bearer", "old-token".into());
380 store.set(session.clone(), "bearer", "new-token".into());
381
382 assert_eq!(store.get(&session, "bearer").as_deref(), Some("new-token"));
383 }
384
385 #[test]
386 fn credentials_store_debug_hides_values() {
387 let store = InMemoryCredentialsStore::new();
388 let session = SessionId::new("secret");
389 store.set(session, "bearer", "super-secret-token".into());
390
391 let debug_output = format!("{store:?}");
392 assert!(
393 !debug_output.contains("super-secret"),
394 "debug output should not expose credentials: {debug_output}"
395 );
396 assert!(debug_output.contains("sessions"));
397 }
398
399 #[tokio::test]
400 async fn auth_interceptor_basic_scheme() {
401 let store = Arc::new(InMemoryCredentialsStore::new());
402 let session = SessionId::new("basic-test");
403 store.set(session.clone(), "basic", "dXNlcjpwYXNz".into());
404
405 let interceptor = AuthInterceptor::with_scheme(store, session, "basic");
406 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
407 interceptor.before(&mut req).await.unwrap();
408
409 assert_eq!(
410 req.extra_headers.get("authorization").map(String::as_str),
411 Some("Basic dXNlcjpwYXNz")
412 );
413 }
414
415 #[tokio::test]
416 async fn auth_interceptor_custom_scheme() {
417 let store = Arc::new(InMemoryCredentialsStore::new());
418 let session = SessionId::new("custom-test");
419 store.set(session.clone(), "api-key", "my-api-key".into());
420
421 let interceptor = AuthInterceptor::with_scheme(store, session, "api-key");
422 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
423 interceptor.before(&mut req).await.unwrap();
424
425 assert_eq!(
427 req.extra_headers.get("authorization").map(String::as_str),
428 Some("my-api-key")
429 );
430 }
431
432 #[test]
433 fn session_id_display() {
434 let session = SessionId::new("my-session");
435 assert_eq!(session.to_string(), "my-session");
436 }
437
438 #[test]
439 fn session_id_from_string() {
440 let session: SessionId = "test".into();
441 assert_eq!(session, SessionId::new("test"));
442
443 let session: SessionId = String::from("owned").into();
444 assert_eq!(session, SessionId::new("owned"));
445 }
446
447 #[test]
448 fn credentials_store_default_impl() {
449 let store = InMemoryCredentialsStore::default();
450 let session = SessionId::new("test");
451 assert!(store.get(&session, "bearer").is_none());
452 }
453
454 #[tokio::test]
455 async fn auth_interceptor_after_is_noop() {
456 let store = Arc::new(InMemoryCredentialsStore::new());
457 let session = SessionId::new("test");
458 let interceptor = AuthInterceptor::new(store, session);
459 let resp = ClientResponse {
460 method: "test".into(),
461 result: serde_json::Value::Null,
462 status_code: 200,
463 };
464 interceptor.after(&resp).await.unwrap();
465 }
466
467 #[test]
468 fn auth_interceptor_debug_contains_fields() {
469 let store = Arc::new(InMemoryCredentialsStore::new());
470 let session = SessionId::new("debug-session");
471 let interceptor = AuthInterceptor::new(store, session);
472 let debug = format!("{interceptor:?}");
473 assert!(
474 debug.contains("AuthInterceptor"),
475 "debug output missing struct name: {debug}"
476 );
477 assert!(
478 debug.contains("debug-session"),
479 "debug output missing session: {debug}"
480 );
481 assert!(
482 debug.contains("bearer"),
483 "debug output missing scheme: {debug}"
484 );
485 }
486}