1use base64::Engine;
11use base64::engine::general_purpose::STANDARD;
12use thiserror::Error;
13use tracing::debug;
14
15#[derive(Debug, Error)]
17pub enum AuthError {
18 #[error("missing Authorization header")]
20 Missing,
21
22 #[error("malformed Authorization header: {0}")]
24 Malformed(String),
25
26 #[error("unsupported authentication scheme: {0}")]
28 UnsupportedScheme(String),
29
30 #[error("base64 decode error: {0}")]
32 Base64(#[from] base64::DecodeError),
33}
34
35#[derive(Debug, Clone)]
37pub enum AuthCredential {
38 Basic { username: String, password: String },
40
41 Bearer { token: String },
43
44 Negotiate { token_bytes: Vec<u8> },
46
47 ClientCert { subject_dn: String },
49}
50
51pub 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 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
78fn parse_basic(payload: &str) -> Result<AuthCredential, AuthError> {
91 let decoded = STANDARD.decode(payload)?;
92
93 if decoded.contains(&0x00) {
97 return Err(AuthError::Malformed(
98 "Basic credentials contain null byte (rejected for security)".into(),
99 ));
100 }
101
102 let text = String::from_utf8(decoded)
104 .map_err(|e| AuthError::Malformed(format!("non-UTF-8 Basic credentials: {e}")))?;
105
106 let (username, password) = text.split_once(':').ok_or_else(|| {
108 AuthError::Malformed("Basic credentials missing ':' separator (RFC 7617 §2)".into())
109 })?;
110
111 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
126fn 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
134fn 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
141pub 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
152pub const WWW_AUTHENTICATE_BASIC: &str = "Basic realm=\"kipuka-est\", charset=\"UTF-8\"";
164
165#[derive(Debug, Clone)]
178pub struct BasicChallenge {
179 pub realm: String,
185
186 pub charset: String,
192}
193
194impl BasicChallenge {
195 pub fn new(realm: &str) -> Self {
199 Self {
200 realm: realm.to_owned(),
201 charset: "UTF-8".to_owned(),
202 }
203 }
204
205 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 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 #[test]
280 fn basic_auth_valid_utf8() {
281 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 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 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 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 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 #[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}