Skip to main content

kipuka_util/
auth.rs

1//! HTTP authentication header parsing.
2//!
3//! Extracts credentials from `Authorization` headers for EST enrollment
4//! authentication. Supports:
5//! - HTTP Basic (RFC 7617) -- username:password for OTP validation
6//! - Bearer token (RFC 6750) -- OAuth2 / JWT tokens
7//! - Negotiate (RFC 4559) -- GSSAPI/Kerberos SPNEGO tokens
8//! - Client certificate -- extracted from TLS connection info
9
10use base64::Engine;
11use base64::engine::general_purpose::STANDARD;
12use thiserror::Error;
13use tracing::debug;
14
15/// Errors during authentication header parsing.
16#[derive(Debug, Error)]
17pub enum AuthError {
18    /// The `Authorization` header is missing.
19    #[error("missing Authorization header")]
20    Missing,
21
22    /// The header value is not valid UTF-8 or is malformed.
23    #[error("malformed Authorization header: {0}")]
24    Malformed(String),
25
26    /// The authentication scheme is not supported.
27    #[error("unsupported authentication scheme: {0}")]
28    UnsupportedScheme(String),
29
30    /// Base64 decoding failed.
31    #[error("base64 decode error: {0}")]
32    Base64(#[from] base64::DecodeError),
33}
34
35/// Parsed credential from an HTTP `Authorization` header.
36#[derive(Debug, Clone)]
37pub enum AuthCredential {
38    /// HTTP Basic: username and password (password may be an OTP).
39    Basic { username: String, password: String },
40
41    /// Bearer token (opaque string; interpretation is caller's responsibility).
42    Bearer { token: String },
43
44    /// Negotiate (GSSAPI/SPNEGO): raw token bytes from base64-decoded header.
45    Negotiate { token_bytes: Vec<u8> },
46
47    /// Client certificate distinguished name extracted from TLS peer info.
48    ClientCert { subject_dn: String },
49}
50
51/// Parse an `Authorization` header value into a structured credential.
52pub fn parse_authorization(header_value: &str) -> Result<AuthCredential, AuthError> {
53    let header_value = header_value.trim();
54    if header_value.is_empty() {
55        return Err(AuthError::Missing);
56    }
57
58    // Split on first space: "Scheme <credentials>"
59    let (scheme, payload) = header_value
60        .split_once(' ')
61        .ok_or_else(|| AuthError::Malformed("expected 'Scheme <credentials>'".into()))?;
62
63    let payload = payload.trim();
64    if payload.is_empty() {
65        return Err(AuthError::Malformed(
66            "empty credentials after scheme".into(),
67        ));
68    }
69
70    match scheme.to_ascii_lowercase().as_str() {
71        "basic" => parse_basic(payload),
72        "bearer" => parse_bearer(payload),
73        "negotiate" => parse_negotiate(payload),
74        other => Err(AuthError::UnsupportedScheme(other.to_owned())),
75    }
76}
77
78/// Parse HTTP Basic authentication (RFC 7617 Section 2).
79///
80/// The `Authorization: Basic` header carries base64-encoded credentials
81/// in the form `user-id:password`.  Per RFC 7617:
82///
83/// - **Section 2**: the first colon separates the user-id from the password.
84///   The user-id MUST NOT contain a colon; the password may.
85/// - **Section 2.1**: credentials SHOULD be encoded as UTF-8 when the
86///   `charset="UTF-8"` parameter is present in the WWW-Authenticate
87///   challenge.  We always decode as UTF-8 and reject non-UTF-8.
88/// - **Security**: null bytes (0x00) in credentials are rejected to prevent
89///   injection attacks against backends that treat null as a terminator.
90fn parse_basic(payload: &str) -> Result<AuthCredential, AuthError> {
91    let decoded = STANDARD.decode(payload)?;
92
93    // RFC 7617 §2.1: reject null bytes in the decoded credentials.
94    // Null bytes can cause truncation in C-based backends (LDAP, PAM)
95    // and should never appear in legitimate credentials.
96    if decoded.contains(&0x00) {
97        return Err(AuthError::Malformed(
98            "Basic credentials contain null byte (rejected for security)".into(),
99        ));
100    }
101
102    // RFC 7617 §2.1: decode as UTF-8.
103    let text = String::from_utf8(decoded)
104        .map_err(|e| AuthError::Malformed(format!("non-UTF-8 Basic credentials: {e}")))?;
105
106    // RFC 7617 §2: the user-id and password are separated by the first colon.
107    let (username, password) = text.split_once(':').ok_or_else(|| {
108        AuthError::Malformed("Basic credentials missing ':' separator (RFC 7617 §2)".into())
109    })?;
110
111    // RFC 7617 §2: the user-id MUST NOT be empty.
112    if username.is_empty() {
113        return Err(AuthError::Malformed(
114            "Basic auth user-id is empty (RFC 7617 §2)".into(),
115        ));
116    }
117
118    debug!(username = %username, "parsed Basic auth credential (RFC 7617)");
119
120    Ok(AuthCredential::Basic {
121        username: username.to_owned(),
122        password: password.to_owned(),
123    })
124}
125
126/// Parse Bearer token (RFC 6750).
127fn parse_bearer(payload: &str) -> Result<AuthCredential, AuthError> {
128    debug!("parsed Bearer token credential");
129    Ok(AuthCredential::Bearer {
130        token: payload.to_owned(),
131    })
132}
133
134/// Parse Negotiate (GSSAPI/SPNEGO) token (RFC 4559).
135fn parse_negotiate(payload: &str) -> Result<AuthCredential, AuthError> {
136    let token_bytes = STANDARD.decode(payload)?;
137    debug!(token_len = token_bytes.len(), "parsed Negotiate credential");
138    Ok(AuthCredential::Negotiate { token_bytes })
139}
140
141/// Extract the subject DN from a client certificate.
142///
143/// Convenience function for building an [`AuthCredential::ClientCert`]
144/// from TLS peer certificate information.
145pub fn client_cert_credential(subject_dn: &str) -> AuthCredential {
146    debug!(subject_dn = %subject_dn, "client certificate credential");
147    AuthCredential::ClientCert {
148        subject_dn: subject_dn.to_owned(),
149    }
150}
151
152// ── RFC 7617 WWW-Authenticate challenge ─────────────────────────────────────
153
154/// Default `WWW-Authenticate` header value for HTTP Basic authentication.
155///
156/// RFC 7617 Section 2.2: the challenge includes:
157/// - `realm` — the protection space identifier.
158/// - `charset="UTF-8"` — indicates the server accepts UTF-8 encoded
159///   credentials per RFC 7617 Section 2.1.
160///
161/// EST servers use this to prompt clients for OTP credentials when
162/// mTLS is not available.
163pub const WWW_AUTHENTICATE_BASIC: &str = "Basic realm=\"kipuka-est\", charset=\"UTF-8\"";
164
165/// Builder for `WWW-Authenticate: Basic` challenge headers.
166///
167/// RFC 7617 Section 2.2: the Basic challenge contains a `realm` parameter
168/// identifying the protection space, and an optional `charset` parameter
169/// indicating the server's preferred encoding for credentials.
170///
171/// # Example
172///
173/// ```ignore
174/// let challenge = BasicChallenge::new("my-realm");
175/// assert_eq!(challenge.to_header_value(), "Basic realm=\"my-realm\", charset=\"UTF-8\"");
176/// ```
177#[derive(Debug, Clone)]
178pub struct BasicChallenge {
179    /// The realm string identifying the protection space.
180    ///
181    /// RFC 7617 Section 2.2: the realm value is a case-sensitive string
182    /// defined by the origin server.  Clients use it to determine which
183    /// stored credentials to send.
184    pub realm: String,
185
186    /// The charset parameter.
187    ///
188    /// RFC 7617 Section 2.1: when present and set to "UTF-8", it indicates
189    /// the server supports UTF-8 encoded credentials.  This is the only
190    /// value defined by the RFC.
191    pub charset: String,
192}
193
194impl BasicChallenge {
195    /// Create a new Basic challenge with the given realm.
196    ///
197    /// The `charset` defaults to `"UTF-8"` per RFC 7617 Section 2.1.
198    pub fn new(realm: &str) -> Self {
199        Self {
200            realm: realm.to_owned(),
201            charset: "UTF-8".to_owned(),
202        }
203    }
204
205    /// Format the challenge as a `WWW-Authenticate` header value.
206    ///
207    /// RFC 7617 Section 2.2:
208    /// ```text
209    /// WWW-Authenticate: Basic realm="<realm>", charset="UTF-8"
210    /// ```
211    pub fn to_header_value(&self) -> String {
212        format!(
213            "Basic realm=\"{}\", charset=\"{}\"",
214            self.realm, self.charset
215        )
216    }
217}
218
219impl Default for BasicChallenge {
220    fn default() -> Self {
221        Self::new("kipuka-est")
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn parse_basic_auth() {
231        // "user:pass" base64-encoded
232        let cred = parse_authorization("Basic dXNlcjpwYXNz").unwrap();
233        match cred {
234            AuthCredential::Basic { username, password } => {
235                assert_eq!(username, "user");
236                assert_eq!(password, "pass");
237            }
238            _ => panic!("expected Basic"),
239        }
240    }
241
242    #[test]
243    fn parse_bearer_auth() {
244        let cred = parse_authorization("Bearer eyJhbGciOiJSUzI1NiJ9.test").unwrap();
245        match cred {
246            AuthCredential::Bearer { token } => {
247                assert_eq!(token, "eyJhbGciOiJSUzI1NiJ9.test");
248            }
249            _ => panic!("expected Bearer"),
250        }
251    }
252
253    #[test]
254    fn parse_negotiate_auth() {
255        let cred = parse_authorization("Negotiate YWJjZA==").unwrap();
256        match cred {
257            AuthCredential::Negotiate { token_bytes } => {
258                assert_eq!(token_bytes, b"abcd");
259            }
260            _ => panic!("expected Negotiate"),
261        }
262    }
263
264    #[test]
265    fn rejects_unsupported_scheme() {
266        assert!(matches!(
267            parse_authorization("Digest abc123"),
268            Err(AuthError::UnsupportedScheme(_))
269        ));
270    }
271
272    #[test]
273    fn rejects_missing_header() {
274        assert!(matches!(parse_authorization(""), Err(AuthError::Missing)));
275    }
276
277    // ── RFC 7617 compliance tests ───────────────────────────────────────
278
279    #[test]
280    fn basic_auth_valid_utf8() {
281        // "ユーザー:パスワード" (Japanese user:password) base64-encoded
282        let encoded = STANDARD.encode("ユーザー:パスワード");
283        let header = format!("Basic {encoded}");
284        let cred = parse_authorization(&header).unwrap();
285        match cred {
286            AuthCredential::Basic { username, password } => {
287                assert_eq!(username, "ユーザー");
288                assert_eq!(password, "パスワード");
289            }
290            _ => panic!("expected Basic"),
291        }
292    }
293
294    #[test]
295    fn basic_auth_colon_in_password() {
296        // RFC 7617 §2: password may contain colons.
297        // "user:pass:with:colons" base64-encoded
298        let encoded = STANDARD.encode("user:pass:with:colons");
299        let header = format!("Basic {encoded}");
300        let cred = parse_authorization(&header).unwrap();
301        match cred {
302            AuthCredential::Basic { username, password } => {
303                assert_eq!(username, "user");
304                assert_eq!(password, "pass:with:colons");
305            }
306            _ => panic!("expected Basic"),
307        }
308    }
309
310    #[test]
311    fn basic_auth_missing_colon() {
312        // RFC 7617 §2: credentials without a colon separator are malformed.
313        let encoded = STANDARD.encode("no-colon-here");
314        let header = format!("Basic {encoded}");
315        let result = parse_authorization(&header);
316        assert!(result.is_err());
317        let err = result.unwrap_err();
318        assert!(
319            err.to_string().contains("':'"),
320            "error should mention missing colon: {err}"
321        );
322    }
323
324    #[test]
325    fn basic_auth_empty_username() {
326        // RFC 7617 §2: user-id MUST NOT be empty.
327        let encoded = STANDARD.encode(":password");
328        let header = format!("Basic {encoded}");
329        let result = parse_authorization(&header);
330        assert!(result.is_err());
331        let err = result.unwrap_err();
332        assert!(
333            err.to_string().contains("empty"),
334            "error should mention empty user-id: {err}"
335        );
336    }
337
338    #[test]
339    fn basic_auth_null_byte_rejected() {
340        // Security: null bytes in credentials are rejected.
341        let creds_with_null = b"user\x00:password";
342        let encoded = STANDARD.encode(creds_with_null);
343        let header = format!("Basic {encoded}");
344        let result = parse_authorization(&header);
345        assert!(result.is_err());
346        let err = result.unwrap_err();
347        assert!(
348            err.to_string().contains("null byte"),
349            "error should mention null byte: {err}"
350        );
351    }
352
353    // ── BasicChallenge / WWW-Authenticate tests ─────────────────────────
354
355    #[test]
356    fn basic_challenge_default() {
357        let challenge = BasicChallenge::default();
358        assert_eq!(challenge.realm, "kipuka-est");
359        assert_eq!(challenge.charset, "UTF-8");
360        assert_eq!(
361            challenge.to_header_value(),
362            "Basic realm=\"kipuka-est\", charset=\"UTF-8\""
363        );
364    }
365
366    #[test]
367    fn basic_challenge_custom_realm() {
368        let challenge = BasicChallenge::new("my-custom-realm");
369        assert_eq!(
370            challenge.to_header_value(),
371            "Basic realm=\"my-custom-realm\", charset=\"UTF-8\""
372        );
373    }
374
375    #[test]
376    fn www_authenticate_constant() {
377        assert_eq!(
378            WWW_AUTHENTICATE_BASIC,
379            "Basic realm=\"kipuka-est\", charset=\"UTF-8\""
380        );
381    }
382}