Skip to main content

siderite_core/security/
http_auth.rs

1//! HTTP Bearer and Basic authentication.
2
3use super::{
4    NOT_AUTHENTICATED, authorization_credentials, bearer_token, document_scheme, unauthorized,
5};
6use crate::error::ApiError;
7use crate::extract::FromRequestParts;
8use base64::Engine as _;
9use base64::engine::general_purpose::STANDARD;
10use http::request::Parts;
11use serde_json::json;
12use siderite_openapi::{Operation, SchemaRegistry};
13use std::fmt;
14
15const BASIC_CHALLENGE: &str = r#"Basic realm="api""#;
16
17/// Bearer token from `Authorization: Bearer <token>` (scheme `HTTPBearer`).
18///
19/// Missing or malformed headers yield `401` with `WWW-Authenticate: Bearer`.
20/// The token is not validated; use [`Authenticate`](super::Authenticate).
21/// Wrap in `Option` for optional authentication.
22#[derive(Clone, PartialEq, Eq)]
23pub struct HttpBearer {
24    /// The raw token, without the `Bearer` prefix.
25    pub token: String,
26}
27
28impl fmt::Debug for HttpBearer {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.debug_struct("HttpBearer")
31            .field("token", &"[REDACTED]")
32            .finish()
33    }
34}
35
36impl FromRequestParts for HttpBearer {
37    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
38        bearer_token(parts).map(|token| Self { token })
39    }
40
41    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
42        document_scheme(
43            op,
44            registry,
45            "HTTPBearer",
46            json!({"type": "http", "scheme": "bearer"}),
47            &[],
48        );
49    }
50}
51
52/// Credentials from `Authorization: Basic <base64(user:pass)>` (scheme
53/// `HTTPBasic`).
54///
55/// Missing or malformed headers yield `401` with
56/// `WWW-Authenticate: Basic realm="api"`. The password is split at the first
57/// colon, so it may itself contain colons.
58#[derive(Clone, PartialEq, Eq)]
59pub struct HttpBasic {
60    /// The user name.
61    pub username: String,
62    /// The password.
63    pub password: String,
64}
65
66impl fmt::Debug for HttpBasic {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("HttpBasic")
69            .field("username", &self.username)
70            .field("password", &"[REDACTED]")
71            .finish()
72    }
73}
74
75/// Decode `base64(username:password)`; `None` when malformed.
76fn decode_basic(encoded: &str) -> Option<HttpBasic> {
77    let bytes = STANDARD.decode(encoded.trim()).ok()?;
78    let text = String::from_utf8(bytes).ok()?;
79    let (username, password) = text.split_once(':')?;
80    Some(HttpBasic {
81        username: username.to_owned(),
82        password: password.to_owned(),
83    })
84}
85
86impl FromRequestParts for HttpBasic {
87    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
88        let encoded = authorization_credentials(parts, "Basic")
89            .ok_or_else(|| unauthorized(BASIC_CHALLENGE, NOT_AUTHENTICATED).absent())?;
90        decode_basic(encoded)
91            .ok_or_else(|| unauthorized(BASIC_CHALLENGE, "Invalid basic credentials."))
92    }
93
94    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
95        document_scheme(
96            op,
97            registry,
98            "HTTPBasic",
99            json!({"type": "http", "scheme": "basic"}),
100            &[],
101        );
102    }
103}
104
105#[cfg(test)]
106#[allow(clippy::unwrap_used)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn decodes_basic_credentials() {
112        let user = decode_basic(&STANDARD.encode("ann:s3:cret")).unwrap();
113        assert_eq!(user.username, "ann");
114        assert_eq!(user.password, "s3:cret");
115    }
116
117    #[test]
118    fn rejects_malformed_basic_credentials() {
119        assert!(decode_basic("!!!not-base64").is_none());
120        assert!(decode_basic(&STANDARD.encode("no-colon")).is_none());
121        assert!(decode_basic(&STANDARD.encode([0xff, b':', 0xfe])).is_none());
122    }
123
124    #[test]
125    fn debug_output_redacts_secrets() {
126        let basic = HttpBasic {
127            username: "ann".into(),
128            password: "hunter2".into(),
129        };
130        let bearer = HttpBearer {
131            token: "tok-123".into(),
132        };
133        let text = format!("{basic:?} {bearer:?}");
134        assert!(!text.contains("hunter2") && !text.contains("tok-123"));
135    }
136}