1use serde::Serialize;
2use serde_json::Value;
3
4pub 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, _ => 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
35pub 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
62pub 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
110pub 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 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 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 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 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}