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 hook = std::panic::take_hook();
278 std::panic::set_hook(Box::new(|_| {}));
279 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
280 let _guard = poisoner.inner.write().expect("uncontended");
281 panic!("poison the lock");
282 }));
283 std::panic::set_hook(hook);
284 assert!(outcome.is_err(), "the closure must actually have panicked");
285 assert!(store.inner.is_poisoned(), "and poisoned the lock");
286
287 let rendered =
288 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| format!("{store:?}")))
289 .expect("Debug must not panic on a poisoned lock");
290 assert!(
291 rendered.contains("poisoned"),
292 "and must say so rather than printing a count read out of a map a \
293 writer panicked inside: {rendered}"
294 );
295 }
296
297 #[test]
298 fn credentials_store_set_get_remove() {
299 let store = InMemoryCredentialsStore::new();
300 let session = SessionId::new("sess-1");
301
302 assert!(store.get(&session, "bearer").is_none());
303
304 store.set(session.clone(), "bearer", "my-token".into());
305 assert_eq!(store.get(&session, "bearer").as_deref(), Some("my-token"));
306
307 store.remove(&session, "bearer");
308 assert!(store.get(&session, "bearer").is_none());
309 }
310
311 #[tokio::test]
312 async fn auth_interceptor_injects_bearer() {
313 let store = Arc::new(InMemoryCredentialsStore::new());
314 let session = SessionId::new("test");
315 store.set(session.clone(), "bearer", "my-secret-token".into());
316
317 let interceptor = AuthInterceptor::new(store, session);
318 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
319
320 interceptor.before(&mut req).await.unwrap();
321
322 assert_eq!(
323 req.extra_headers.get("authorization").map(String::as_str),
324 Some("Bearer my-secret-token")
325 );
326 }
327
328 #[tokio::test]
329 async fn auth_interceptor_no_credential_no_header() {
330 let store = Arc::new(InMemoryCredentialsStore::new());
331 let session = SessionId::new("empty");
332 let interceptor = AuthInterceptor::new(store, session);
333
334 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
335 interceptor.before(&mut req).await.unwrap();
336
337 assert!(!req.extra_headers.contains_key("authorization"));
338 }
339
340 #[test]
341 fn credentials_store_multiple_sessions() {
342 let store = InMemoryCredentialsStore::new();
343 let s1 = SessionId::new("session-1");
344 let s2 = SessionId::new("session-2");
345
346 store.set(s1.clone(), "bearer", "token-1".into());
347 store.set(s2.clone(), "bearer", "token-2".into());
348
349 assert_eq!(store.get(&s1, "bearer").as_deref(), Some("token-1"));
350 assert_eq!(store.get(&s2, "bearer").as_deref(), Some("token-2"));
351
352 store.remove(&s1, "bearer");
354 assert!(store.get(&s1, "bearer").is_none());
355 assert_eq!(store.get(&s2, "bearer").as_deref(), Some("token-2"));
356 }
357
358 #[test]
359 fn credentials_store_multiple_schemes() {
360 let store = InMemoryCredentialsStore::new();
361 let session = SessionId::new("multi-scheme");
362
363 store.set(session.clone(), "bearer", "bearer-tok".into());
364 store.set(session.clone(), "api-key", "key-123".into());
365
366 assert_eq!(store.get(&session, "bearer").as_deref(), Some("bearer-tok"));
367 assert_eq!(store.get(&session, "api-key").as_deref(), Some("key-123"));
368 }
369
370 #[test]
371 fn credentials_store_overwrite() {
372 let store = InMemoryCredentialsStore::new();
373 let session = SessionId::new("overwrite");
374
375 store.set(session.clone(), "bearer", "old-token".into());
376 store.set(session.clone(), "bearer", "new-token".into());
377
378 assert_eq!(store.get(&session, "bearer").as_deref(), Some("new-token"));
379 }
380
381 #[test]
382 fn credentials_store_debug_hides_values() {
383 let store = InMemoryCredentialsStore::new();
384 let session = SessionId::new("secret");
385 store.set(session, "bearer", "super-secret-token".into());
386
387 let debug_output = format!("{store:?}");
388 assert!(
389 !debug_output.contains("super-secret"),
390 "debug output should not expose credentials: {debug_output}"
391 );
392 assert!(debug_output.contains("sessions"));
393 }
394
395 #[tokio::test]
396 async fn auth_interceptor_basic_scheme() {
397 let store = Arc::new(InMemoryCredentialsStore::new());
398 let session = SessionId::new("basic-test");
399 store.set(session.clone(), "basic", "dXNlcjpwYXNz".into());
400
401 let interceptor = AuthInterceptor::with_scheme(store, session, "basic");
402 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
403 interceptor.before(&mut req).await.unwrap();
404
405 assert_eq!(
406 req.extra_headers.get("authorization").map(String::as_str),
407 Some("Basic dXNlcjpwYXNz")
408 );
409 }
410
411 #[tokio::test]
412 async fn auth_interceptor_custom_scheme() {
413 let store = Arc::new(InMemoryCredentialsStore::new());
414 let session = SessionId::new("custom-test");
415 store.set(session.clone(), "api-key", "my-api-key".into());
416
417 let interceptor = AuthInterceptor::with_scheme(store, session, "api-key");
418 let mut req = ClientRequest::new("message/send", serde_json::Value::Null);
419 interceptor.before(&mut req).await.unwrap();
420
421 assert_eq!(
423 req.extra_headers.get("authorization").map(String::as_str),
424 Some("my-api-key")
425 );
426 }
427
428 #[test]
429 fn session_id_display() {
430 let session = SessionId::new("my-session");
431 assert_eq!(session.to_string(), "my-session");
432 }
433
434 #[test]
435 fn session_id_from_string() {
436 let session: SessionId = "test".into();
437 assert_eq!(session, SessionId::new("test"));
438
439 let session: SessionId = String::from("owned").into();
440 assert_eq!(session, SessionId::new("owned"));
441 }
442
443 #[test]
444 fn credentials_store_default_impl() {
445 let store = InMemoryCredentialsStore::default();
446 let session = SessionId::new("test");
447 assert!(store.get(&session, "bearer").is_none());
448 }
449
450 #[tokio::test]
451 async fn auth_interceptor_after_is_noop() {
452 let store = Arc::new(InMemoryCredentialsStore::new());
453 let session = SessionId::new("test");
454 let interceptor = AuthInterceptor::new(store, session);
455 let resp = ClientResponse {
456 method: "test".into(),
457 result: serde_json::Value::Null,
458 status_code: 200,
459 };
460 interceptor.after(&resp).await.unwrap();
461 }
462
463 #[test]
464 fn auth_interceptor_debug_contains_fields() {
465 let store = Arc::new(InMemoryCredentialsStore::new());
466 let session = SessionId::new("debug-session");
467 let interceptor = AuthInterceptor::new(store, session);
468 let debug = format!("{interceptor:?}");
469 assert!(
470 debug.contains("AuthInterceptor"),
471 "debug output missing struct name: {debug}"
472 );
473 assert!(
474 debug.contains("debug-session"),
475 "debug output missing session: {debug}"
476 );
477 assert!(
478 debug.contains("bearer"),
479 "debug output missing scheme: {debug}"
480 );
481 }
482}