jmap_base_client/auth.rs
1//! Auth traits and credential implementations for JMAP clients.
2//!
3//! Provides [`TransportConfig`] (TLS/HTTP client construction) and
4//! [`AuthProvider`] (per-request credential injection), plus built-in
5//! implementations: [`DefaultTransport`], [`CustomCaTransport`],
6//! [`NoneAuth`], [`BearerAuth`], and [`BasicAuth`].
7
8use std::sync::Arc;
9
10use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
11use base64::Engine as _;
12use reqwest::header::HeaderValue;
13
14use crate::error::ClientError;
15
16// ---------------------------------------------------------------------------
17// TransportConfig — HTTP client construction (TLS, timeouts, trust roots)
18// ---------------------------------------------------------------------------
19
20/// Controls how the underlying [`reqwest::Client`] is constructed.
21///
22/// Implementations configure TLS trust roots, client certificates, and
23/// connect timeouts. This is separate from credential injection
24/// (see [`AuthProvider`]) so transports and credentials compose freely.
25///
26/// **Implement this trait** when you need custom TLS logic (e.g. a private CA
27/// or a client certificate). For custom per-request credentials only,
28/// implement [`AuthProvider`] instead. [`DefaultTransport`] covers the common
29/// case of publicly-trusted TLS with no custom certificates.
30///
31/// **Maintainer note (bd:JMAP-6lsm.19):** if you add a new method to this
32/// trait, update the manual blanket impl for `Box<dyn TransportConfig>` at
33/// the bottom of this file. The crate ships a hand-written forwarding impl
34/// for the boxed trait object so callers can store heterogeneous transport
35/// configurations behind a single type. Adding a method here without
36/// mirroring it on the blanket impl silently breaks the
37/// `JmapClient::new(Box::<dyn TransportConfig>::new(...))` call shape.
38pub trait TransportConfig: Send + Sync {
39 /// Build the [`reqwest::Client`] for this transport configuration.
40 fn build_client(&self) -> Result<reqwest::Client, ClientError>;
41}
42
43/// Standard reqwest client with a 10-second connect timeout; no custom TLS.
44///
45/// Use for servers with publicly-trusted certificates. Pair with any
46/// [`AuthProvider`] for credential injection.
47#[derive(Debug, Clone)]
48pub struct DefaultTransport;
49
50impl TransportConfig for DefaultTransport {
51 fn build_client(&self) -> Result<reqwest::Client, ClientError> {
52 default_reqwest_client()
53 }
54}
55
56/// Custom CA trust root (DER-encoded). No `Authorization` header is injected.
57///
58/// Use when the server presents a certificate signed by a private CA.
59/// Pair with any [`AuthProvider`] for credential injection — including
60/// [`BearerAuth`] or [`BasicAuth`] if the server also requires credentials.
61#[derive(Debug, Clone)]
62pub struct CustomCaTransport {
63 der_cert: Vec<u8>,
64}
65
66impl CustomCaTransport {
67 /// Construct a `CustomCaTransport` from a DER-encoded CA certificate.
68 pub fn new(der_cert: Vec<u8>) -> Self {
69 Self { der_cert }
70 }
71}
72
73impl TransportConfig for CustomCaTransport {
74 fn build_client(&self) -> Result<reqwest::Client, ClientError> {
75 let cert =
76 reqwest::Certificate::from_der(&self.der_cert).map_err(ClientError::from_reqwest)?;
77 let client = reqwest::ClientBuilder::new()
78 .connect_timeout(std::time::Duration::from_secs(10))
79 .add_root_certificate(cert)
80 .build()
81 .map_err(ClientError::from_reqwest)?;
82 Ok(client)
83 }
84}
85
86// ---------------------------------------------------------------------------
87// AuthProvider — per-request credential injection (Authorization header)
88// ---------------------------------------------------------------------------
89
90/// Injects per-request authentication credentials.
91///
92/// Separate from transport configuration ([`TransportConfig`]) so any
93/// credential scheme can be paired with any transport.
94///
95/// **Implement this trait** when you need a custom `Authorization` header or
96/// other per-request credential scheme. For custom TLS/trust-root logic
97/// implement [`TransportConfig`] instead. [`NoneAuth`], [`BearerAuth`], and
98/// [`BasicAuth`] cover the common cases.
99///
100/// Implementations **must not** log the return value of [`auth_header`];
101/// it contains credentials.
102///
103/// [`auth_header`]: AuthProvider::auth_header
104///
105/// **Maintainer note (bd:JMAP-6lsm.19):** if you add a new method to this
106/// trait, update BOTH manual blanket impls — `Box<dyn AuthProvider>` and
107/// `Arc<dyn AuthProvider>` — at the bottom of this file. The crate
108/// supports both Box and Arc trait-object call shapes (e.g. for sharing
109/// one credential source across multiple `JmapClient`s), and a missing
110/// blanket method silently breaks one of those shapes without breaking
111/// the other.
112pub trait AuthProvider: Send + Sync {
113 /// Return an optional `(header-name, header-value)` pair to attach to
114 /// every request.
115 ///
116 /// Returns `None` when no `Authorization` header is required.
117 ///
118 /// Both strings borrow from `self` and must live at least as long as the
119 /// `&self` borrow. Implementations that pre-compute the values at
120 /// construction time can return `&self.field` directly, avoiding any
121 /// per-request allocation.
122 ///
123 /// # Implementation contract
124 ///
125 /// The returned strings **must** be valid HTTP field values (RFC 9110 §5):
126 /// - Header name: lowercase ASCII token characters only (no spaces, no
127 /// control characters); e.g. `"authorization"`.
128 /// - Header value: visible ASCII characters (0x21–0x7E) and horizontal tab
129 /// (0x09) only; no other control characters.
130 ///
131 /// Implementations that violate this contract will cause
132 /// [`ClientError::InvalidArgument`] in `connect_ws` (`ws/mod.rs`), which
133 /// parses the value into a typed [`http::HeaderValue`]. On HTTP code paths
134 /// reqwest returns the error from `.send()` as a builder error rather than
135 /// an `InvalidArgument` — the error type differs between the two paths.
136 /// Test all custom `AuthProvider` implementations against both HTTP and
137 /// WebSocket call paths.
138 fn auth_header(&self) -> Option<(&str, &str)>;
139}
140
141/// No authentication: no `Authorization` header.
142#[derive(Debug, Clone)]
143pub struct NoneAuth;
144
145impl AuthProvider for NoneAuth {
146 fn auth_header(&self) -> Option<(&str, &str)> {
147 None
148 }
149}
150
151/// Bearer-token authentication (`Authorization: Bearer <token>`).
152#[derive(Clone)]
153pub struct BearerAuth {
154 // Pre-validated at construction and stored as String: avoids per-request
155 // allocation and ensures invalid credentials fail at construction, not at
156 // the first request. Storing as String eliminates the need for a fallible
157 // to_str() call in auth_header().
158 header_string: String,
159}
160
161impl BearerAuth {
162 /// Construct a `BearerAuth` from a Bearer token string.
163 ///
164 /// # Errors
165 ///
166 /// - [`ClientError::InvalidArgument`] if `token` is empty or contains
167 /// whitespace (RFC 6750 §2.1 bearer tokens must not contain whitespace).
168 /// - [`ClientError::InvalidHeaderValue`] if `token` contains characters that
169 /// are not valid in an HTTP header value (non-visible-ASCII octets).
170 pub fn new(token: &str) -> Result<Self, ClientError> {
171 if token.is_empty() || token.chars().any(|c| c.is_ascii_whitespace()) {
172 return Err(ClientError::InvalidArgument(
173 "BearerAuth token may not be empty or contain whitespace (RFC 6750 §2.1)".into(),
174 ));
175 }
176 let header_string = format!("Bearer {token}");
177 // Validate the header value is legal (no control characters, etc.).
178 HeaderValue::from_str(&header_string).map_err(ClientError::from_invalid_header)?;
179 Ok(Self { header_string })
180 }
181}
182
183impl std::fmt::Debug for BearerAuth {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 f.debug_struct("BearerAuth")
186 .field("token", &"[REDACTED]")
187 .finish()
188 }
189}
190
191impl AuthProvider for BearerAuth {
192 fn auth_header(&self) -> Option<(&str, &str)> {
193 Some(("authorization", &self.header_string))
194 }
195}
196
197/// HTTP Basic authentication (`Authorization: Basic <base64(username:password)>`).
198///
199/// Credentials are encoded per RFC 7617: `base64(username ":" password)`.
200#[derive(Clone)]
201pub struct BasicAuth {
202 // Pre-validated at construction and stored as String: avoids per-request
203 // allocation and ensures invalid credentials fail at construction, not at
204 // the first request. Storing as String eliminates the need for a fallible
205 // to_str() call in auth_header().
206 header_string: String,
207}
208
209impl BasicAuth {
210 /// Construct a `BasicAuth` from a username and password.
211 ///
212 /// # Errors
213 ///
214 /// - [`ClientError::InvalidArgument`] if `username` contains a colon (`:`),
215 /// which is forbidden by RFC 7617 §2.
216 /// - [`ClientError::InvalidHeaderValue`] if the resulting header value
217 /// contains characters that are not valid in an HTTP header value.
218 pub fn new(username: &str, password: &str) -> Result<Self, ClientError> {
219 if username.contains(':') {
220 return Err(ClientError::InvalidArgument(
221 "BasicAuth username may not contain ':'".into(),
222 ));
223 }
224 let encoded = BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes());
225 let header_string = format!("Basic {encoded}");
226 // Validate the header value is legal (base64 is always printable ASCII,
227 // but keep the check for correctness).
228 HeaderValue::from_str(&header_string).map_err(ClientError::from_invalid_header)?;
229 Ok(Self { header_string })
230 }
231}
232
233impl std::fmt::Debug for BasicAuth {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.debug_struct("BasicAuth")
236 .field("credentials", &"[REDACTED]")
237 .finish()
238 }
239}
240
241impl AuthProvider for BasicAuth {
242 fn auth_header(&self) -> Option<(&str, &str)> {
243 Some(("authorization", &self.header_string))
244 }
245}
246
247// ---------------------------------------------------------------------------
248// Internal helper
249// ---------------------------------------------------------------------------
250
251/// Build a standard reqwest client with a 10-second connect timeout.
252fn default_reqwest_client() -> Result<reqwest::Client, ClientError> {
253 reqwest::ClientBuilder::new()
254 .connect_timeout(std::time::Duration::from_secs(10))
255 .build()
256 .map_err(ClientError::from_reqwest)
257}
258
259// ---------------------------------------------------------------------------
260// Blanket impl for Box<dyn TransportConfig>
261// ---------------------------------------------------------------------------
262//
263// Allows `Box<dyn TransportConfig>` to satisfy `impl TransportConfig`, so
264// factory functions (e.g. `Config::transport`) can return a boxed
265// trait object and pass it directly to `JmapClient::new`.
266//
267// There is intentionally NO `Arc<dyn TransportConfig>` blanket here.
268// TransportConfig is consumed once at `JmapClient::new` to build the
269// reqwest::Client. The resulting Client is stored; the TransportConfig itself
270// is not kept. Arc would imply shared ownership of something that is not
271// shared after construction.
272//
273// Maintenance cost: every method added to `TransportConfig` must be mirrored here.
274impl TransportConfig for Box<dyn TransportConfig> {
275 fn build_client(&self) -> Result<reqwest::Client, ClientError> {
276 (**self).build_client()
277 }
278}
279
280// ---------------------------------------------------------------------------
281// Blanket impl for Arc<dyn AuthProvider>
282// ---------------------------------------------------------------------------
283//
284// Allows `Arc<dyn AuthProvider>` to satisfy `impl AuthProvider`, enabling
285// `JmapClient` to be `Clone` (Arc is Clone).
286//
287// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
288impl AuthProvider for Arc<dyn AuthProvider> {
289 fn auth_header(&self) -> Option<(&str, &str)> {
290 (**self).auth_header()
291 }
292}
293
294// ---------------------------------------------------------------------------
295// Blanket impl for Box<dyn AuthProvider>
296// ---------------------------------------------------------------------------
297//
298// Allows `Box<dyn AuthProvider>` to satisfy `impl AuthProvider + 'static`,
299// so factory functions (e.g. `Config::auth`) can return a boxed
300// trait object and pass it directly to `JmapClient::new`.
301//
302// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
303impl AuthProvider for Box<dyn AuthProvider> {
304 fn auth_header(&self) -> Option<(&str, &str)> {
305 (**self).auth_header()
306 }
307}
308
309// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 /// Oracle: NoneAuth has no authentication header — verified by inspection of the spec.
318 #[test]
319 fn none_auth_no_header() {
320 assert!(NoneAuth.auth_header().is_none());
321 }
322
323 /// Oracle: BearerAuth constructs successfully with a valid ASCII token.
324 #[test]
325 fn bearer_auth_valid_constructs() {
326 assert!(BearerAuth::new("tok123").is_ok());
327 }
328
329 /// Oracle: BearerAuth header value is "Bearer " + the literal token string.
330 /// Verified by inspection: the Authorization header MUST be "Bearer tok123".
331 #[test]
332 fn bearer_auth_header() {
333 let auth = BearerAuth::new("tok123").expect("valid ASCII token must construct");
334 let (name, value) = auth.auth_header().expect("BearerAuth must return a header");
335 assert_eq!(name, "authorization");
336 assert_eq!(value, "Bearer tok123");
337 }
338
339 /// Oracle: BearerAuth constructor rejects tokens containing C0 control characters.
340 /// HeaderValue::from_str rejects bytes 0x00-0x08 and 0x0A-0x1F (C0 controls,
341 /// excluding HTAB 0x09) and 0x7F (DEL). '\x01' (SOH) is unconditionally invalid
342 /// per RFC 7230 §3.2.6 and the http crate's header validation.
343 #[test]
344 fn bearer_auth_invalid_token_rejected() {
345 let result = BearerAuth::new("tok\x01abc");
346 assert!(
347 result.is_err(),
348 "token with C0 control character must be rejected by constructor"
349 );
350 }
351
352 /// Oracle: BasicAuth constructs successfully with valid username and password.
353 #[test]
354 fn basic_auth_valid_constructs() {
355 assert!(BasicAuth::new("alice", "s3cr3t").is_ok());
356 }
357
358 /// Oracle: BasicAuth constructor rejects usernames containing a colon (RFC 7617 §2).
359 #[test]
360 fn basic_auth_colon_in_username_rejected() {
361 let result = BasicAuth::new("ali:ce", "s3cr3t");
362 match result {
363 Ok(_) => panic!("username with colon must be rejected by constructor"),
364 Err(e) => {
365 let err_msg = e.to_string();
366 assert!(
367 err_msg.contains("username"),
368 "error message should mention 'username', got: {err_msg}"
369 );
370 }
371 }
372 }
373
374 /// Oracle: `echo -n "alice:s3cr3t" | base64` → `YWxpY2U6czNjcjN0` (RFC 7617 §2)
375 /// This expected value is computed independently of the code under test.
376 #[test]
377 fn basic_auth_header() {
378 let auth = BasicAuth::new("alice", "s3cr3t").expect("valid credentials must construct");
379 let (name, value) = auth.auth_header().expect("BasicAuth must return a header");
380 assert_eq!(name, "authorization");
381 assert_eq!(value, "Basic YWxpY2U6czNjcjN0");
382 }
383
384 /// Oracle: CustomCaTransport injects no auth header — it is a transport only.
385 #[test]
386 fn custom_ca_transport_no_build_with_empty_cert() {
387 // Empty DER bytes will fail Certificate::from_der; this test confirms
388 // CustomCaTransport is constructible and that auth is separate.
389 let transport = CustomCaTransport::new(vec![]);
390 assert!(transport.build_client().is_err(), "empty DER must fail");
391 }
392
393 /// Oracle: BearerAuth constructor rejects an empty token string.
394 /// An empty token would produce "Bearer " which is a malformed credential.
395 #[test]
396 fn bearer_auth_empty_token_rejected() {
397 let result = BearerAuth::new("");
398 match result {
399 Ok(_) => panic!("empty token must be rejected by constructor"),
400 Err(ClientError::InvalidArgument(msg)) => {
401 assert!(
402 msg.contains("empty"),
403 "error message should mention 'empty', got: {msg}"
404 );
405 }
406 Err(e) => panic!("expected InvalidArgument, got: {e}"),
407 }
408 }
409
410 /// Oracle: BearerAuth constructor rejects a whitespace-only token string.
411 /// A whitespace-only token would produce "Bearer " which is a malformed credential.
412 #[test]
413 fn bearer_auth_whitespace_only_token_rejected() {
414 let result = BearerAuth::new(" ");
415 match result {
416 Ok(_) => panic!("whitespace-only token must be rejected by constructor"),
417 Err(ClientError::InvalidArgument(msg)) => {
418 assert!(
419 msg.contains("whitespace"),
420 "error message should mention 'whitespace', got: {msg}"
421 );
422 }
423 Err(e) => panic!("expected InvalidArgument, got: {e}"),
424 }
425 }
426
427 /// Oracle: DefaultTransport uses the default reqwest::Client which always builds successfully.
428 #[tokio::test]
429 async fn default_transport_builds_client() {
430 DefaultTransport
431 .build_client()
432 .expect("DefaultTransport::build_client must succeed");
433 }
434
435 /// Oracle: BearerAuth's Debug impl never reveals the underlying token.
436 ///
437 /// Tripwire against a future refactor that adds `#[derive(Debug)]` to
438 /// BearerAuth (clearing the manual redacting impl), or that prints the
439 /// inner `header_string`. The canary literal is the independent
440 /// oracle — it is under the test's control, never derived from
441 /// BearerAuth's internal state.
442 #[test]
443 fn bearer_auth_debug_does_not_leak_token() {
444 const CANARY: &str = "CANARY-TOKEN-DO-NOT-LEAK-123";
445 let auth = BearerAuth::new(CANARY).expect("valid ASCII token must construct");
446 let dbg = format!("{auth:?}");
447 assert!(
448 !dbg.contains(CANARY),
449 "BearerAuth Debug must not contain the raw token; got: {dbg}"
450 );
451 }
452
453 /// Oracle: BasicAuth's Debug impl never reveals the underlying credentials.
454 ///
455 /// Same tripwire shape as `bearer_auth_debug_does_not_leak_token`.
456 /// The canary username and password are independent literals; the
457 /// assertion verifies neither, nor the base64 encoding of their
458 /// concatenation, appears in the Debug output.
459 #[test]
460 fn basic_auth_debug_does_not_leak_credentials() {
461 const CANARY_USER: &str = "CANARY-USER-DO-NOT-LEAK";
462 const CANARY_PASS: &str = "CANARY-PASS-DO-NOT-LEAK";
463 let auth =
464 BasicAuth::new(CANARY_USER, CANARY_PASS).expect("valid credentials must construct");
465 let dbg = format!("{auth:?}");
466 assert!(
467 !dbg.contains(CANARY_USER),
468 "BasicAuth Debug must not contain the raw username; got: {dbg}"
469 );
470 assert!(
471 !dbg.contains(CANARY_PASS),
472 "BasicAuth Debug must not contain the raw password; got: {dbg}"
473 );
474 // Also catch a regression that prints the pre-validated header_string,
475 // which would surface the base64-encoded credentials.
476 let base64_pair = BASE64_STANDARD.encode(format!("{CANARY_USER}:{CANARY_PASS}"));
477 assert!(
478 !dbg.contains(&base64_pair),
479 "BasicAuth Debug must not contain the base64-encoded credentials; got: {dbg}"
480 );
481 }
482
483 // Note: a dyn-AuthProvider Debug test (bead JMAP-sc1b.79 item #4) is
484 // intentionally omitted. The AuthProvider trait does not have
485 // `std::fmt::Debug` as a supertrait, so `Box<dyn AuthProvider>` is
486 // not `Debug`-formattable. Adding `Debug` to the trait bound would
487 // be a foundation-crate public API change far outside the scope of
488 // a regression-test bead. The concrete-type tests above already
489 // catch the hygiene contract for every shipped AuthProvider
490 // implementation; the only way a new AuthProvider leaks credentials
491 // via Debug is if its own concrete impl does so, and that is
492 // caught by the new-impl reviewer (cookie-cutter rule).
493}