Skip to main content

rskit_httpclient/
auth.rs

1//! Authentication types for HTTP requests.
2//!
3//! Credential values are stored as [`SecretString`]
4//! so debug output redacts bearer tokens, basic passwords, and API keys. Header
5//! values are exposed only by [`Auth::header`] when applying a request.
6
7use base64::Engine;
8use http::header::AUTHORIZATION;
9use rskit_errors::{AppError, AppResult, ErrorCode};
10use rskit_security::{BASIC_AUTH_SCHEME, BEARER_AUTH_SCHEME, SecretString};
11use std::fmt;
12
13/// Authentication method for HTTP requests.
14///
15/// Secret-bearing variants redact their values in [`Debug`](std::fmt::Debug)
16/// output while preserving the plaintext for request header application.
17#[derive(Debug, Clone, Default)]
18#[non_exhaustive]
19pub enum Auth {
20    /// Bearer token authentication: `Authorization: Bearer <token>`
21    Bearer(SecretString),
22    /// HTTP Basic authentication: `Authorization: Basic <base64(username:password)>`
23    Basic {
24        /// Username for basic authentication
25        username: String,
26        /// Password for basic authentication
27        password: SecretString,
28    },
29    /// API key authentication: custom header with key value
30    ApiKey {
31        /// Header name for the API key
32        name: String,
33        /// API key value
34        value: SecretString,
35    },
36    /// No authentication.
37    #[default]
38    None,
39}
40
41impl Auth {
42    /// Creates a Bearer token auth from plaintext.
43    pub fn bearer(token: impl Into<String>) -> Self {
44        Self::bearer_secret(SecretString::new(token))
45    }
46
47    /// Creates a Bearer token auth from a redacting secret.
48    pub fn bearer_secret(token: SecretString) -> Self {
49        Auth::Bearer(token)
50    }
51
52    /// Creates a Basic auth from a plaintext password.
53    pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
54        Self::basic_secret(username, SecretString::new(password))
55    }
56
57    /// Creates a Basic auth from a redacting password secret.
58    pub fn basic_secret(username: impl Into<String>, password: SecretString) -> Self {
59        Auth::Basic {
60            username: username.into(),
61            password,
62        }
63    }
64
65    /// Creates an API key auth with custom header name and plaintext value.
66    pub fn api_key(name: impl Into<String>, value: impl Into<String>) -> Self {
67        Self::api_key_secret(name, SecretString::new(value))
68    }
69
70    /// Creates an API key auth with a custom header name and redacting secret value.
71    pub fn api_key_secret(name: impl Into<String>, value: SecretString) -> Self {
72        Auth::ApiKey {
73            name: name.into(),
74            value,
75        }
76    }
77
78    /// Returns the header name and value for this authentication method.
79    ///
80    /// # Errors
81    /// Returns an error when the configured API-key header is invalid.
82    pub fn header(&self) -> AppResult<Option<(String, String)>> {
83        match self {
84            Auth::Bearer(token) => Ok(Some((
85                AUTHORIZATION.as_str().to_string(),
86                format!("{BEARER_AUTH_SCHEME} {}", token.expose()),
87            ))),
88            Auth::Basic { username, password } => {
89                let credentials = format!("{}:{}", username, password.expose());
90                let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
91                Ok(Some((
92                    AUTHORIZATION.as_str().to_string(),
93                    format!("{BASIC_AUTH_SCHEME} {encoded}"),
94                )))
95            }
96            Auth::ApiKey { name, value } => {
97                if name.parse::<http::HeaderName>().is_err() {
98                    return Err(AppError::new(
99                        ErrorCode::InvalidInput,
100                        format!("invalid API key header name '{name}'"),
101                    ));
102                }
103                if value.expose().parse::<http::HeaderValue>().is_err() {
104                    return Err(AppError::new(
105                        ErrorCode::InvalidInput,
106                        format!("invalid API key header value for '{name}'"),
107                    ));
108                }
109                Ok(Some((name.clone(), value.expose().to_string())))
110            }
111            Auth::None => Ok(None),
112        }
113    }
114}
115
116impl fmt::Display for Auth {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Auth::Bearer(_) => write!(f, "{BEARER_AUTH_SCHEME}"),
120            Auth::Basic { .. } => write!(f, "{BASIC_AUTH_SCHEME}"),
121            Auth::ApiKey { name, .. } => write!(f, "ApiKey({})", name),
122            Auth::None => write!(f, "None"),
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn api_key_rejects_invalid_header_value() {
133        let auth = Auth::api_key("x-api-key", "bad\nvalue");
134        assert!(auth.header().is_err());
135    }
136
137    #[test]
138    fn api_key_rejects_invalid_header_name() {
139        let auth = Auth::api_key("bad header", "secret");
140
141        let error = auth.header().expect_err("invalid header name");
142
143        assert_eq!(error.code(), ErrorCode::InvalidInput);
144        assert!(error.message().contains("invalid API key header name"));
145    }
146
147    #[test]
148    fn api_key_and_none_headers_are_explicit() {
149        assert_eq!(
150            Auth::api_key("x-api-key", "secret").header().unwrap(),
151            Some(("x-api-key".to_string(), "secret".to_string()))
152        );
153        assert_eq!(Auth::None.header().unwrap(), None);
154        assert_eq!(Auth::None.to_string(), "None");
155    }
156
157    #[test]
158    fn debug_redacts_secret_values() {
159        let cases = [
160            format!("{:?}", Auth::bearer("secret-token")),
161            format!("{:?}", Auth::basic("user", "secret-password")),
162            format!("{:?}", Auth::api_key("x-api-key", "secret-key")),
163        ];
164
165        for formatted in cases {
166            assert!(formatted.contains("SecretString(***)"));
167            assert!(!formatted.contains("secret-token"));
168            assert!(!formatted.contains("secret-password"));
169            assert!(!formatted.contains("secret-key"));
170        }
171    }
172
173    #[test]
174    fn header_exposes_secret_only_for_request_application() {
175        let auth = Auth::bearer_secret(SecretString::new("secret-token"));
176
177        assert_eq!(
178            auth.header().unwrap(),
179            Some((
180                AUTHORIZATION.as_str().to_string(),
181                format!("{BEARER_AUTH_SCHEME} secret-token")
182            ))
183        );
184    }
185}