1use 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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct RequestSignature {
201 pub key_id: String,
202 pub timestamp: i64,
203 pub signature: String,
204}
205
206pub 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#[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}