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.
30pub trait TransportConfig: Send + Sync {
31 /// Build the [`reqwest::Client`] for this transport configuration.
32 fn build_client(&self) -> Result<reqwest::Client, ClientError>;
33}
34
35/// Standard reqwest client with a 10-second connect timeout; no custom TLS.
36///
37/// Use for servers with publicly-trusted certificates. Pair with any
38/// [`AuthProvider`] for credential injection.
39#[derive(Debug, Clone)]
40pub struct DefaultTransport;
41
42impl TransportConfig for DefaultTransport {
43 fn build_client(&self) -> Result<reqwest::Client, ClientError> {
44 default_reqwest_client()
45 }
46}
47
48/// Custom CA trust root (DER-encoded). No `Authorization` header is injected.
49///
50/// Use when the server presents a certificate signed by a private CA.
51/// Pair with any [`AuthProvider`] for credential injection — including
52/// [`BearerAuth`] or [`BasicAuth`] if the server also requires credentials.
53#[derive(Debug, Clone)]
54pub struct CustomCaTransport {
55 der_cert: Vec<u8>,
56}
57
58impl CustomCaTransport {
59 /// Construct a `CustomCaTransport` from a DER-encoded CA certificate.
60 pub fn new(der_cert: Vec<u8>) -> Self {
61 Self { der_cert }
62 }
63}
64
65impl TransportConfig for CustomCaTransport {
66 fn build_client(&self) -> Result<reqwest::Client, ClientError> {
67 let cert = reqwest::Certificate::from_der(&self.der_cert)?;
68 let client = reqwest::ClientBuilder::new()
69 .connect_timeout(std::time::Duration::from_secs(10))
70 .add_root_certificate(cert)
71 .build()?;
72 Ok(client)
73 }
74}
75
76// ---------------------------------------------------------------------------
77// AuthProvider — per-request credential injection (Authorization header)
78// ---------------------------------------------------------------------------
79
80/// Injects per-request authentication credentials.
81///
82/// Separate from transport configuration ([`TransportConfig`]) so any
83/// credential scheme can be paired with any transport.
84///
85/// **Implement this trait** when you need a custom `Authorization` header or
86/// other per-request credential scheme. For custom TLS/trust-root logic
87/// implement [`TransportConfig`] instead. [`NoneAuth`], [`BearerAuth`], and
88/// [`BasicAuth`] cover the common cases.
89///
90/// Implementations **must not** log the return value of [`auth_header`];
91/// it contains credentials.
92///
93/// [`auth_header`]: AuthProvider::auth_header
94pub trait AuthProvider: Send + Sync {
95 /// Return an optional `(header-name, header-value)` pair to attach to
96 /// every request.
97 ///
98 /// Returns `None` when no `Authorization` header is required.
99 ///
100 /// Both strings borrow from `self` and must live at least as long as the
101 /// `&self` borrow. Implementations that pre-compute the values at
102 /// construction time can return `&self.field` directly, avoiding any
103 /// per-request allocation.
104 ///
105 /// # Implementation contract
106 ///
107 /// The returned strings **must** be valid HTTP field values (RFC 9110 §5):
108 /// - Header name: lowercase ASCII token characters only (no spaces, no
109 /// control characters); e.g. `"authorization"`.
110 /// - Header value: visible ASCII characters (0x21–0x7E) and horizontal tab
111 /// (0x09) only; no other control characters.
112 ///
113 /// Implementations that violate this contract will cause
114 /// [`ClientError::InvalidArgument`] in `connect_ws` (`ws/mod.rs`), which
115 /// parses the value into a typed [`http::HeaderValue`]. On HTTP code paths
116 /// reqwest returns the error from `.send()` as a builder error rather than
117 /// an `InvalidArgument` — the error type differs between the two paths.
118 /// Test all custom `AuthProvider` implementations against both HTTP and
119 /// WebSocket call paths.
120 fn auth_header(&self) -> Option<(&str, &str)>;
121}
122
123/// No authentication: no `Authorization` header.
124#[derive(Debug, Clone)]
125pub struct NoneAuth;
126
127impl AuthProvider for NoneAuth {
128 fn auth_header(&self) -> Option<(&str, &str)> {
129 None
130 }
131}
132
133/// Bearer-token authentication (`Authorization: Bearer <token>`).
134#[derive(Clone)]
135pub struct BearerAuth {
136 // Pre-validated at construction and stored as String: avoids per-request
137 // allocation and ensures invalid credentials fail at construction, not at
138 // the first request. Storing as String eliminates the need for a fallible
139 // to_str() call in auth_header().
140 header_string: String,
141}
142
143impl BearerAuth {
144 /// Construct a `BearerAuth` from a Bearer token string.
145 ///
146 /// # Errors
147 ///
148 /// - [`ClientError::InvalidArgument`] if `token` is empty or contains
149 /// whitespace (RFC 6750 §2.1 bearer tokens must not contain whitespace).
150 /// - [`ClientError::InvalidHeaderValue`] if `token` contains characters that
151 /// are not valid in an HTTP header value (non-visible-ASCII octets).
152 pub fn new(token: &str) -> Result<Self, ClientError> {
153 if token.is_empty() || token.chars().any(|c| c.is_ascii_whitespace()) {
154 return Err(ClientError::InvalidArgument(
155 "BearerAuth token may not be empty or contain whitespace (RFC 6750 §2.1)".into(),
156 ));
157 }
158 let header_string = format!("Bearer {token}");
159 // Validate the header value is legal (no control characters, etc.).
160 HeaderValue::from_str(&header_string)?;
161 Ok(Self { header_string })
162 }
163}
164
165impl std::fmt::Debug for BearerAuth {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("BearerAuth")
168 .field("token", &"[REDACTED]")
169 .finish()
170 }
171}
172
173impl AuthProvider for BearerAuth {
174 fn auth_header(&self) -> Option<(&str, &str)> {
175 Some(("authorization", &self.header_string))
176 }
177}
178
179/// HTTP Basic authentication (`Authorization: Basic <base64(username:password)>`).
180///
181/// Credentials are encoded per RFC 7617: `base64(username ":" password)`.
182#[derive(Clone)]
183pub struct BasicAuth {
184 // Pre-validated at construction and stored as String: avoids per-request
185 // allocation and ensures invalid credentials fail at construction, not at
186 // the first request. Storing as String eliminates the need for a fallible
187 // to_str() call in auth_header().
188 header_string: String,
189}
190
191impl BasicAuth {
192 /// Construct a `BasicAuth` from a username and password.
193 ///
194 /// # Errors
195 ///
196 /// - [`ClientError::InvalidArgument`] if `username` contains a colon (`:`),
197 /// which is forbidden by RFC 7617 §2.
198 /// - [`ClientError::InvalidHeaderValue`] if the resulting header value
199 /// contains characters that are not valid in an HTTP header value.
200 pub fn new(username: &str, password: &str) -> Result<Self, ClientError> {
201 if username.contains(':') {
202 return Err(ClientError::InvalidArgument(
203 "BasicAuth username may not contain ':'".into(),
204 ));
205 }
206 let encoded = BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes());
207 let header_string = format!("Basic {encoded}");
208 // Validate the header value is legal (base64 is always printable ASCII,
209 // but keep the check for correctness).
210 HeaderValue::from_str(&header_string)?;
211 Ok(Self { header_string })
212 }
213}
214
215impl std::fmt::Debug for BasicAuth {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 f.debug_struct("BasicAuth")
218 .field("credentials", &"[REDACTED]")
219 .finish()
220 }
221}
222
223impl AuthProvider for BasicAuth {
224 fn auth_header(&self) -> Option<(&str, &str)> {
225 Some(("authorization", &self.header_string))
226 }
227}
228
229// ---------------------------------------------------------------------------
230// Internal helper
231// ---------------------------------------------------------------------------
232
233/// Build a standard reqwest client with a 10-second connect timeout.
234fn default_reqwest_client() -> Result<reqwest::Client, ClientError> {
235 reqwest::ClientBuilder::new()
236 .connect_timeout(std::time::Duration::from_secs(10))
237 .build()
238 .map_err(ClientError::Http)
239}
240
241// ---------------------------------------------------------------------------
242// Blanket impl for Box<dyn TransportConfig>
243// ---------------------------------------------------------------------------
244//
245// Allows `Box<dyn TransportConfig>` to satisfy `impl TransportConfig`, so
246// factory functions (e.g. `Config::transport`) can return a boxed
247// trait object and pass it directly to `JmapClient::new`.
248//
249// There is intentionally NO `Arc<dyn TransportConfig>` blanket here.
250// TransportConfig is consumed once at `JmapClient::new` to build the
251// reqwest::Client. The resulting Client is stored; the TransportConfig itself
252// is not kept. Arc would imply shared ownership of something that is not
253// shared after construction.
254//
255// Maintenance cost: every method added to `TransportConfig` must be mirrored here.
256impl TransportConfig for Box<dyn TransportConfig> {
257 fn build_client(&self) -> Result<reqwest::Client, ClientError> {
258 (**self).build_client()
259 }
260}
261
262// ---------------------------------------------------------------------------
263// Blanket impl for Arc<dyn AuthProvider>
264// ---------------------------------------------------------------------------
265//
266// Allows `Arc<dyn AuthProvider>` to satisfy `impl AuthProvider`, enabling
267// `JmapClient` to be `Clone` (Arc is Clone).
268//
269// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
270impl AuthProvider for Arc<dyn AuthProvider> {
271 fn auth_header(&self) -> Option<(&str, &str)> {
272 (**self).auth_header()
273 }
274}
275
276// ---------------------------------------------------------------------------
277// Blanket impl for Box<dyn AuthProvider>
278// ---------------------------------------------------------------------------
279//
280// Allows `Box<dyn AuthProvider>` to satisfy `impl AuthProvider + 'static`,
281// so factory functions (e.g. `Config::auth`) can return a boxed
282// trait object and pass it directly to `JmapClient::new`.
283//
284// Maintenance cost: every method added to `AuthProvider` must be mirrored here.
285impl AuthProvider for Box<dyn AuthProvider> {
286 fn auth_header(&self) -> Option<(&str, &str)> {
287 (**self).auth_header()
288 }
289}
290
291// ---------------------------------------------------------------------------
292// Tests
293// ---------------------------------------------------------------------------
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 /// Oracle: NoneAuth has no authentication header — verified by inspection of the spec.
300 #[test]
301 fn none_auth_no_header() {
302 assert!(NoneAuth.auth_header().is_none());
303 }
304
305 /// Oracle: BearerAuth constructs successfully with a valid ASCII token.
306 #[test]
307 fn bearer_auth_valid_constructs() {
308 assert!(BearerAuth::new("tok123").is_ok());
309 }
310
311 /// Oracle: BearerAuth header value is "Bearer " + the literal token string.
312 /// Verified by inspection: the Authorization header MUST be "Bearer tok123".
313 #[test]
314 fn bearer_auth_header() {
315 let auth = BearerAuth::new("tok123").expect("valid ASCII token must construct");
316 let (name, value) = auth.auth_header().expect("BearerAuth must return a header");
317 assert_eq!(name, "authorization");
318 assert_eq!(value, "Bearer tok123");
319 }
320
321 /// Oracle: BearerAuth constructor rejects tokens containing C0 control characters.
322 /// HeaderValue::from_str rejects bytes 0x00-0x08 and 0x0A-0x1F (C0 controls,
323 /// excluding HTAB 0x09) and 0x7F (DEL). '\x01' (SOH) is unconditionally invalid
324 /// per RFC 7230 §3.2.6 and the http crate's header validation.
325 #[test]
326 fn bearer_auth_invalid_token_rejected() {
327 let result = BearerAuth::new("tok\x01abc");
328 assert!(
329 result.is_err(),
330 "token with C0 control character must be rejected by constructor"
331 );
332 }
333
334 /// Oracle: BasicAuth constructs successfully with valid username and password.
335 #[test]
336 fn basic_auth_valid_constructs() {
337 assert!(BasicAuth::new("alice", "s3cr3t").is_ok());
338 }
339
340 /// Oracle: BasicAuth constructor rejects usernames containing a colon (RFC 7617 §2).
341 #[test]
342 fn basic_auth_colon_in_username_rejected() {
343 let result = BasicAuth::new("ali:ce", "s3cr3t");
344 match result {
345 Ok(_) => panic!("username with colon must be rejected by constructor"),
346 Err(e) => {
347 let err_msg = e.to_string();
348 assert!(
349 err_msg.contains("username"),
350 "error message should mention 'username', got: {err_msg}"
351 );
352 }
353 }
354 }
355
356 /// Oracle: `echo -n "alice:s3cr3t" | base64` → `YWxpY2U6czNjcjN0` (RFC 7617 §2)
357 /// This expected value is computed independently of the code under test.
358 #[test]
359 fn basic_auth_header() {
360 let auth = BasicAuth::new("alice", "s3cr3t").expect("valid credentials must construct");
361 let (name, value) = auth.auth_header().expect("BasicAuth must return a header");
362 assert_eq!(name, "authorization");
363 assert_eq!(value, "Basic YWxpY2U6czNjcjN0");
364 }
365
366 /// Oracle: CustomCaTransport injects no auth header — it is a transport only.
367 #[test]
368 fn custom_ca_transport_no_build_with_empty_cert() {
369 // Empty DER bytes will fail Certificate::from_der; this test confirms
370 // CustomCaTransport is constructible and that auth is separate.
371 let transport = CustomCaTransport::new(vec![]);
372 assert!(transport.build_client().is_err(), "empty DER must fail");
373 }
374
375 /// Oracle: BearerAuth constructor rejects an empty token string.
376 /// An empty token would produce "Bearer " which is a malformed credential.
377 #[test]
378 fn bearer_auth_empty_token_rejected() {
379 let result = BearerAuth::new("");
380 match result {
381 Ok(_) => panic!("empty token must be rejected by constructor"),
382 Err(ClientError::InvalidArgument(msg)) => {
383 assert!(
384 msg.contains("empty"),
385 "error message should mention 'empty', got: {msg}"
386 );
387 }
388 Err(e) => panic!("expected InvalidArgument, got: {e}"),
389 }
390 }
391
392 /// Oracle: BearerAuth constructor rejects a whitespace-only token string.
393 /// A whitespace-only token would produce "Bearer " which is a malformed credential.
394 #[test]
395 fn bearer_auth_whitespace_only_token_rejected() {
396 let result = BearerAuth::new(" ");
397 match result {
398 Ok(_) => panic!("whitespace-only token must be rejected by constructor"),
399 Err(ClientError::InvalidArgument(msg)) => {
400 assert!(
401 msg.contains("whitespace"),
402 "error message should mention 'whitespace', got: {msg}"
403 );
404 }
405 Err(e) => panic!("expected InvalidArgument, got: {e}"),
406 }
407 }
408
409 /// Oracle: DefaultTransport uses the default reqwest::Client which always builds successfully.
410 #[tokio::test]
411 async fn default_transport_builds_client() {
412 DefaultTransport
413 .build_client()
414 .expect("DefaultTransport::build_client must succeed");
415 }
416}