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;
13use zeroize::Zeroizing;
14
15use crate::error::ClientError;
16
17// ---------------------------------------------------------------------------
18// TransportConfig — HTTP client construction (TLS, timeouts, trust roots)
19// ---------------------------------------------------------------------------
20
21/// Opaque HTTP client returned by [`TransportConfig::build_client`]
22/// (bd:JMAP-6r7c.36).
23///
24/// The inner third-party type is private; the wrapper exists so the JMAP
25/// transport identity does not leak through the public trait signature.
26/// A future swap of the underlying HTTP library (e.g. `ureq`, `hyper-util`
27/// directly, `curl`) replaces the wrapped type without breaking any
28/// downstream extension client or custom `TransportConfig` impl that
29/// returns `Result<HttpClient, ClientError>` from `build_client`.
30///
31/// Custom transports construct via [`HttpClient::new`] — that signature
32/// still references [`reqwest::Client`] (the only construction path the
33/// kit knows how to make HTTP requests against). The partial-wrap
34/// argument mirrors [`ParseError`](crate::error::ParseError) /
35/// [`SerializeError`](crate::error::SerializeError): the variant
36/// payload / return type is opaque, but the construction signature still
37/// names the third-party type so callers have a way in. A future
38/// transport swap would deprecate this constructor in favor of an
39/// analogous one for the new HTTP client; the wrapper type itself
40/// stays stable.
41#[non_exhaustive]
42pub struct HttpClient(reqwest::Client);
43
44impl HttpClient {
45 /// Wrap a [`reqwest::Client`] into an opaque [`HttpClient`].
46 ///
47 /// Custom [`TransportConfig`] impls use this constructor to wrap a
48 /// reqwest client they built with their own TLS / proxy / timeout
49 /// configuration:
50 ///
51 /// ```rust,ignore
52 /// impl TransportConfig for MyCustomTransport {
53 /// fn build_client(&self) -> Result<HttpClient, ClientError> {
54 /// let client = reqwest::ClientBuilder::new()
55 /// .proxy(...)
56 /// .build()
57 /// .map_err(ClientError::from_reqwest)?;
58 /// Ok(HttpClient::new(client))
59 /// }
60 /// }
61 /// ```
62 pub fn new(client: reqwest::Client) -> Self {
63 Self(client)
64 }
65
66 /// Consume the wrapper and return the inner [`reqwest::Client`].
67 ///
68 /// `pub(crate)` so only this crate's [`JmapClient`](crate::JmapClient)
69 /// construction path can unwrap — external code cannot reach inside
70 /// the opaque wrapper. A future swap of the HTTP transport would
71 /// change the return type here without affecting external callers
72 /// (who only see the typed `Result<HttpClient, _>` from
73 /// [`TransportConfig::build_client`]).
74 pub(crate) fn into_inner(self) -> reqwest::Client {
75 self.0
76 }
77}
78
79impl std::fmt::Debug for HttpClient {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_tuple("HttpClient").finish()
82 }
83}
84
85/// Controls how the underlying [`HttpClient`] is constructed.
86///
87/// Implementations configure TLS trust roots, client certificates, and
88/// connect timeouts. This is separate from credential injection
89/// (see [`AuthProvider`]) so transports and credentials compose freely.
90///
91/// **Implement this trait** when you need custom TLS logic (e.g. a private CA
92/// or a client certificate). For custom per-request credentials only,
93/// implement [`AuthProvider`] instead. [`DefaultTransport`] covers the common
94/// case of publicly-trusted TLS with no custom certificates.
95///
96/// **Return type contract (bd:JMAP-6r7c.36).** `build_client` returns an
97/// opaque [`HttpClient`] wrapper, not a bare [`reqwest::Client`]. Custom
98/// impls construct via [`HttpClient::new`] after building their reqwest
99/// client; the wrapper insulates the trait's public surface from a
100/// future HTTP-transport swap.
101///
102/// **Maintainer note (bd:JMAP-6lsm.19):** if you add a new method to this
103/// trait, update the manual blanket impl for `Box<dyn TransportConfig>` at
104/// the bottom of this file. The crate ships a hand-written forwarding impl
105/// for the boxed trait object so callers can store heterogeneous transport
106/// configurations behind a single type. Adding a method here without
107/// mirroring it on the blanket impl silently breaks the
108/// `JmapClient::new(Box::<dyn TransportConfig>::new(...))` call shape.
109pub trait TransportConfig: Send + Sync {
110 /// Build the [`HttpClient`] for this transport configuration.
111 fn build_client(&self) -> Result<HttpClient, ClientError>;
112}
113
114/// Standard reqwest client with a 10-second connect timeout; no custom TLS.
115///
116/// Use for servers with publicly-trusted certificates. Pair with any
117/// [`AuthProvider`] for credential injection.
118#[derive(Debug, Clone)]
119pub struct DefaultTransport;
120
121impl TransportConfig for DefaultTransport {
122 fn build_client(&self) -> Result<HttpClient, ClientError> {
123 default_reqwest_client().map(HttpClient::new)
124 }
125}
126
127/// Custom CA trust root (DER-encoded). No `Authorization` header is injected.
128///
129/// Use when the server presents a certificate signed by a private CA.
130/// Pair with any [`AuthProvider`] for credential injection — including
131/// [`BearerAuth`] or [`BasicAuth`] if the server also requires credentials.
132///
133/// # Trust scope (bd:JMAP-6r7c.57)
134///
135/// **The bundled public webpki-roots are DISABLED in the constructed
136/// reqwest client.** This type is intended for private-CA pinning —
137/// connecting to a JMAP server identified by a private CA the operator
138/// controls, *refusing* certificates signed by any public CA. That is
139/// the threat model where this transport matters: a corporate internal
140/// JMAP server, a service-mesh deployment, an air-gapped network. A
141/// compromised or malicious public CA (DigiNotar 2011, Symantec 2017,
142/// etc.) issuing a certificate for the target host name would otherwise
143/// bypass the private-CA defense entirely; disabling the public roots
144/// closes that gap.
145///
146/// If you want trust against BOTH the bundled public roots AND a custom
147/// CA (a "hybrid" deployment), `CustomCaTransport` is the wrong tool —
148/// implement [`TransportConfig`] directly with the additive behaviour
149/// (`reqwest::ClientBuilder::add_root_certificate` does NOT call
150/// `.tls_built_in_root_certs(false)` by default, so a hand-rolled impl
151/// has the additive shape automatically).
152#[derive(Clone)]
153pub struct CustomCaTransport {
154 der_cert: Vec<u8>,
155}
156
157impl CustomCaTransport {
158 /// Construct a `CustomCaTransport` from a DER-encoded CA certificate.
159 pub fn new(der_cert: Vec<u8>) -> Self {
160 Self { der_cert }
161 }
162
163 /// Construct a `CustomCaTransport` from a PEM-encoded CA certificate
164 /// (bd:JMAP-6r7c.37).
165 ///
166 /// Operators typically distribute private-CA certificates as PEM
167 /// files (text-format, `-----BEGIN CERTIFICATE-----` framing).
168 /// Without this helper, every caller has to convert PEM to DER
169 /// themselves before passing to [`CustomCaTransport::new`]:
170 ///
171 /// ```rust,ignore
172 /// // Without from_pem_bytes (the long way):
173 /// let pem_bytes = std::fs::read("ca.pem")?;
174 /// let der = rustls_pemfile::certs(&mut pem_bytes.as_slice())
175 /// .next()
176 /// .transpose()?
177 /// .ok_or("no certificate in PEM file")?
178 /// .to_vec();
179 /// let transport = CustomCaTransport::new(der);
180 ///
181 /// // With from_pem_bytes (the short way):
182 /// let transport = CustomCaTransport::from_pem_bytes(&std::fs::read("ca.pem")?)?;
183 /// ```
184 ///
185 /// The first PEM-framed certificate in `pem_bytes` is used. To use
186 /// a different certificate from a multi-cert bundle, split the
187 /// bundle yourself and pass the desired one. Multi-cert chains
188 /// (root + intermediate) require constructing a custom
189 /// [`TransportConfig`] implementation that adds multiple roots —
190 /// `CustomCaTransport` is single-root.
191 ///
192 /// # Errors
193 ///
194 /// Returns [`ClientError::InvalidArgument`] if `pem_bytes` does not
195 /// contain a recognisable PEM-framed certificate or if the PEM
196 /// body cannot be base64-decoded.
197 ///
198 /// **DER validity is NOT checked at this stage.** This matches the
199 /// existing [`CustomCaTransport::new`] contract — invalid DER
200 /// (PEM body that decodes to non-DER bytes) is detected later when
201 /// the `JmapClient` is constructed and the underlying transport
202 /// tries to load the root, at which point it surfaces as
203 /// [`ClientError::Http`]. The PEM helper deliberately matches the
204 /// DER helper's behaviour: cheap validation here, full validation
205 /// at client-build time.
206 pub fn from_pem_bytes(pem_bytes: &[u8]) -> Result<Self, ClientError> {
207 // The PEM-to-DER conversion uses a minimal in-line decoder so
208 // this crate does not need to depend on rustls_pemfile. DER
209 // semantic validity is the underlying transport's
210 // responsibility (it happens at build_client time, where
211 // reqwest::Certificate::from_der + ClientBuilder do the
212 // actual rustls/native-tls parse).
213 let cert_bytes = parse_first_pem_cert(pem_bytes).ok_or_else(|| {
214 ClientError::InvalidArgument(
215 "CustomCaTransport::from_pem_bytes: no PEM-framed certificate found in input"
216 .into(),
217 )
218 })?;
219 Ok(Self {
220 der_cert: cert_bytes,
221 })
222 }
223}
224
225/// Extract the DER bytes of the first PEM-framed certificate in `input`.
226///
227/// PEM (RFC 7468) format: `-----BEGIN <label>-----` / base64 body /
228/// `-----END <label>-----`. We accept any label whose payload is a
229/// valid DER certificate (the most common label is `CERTIFICATE`;
230/// some toolchains emit `X509 CERTIFICATE` or `TRUSTED CERTIFICATE`).
231///
232/// Returns `None` if no PEM frame is found or if the base64 body
233/// cannot be decoded. The DER validity check is the caller's
234/// responsibility (do it via `reqwest::Certificate::from_der`).
235fn parse_first_pem_cert(input: &[u8]) -> Option<Vec<u8>> {
236 use base64::Engine as _;
237 let text = std::str::from_utf8(input).ok()?;
238 // Find any BEGIN line. RFC 7468 §3 mandates exactly five hyphens
239 // and an ASCII-uppercase label; we accept the common shapes.
240 let begin_idx = text.find("-----BEGIN ")?;
241 let after_begin = &text[begin_idx + "-----BEGIN ".len()..];
242 let begin_eol = after_begin.find('\n')?;
243 let label = after_begin[..begin_eol].trim().trim_end_matches('-').trim();
244 let end_marker = format!("-----END {label}-----");
245 let body_start = begin_idx + "-----BEGIN ".len() + begin_eol + 1;
246 let end_offset = text[body_start..].find(end_marker.as_str())?;
247 let body = &text[body_start..body_start + end_offset];
248 // Strip whitespace from the base64 body. PEM allows line wraps
249 // every 64 chars per RFC 7468 §3; the base64 standard engine
250 // does not accept embedded whitespace.
251 let body_no_ws: String = body.chars().filter(|c| !c.is_whitespace()).collect();
252 base64::engine::general_purpose::STANDARD
253 .decode(body_no_ws)
254 .ok()
255}
256
257/// Extract the DER bytes of every PEM-framed certificate in `input`,
258/// in input order (bd:JMAP-6r7c.65).
259///
260/// Iterates `-----BEGIN ... -----` / `-----END ... -----` frames and
261/// decodes each base64 body. Skips frames that fail to decode (matches
262/// `parse_first_pem_cert`'s lenient posture: DER semantic validity is
263/// checked later by the underlying transport at `build_client` time).
264/// Returns an empty `Vec` if no PEM frame is found.
265fn parse_all_pem_certs(input: &[u8]) -> Vec<Vec<u8>> {
266 use base64::Engine as _;
267 let Ok(text) = std::str::from_utf8(input) else {
268 return Vec::new();
269 };
270 let mut out = Vec::new();
271 let mut rest = text;
272 while let Some(begin_idx) = rest.find("-----BEGIN ") {
273 let after_begin = &rest[begin_idx + "-----BEGIN ".len()..];
274 let Some(begin_eol) = after_begin.find('\n') else {
275 break;
276 };
277 let label = after_begin[..begin_eol].trim().trim_end_matches('-').trim();
278 let end_marker = format!("-----END {label}-----");
279 let body_start = begin_idx + "-----BEGIN ".len() + begin_eol + 1;
280 let Some(end_offset) = rest[body_start..].find(end_marker.as_str()) else {
281 break;
282 };
283 let body = &rest[body_start..body_start + end_offset];
284 let body_no_ws: String = body.chars().filter(|c| !c.is_whitespace()).collect();
285 if let Ok(der) = base64::engine::general_purpose::STANDARD.decode(body_no_ws) {
286 out.push(der);
287 }
288 let consumed = body_start + end_offset + end_marker.len();
289 rest = &rest[consumed..];
290 }
291 out
292}
293
294/// Manual `Debug` impl that redacts the DER-encoded CA bytes
295/// (bd:JMAP-6r7c.13).
296///
297/// The DER bytes are not a credential, but they are deployment-identifying
298/// material: a CA certificate uniquely identifies the deployment's PKI
299/// (Subject DN, public key, signing algorithm, validity window, X.509
300/// extensions). In federated or multi-tenant scenarios, surfacing those
301/// bytes in `tracing` output reveals which private-CA-using customer the
302/// client is configured to talk to. Print the length only and let the
303/// caller obtain the bytes via a constructor-controlled path if they
304/// genuinely need them.
305///
306/// Mirrors the redacting `Debug` impls on `BearerAuth` and `BasicAuth`
307/// in this file and on `Session` and `AccountInfo` in `request.rs`.
308impl std::fmt::Debug for CustomCaTransport {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 f.debug_struct("CustomCaTransport")
311 .field("der_cert", &format_args!("<{} bytes>", self.der_cert.len()))
312 .finish()
313 }
314}
315
316impl TransportConfig for CustomCaTransport {
317 fn build_client(&self) -> Result<HttpClient, ClientError> {
318 let cert =
319 reqwest::Certificate::from_der(&self.der_cert).map_err(ClientError::from_reqwest)?;
320 // Replace (not augment) the trust root set with the configured
321 // private CA. tls_built_in_root_certs(false) disables the bundled
322 // webpki-roots before add_root_certificate adds the private CA —
323 // the order matters because reqwest treats add_root_certificate
324 // as additive (bd:JMAP-6r7c.57).
325 let client = reqwest::ClientBuilder::new()
326 .connect_timeout(std::time::Duration::from_secs(10))
327 .tls_built_in_root_certs(false)
328 .add_root_certificate(cert)
329 .build()
330 .map_err(ClientError::from_reqwest)?;
331 Ok(HttpClient::new(client))
332 }
333}
334
335// ---------------------------------------------------------------------------
336// CustomTransportBuilder (bd:JMAP-6r7c.65)
337// ---------------------------------------------------------------------------
338
339/// Builder for a [`TransportConfig`] with multi-root trust chains and
340/// optional mTLS client certificate (bd:JMAP-6r7c.65).
341///
342/// [`CustomCaTransport`] is single-root and has no mTLS support; the
343/// builder is the richer-configuration counterpart. Common cases:
344///
345/// - **Private PKI with root + intermediate.** Add both via
346/// [`add_root_pem`](Self::add_root_pem) (single cert) or
347/// [`add_roots_pem_bundle`](Self::add_roots_pem_bundle) (bundle of
348/// roots + intermediates).
349/// - **Mutual TLS.** Add a client cert + key via
350/// [`with_client_cert`](Self::with_client_cert).
351/// - **Both.** Compose freely; the builder is chainable.
352///
353/// Like [`CustomCaTransport`], the resulting transport **replaces**
354/// the bundled webpki-roots with the configured trust roots. A
355/// "hybrid" deployment that wants both the bundled public roots AND
356/// custom roots is not currently supported; implement
357/// [`TransportConfig`] directly with the additive behaviour
358/// (`reqwest::ClientBuilder::add_root_certificate` is additive by
359/// default — a hand-rolled impl that omits
360/// `.tls_built_in_root_certs(false)` keeps the bundled roots).
361///
362/// # Usage
363///
364/// ```rust,ignore
365/// use jmap_base_client::auth::CustomTransportBuilder;
366///
367/// let transport = CustomTransportBuilder::new()
368/// .add_root_pem(&std::fs::read("ca-root.pem")?)?
369/// .add_root_pem(&std::fs::read("ca-intermediate.pem")?)?
370/// .with_client_cert(
371/// std::fs::read("client.pem")?,
372/// std::fs::read("client.key.pem")?,
373/// )
374/// .build();
375///
376/// let client = JmapClient::new(
377/// transport,
378/// BearerAuth::new(token)?,
379/// "https://internal-jmap.corp",
380/// ClientConfig::default(),
381/// )?;
382/// ```
383#[derive(Default)]
384pub struct CustomTransportBuilder {
385 roots_der: Vec<Vec<u8>>,
386 // (cert_pem, key_pem) pair — concatenated into a single PEM bundle
387 // for reqwest::Identity::from_pem at build_client time.
388 client_identity: Option<(Vec<u8>, Vec<u8>)>,
389}
390
391impl CustomTransportBuilder {
392 /// Construct an empty builder. A builder with no trust roots and
393 /// no client identity will produce a transport that rejects
394 /// every TLS connection (no trust roots configured); add at
395 /// least one root before [`build`](Self::build).
396 pub fn new() -> Self {
397 Self::default()
398 }
399
400 /// Add a DER-encoded trust-root certificate.
401 ///
402 /// Validation of the DER bytes is deferred to
403 /// [`build`](Self::build) (same posture as
404 /// [`CustomCaTransport::new`]). Invalid DER surfaces as
405 /// [`ClientError::Http`] at `JmapClient::new` time.
406 pub fn add_root_der(mut self, der: Vec<u8>) -> Self {
407 self.roots_der.push(der);
408 self
409 }
410
411 /// Add a PEM-encoded trust-root certificate. The first
412 /// PEM-framed certificate in `pem` is consumed; embedded
413 /// chains require [`add_roots_pem_bundle`](Self::add_roots_pem_bundle).
414 ///
415 /// # Errors
416 ///
417 /// Returns [`ClientError::InvalidArgument`] if `pem` does not
418 /// contain a recognisable PEM-framed certificate.
419 pub fn add_root_pem(self, pem: &[u8]) -> Result<Self, ClientError> {
420 let der = parse_first_pem_cert(pem).ok_or_else(|| {
421 ClientError::InvalidArgument(
422 "CustomTransportBuilder::add_root_pem: no PEM-framed certificate found in input"
423 .into(),
424 )
425 })?;
426 Ok(self.add_root_der(der))
427 }
428
429 /// Add every PEM-framed certificate in a multi-cert bundle.
430 ///
431 /// A typical private-PKI deployment ships a bundle containing
432 /// the root plus one or more intermediates as concatenated PEM
433 /// blocks. This method iterates each `-----BEGIN CERTIFICATE-----`
434 /// block in input order and adds each to the trust set.
435 ///
436 /// # Errors
437 ///
438 /// Returns [`ClientError::InvalidArgument`] if no PEM-framed
439 /// certificate is found in the bundle.
440 pub fn add_roots_pem_bundle(mut self, pem_bundle: &[u8]) -> Result<Self, ClientError> {
441 let ders = parse_all_pem_certs(pem_bundle);
442 if ders.is_empty() {
443 return Err(ClientError::InvalidArgument(
444 "CustomTransportBuilder::add_roots_pem_bundle: no PEM-framed \
445 certificates found in bundle"
446 .into(),
447 ));
448 }
449 self.roots_der.extend(ders);
450 Ok(self)
451 }
452
453 /// Configure a client certificate + private key for mutual TLS.
454 ///
455 /// Replaces any previously-configured client identity. The two
456 /// PEM byte slices are stored verbatim and concatenated at
457 /// [`build`](Self::build) time into a single PEM bundle that
458 /// [`reqwest::Identity::from_pem`] consumes.
459 ///
460 /// `cert_pem` may contain a single client cert or a cert +
461 /// intermediates chain. `key_pem` carries the private key
462 /// (PKCS#1 or PKCS#8, RSA or ECDSA — whatever reqwest's rustls
463 /// build supports).
464 pub fn with_client_cert(mut self, cert_pem: Vec<u8>, key_pem: Vec<u8>) -> Self {
465 self.client_identity = Some((cert_pem, key_pem));
466 self
467 }
468
469 /// Consume the builder and return a [`TransportConfig`]
470 /// implementation that produces a [`reqwest::Client`] configured
471 /// with the accumulated trust roots and optional client identity.
472 ///
473 /// Behaves identically to [`CustomCaTransport::build_client`] for
474 /// single-root use; the additional functionality (multi-root +
475 /// mTLS) kicks in when the builder was configured with more than
476 /// one root or a client identity.
477 pub fn build(self) -> BuilderTransport {
478 BuilderTransport {
479 roots_der: self.roots_der,
480 client_identity: self.client_identity,
481 }
482 }
483}
484
485/// Concrete [`TransportConfig`] produced by [`CustomTransportBuilder::build`].
486///
487/// Stores the accumulated trust roots and optional client identity by
488/// value. Cloneable so consumers that need multiple `JmapClient` instances
489/// sharing the same transport configuration can do so without rebuilding.
490#[derive(Clone)]
491pub struct BuilderTransport {
492 roots_der: Vec<Vec<u8>>,
493 client_identity: Option<(Vec<u8>, Vec<u8>)>,
494}
495
496impl std::fmt::Debug for BuilderTransport {
497 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498 f.debug_struct("BuilderTransport")
499 .field(
500 "roots_der",
501 &format_args!("<{} root cert(s)>", self.roots_der.len()),
502 )
503 .field(
504 "client_identity",
505 &format_args!(
506 "<{}>",
507 if self.client_identity.is_some() {
508 "client cert configured"
509 } else {
510 "no client cert"
511 }
512 ),
513 )
514 .finish()
515 }
516}
517
518impl TransportConfig for BuilderTransport {
519 fn build_client(&self) -> Result<HttpClient, ClientError> {
520 let mut builder = reqwest::ClientBuilder::new()
521 .connect_timeout(std::time::Duration::from_secs(10))
522 // Replace (not augment) the trust root set. See
523 // CustomCaTransport rationale (bd:JMAP-6r7c.57). Builder
524 // users who want hybrid trust (bundled + private) are
525 // expected to implement TransportConfig directly.
526 .tls_built_in_root_certs(false);
527
528 for der in &self.roots_der {
529 let cert = reqwest::Certificate::from_der(der).map_err(ClientError::from_reqwest)?;
530 builder = builder.add_root_certificate(cert);
531 }
532
533 if let Some((cert_pem, key_pem)) = &self.client_identity {
534 // reqwest::Identity::from_pem expects the cert chain and
535 // private key concatenated into one PEM bundle. Build
536 // that bundle locally without leaking either input across
537 // the function boundary.
538 let mut bundle = Vec::with_capacity(cert_pem.len() + key_pem.len() + 1);
539 bundle.extend_from_slice(cert_pem);
540 if !cert_pem.ends_with(b"\n") {
541 bundle.push(b'\n');
542 }
543 bundle.extend_from_slice(key_pem);
544 let identity =
545 reqwest::Identity::from_pem(&bundle).map_err(ClientError::from_reqwest)?;
546 builder = builder.identity(identity);
547 }
548
549 let client = builder.build().map_err(ClientError::from_reqwest)?;
550 Ok(HttpClient::new(client))
551 }
552}
553
554// ---------------------------------------------------------------------------
555// AuthProvider — per-request credential injection (Authorization header)
556// ---------------------------------------------------------------------------
557
558/// Single HTTP `(name, value)` header pair, returned by
559/// [`AuthProvider::auth_header`] (bd:JMAP-6r7c.62, bd:JMAP-6r7c.20).
560///
561/// The wrapper exists for two purposes:
562///
563/// 1. **Compile-time secret-typing.** [`AuthHeader`]'s `Debug` impl
564/// redacts the header value to `"[REDACTED]"`. A future
565/// [`AuthProvider`] impl that writes
566/// `tracing::trace!(?header, "injecting")` cannot leak the credential
567/// through that path because the wrapper's `Debug` output never
568/// contains the value bytes. The pre-bd:JMAP-6r7c.62 shape
569/// (`Option<(&str, &str)>`) had no such guard — a string tuple
570/// formats verbatim via `?`-syntax.
571/// 2. **Bounded API surface.** The wrapper packages exactly one
572/// `(name, value)` pair. The trait's signature does not admit a
573/// list, a sequence, or a per-request-computed value. This is the
574/// intentional limitation: `AuthProvider` covers "static,
575/// per-connection single-header auth schemes" only (Bearer, Basic,
576/// mTLS via [`TransportConfig`]). Schemes that need multiple
577/// request-dependent headers (AWS SigV4, OAuth request signing) or
578/// async credential refresh require a different abstraction —
579/// currently, custom [`TransportConfig`] impls that wire per-request
580/// middleware (bd:JMAP-6r7c.20).
581///
582/// Construct via [`AuthHeader::new`] — both `name` and `value` are
583/// caller-supplied borrows; the wrapper stashes them as-is. The
584/// constructor does not validate HTTP-header-value syntax; downstream
585/// consumers (e.g. [`connect_ws`](crate::ws::connect_ws)) validate at
586/// the call site and surface [`ClientError::InvalidArgument`] for
587/// invalid bytes.
588#[non_exhaustive]
589#[derive(Clone, Copy)]
590pub struct AuthHeader<'a> {
591 name: &'a str,
592 value: &'a str,
593}
594
595impl<'a> AuthHeader<'a> {
596 /// Construct an [`AuthHeader`] from a header name and value borrow.
597 pub fn new(name: &'a str, value: &'a str) -> Self {
598 Self { name, value }
599 }
600
601 /// Borrow the header name. Lowercase-ASCII per RFC 9110 §5.1.
602 pub fn name(&self) -> &'a str {
603 self.name
604 }
605
606 /// Borrow the header value.
607 ///
608 /// **Do not log this return value.** The value is credential
609 /// material; see the type-level rustdoc. The constructor name is
610 /// deliberately explicit ([`expose_value`](Self::expose_value)) so a
611 /// call site reveals the intent — a `tracing::*` line that
612 /// references `header.expose_value()` is visible in code review,
613 /// whereas a `?header` formatter is not.
614 pub fn expose_value(&self) -> &'a str {
615 self.value
616 }
617}
618
619impl std::fmt::Debug for AuthHeader<'_> {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 f.debug_struct("AuthHeader")
622 .field("name", &self.name)
623 .field("value", &"[REDACTED]")
624 .finish()
625 }
626}
627
628/// Injects per-request authentication credentials.
629///
630/// Separate from transport configuration ([`TransportConfig`]) so any
631/// credential scheme can be paired with any transport.
632///
633/// **Implement this trait** when you need a custom `Authorization` header or
634/// other per-request credential scheme. For custom TLS/trust-root logic
635/// implement [`TransportConfig`] instead. [`NoneAuth`], [`BearerAuth`], and
636/// [`BasicAuth`] cover the common cases.
637///
638/// Implementations **must not** log the return value of [`auth_header`];
639/// it contains credentials. The [`AuthHeader`] return type provides a
640/// compile-time guard against the most common leak path — its `Debug`
641/// impl redacts the value bytes — but the explicit
642/// [`expose_value`](AuthHeader::expose_value) accessor must not be fed
643/// into a `tracing::*` argument either.
644///
645/// [`auth_header`]: AuthProvider::auth_header
646///
647/// # Intentional limitation: static single-header per-connection schemes (bd:JMAP-6r7c.20)
648///
649/// The trait shape commits the kit to "static, per-connection,
650/// single-header auth schemes" — bearer-token, HTTP Basic, mTLS via
651/// [`TransportConfig`]. Three constraints follow from the
652/// [`AuthHeader`] return type:
653///
654/// 1. **One header per request.** A scheme that needs to attach
655/// multiple headers per request (AWS SigV4 carries
656/// `Authorization`, `X-Amz-Date`, and `X-Amz-Security-Token`
657/// together) cannot be expressed by this trait.
658/// 2. **No per-request signature.** [`auth_header`] takes `&self`
659/// only — there is no access to the request URL, method, or body.
660/// Schemes that compute an HMAC over the request body (SigV4,
661/// OAuth request signing) cannot be expressed.
662/// 3. **No async refresh.** [`auth_header`] is sync. A scheme that
663/// needs to refresh an expired OAuth token before returning
664/// cannot await inside this method.
665///
666/// Workaround for callers who need any of the three: implement a
667/// custom [`TransportConfig`] that wires per-request middleware into
668/// the [`HttpClient`] it returns from
669/// [`build_client`](TransportConfig::build_client). The middleware can
670/// observe the full request, compute signatures, and refresh tokens
671/// asynchronously. The cost is the awkward layering inversion — TLS
672/// config and credential injection conceptually belong to different
673/// traits — but it does compose against the existing
674/// [`AuthProvider::auth_header`] trait without breakage.
675///
676/// A future reshape that supports the three constraints (likely a
677/// new trait, not a backward-compatible widening of this one) would
678/// not deprecate `AuthProvider`. The current trait stays as the
679/// "fast path for the common case" alongside any richer abstraction.
680///
681/// # Credential lifetime
682///
683/// Implementations that cache header bytes (e.g. [`BearerAuth`],
684/// [`BasicAuth`]) SHOULD wrap the cached buffer in [`zeroize::Zeroizing`]
685/// or equivalent so the credential is overwritten on drop rather than
686/// left in freed heap until the allocator re-uses the slab. Callers that
687/// build a credential string before passing it into a constructor (e.g.
688/// `BearerAuth::new(token)`) SHOULD likewise store that string in a
689/// `Zeroizing<String>` — the zeroization done by the auth-type is bounded
690/// by what the type owns and cannot reach back into the caller's buffer
691/// (bd:JMAP-6r7c.59).
692///
693/// **Maintainer note (bd:JMAP-6lsm.19):** if you add a new method to this
694/// trait, update BOTH manual blanket impls — `Box<dyn AuthProvider>` and
695/// `Arc<dyn AuthProvider>` — at the bottom of this file. The crate
696/// supports both Box and Arc trait-object call shapes (e.g. for sharing
697/// one credential source across multiple `JmapClient`s), and a missing
698/// blanket method silently breaks one of those shapes without breaking
699/// the other.
700pub trait AuthProvider: Send + Sync {
701 /// Return an optional [`AuthHeader`] to attach to every request.
702 ///
703 /// Returns `None` when no `Authorization` header is required.
704 ///
705 /// The header name and value both borrow from `self` and must live
706 /// at least as long as the `&self` borrow. Implementations that
707 /// pre-compute the values at construction time can return
708 /// `AuthHeader::new("authorization", &self.field)` directly,
709 /// avoiding any per-request allocation.
710 ///
711 /// # Implementation contract
712 ///
713 /// The returned strings **must** be valid HTTP field values (RFC 9110 §5):
714 /// - Header name: lowercase ASCII token characters only (no spaces, no
715 /// control characters); e.g. `"authorization"`.
716 /// - Header value: visible ASCII characters (0x21–0x7E) and horizontal tab
717 /// (0x09) only; no other control characters.
718 ///
719 /// Implementations that violate this contract will cause
720 /// [`ClientError::InvalidArgument`] in `connect_ws` (`ws/mod.rs`), which
721 /// parses the value into a typed [`http::HeaderValue`]. On HTTP code paths
722 /// reqwest returns the error from `.send()` as a builder error rather than
723 /// an `InvalidArgument` — the error type differs between the two paths.
724 /// Test all custom `AuthProvider` implementations against both HTTP and
725 /// WebSocket call paths.
726 fn auth_header(&self) -> Option<AuthHeader<'_>>;
727}
728
729/// No authentication: no `Authorization` header.
730#[derive(Debug, Clone)]
731pub struct NoneAuth;
732
733impl AuthProvider for NoneAuth {
734 fn auth_header(&self) -> Option<AuthHeader<'_>> {
735 None
736 }
737}
738
739/// Bearer-token authentication (`Authorization: Bearer <token>`).
740///
741/// # Drop-path zeroization
742///
743/// The cached header string is wrapped in [`zeroize::Zeroizing`] so its
744/// buffer is overwritten with zeros before being returned to the allocator
745/// on drop. This defends against credential recovery from process core
746/// dumps, `/proc/PID/mem` inspection, and post-drop heap re-use across
747/// tenants in long-running multi-user JMAP clients (bd:JMAP-6r7c.59).
748/// Callers that hold the original token string SHOULD also store it in a
749/// `Zeroizing<String>` or equivalent — the zeroization here is bounded by
750/// what this type owns.
751///
752/// # Do not move validation from construction to per-request (bd:JMAP-6r7c.18)
753///
754/// A future contributor may suggest "just store the token field and call
755/// `HeaderValue::from_str` in `auth_header` on each request". This is the
756/// wrong simplification for both `BearerAuth` and `BasicAuth`. Five
757/// reasons:
758///
759/// 1. **Fail-fast at auth setup.** Validation at construction means
760/// invalid credentials surface at `BearerAuth::new()` return value —
761/// the caller fails near the bug source (their auth-setup code).
762/// Per-request validation pushes failures to the first
763/// `JmapClient::call()` or `fetch_session()`, far from the bug and
764/// harder to debug.
765/// 2. **Hot-path performance.** `auth_header` is called on every HTTP
766/// request and every WebSocket connection. `HeaderValue::from_str`
767/// walks the string and rejects on the first non-VCHAR/SP/HTAB
768/// octet (RFC 7230 §3.2.6) — non-trivial work for a hot path.
769/// Pre-validation moves that work out of every request.
770/// 3. **Infallible accessor signature.** Pre-validation lets
771/// `auth_header` keep the signature
772/// `fn auth_header(&self) -> Option<AuthHeader<'_>>` — infallible.
773/// Per-request validation would require
774/// `Result<Option<(&str, &str)>, ClientError>`, propagating an
775/// extra error layer through every call site (HTTP `call`, blob
776/// upload/download, WebSocket connect, session fetch).
777/// 4. **Borrow simplicity.** Storing as `Zeroizing<String>` lets
778/// `auth_header` return borrows directly without ownership tricks
779/// (`Cow`, `Box<str>`, etc.). The borrow checker stays simple, the
780/// call sites stay readable.
781/// 5. **Debug-redaction tripwire compatibility.** The manual `Debug`
782/// impls on `BearerAuth` and `BasicAuth` (auth.rs further below)
783/// target the stored field. A future contributor adding
784/// `#[derive(Debug)]` instead of the manual impl is caught
785/// immediately by the existing canary tests
786/// `bearer_auth_debug_does_not_leak_token` and
787/// `basic_auth_debug_does_not_leak_credentials` (bd:JMAP-sc1b.79).
788/// Moving to per-request validation requires the field shape to
789/// change in a way that re-derives the canary contract — extra
790/// surface area for review without buying anything.
791///
792/// This is the same pre-validate-at-construction pattern `rustls` and
793/// `reqwest` use for their own type designs. It is not over-engineering.
794#[derive(Clone)]
795pub struct BearerAuth {
796 // Pre-validated at construction and stored as String: avoids per-request
797 // allocation and ensures invalid credentials fail at construction, not at
798 // the first request. Storing as String eliminates the need for a fallible
799 // to_str() call in auth_header().
800 //
801 // Wrapped in Zeroizing<String> so the buffer is overwritten on drop
802 // (see type-level doc). Zeroizing<String> Derefs to String, which Derefs
803 // to &str, so `&self.header_string` in auth_header() coerces cleanly.
804 header_string: Zeroizing<String>,
805}
806
807impl BearerAuth {
808 /// Construct a `BearerAuth` from a Bearer token string.
809 ///
810 /// # Errors
811 ///
812 /// - [`ClientError::InvalidArgument`] if `token` is empty or contains
813 /// whitespace (RFC 6750 §2.1 bearer tokens must not contain whitespace).
814 /// - [`ClientError::InvalidHeaderValue`] if `token` contains characters that
815 /// are not valid in an HTTP header value (non-visible-ASCII octets).
816 pub fn new(token: &str) -> Result<Self, ClientError> {
817 if token.is_empty() || token.chars().any(|c| c.is_ascii_whitespace()) {
818 return Err(ClientError::InvalidArgument(
819 "BearerAuth token may not be empty or contain whitespace (RFC 6750 §2.1)".into(),
820 ));
821 }
822 let header_string = Zeroizing::new(format!("Bearer {token}"));
823 // Validate the header value is legal (no control characters, etc.).
824 HeaderValue::from_str(&header_string).map_err(ClientError::from_invalid_header)?;
825 Ok(Self { header_string })
826 }
827}
828
829impl std::fmt::Debug for BearerAuth {
830 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
831 f.debug_struct("BearerAuth")
832 .field("token", &"[REDACTED]")
833 .finish()
834 }
835}
836
837impl AuthProvider for BearerAuth {
838 fn auth_header(&self) -> Option<AuthHeader<'_>> {
839 Some(AuthHeader::new("authorization", &self.header_string))
840 }
841}
842
843/// HTTP Basic authentication (`Authorization: Basic <base64(username:password)>`).
844///
845/// Credentials are encoded per RFC 7617: `base64(username ":" password)`.
846///
847/// # Drop-path zeroization
848///
849/// The cached header string is wrapped in [`zeroize::Zeroizing`] so its
850/// buffer is overwritten with zeros before being returned to the allocator
851/// on drop. The intermediate `username:password` plaintext built during
852/// base64 encoding is ALSO zeroized — that buffer is the most
853/// attack-relevant artifact because it carries the raw password rather
854/// than the base64-encoded form. See [`BearerAuth`] for the threat model.
855/// (bd:JMAP-6r7c.59)
856#[derive(Clone)]
857pub struct BasicAuth {
858 // Pre-validated at construction and stored as String: avoids per-request
859 // allocation and ensures invalid credentials fail at construction, not at
860 // the first request. Storing as String eliminates the need for a fallible
861 // to_str() call in auth_header().
862 //
863 // Wrapped in Zeroizing<String> so the buffer is overwritten on drop
864 // (see type-level doc).
865 header_string: Zeroizing<String>,
866}
867
868impl BasicAuth {
869 /// Construct a `BasicAuth` from a username and password.
870 ///
871 /// # Errors
872 ///
873 /// - [`ClientError::InvalidArgument`] if `username` contains a colon (`:`),
874 /// which is forbidden by RFC 7617 §2.
875 /// - [`ClientError::InvalidHeaderValue`] if the resulting header value
876 /// contains characters that are not valid in an HTTP header value.
877 pub fn new(username: &str, password: &str) -> Result<Self, ClientError> {
878 if username.contains(':') {
879 return Err(ClientError::InvalidArgument(
880 "BasicAuth username may not contain ':'".into(),
881 ));
882 }
883 // The intermediate plaintext buffer is the most sensitive artifact
884 // — it carries the raw password, whereas the base64-encoded form is
885 // one step further from a credential a replay attacker can use.
886 // Wrap it in Zeroizing so the buffer is overwritten when the local
887 // goes out of scope at the end of this function.
888 let plaintext = Zeroizing::new(format!("{username}:{password}"));
889 let encoded = BASE64_STANDARD.encode(plaintext.as_bytes());
890 let header_string = Zeroizing::new(format!("Basic {encoded}"));
891 // Validate the header value is legal (base64 is always printable ASCII,
892 // but keep the check for correctness).
893 HeaderValue::from_str(&header_string).map_err(ClientError::from_invalid_header)?;
894 Ok(Self { header_string })
895 }
896}
897
898impl std::fmt::Debug for BasicAuth {
899 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900 f.debug_struct("BasicAuth")
901 .field("credentials", &"[REDACTED]")
902 .finish()
903 }
904}
905
906impl AuthProvider for BasicAuth {
907 fn auth_header(&self) -> Option<AuthHeader<'_>> {
908 Some(AuthHeader::new("authorization", &self.header_string))
909 }
910}
911
912// ---------------------------------------------------------------------------
913// Internal helper
914// ---------------------------------------------------------------------------
915
916/// Build a standard reqwest client with a 10-second connect timeout.
917fn default_reqwest_client() -> Result<reqwest::Client, ClientError> {
918 reqwest::ClientBuilder::new()
919 .connect_timeout(std::time::Duration::from_secs(10))
920 .build()
921 .map_err(ClientError::from_reqwest)
922}
923
924// ---------------------------------------------------------------------------
925// Blanket impl for Box<dyn TransportConfig>
926// ---------------------------------------------------------------------------
927//
928// Allows `Box<dyn TransportConfig>` to satisfy `impl TransportConfig`, so
929// factory functions (e.g. `Config::transport`) can return a boxed
930// trait object and pass it directly to `JmapClient::new`.
931//
932// There is intentionally NO `Arc<dyn TransportConfig>` blanket here.
933// TransportConfig is consumed once at `JmapClient::new` to build the
934// reqwest::Client. The resulting Client is stored; the TransportConfig itself
935// is not kept. Arc would imply shared ownership of something that is not
936// shared after construction.
937//
938// Maintenance cost: every method added to `TransportConfig` must be mirrored here.
939impl TransportConfig for Box<dyn TransportConfig> {
940 fn build_client(&self) -> Result<HttpClient, ClientError> {
941 (**self).build_client()
942 }
943}
944
945// ---------------------------------------------------------------------------
946// Blanket impl for Arc<dyn AuthProvider>
947// ---------------------------------------------------------------------------
948//
949// Allows `Arc<dyn AuthProvider>` to satisfy `impl AuthProvider`, enabling
950// `JmapClient` to be `Clone` (Arc is Clone).
951//
952// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
953impl AuthProvider for Arc<dyn AuthProvider> {
954 fn auth_header(&self) -> Option<AuthHeader<'_>> {
955 (**self).auth_header()
956 }
957}
958
959// ---------------------------------------------------------------------------
960// Blanket impl for Box<dyn AuthProvider>
961// ---------------------------------------------------------------------------
962//
963// Allows `Box<dyn AuthProvider>` to satisfy `impl AuthProvider + 'static`,
964// so factory functions (e.g. `Config::auth`) can return a boxed
965// trait object and pass it directly to `JmapClient::new`.
966//
967// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
968impl AuthProvider for Box<dyn AuthProvider> {
969 fn auth_header(&self) -> Option<AuthHeader<'_>> {
970 (**self).auth_header()
971 }
972}
973
974// ---------------------------------------------------------------------------
975// Tests
976// ---------------------------------------------------------------------------
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 /// Oracle: NoneAuth has no authentication header — verified by inspection of the spec.
983 #[test]
984 fn none_auth_no_header() {
985 assert!(NoneAuth.auth_header().is_none());
986 }
987
988 /// Oracle: BearerAuth constructs successfully with a valid ASCII token.
989 #[test]
990 fn bearer_auth_valid_constructs() {
991 assert!(BearerAuth::new("tok123").is_ok());
992 }
993
994 /// Oracle: BearerAuth header value is "Bearer " + the literal token string.
995 /// Verified by inspection: the Authorization header MUST be "Bearer tok123".
996 #[test]
997 fn bearer_auth_header() {
998 let auth = BearerAuth::new("tok123").expect("valid ASCII token must construct");
999 let header = auth.auth_header().expect("BearerAuth must return a header");
1000 assert_eq!(header.name(), "authorization");
1001 assert_eq!(header.expose_value(), "Bearer tok123");
1002 }
1003
1004 /// Oracle: BearerAuth constructor rejects tokens containing C0 control characters.
1005 /// HeaderValue::from_str rejects bytes 0x00-0x08 and 0x0A-0x1F (C0 controls,
1006 /// excluding HTAB 0x09) and 0x7F (DEL). '\x01' (SOH) is unconditionally invalid
1007 /// per RFC 7230 §3.2.6 and the http crate's header validation.
1008 #[test]
1009 fn bearer_auth_invalid_token_rejected() {
1010 let result = BearerAuth::new("tok\x01abc");
1011 assert!(
1012 result.is_err(),
1013 "token with C0 control character must be rejected by constructor"
1014 );
1015 }
1016
1017 /// Oracle: BasicAuth constructs successfully with valid username and password.
1018 #[test]
1019 fn basic_auth_valid_constructs() {
1020 assert!(BasicAuth::new("alice", "s3cr3t").is_ok());
1021 }
1022
1023 /// Oracle: BasicAuth constructor rejects usernames containing a colon (RFC 7617 §2).
1024 #[test]
1025 fn basic_auth_colon_in_username_rejected() {
1026 let result = BasicAuth::new("ali:ce", "s3cr3t");
1027 match result {
1028 Ok(_) => panic!("username with colon must be rejected by constructor"),
1029 Err(e) => {
1030 let err_msg = e.to_string();
1031 assert!(
1032 err_msg.contains("username"),
1033 "error message should mention 'username', got: {err_msg}"
1034 );
1035 }
1036 }
1037 }
1038
1039 /// Oracle: `echo -n "alice:s3cr3t" | base64` → `YWxpY2U6czNjcjN0` (RFC 7617 §2)
1040 /// This expected value is computed independently of the code under test.
1041 #[test]
1042 fn basic_auth_header() {
1043 let auth = BasicAuth::new("alice", "s3cr3t").expect("valid credentials must construct");
1044 let header = auth.auth_header().expect("BasicAuth must return a header");
1045 assert_eq!(header.name(), "authorization");
1046 assert_eq!(header.expose_value(), "Basic YWxpY2U6czNjcjN0");
1047 }
1048
1049 /// Oracle: CustomCaTransport injects no auth header — it is a transport only.
1050 #[test]
1051 fn custom_ca_transport_no_build_with_empty_cert() {
1052 // Empty DER bytes will fail Certificate::from_der; this test confirms
1053 // CustomCaTransport is constructible and that auth is separate.
1054 let transport = CustomCaTransport::new(vec![]);
1055 assert!(transport.build_client().is_err(), "empty DER must fail");
1056 }
1057
1058 // bd:JMAP-6r7c.65 — CustomTransportBuilder tests below.
1059
1060 /// Oracle: `parse_all_pem_certs` extracts every PEM-framed
1061 /// certificate in a multi-cert bundle and skips non-certificate
1062 /// content between frames. Hand-rolled fixture: two valid PEM
1063 /// frames concatenated with leading and trailing prose.
1064 #[test]
1065 fn parse_all_pem_certs_handles_multi_cert_bundle() {
1066 // Read the single-cert fixture and concatenate it with itself
1067 // so the bundle has two identical PEM frames. The parser MUST
1068 // emit two DER blobs even though the content is duplicate.
1069 let single = std::fs::read("tests/fixtures/tls/test-ca.pem")
1070 .expect("test-ca.pem fixture must exist");
1071 let mut bundle = b"# Comment that the parser must ignore\n".to_vec();
1072 bundle.extend_from_slice(&single);
1073 bundle.extend_from_slice(b"\n# Another comment between frames\n");
1074 bundle.extend_from_slice(&single);
1075 bundle.extend_from_slice(b"\n# Trailing comment\n");
1076
1077 let ders = parse_all_pem_certs(&bundle);
1078 assert_eq!(ders.len(), 2, "two-cert bundle must produce two DER blobs");
1079 assert!(!ders[0].is_empty(), "first DER must be non-empty");
1080 assert!(!ders[1].is_empty(), "second DER must be non-empty");
1081 // Deterministic same-input check: both decoded DERs must match
1082 // because the bundle contains the same cert twice.
1083 assert_eq!(
1084 ders[0], ders[1],
1085 "duplicate-input bundle must produce identical DER blobs"
1086 );
1087 }
1088
1089 /// Oracle: `CustomTransportBuilder::add_root_pem` accepts a
1090 /// fixture PEM and `build` produces a working `TransportConfig`.
1091 #[test]
1092 fn custom_transport_builder_single_pem_root_builds() {
1093 let pem = std::fs::read("tests/fixtures/tls/test-ca.pem")
1094 .expect("test-ca.pem fixture must exist");
1095 let transport = CustomTransportBuilder::new()
1096 .add_root_pem(&pem)
1097 .expect("PEM fixture must parse")
1098 .build();
1099 transport
1100 .build_client()
1101 .expect("single-root build_client must succeed");
1102 }
1103
1104 /// Oracle: `add_roots_pem_bundle` accepts a multi-PEM bundle.
1105 /// Two identical PEM frames concatenated produces a transport
1106 /// with two trust roots loaded.
1107 #[test]
1108 fn custom_transport_builder_multi_root_bundle_builds() {
1109 let single = std::fs::read("tests/fixtures/tls/test-ca.pem")
1110 .expect("test-ca.pem fixture must exist");
1111 let mut bundle = single.clone();
1112 bundle.extend_from_slice(b"\n");
1113 bundle.extend_from_slice(&single);
1114
1115 let transport = CustomTransportBuilder::new()
1116 .add_roots_pem_bundle(&bundle)
1117 .expect("two-cert PEM bundle must parse")
1118 .build();
1119 transport
1120 .build_client()
1121 .expect("multi-root build_client must succeed");
1122 }
1123
1124 /// Oracle: `add_root_pem` rejects input that is not a recognisable
1125 /// PEM-framed certificate. Returns ClientError::InvalidArgument
1126 /// rather than ClientError::Http — the parse error is at the
1127 /// PEM-decode boundary, not at reqwest's TLS layer.
1128 #[test]
1129 fn custom_transport_builder_add_root_pem_invalid_returns_invalid_argument() {
1130 let result = CustomTransportBuilder::new().add_root_pem(b"not a pem");
1131 match result {
1132 Ok(_) => panic!("garbage input must not produce a valid builder"),
1133 Err(ClientError::InvalidArgument(msg)) => {
1134 assert!(
1135 msg.contains("CustomTransportBuilder::add_root_pem"),
1136 "error must identify the offending method: {msg}"
1137 );
1138 }
1139 Err(other) => panic!("expected InvalidArgument, got {other:?}"),
1140 }
1141 }
1142
1143 /// Oracle: `add_roots_pem_bundle` on input with no PEM frames
1144 /// returns ClientError::InvalidArgument.
1145 #[test]
1146 fn custom_transport_builder_empty_bundle_returns_invalid_argument() {
1147 let result = CustomTransportBuilder::new().add_roots_pem_bundle(b"plain text");
1148 match result {
1149 Ok(_) => panic!("input without PEM frames must not produce a valid builder"),
1150 Err(ClientError::InvalidArgument(msg)) => {
1151 assert!(
1152 msg.contains("CustomTransportBuilder::add_roots_pem_bundle"),
1153 "error must identify the offending method: {msg}"
1154 );
1155 }
1156 Err(other) => panic!("expected InvalidArgument, got {other:?}"),
1157 }
1158 }
1159
1160 /// Oracle: `with_client_cert` configured with bogus PEM bytes
1161 /// surfaces the reqwest::Identity parse failure as
1162 /// ClientError::Http at build_client time. The builder itself
1163 /// does not validate the bytes (matches the DER posture of
1164 /// add_root_der + add_root_pem — full validation is deferred to
1165 /// build).
1166 #[test]
1167 fn custom_transport_builder_with_client_cert_invalid_fails_at_build() {
1168 // Valid root, invalid client identity.
1169 let pem = std::fs::read("tests/fixtures/tls/test-ca.pem")
1170 .expect("test-ca.pem fixture must exist");
1171 let transport = CustomTransportBuilder::new()
1172 .add_root_pem(&pem)
1173 .expect("PEM fixture must parse")
1174 .with_client_cert(b"not a cert PEM".to_vec(), b"not a key PEM".to_vec())
1175 .build();
1176 let result = transport.build_client();
1177 assert!(
1178 matches!(result, Err(ClientError::Http(_))),
1179 "invalid client identity must surface as ClientError::Http, got {result:?}"
1180 );
1181 }
1182
1183 /// Oracle: `BuilderTransport::Debug` opaquely describes the
1184 /// trust-root count and identity presence without leaking the
1185 /// raw cert bytes. Mirror the tripwire pattern from the
1186 /// CustomCaTransport Debug-redaction test (bd:JMAP-6r7c.13).
1187 #[test]
1188 fn builder_transport_debug_does_not_leak_cert_bytes() {
1189 let canary = vec![0xCA_u8; 32];
1190 let transport = CustomTransportBuilder::new().add_root_der(canary).build();
1191 let dbg = format!("{transport:?}");
1192 assert!(
1193 !dbg.contains("cacacacacacacacacacacacacacacacacacacacacacacacacacacacacacacaca"),
1194 "BuilderTransport Debug must not contain lowercase-hex DER bytes; got: {dbg}"
1195 );
1196 assert!(
1197 dbg.contains("1 root cert"),
1198 "BuilderTransport Debug must surface the root count for diagnostics; got: {dbg}"
1199 );
1200 }
1201
1202 /// Oracle: BearerAuth constructor rejects an empty token string.
1203 /// An empty token would produce "Bearer " which is a malformed credential.
1204 #[test]
1205 fn bearer_auth_empty_token_rejected() {
1206 let result = BearerAuth::new("");
1207 match result {
1208 Ok(_) => panic!("empty token must be rejected by constructor"),
1209 Err(ClientError::InvalidArgument(msg)) => {
1210 assert!(
1211 msg.contains("empty"),
1212 "error message should mention 'empty', got: {msg}"
1213 );
1214 }
1215 Err(e) => panic!("expected InvalidArgument, got: {e}"),
1216 }
1217 }
1218
1219 /// Oracle: BearerAuth constructor rejects a whitespace-only token string.
1220 /// A whitespace-only token would produce "Bearer " which is a malformed credential.
1221 #[test]
1222 fn bearer_auth_whitespace_only_token_rejected() {
1223 let result = BearerAuth::new(" ");
1224 match result {
1225 Ok(_) => panic!("whitespace-only token must be rejected by constructor"),
1226 Err(ClientError::InvalidArgument(msg)) => {
1227 assert!(
1228 msg.contains("whitespace"),
1229 "error message should mention 'whitespace', got: {msg}"
1230 );
1231 }
1232 Err(e) => panic!("expected InvalidArgument, got: {e}"),
1233 }
1234 }
1235
1236 /// Oracle: DefaultTransport uses the default reqwest::Client which always builds successfully.
1237 #[tokio::test]
1238 async fn default_transport_builds_client() {
1239 DefaultTransport
1240 .build_client()
1241 .expect("DefaultTransport::build_client must succeed");
1242 }
1243
1244 /// bd:JMAP-6r7c.36 — `TransportConfig::build_client` now returns
1245 /// `Result<HttpClient, _>`, not `Result<reqwest::Client, _>`. The
1246 /// wrapper exists so the trait's public signature does not name the
1247 /// underlying HTTP library, insulating extension clients and custom
1248 /// transport impls from a future transport swap.
1249 ///
1250 /// The compile-time witness below pins the new shape; if a future
1251 /// refactor accidentally widens the return type back to
1252 /// `reqwest::Client`, the explicit typed `let` binding here breaks
1253 /// the build.
1254 #[tokio::test]
1255 async fn build_client_returns_opaque_http_client() {
1256 let result: Result<HttpClient, ClientError> = DefaultTransport.build_client();
1257 let http = result.expect("DefaultTransport::build_client must succeed");
1258 // Debug output is opaque — no inner reqwest::Client representation.
1259 let dbg = format!("{http:?}");
1260 assert_eq!(
1261 dbg, "HttpClient",
1262 "HttpClient Debug must be opaque; the wrapper is the only public surface"
1263 );
1264 }
1265
1266 /// bd:JMAP-6r7c.36 — A custom `TransportConfig` impl constructs the
1267 /// returned `HttpClient` via `HttpClient::new(reqwest::Client)`. This
1268 /// pins the public construction path; if the constructor signature
1269 /// changes, the custom-impl pattern below fails to compile and
1270 /// downstream consumers will pick up the same migration signal at
1271 /// build time.
1272 #[test]
1273 fn http_client_new_is_callable_from_custom_transport_impl() {
1274 struct StubTransport;
1275 impl TransportConfig for StubTransport {
1276 fn build_client(&self) -> Result<HttpClient, ClientError> {
1277 let client = reqwest::ClientBuilder::new()
1278 .build()
1279 .map_err(ClientError::from_reqwest)?;
1280 Ok(HttpClient::new(client))
1281 }
1282 }
1283
1284 StubTransport
1285 .build_client()
1286 .expect("custom transport must build the opaque HttpClient");
1287 }
1288
1289 /// bd:JMAP-6r7c.62 — `AuthHeader`'s `Debug` impl MUST redact the value
1290 /// bytes to "[REDACTED]". This is the compile-time guard against a
1291 /// future `AuthProvider` impl that writes `tracing::trace!(?header,
1292 /// ...)`. The pre-bd:JMAP-6r7c.62 shape `Option<(&str, &str)>` would
1293 /// have rendered the value verbatim via `?`-formatter. The canary
1294 /// literal is the test's independent oracle, never derived from
1295 /// `AuthHeader`'s internal state.
1296 #[test]
1297 fn auth_header_debug_redacts_value() {
1298 const CANARY: &str = "CANARY-AUTH-VALUE-DO-NOT-LEAK-456";
1299 let header = AuthHeader::new("authorization", CANARY);
1300 let dbg = format!("{header:?}");
1301 assert!(
1302 !dbg.contains(CANARY),
1303 "AuthHeader Debug must not contain the canary value: {dbg}"
1304 );
1305 assert!(
1306 dbg.contains("[REDACTED]"),
1307 "AuthHeader Debug must render '[REDACTED]' for the value field: {dbg}"
1308 );
1309 // The name is non-sensitive and may surface to aid diagnostics.
1310 assert!(
1311 dbg.contains("authorization"),
1312 "AuthHeader Debug should include the header name for diagnostic value: {dbg}"
1313 );
1314 }
1315
1316 /// bd:JMAP-6r7c.62 — `expose_value` is the only path to the credential
1317 /// bytes, so the call-site name (`expose_value`) is the visible
1318 /// signal in code review. This test pins the accessor name + return
1319 /// value, so a future rename of the accessor breaks the test loudly.
1320 #[test]
1321 fn auth_header_expose_value_returns_credential_bytes() {
1322 const VALUE: &str = "Bearer some-token-123";
1323 let header = AuthHeader::new("authorization", VALUE);
1324 assert_eq!(header.name(), "authorization");
1325 assert_eq!(header.expose_value(), VALUE);
1326 }
1327
1328 /// Oracle: BearerAuth's Debug impl never reveals the underlying token.
1329 ///
1330 /// Tripwire against a future refactor that adds `#[derive(Debug)]` to
1331 /// BearerAuth (clearing the manual redacting impl), or that prints the
1332 /// inner `header_string`. The canary literal is the independent
1333 /// oracle — it is under the test's control, never derived from
1334 /// BearerAuth's internal state.
1335 #[test]
1336 fn bearer_auth_debug_does_not_leak_token() {
1337 const CANARY: &str = "CANARY-TOKEN-DO-NOT-LEAK-123";
1338 let auth = BearerAuth::new(CANARY).expect("valid ASCII token must construct");
1339 let dbg = format!("{auth:?}");
1340 assert!(
1341 !dbg.contains(CANARY),
1342 "BearerAuth Debug must not contain the raw token; got: {dbg}"
1343 );
1344 }
1345
1346 /// Oracle: BasicAuth's Debug impl never reveals the underlying credentials.
1347 ///
1348 /// Same tripwire shape as `bearer_auth_debug_does_not_leak_token`.
1349 /// The canary username and password are independent literals; the
1350 /// assertion verifies neither, nor the base64 encoding of their
1351 /// concatenation, appears in the Debug output.
1352 #[test]
1353 fn basic_auth_debug_does_not_leak_credentials() {
1354 const CANARY_USER: &str = "CANARY-USER-DO-NOT-LEAK";
1355 const CANARY_PASS: &str = "CANARY-PASS-DO-NOT-LEAK";
1356 let auth =
1357 BasicAuth::new(CANARY_USER, CANARY_PASS).expect("valid credentials must construct");
1358 let dbg = format!("{auth:?}");
1359 assert!(
1360 !dbg.contains(CANARY_USER),
1361 "BasicAuth Debug must not contain the raw username; got: {dbg}"
1362 );
1363 assert!(
1364 !dbg.contains(CANARY_PASS),
1365 "BasicAuth Debug must not contain the raw password; got: {dbg}"
1366 );
1367 // Also catch a regression that prints the pre-validated header_string,
1368 // which would surface the base64-encoded credentials.
1369 let base64_pair = BASE64_STANDARD.encode(format!("{CANARY_USER}:{CANARY_PASS}"));
1370 assert!(
1371 !dbg.contains(&base64_pair),
1372 "BasicAuth Debug must not contain the base64-encoded credentials; got: {dbg}"
1373 );
1374 }
1375
1376 /// Oracle: `CustomCaTransport`'s Debug impl never prints the raw DER
1377 /// certificate bytes (bd:JMAP-6r7c.13).
1378 ///
1379 /// CA DER bytes are not a credential, but they are deployment-identifying
1380 /// material — Subject DN, public key, signing algorithm, X.509
1381 /// extensions. Surfacing them in `tracing` output reveals which private-
1382 /// CA-using customer the client is configured for. The canary byte
1383 /// sequence is an unmistakable repeating literal `0xCA` 32 times — the
1384 /// test asserts neither the lower-hex nor the upper-hex nor the
1385 /// Rust-debug `[202, 202, ...]` rendering of those bytes appears in the
1386 /// Debug output. Same tripwire shape as the BearerAuth and BasicAuth
1387 /// tests above.
1388 #[test]
1389 fn custom_ca_transport_debug_does_not_leak_der_bytes() {
1390 // 32 copies of 0xCA — an unmistakable sentinel byte. No conformant
1391 // DER encoder produces a run like this, so any leakage path
1392 // surfaces it intact.
1393 let canary_der = vec![0xCA_u8; 32];
1394 let transport = CustomCaTransport::new(canary_der);
1395 let dbg = format!("{transport:?}");
1396 // Lowercase hex rendering of the canary.
1397 assert!(
1398 !dbg.contains("cacacacacacacacacacacacacacacacacacacacacacacacacacacacacacacaca"),
1399 "CustomCaTransport Debug must not contain lowercase-hex DER bytes; got: {dbg}"
1400 );
1401 // Uppercase hex rendering — in case a future fmt::Debug uses {:X}.
1402 assert!(
1403 !dbg.contains("CACACACACACACACACACACACACACACACACACACACACACACACACACACACACACACACA"),
1404 "CustomCaTransport Debug must not contain uppercase-hex DER bytes; got: {dbg}"
1405 );
1406 // Rust `[u8]` default Debug rendering — `[202, 202, ...]`. A
1407 // derive(Debug) regression on the field would emit this shape.
1408 assert!(
1409 !dbg.contains("202, 202, 202"),
1410 "CustomCaTransport Debug must not contain decimal-byte DER bytes; got: {dbg}"
1411 );
1412 // Positive assertion: the redacted form mentions the length, so a
1413 // reader of `tracing` output still knows the field is non-empty.
1414 assert!(
1415 dbg.contains("32 bytes"),
1416 "CustomCaTransport Debug should record the DER byte length; got: {dbg}"
1417 );
1418 }
1419
1420 // bd:JMAP-6r7c.37 — PEM constructor tests.
1421 //
1422 // Oracle: a hand-generated self-signed certificate produced by
1423 // `openssl req -x509 -newkey rsa:2048 -nodes -days 36500
1424 // -subj "/CN=JMAP-6r7c.37 test CA"`. The PEM and DER forms of the
1425 // same certificate are committed under tests/fixtures/tls/. The PEM
1426 // → DER conversion ran via `openssl x509 -outform DER`. Both files
1427 // are oracles independent of the code under test: the PEM was not
1428 // produced by `parse_first_pem_cert` and the DER was not produced
1429 // by reqwest. The test asserts the round-trip matches OpenSSL's
1430 // canonical bytes.
1431 const TEST_CA_PEM: &[u8] = include_bytes!("../tests/fixtures/tls/test-ca.pem");
1432 const TEST_CA_DER: &[u8] = include_bytes!("../tests/fixtures/tls/test-ca.der");
1433
1434 #[test]
1435 fn from_pem_bytes_extracts_der_matching_openssl_oracle() {
1436 let transport = CustomCaTransport::from_pem_bytes(TEST_CA_PEM)
1437 .expect("test-ca.pem fixture must parse as a valid CA");
1438 assert_eq!(
1439 transport.der_cert.as_slice(),
1440 TEST_CA_DER,
1441 "PEM-decoded DER must match the openssl-produced reference DER fixture"
1442 );
1443 }
1444
1445 #[test]
1446 fn from_pem_bytes_rejects_empty_input() {
1447 let err = CustomCaTransport::from_pem_bytes(b"").expect_err("empty input must be rejected");
1448 assert!(
1449 matches!(err, ClientError::InvalidArgument(_)),
1450 "empty input must surface as InvalidArgument; got {err:?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn from_pem_bytes_rejects_input_with_no_pem_framing() {
1456 let err = CustomCaTransport::from_pem_bytes(b"this is not a PEM file")
1457 .expect_err("non-PEM input must be rejected");
1458 assert!(
1459 matches!(err, ClientError::InvalidArgument(_)),
1460 "non-PEM input must surface as InvalidArgument; got {err:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn from_pem_bytes_rejects_pem_with_invalid_base64() {
1466 // PEM framing with junk inside — should fail base64 decode.
1467 let bad =
1468 b"-----BEGIN CERTIFICATE-----\nNOT VALID BASE64 @#$%\n-----END CERTIFICATE-----\n";
1469 let err =
1470 CustomCaTransport::from_pem_bytes(bad).expect_err("invalid base64 must be rejected");
1471 assert!(
1472 matches!(err, ClientError::InvalidArgument(_)),
1473 "invalid-base64 PEM must surface as InvalidArgument; got {err:?}"
1474 );
1475 }
1476
1477 #[test]
1478 fn from_pem_bytes_accepts_garbage_der_payload_deferring_validation_to_build() {
1479 use base64::Engine as _;
1480 // Properly-PEM-framed garbage bytes: PEM framing is correct,
1481 // base64 decodes OK, but the inner bytes are not a DER
1482 // certificate. By design (matching CustomCaTransport::new's
1483 // contract), from_pem_bytes accepts these bytes — DER validity
1484 // is checked at build_client() time, where it surfaces as
1485 // ClientError::Http through reqwest. This test documents that
1486 // contract.
1487 let garbage_der = [0u8; 16];
1488 let body = base64::engine::general_purpose::STANDARD.encode(garbage_der);
1489 let pem = format!("-----BEGIN CERTIFICATE-----\n{body}\n-----END CERTIFICATE-----\n");
1490 let transport = CustomCaTransport::from_pem_bytes(pem.as_bytes())
1491 .expect("PEM framing OK + base64 OK = constructor accepts");
1492 assert_eq!(
1493 transport.der_cert.as_slice(),
1494 &garbage_der,
1495 "PEM helper must extract the exact base64-decoded bytes"
1496 );
1497 // build_client() is where rustls/native-tls actually parses the
1498 // DER and would reject the garbage. Exercising that here would
1499 // require constructing a real ClientBuilder, which is covered
1500 // by the broader test suite's integration tests.
1501 }
1502
1503 // Note: a dyn-AuthProvider Debug test (bead JMAP-sc1b.79 item #4) is
1504 // intentionally omitted. The AuthProvider trait does not have
1505 // `std::fmt::Debug` as a supertrait, so `Box<dyn AuthProvider>` is
1506 // not `Debug`-formattable. Adding `Debug` to the trait bound would
1507 // be a foundation-crate public API change far outside the scope of
1508 // a regression-test bead. The concrete-type tests above already
1509 // catch the hygiene contract for every shipped AuthProvider
1510 // implementation; the only way a new AuthProvider leaks credentials
1511 // via Debug is if its own concrete impl does so, and that is
1512 // caught by the new-impl reviewer (cookie-cutter rule).
1513}