clawspec_core/client/
auth.rs

1use std::fmt;
2
3use http::HeaderValue;
4use reqwest::header::{AUTHORIZATION, HeaderName};
5use serde::{Deserialize, Serialize};
6use zeroize::{Zeroize, ZeroizeOnDrop};
7
8#[cfg(feature = "oauth2")]
9use super::oauth2::SharedOAuth2Config;
10
11/// Errors that can occur during authentication processing.
12///
13/// This enum provides granular error information for authentication-related failures,
14/// allowing for more specific error handling and better debugging.
15#[derive(Debug, Clone, PartialEq, Eq, derive_more::Error, derive_more::Display)]
16pub enum AuthenticationError {
17    /// Bearer token contains invalid characters for HTTP headers.
18    #[display("Bearer token contains invalid characters: {message}")]
19    InvalidBearerToken {
20        /// Description of the invalid characters or format issue.
21        message: String,
22    },
23
24    /// Basic authentication username contains invalid characters.
25    #[display("Basic auth username contains invalid characters: {message}")]
26    InvalidUsername {
27        /// Description of the invalid characters or format issue.
28        message: String,
29    },
30
31    /// Basic authentication password contains invalid characters.
32    #[display("Basic auth password contains invalid characters: {message}")]
33    InvalidPassword {
34        /// Description of the invalid characters or format issue.
35        message: String,
36    },
37
38    /// API key header name is invalid.
39    #[display("Invalid API key header name '{header_name}': {message}")]
40    InvalidHeaderName {
41        /// The invalid header name that was provided.
42        header_name: String,
43        /// Description of why the header name is invalid.
44        message: String,
45    },
46
47    /// API key value contains invalid characters for HTTP headers.
48    #[display("API key contains invalid characters: {message}")]
49    InvalidApiKey {
50        /// Description of the invalid characters or format issue.
51        message: String,
52    },
53
54    /// Base64 encoding failed during Basic authentication processing.
55    #[display("Base64 encoding failed: {message}")]
56    EncodingError {
57        /// Description of the encoding failure.
58        message: String,
59    },
60
61    /// OAuth2 token is not yet acquired.
62    #[cfg(feature = "oauth2")]
63    #[display("OAuth2 token has not been acquired yet")]
64    OAuth2TokenNotAcquired,
65
66    /// OAuth2 error.
67    #[cfg(feature = "oauth2")]
68    #[display("OAuth2 error: {message}")]
69    OAuth2Error {
70        /// Description of the OAuth2 error.
71        message: String,
72    },
73}
74
75/// Secure wrapper for sensitive string data that automatically zeroes memory on drop.
76///
77/// This wrapper ensures that sensitive authentication data is securely cleared from memory
78/// when it's no longer needed, providing protection against memory inspection attacks.
79#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
80pub struct SecureString(String);
81
82impl SecureString {
83    /// Creates a new secure string from the provided value.
84    pub fn new(value: String) -> Self {
85        Self(value)
86    }
87
88    /// Returns a reference to the inner string value.
89    ///
90    /// # Security Note
91    /// The returned reference should not be stored for extended periods
92    /// to minimize exposure time of sensitive data.
93    pub fn as_str(&self) -> &str {
94        &self.0
95    }
96
97    /// Consumes the SecureString and returns the inner String.
98    ///
99    /// # Security Note
100    /// The caller becomes responsible for the secure handling of the returned String.
101    pub fn into_string(mut self) -> String {
102        // Clear the original before returning
103        std::mem::take(&mut self.0)
104    }
105
106    /// Checks if the secure string equals the given string slice.
107    ///
108    /// This method is provided for convenient testing and comparison without
109    /// exposing the internal string value.
110    pub fn equals_str(&self, other: &str) -> bool {
111        self.0 == other
112    }
113}
114
115impl fmt::Debug for SecureString {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.debug_struct("SecureString")
118            .field("value", &"[REDACTED]")
119            .finish()
120    }
121}
122
123impl fmt::Display for SecureString {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(f, "{}", Self::mask_sensitive(&self.0))
126    }
127}
128
129impl From<String> for SecureString {
130    fn from(value: String) -> Self {
131        Self::new(value)
132    }
133}
134
135impl From<&str> for SecureString {
136    fn from(value: &str) -> Self {
137        Self::new(value.to_string())
138    }
139}
140
141impl Serialize for SecureString {
142    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
143    where
144        S: serde::Serializer,
145    {
146        self.0.serialize(serializer)
147    }
148}
149
150impl<'de> Deserialize<'de> for SecureString {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: serde::Deserializer<'de>,
154    {
155        String::deserialize(deserializer).map(Self::new)
156    }
157}
158
159impl SecureString {
160    /// Masks sensitive data for display/logging purposes.
161    fn mask_sensitive(value: &str) -> String {
162        if value.len() <= 8 {
163            "***".to_string()
164        } else {
165            format!("{}...{}", &value[..4], &value[value.len() - 4..])
166        }
167    }
168}
169
170/// Authentication configuration for API requests.
171///
172/// This enum supports various authentication methods commonly used in APIs.
173/// Authentication can be configured at the client level and optionally overridden
174/// for individual requests.
175///
176/// # Security Features
177///
178/// - **Memory Protection**: Sensitive data is automatically cleared from memory when dropped
179/// - **Display Masking**: Credentials are never displayed in full for logging safety
180/// - **Debug Safety**: Authentication data is redacted in debug output
181///
182/// # Examples
183///
184/// ```rust
185/// use clawspec_core::Authentication;
186///
187/// // Bearer token authentication
188/// let auth = Authentication::Bearer("my-api-token".into());
189///
190/// // Basic authentication
191/// let auth = Authentication::Basic {
192///     username: "user".to_string(),
193///     password: "pass".into(),
194/// };
195///
196/// // API key in header
197/// let auth = Authentication::ApiKey {
198///     header_name: "X-API-Key".to_string(),
199///     key: "secret-key".into(),
200/// };
201/// ```
202// Serialize/Deserialize can only be derived when oauth2 feature is not enabled
203// because SharedOAuth2Config doesn't implement these traits
204#[derive(Clone)]
205#[cfg_attr(
206    not(feature = "oauth2"),
207    derive(Serialize, Deserialize),
208    serde(rename_all = "snake_case")
209)]
210pub enum Authentication {
211    /// Bearer token authentication (RFC 6750).
212    /// Adds `Authorization: Bearer <token>` header.
213    Bearer(SecureString),
214
215    /// HTTP Basic authentication (RFC 7617).
216    /// Adds `Authorization: Basic <base64(username:password)>` header.
217    Basic {
218        /// The username for Basic authentication.
219        username: String,
220        /// The password for Basic authentication.
221        password: SecureString,
222    },
223
224    /// API key authentication with custom header.
225    /// Adds `<header_name>: <key>` header.
226    ApiKey {
227        /// The header name for the API key.
228        header_name: String,
229        /// The API key value.
230        key: SecureString,
231    },
232
233    /// OAuth2 authentication.
234    ///
235    /// This variant requires the `oauth2` feature to be enabled.
236    /// Tokens are acquired automatically and cached for reuse.
237    #[cfg(feature = "oauth2")]
238    OAuth2(SharedOAuth2Config),
239}
240
241impl Authentication {
242    /// Converts the authentication into HTTP headers.
243    ///
244    /// Returns a tuple of (HeaderName, HeaderValue) that can be added to the request.
245    ///
246    /// # Errors
247    ///
248    /// Returns `AuthenticationError` if the authentication data contains invalid characters
249    /// or cannot be properly formatted for HTTP headers.
250    pub fn to_header(&self) -> Result<(HeaderName, HeaderValue), AuthenticationError> {
251        match self {
252            Authentication::Bearer(token) => {
253                let header_value = format!("Bearer {}", token.as_str());
254                let value = HeaderValue::from_str(&header_value).map_err(|e| {
255                    AuthenticationError::InvalidBearerToken {
256                        message: e.to_string(),
257                    }
258                })?;
259                Ok((AUTHORIZATION, value))
260            }
261
262            Authentication::Basic { username, password } => {
263                // Validate username doesn't contain invalid characters
264                if username.contains(':') {
265                    return Err(AuthenticationError::InvalidUsername {
266                        message: "Username cannot contain colon (:) character".to_string(),
267                    });
268                }
269
270                use base64::Engine;
271                let credentials_str = format!("{}:{}", username, password.as_str());
272                let credentials = base64::engine::general_purpose::STANDARD.encode(credentials_str);
273
274                let header_value = format!("Basic {credentials}");
275                let value = HeaderValue::from_str(&header_value).map_err(|e| {
276                    AuthenticationError::InvalidPassword {
277                        message: e.to_string(),
278                    }
279                })?;
280                Ok((AUTHORIZATION, value))
281            }
282
283            Authentication::ApiKey { header_name, key } => {
284                let header = HeaderName::from_bytes(header_name.as_bytes()).map_err(|e| {
285                    AuthenticationError::InvalidHeaderName {
286                        header_name: header_name.clone(),
287                        message: e.to_string(),
288                    }
289                })?;
290                let value = HeaderValue::from_str(key.as_str()).map_err(|e| {
291                    AuthenticationError::InvalidApiKey {
292                        message: e.to_string(),
293                    }
294                })?;
295                Ok((header, value))
296            }
297
298            #[cfg(feature = "oauth2")]
299            Authentication::OAuth2(_) => {
300                // OAuth2 authentication requires async token acquisition
301                // This synchronous method cannot be used for OAuth2
302                Err(AuthenticationError::OAuth2TokenNotAcquired)
303            }
304        }
305    }
306}
307
308impl fmt::Debug for Authentication {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match self {
311            Self::Bearer(_) => f.debug_tuple("Bearer").field(&"[REDACTED]").finish(),
312            Self::Basic { username, .. } => f
313                .debug_struct("Basic")
314                .field("username", username)
315                .field("password", &"[REDACTED]")
316                .finish(),
317            Self::ApiKey { header_name, .. } => f
318                .debug_struct("ApiKey")
319                .field("header_name", header_name)
320                .field("key", &"[REDACTED]")
321                .finish(),
322            #[cfg(feature = "oauth2")]
323            Self::OAuth2(config) => f.debug_tuple("OAuth2").field(config).finish(),
324        }
325    }
326}
327
328impl fmt::Display for Authentication {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        match self {
331            Self::Bearer(token) => {
332                write!(f, "Bearer {token}")
333            }
334            Self::Basic { username, .. } => write!(f, "Basic (username: {username})"),
335            Self::ApiKey { header_name, key } => {
336                write!(f, "ApiKey ({header_name}: {key})")
337            }
338            #[cfg(feature = "oauth2")]
339            Self::OAuth2(config) => {
340                write!(f, "OAuth2 (client_id: {})", config.0.client_id)
341            }
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_bearer_authentication() {
352        let auth = Authentication::Bearer("my-secret-token".into());
353        let (header_name, header_value) = auth.to_header().unwrap();
354
355        assert_eq!(header_name, AUTHORIZATION);
356        assert_eq!(header_value, "Bearer my-secret-token");
357    }
358
359    #[test]
360    fn test_basic_authentication() {
361        let auth = Authentication::Basic {
362            username: "user".to_string(),
363            password: "pass".into(),
364        };
365        let (header_name, header_value) = auth.to_header().unwrap();
366
367        assert_eq!(header_name, AUTHORIZATION);
368        // "user:pass" base64 encoded is "dXNlcjpwYXNz"
369        assert_eq!(header_value, "Basic dXNlcjpwYXNz");
370    }
371
372    #[test]
373    fn test_api_key_authentication() {
374        let auth = Authentication::ApiKey {
375            header_name: "X-API-Key".to_string(),
376            key: "secret-key-123".into(),
377        };
378        let (header_name, header_value) = auth.to_header().unwrap();
379
380        assert_eq!(header_name, "X-API-Key");
381        assert_eq!(header_value, "secret-key-123");
382    }
383
384    #[test]
385    fn test_display_masks_secrets() {
386        let auth = Authentication::Bearer("very-secret-token-12345".into());
387        assert_eq!(auth.to_string(), "Bearer very...2345");
388
389        let auth = Authentication::Basic {
390            username: "user".to_string(),
391            password: "password".into(),
392        };
393        assert_eq!(auth.to_string(), "Basic (username: user)");
394
395        let auth = Authentication::ApiKey {
396            header_name: "X-API-Key".to_string(),
397            key: "secret-key-12345".into(),
398        };
399        assert_eq!(auth.to_string(), "ApiKey (X-API-Key: secr...2345)");
400    }
401
402    #[test]
403    fn test_secure_string_mask_short_tokens() {
404        assert_eq!(SecureString::mask_sensitive("short"), "***");
405        assert_eq!(SecureString::mask_sensitive("12345678"), "***");
406        assert_eq!(SecureString::mask_sensitive("123456789"), "1234...6789");
407    }
408
409    // Note: Serialization tests are disabled when oauth2 feature is enabled
410    // because OAuth2 variant doesn't implement Serialize/Deserialize
411    #[cfg(not(feature = "oauth2"))]
412    #[test]
413    fn test_serialization() {
414        let auth = Authentication::Bearer("token".into());
415        let json = serde_json::to_string(&auth).expect("serialize bearer");
416        assert_eq!(json, r#"{"bearer":"token"}"#);
417
418        let auth = Authentication::Basic {
419            username: "user".to_string(),
420            password: "pass".into(),
421        };
422        let json = serde_json::to_string(&auth).expect("serialize basic");
423        assert_eq!(json, r#"{"basic":{"username":"user","password":"pass"}}"#);
424
425        let auth = Authentication::ApiKey {
426            header_name: "X-API-Key".to_string(),
427            key: "secret-key".into(),
428        };
429        let json = serde_json::to_string(&auth).expect("serialize apikey");
430        assert_eq!(
431            json,
432            r#"{"api_key":{"header_name":"X-API-Key","key":"secret-key"}}"#
433        );
434    }
435
436    #[test]
437    fn test_authentication_error_display() {
438        let error = AuthenticationError::InvalidBearerToken {
439            message: "contains null byte".to_string(),
440        };
441        assert_eq!(
442            error.to_string(),
443            "Bearer token contains invalid characters: contains null byte"
444        );
445
446        let error = AuthenticationError::InvalidUsername {
447            message: "contains colon".to_string(),
448        };
449        assert_eq!(
450            error.to_string(),
451            "Basic auth username contains invalid characters: contains colon"
452        );
453
454        let error = AuthenticationError::InvalidHeaderName {
455            header_name: "Invalid Header".to_string(),
456            message: "contains space".to_string(),
457        };
458        assert_eq!(
459            error.to_string(),
460            "Invalid API key header name 'Invalid Header': contains space"
461        );
462    }
463
464    #[test]
465    fn test_authentication_errors() {
466        // Test bearer token with invalid characters
467        let auth = Authentication::Bearer("\0invalid".into());
468        let result = auth.to_header();
469        assert!(result.is_err());
470        match result.unwrap_err() {
471            AuthenticationError::InvalidBearerToken { .. } => {}
472            _ => panic!("Expected InvalidBearerToken error"),
473        }
474
475        // Test basic auth with username containing colon
476        let auth = Authentication::Basic {
477            username: "user:invalid".to_string(),
478            password: "password".into(),
479        };
480        let result = auth.to_header();
481        assert!(result.is_err());
482        match result.unwrap_err() {
483            AuthenticationError::InvalidUsername { .. } => {}
484            _ => panic!("Expected InvalidUsername error"),
485        }
486
487        // Test API key with invalid header name
488        let auth = Authentication::ApiKey {
489            header_name: "Invalid Header".to_string(),
490            key: "key".into(),
491        };
492        let result = auth.to_header();
493        assert!(result.is_err());
494        match result.unwrap_err() {
495            AuthenticationError::InvalidHeaderName { .. } => {}
496            _ => panic!("Expected InvalidHeaderName error"),
497        }
498
499        // Test API key with invalid key value
500        let auth = Authentication::ApiKey {
501            header_name: "X-API-Key".to_string(),
502            key: "\0invalid".into(),
503        };
504        let result = auth.to_header();
505        assert!(result.is_err());
506        match result.unwrap_err() {
507            AuthenticationError::InvalidApiKey { .. } => {}
508            _ => panic!("Expected InvalidApiKey error"),
509        }
510    }
511
512    #[test]
513    fn test_secure_string_debug() {
514        let secure = SecureString::new("secret-password".to_string());
515        let debug_str = format!("{secure:?}");
516        assert_eq!(debug_str, "SecureString { value: \"[REDACTED]\" }");
517        assert!(!debug_str.contains("secret-password"));
518    }
519
520    #[test]
521    fn test_secure_string_display() {
522        let secure = SecureString::new("secret-password-12345".to_string());
523        let display_str = format!("{secure}");
524        assert_eq!(display_str, "secr...2345");
525        assert!(!display_str.contains("secret-password"));
526
527        let short_secure = SecureString::new("short".to_string());
528        let display_str = format!("{short_secure}");
529        assert_eq!(display_str, "***");
530    }
531
532    #[test]
533    fn test_secure_string_conversions() {
534        // Test From<String>
535        let secure: SecureString = "test".to_string().into();
536        assert_eq!(secure.as_str(), "test");
537
538        // Test From<&str>
539        let secure: SecureString = "test".into();
540        assert_eq!(secure.as_str(), "test");
541
542        // Test into_string
543        let secure = SecureString::new("test".to_string());
544        let back_to_string = secure.into_string();
545        assert_eq!(back_to_string, "test");
546    }
547}