Skip to main content

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}