Skip to main content

rskit_httpclient/
auth.rs

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