Skip to main content

rust_zero_core/
auth.rs

1//! Transport-neutral authentication results and time-window-bounded request signatures.
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
4use hmac::{Hmac, Mac};
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6use sha2::Sha256;
7use std::{collections::HashMap, fmt, sync::Arc, time::Duration};
8
9/// Transport-neutral HS256 validation errors.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum JwtValidationError {
12    Malformed,
13    UnsupportedAlgorithm,
14    InvalidSignature,
15    Expired,
16    NotYetValid,
17    InvalidClaims,
18}
19
20impl From<JwtValidationError> for AuthFailure {
21    fn from(error: JwtValidationError) -> Self {
22        match error {
23            JwtValidationError::Malformed
24            | JwtValidationError::UnsupportedAlgorithm
25            | JwtValidationError::InvalidClaims => Self::MalformedCredentials,
26            JwtValidationError::InvalidSignature => Self::InvalidCredentials,
27            JwtValidationError::Expired => Self::ExpiredCredentials,
28            JwtValidationError::NotYetValid => Self::NotYetValid,
29        }
30    }
31}
32
33pub fn decode_jwt_hs256<T>(
34    token: &str,
35    secrets: &[Arc<[u8]>],
36    leeway_seconds: u64,
37    now_unix_seconds: u64,
38) -> Result<T, JwtValidationError>
39where
40    T: DeserializeOwned,
41{
42    let mut segments = token.split('.');
43    let header = segments.next().ok_or(JwtValidationError::Malformed)?;
44    let claims = segments.next().ok_or(JwtValidationError::Malformed)?;
45    let signature = segments.next().ok_or(JwtValidationError::Malformed)?;
46    if segments.next().is_some() {
47        return Err(JwtValidationError::Malformed);
48    }
49    let header_value: serde_json::Value = serde_json::from_slice(
50        &URL_SAFE_NO_PAD
51            .decode(header)
52            .map_err(|_| JwtValidationError::Malformed)?,
53    )
54    .map_err(|_| JwtValidationError::Malformed)?;
55    if header_value.get("alg").and_then(|value| value.as_str()) != Some("HS256") {
56        return Err(JwtValidationError::UnsupportedAlgorithm);
57    }
58    let supplied = URL_SAFE_NO_PAD
59        .decode(signature)
60        .map_err(|_| JwtValidationError::Malformed)?;
61    let canonical = format!("{header}.{claims}");
62    let valid = secrets.iter().any(|secret| {
63        Hmac::<Sha256>::new_from_slice(secret)
64            .map(|mut mac| {
65                mac.update(canonical.as_bytes());
66                mac.verify_slice(&supplied).is_ok()
67            })
68            .unwrap_or(false)
69    });
70    if !valid {
71        return Err(JwtValidationError::InvalidSignature);
72    }
73    let claim_bytes = URL_SAFE_NO_PAD
74        .decode(claims)
75        .map_err(|_| JwtValidationError::Malformed)?;
76    let claim_value: serde_json::Value =
77        serde_json::from_slice(&claim_bytes).map_err(|_| JwtValidationError::InvalidClaims)?;
78    if claim_value
79        .get("exp")
80        .and_then(serde_json::Value::as_u64)
81        .is_some_and(|expires| now_unix_seconds > expires.saturating_add(leeway_seconds))
82    {
83        return Err(JwtValidationError::Expired);
84    }
85    if claim_value
86        .get("nbf")
87        .and_then(serde_json::Value::as_u64)
88        .is_some_and(|not_before| now_unix_seconds.saturating_add(leeway_seconds) < not_before)
89    {
90        return Err(JwtValidationError::NotYetValid);
91    }
92    serde_json::from_slice(&claim_bytes).map_err(|_| JwtValidationError::InvalidClaims)
93}
94
95pub fn encode_jwt_hs256<T>(claims: &T, secret: &[u8]) -> Result<String, JwtValidationError>
96where
97    T: Serialize,
98{
99    if secret.is_empty() {
100        return Err(JwtValidationError::InvalidSignature);
101    }
102    let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
103    let claims = URL_SAFE_NO_PAD
104        .encode(serde_json::to_vec(claims).map_err(|_| JwtValidationError::InvalidClaims)?);
105    let signing_input = format!("{header}.{claims}");
106    let mut mac =
107        Hmac::<Sha256>::new_from_slice(secret).map_err(|_| JwtValidationError::InvalidSignature)?;
108    mac.update(signing_input.as_bytes());
109    Ok(format!(
110        "{signing_input}.{}",
111        URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
112    ))
113}
114
115pub const AUTH_KEY_ID_HEADER: &str = "x-rust-zero-key-id";
116pub const AUTH_TIMESTAMP_HEADER: &str = "x-rust-zero-timestamp";
117pub const AUTH_SIGNATURE_HEADER: &str = "x-rust-zero-signature";
118
119/// Selects JWT values into handler-facing names using dot-separated payload paths.
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct JwtClaimProjection {
122    #[serde(default)]
123    pub fields: std::collections::BTreeMap<String, String>,
124}
125
126impl JwtClaimProjection {
127    pub fn new(fields: impl IntoIterator<Item = (String, String)>) -> Self {
128        Self {
129            fields: fields.into_iter().collect(),
130        }
131    }
132
133    pub fn project(
134        &self,
135        claims: &serde_json::Value,
136    ) -> std::collections::BTreeMap<String, serde_json::Value> {
137        self.fields
138            .iter()
139            .filter_map(|(name, path)| {
140                path.split('.')
141                    .try_fold(claims, |value, segment| value.get(segment))
142                    .cloned()
143                    .map(|value| (name.clone(), value))
144            })
145            .collect()
146    }
147}
148
149/// Stable authentication failures shared by HTTP and gRPC adapters.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum AuthFailure {
152    MissingCredentials,
153    MalformedCredentials,
154    InvalidCredentials,
155    ExpiredCredentials,
156    NotYetValid,
157    MissingSignature,
158    InvalidSignature,
159    StaleSignature,
160}
161
162impl AuthFailure {
163    pub const fn code(self) -> &'static str {
164        match self {
165            Self::MissingCredentials => "auth_missing_credentials",
166            Self::MalformedCredentials => "auth_malformed_credentials",
167            Self::InvalidCredentials => "auth_invalid_credentials",
168            Self::ExpiredCredentials => "auth_expired_credentials",
169            Self::NotYetValid => "auth_not_yet_valid",
170            Self::MissingSignature => "auth_missing_signature",
171            Self::InvalidSignature => "auth_invalid_signature",
172            Self::StaleSignature => "auth_stale_signature",
173        }
174    }
175
176    pub const fn message(self) -> &'static str {
177        match self {
178            Self::MissingCredentials => "authentication credentials are required",
179            Self::MalformedCredentials => "authentication credentials are malformed",
180            Self::InvalidCredentials => "authentication credentials are invalid",
181            Self::ExpiredCredentials => "authentication credentials have expired",
182            Self::NotYetValid => "authentication credentials are not yet valid",
183            Self::MissingSignature => "request signature is required",
184            Self::InvalidSignature => "request signature is invalid",
185            Self::StaleSignature => "request signature timestamp is outside the allowed window",
186        }
187    }
188}
189
190impl fmt::Display for AuthFailure {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter.write_str(self.message())
193    }
194}
195
196impl std::error::Error for AuthFailure {}
197
198/// Signature fields transported as HTTP headers or gRPC metadata.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct RequestSignature {
201    pub key_id: String,
202    pub timestamp: i64,
203    pub signature: String,
204}
205
206/// Signs a transport target using `HMAC-SHA256(timestamp + method + target)`.
207///
208/// For REST, `target` should be the path and query. For gRPC, it should be the canonical
209/// `/package.Service/Method` path. Including the method prevents cross-verb replay.
210pub fn sign_request(
211    key_id: impl Into<String>,
212    secret: &[u8],
213    timestamp: i64,
214    method: &str,
215    target: &str,
216) -> Result<RequestSignature, AuthFailure> {
217    if secret.is_empty() || method.is_empty() || target.is_empty() {
218        return Err(AuthFailure::InvalidSignature);
219    }
220    let mut mac =
221        Hmac::<Sha256>::new_from_slice(secret).map_err(|_| AuthFailure::InvalidSignature)?;
222    mac.update(canonical(timestamp, method, target).as_bytes());
223    Ok(RequestSignature {
224        key_id: key_id.into(),
225        timestamp,
226        signature: URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()),
227    })
228}
229
230/// Verifies named signing keys and rejects signatures outside a bounded clock-skew window.
231#[derive(Clone)]
232pub struct RequestSignatureVerifier {
233    keys: Arc<HashMap<String, Arc<[u8]>>>,
234    max_clock_skew: Duration,
235}
236
237impl fmt::Debug for RequestSignatureVerifier {
238    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239        formatter
240            .debug_struct("RequestSignatureVerifier")
241            .field("key_ids", &self.keys.keys())
242            .field("max_clock_skew", &self.max_clock_skew)
243            .finish()
244    }
245}
246
247impl RequestSignatureVerifier {
248    pub fn new(
249        keys: impl IntoIterator<Item = (String, Vec<u8>)>,
250        max_clock_skew: Duration,
251    ) -> Result<Self, AuthFailure> {
252        if max_clock_skew.is_zero() {
253            return Err(AuthFailure::InvalidSignature);
254        }
255        let keys: HashMap<_, _> = keys
256            .into_iter()
257            .map(|(id, secret)| (id, Arc::<[u8]>::from(secret)))
258            .collect();
259        if keys.is_empty()
260            || keys
261                .iter()
262                .any(|(id, secret)| id.is_empty() || secret.is_empty())
263        {
264            return Err(AuthFailure::InvalidSignature);
265        }
266        Ok(Self {
267            keys: Arc::new(keys),
268            max_clock_skew,
269        })
270    }
271
272    pub fn verify(
273        &self,
274        signature: &RequestSignature,
275        method: &str,
276        target: &str,
277        now_unix_seconds: i64,
278    ) -> Result<(), AuthFailure> {
279        if now_unix_seconds.abs_diff(signature.timestamp) > self.max_clock_skew.as_secs() {
280            return Err(AuthFailure::StaleSignature);
281        }
282        let secret = self
283            .keys
284            .get(&signature.key_id)
285            .ok_or(AuthFailure::InvalidSignature)?;
286        let supplied = URL_SAFE_NO_PAD
287            .decode(&signature.signature)
288            .map_err(|_| AuthFailure::InvalidSignature)?;
289        let mut mac =
290            Hmac::<Sha256>::new_from_slice(secret).map_err(|_| AuthFailure::InvalidSignature)?;
291        mac.update(canonical(signature.timestamp, method, target).as_bytes());
292        mac.verify_slice(&supplied)
293            .map_err(|_| AuthFailure::InvalidSignature)
294    }
295}
296
297fn canonical(timestamp: i64, method: &str, target: &str) -> String {
298    format!("{timestamp}\n{}\n{target}", method.to_ascii_uppercase())
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn verifies_rotation_keys_and_rejects_replay_or_target_changes() {
307        let verifier = RequestSignatureVerifier::new(
308            [("current".to_owned(), b"secret".to_vec())],
309            Duration::from_secs(30),
310        )
311        .unwrap();
312        let signature = sign_request("current", b"secret", 1_000, "post", "/v1/jobs?a=1").unwrap();
313
314        assert_eq!(
315            verifier.verify(&signature, "POST", "/v1/jobs?a=1", 1_020),
316            Ok(())
317        );
318        assert_eq!(
319            verifier.verify(&signature, "POST", "/v1/jobs?a=2", 1_020),
320            Err(AuthFailure::InvalidSignature)
321        );
322        assert_eq!(
323            verifier.verify(&signature, "POST", "/v1/jobs?a=1", 1_031),
324            Err(AuthFailure::StaleSignature)
325        );
326    }
327}