Skip to main content

har/
jwt.rs

1use serde::Serialize;
2use serde_json::Value;
3
4/// Decode url-safe base64 (no padding required). Returns None on invalid input.
5pub fn base64url_decode(s: &str) -> Option<Vec<u8>> {
6    let mut bits: u32 = 0;
7    let mut nbits: u32 = 0;
8    let mut out = Vec::new();
9    for c in s.bytes() {
10        let v: u32 = match c {
11            b'A'..=b'Z' => (c - b'A') as u32,
12            b'a'..=b'z' => (c - b'a' + 26) as u32,
13            b'0'..=b'9' => (c - b'0' + 52) as u32,
14            b'-' => 62,
15            b'_' => 63,
16            b'=' => break, // padding
17            _ => return None,
18        };
19        bits = (bits << 6) | v;
20        nbits += 6;
21        if nbits >= 8 {
22            nbits -= 8;
23            out.push((bits >> nbits) as u8);
24        }
25    }
26    Some(out)
27}
28
29#[derive(Debug, Clone)]
30pub struct JwtParts {
31    pub header: Value,
32    pub claims: Value,
33}
34
35/// Decode a `header.payload.signature` JWT into header + claims JSON.
36/// The signature is ignored (never decoded or returned).
37pub fn decode_jwt(token: &str) -> Option<JwtParts> {
38    let parts: Vec<&str> = token.split('.').collect();
39    if parts.len() != 3 {
40        return None;
41    }
42    let header: Value = serde_json::from_slice(&base64url_decode(parts[0])?).ok()?;
43    let claims: Value = serde_json::from_slice(&base64url_decode(parts[1])?).ok()?;
44    Some(JwtParts { header, claims })
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct JwtSummary {
49    pub alg: Option<String>,
50    pub typ: Option<String>,
51    pub iss: Option<String>,
52    pub aud: Option<String>,
53    pub sub_hash: Option<String>,
54    pub iat: Option<i64>,
55    pub nbf: Option<i64>,
56    pub exp: Option<i64>,
57    pub expired: Option<bool>,
58    pub seconds_to_expiry: Option<i64>,
59    pub clock_skew_hint: Option<String>,
60}
61
62/// Summarize a JWT's header/claims, redacting `sub` to a hash and computing
63/// expiry/skew against `ref_epoch_ms` (the using request's reconstructed time).
64pub fn summarize(parts: &JwtParts, ref_epoch_ms: Option<i64>) -> JwtSummary {
65    let h = &parts.header;
66    let c = &parts.claims;
67    let get_str = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_str()).map(String::from);
68    let get_i64 = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_i64());
69
70    let aud = c.get("aud").map(|a| match a {
71        Value::String(s) => s.clone(),
72        Value::Array(arr) => arr
73            .iter()
74            .filter_map(|x| x.as_str())
75            .collect::<Vec<_>>()
76            .join(","),
77        other => other.to_string(),
78    });
79    let sub_hash = c.get("sub").and_then(|s| s.as_str()).map(token_hash);
80    let iat = get_i64(c, "iat");
81    let nbf = get_i64(c, "nbf");
82    let exp = get_i64(c, "exp");
83
84    let (expired, seconds_to_expiry) = match (exp, ref_epoch_ms) {
85        (Some(e), Some(r)) => (Some(e * 1000 < r), Some(e - r / 1000)),
86        _ => (None, None),
87    };
88    let clock_skew_hint = match (iat, ref_epoch_ms) {
89        (Some(i), Some(r)) if i * 1000 > r + 60_000 => {
90            Some("token iat is in the future (clock skew?)".to_string())
91        }
92        _ => None,
93    };
94
95    JwtSummary {
96        alg: get_str(h, "alg"),
97        typ: get_str(h, "typ"),
98        iss: get_str(c, "iss"),
99        aud,
100        sub_hash,
101        iat,
102        nbf,
103        exp,
104        expired,
105        seconds_to_expiry,
106        clock_skew_hint,
107    }
108}
109
110/// Stable, non-reversible short hash of a token/value (for grouping + sub redaction).
111pub fn token_hash(s: &str) -> String {
112    use std::hash::{Hash, Hasher};
113    let mut h = std::collections::hash_map::DefaultHasher::new();
114    s.hash(&mut h);
115    format!("{:016x}", h.finish())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::{JwtParts, base64url_decode, decode_jwt, summarize, token_hash};
121    use serde_json::json;
122
123    // jwt.io default token: header {"alg":"HS256","typ":"JWT"},
124    // payload {"sub":"1234567890","name":"John Doe","iat":1516239022}
125    const SAMPLE: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
126
127    #[test]
128    fn base64url_decodes_header() {
129        let bytes = base64url_decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").unwrap();
130        assert_eq!(
131            String::from_utf8(bytes).unwrap(),
132            r#"{"alg":"HS256","typ":"JWT"}"#
133        );
134    }
135
136    #[test]
137    fn decodes_jwt_header_and_claims() {
138        let parts = decode_jwt(SAMPLE).unwrap();
139        assert_eq!(parts.header.get("alg").unwrap(), "HS256");
140        assert_eq!(
141            parts.claims.get("iat").unwrap().as_i64().unwrap(),
142            1516239022
143        );
144    }
145
146    #[test]
147    fn rejects_non_jwt() {
148        assert!(decode_jwt("not.a.jwt").is_none());
149        assert!(decode_jwt("only.twoparts").is_none());
150    }
151
152    #[test]
153    fn summary_redacts_sub_and_flags_expiry() {
154        let parts = JwtParts {
155            header: json!({"alg": "RS256", "typ": "JWT"}),
156            claims: json!({"iss": "acme", "sub": "secret-user", "exp": 1000, "iat": 100}),
157        };
158        // reference time = 2000s -> exp 1000s is in the past -> expired
159        let s = summarize(&parts, Some(2_000_000));
160        assert_eq!(s.iss.as_deref(), Some("acme"));
161        assert_eq!(s.expired, Some(true));
162        assert_eq!(s.seconds_to_expiry, Some(-1000));
163        // sub is hashed, never raw
164        assert!(s.sub_hash.is_some());
165        assert_ne!(s.sub_hash.as_deref(), Some("secret-user"));
166    }
167
168    #[test]
169    fn summary_detects_future_iat_skew() {
170        let parts = JwtParts {
171            header: json!({"alg": "HS256"}),
172            claims: json!({"iat": 5000}),
173        };
174        // reference time = 1000s; iat 5000s is far in the future
175        let s = summarize(&parts, Some(1_000_000));
176        assert!(s.clock_skew_hint.is_some());
177    }
178
179    #[test]
180    fn token_hash_is_stable_and_not_raw() {
181        let h = token_hash(SAMPLE);
182        assert_eq!(h, token_hash(SAMPLE));
183        assert!(!h.contains("eyJ"));
184    }
185}