Skip to main content

squeeze/
jwt.rs

1use super::{Anchor, ByteSet, Finder};
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Jwt {}
6
7impl Jwt {
8    /// Unpadded base64url alphabet (RFC 4648 section 5): `A-Z a-z 0-9 - _`.
9    /// `+`, `/` and `=` are *not* part of it: treating them as segment
10    /// characters both missed JWTs after `TOKEN=` / `?t=` / `/verify/`
11    /// (boundary-before check) and glued `=` padding into signatures.
12    fn is_base64url(b: u8) -> bool {
13        b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
14    }
15
16    /// End of the run of base64url characters starting at `start`.
17    fn run_end(input: &[u8], start: usize) -> usize {
18        let mut pos = start;
19        while pos < input.len() && Self::is_base64url(input[pos]) {
20            pos += 1;
21        }
22        pos
23    }
24
25    /// Match a JWT starting exactly at `pos` (which must hold `e`).
26    /// Returns the exclusive end of the match.
27    fn match_end(input: &[u8], pos: usize) -> Option<usize> {
28        // Header: base64url-encoded JSON containing "alg"; the base64 of
29        // `{"` always starts with "eyJ".
30        let header_end = Self::run_end(input, pos);
31        if header_end - pos < 4 || &input[pos..pos + 3] != b"eyJ" {
32            return None;
33        }
34        if header_end >= input.len() || input[header_end] != b'.' {
35            return None;
36        }
37        // Payload: base64url of a JSON claims object. `{` = 0x7B, so the
38        // first sextet is 011110 -> 'e'; the shortest legal payload is
39        // `e30` (= `{}`).
40        let payload_start = header_end + 1;
41        if payload_start >= input.len() || input[payload_start] != b'e' {
42            return None;
43        }
44        let payload_end = Self::run_end(input, payload_start);
45        if payload_end - payload_start < 3 {
46            return None;
47        }
48        if payload_end >= input.len() || input[payload_end] != b'.' {
49            return None;
50        }
51        // Signature: empty for unsecured JWTs (RFC 7519 section 6), in
52        // which case the match includes the trailing dot; otherwise at
53        // least 4 base64url characters.
54        let sig_start = payload_end + 1;
55        let sig_end = Self::run_end(input, sig_start);
56        if sig_end == sig_start {
57            return Some(sig_start);
58        }
59        if sig_end - sig_start < 4 {
60            return None;
61        }
62        // Boundary after: a fourth dotted segment means this candidate is
63        // not the signature; the caller retries further in.
64        if sig_end < input.len() && input[sig_end] == b'.' {
65            return None;
66        }
67        Some(sig_end)
68    }
69}
70
71impl Finder for Jwt {
72    fn line_agnostic(&self) -> bool {
73        // Matches never contain a line terminator and `\n`/`\r` end every
74        // walk exactly like the end of the input does.
75        true
76    }
77
78    fn id(&self) -> &'static str {
79        "jwt"
80    }
81
82    fn dispatchable(&self) -> bool {
83        true
84    }
85
86    fn could_start_at(&self, byte: u8) -> bool {
87        byte == b'e'
88    }
89
90    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
91        !Self::is_base64url(prev)
92    }
93
94    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
95        next == b'y'
96    }
97
98    fn anchor(&self) -> Option<Anchor> {
99        // Every match starts with `eyJ`: `J` is far rarer than `e`, and the
100        // first one is two bytes in.
101        Some(Anchor::new(ByteSet::from_bytes(b"J"), ByteSet::from_bytes(b"ey")).back(2))
102    }
103
104    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
105        if input[pos] != b'e' {
106            return None;
107        }
108        // Boundary before: an immediately preceding base64url character
109        // means `pos` is inside a longer run.
110        if pos > 0 && Self::is_base64url(input[pos - 1]) {
111            return None;
112        }
113        Self::match_end(input, pos).map(|end| pos..end)
114    }
115
116    fn find(&self, s: &str) -> Option<Range<usize>> {
117        let input = s.as_bytes();
118        let mut idx = 0;
119
120        while idx < input.len() {
121            // JWT headers always start with "eyJ" (base64 of '{"').
122            if input[idx] == b'e'
123                && (idx == 0 || !Self::is_base64url(input[idx - 1]))
124                && let Some(end) = Self::match_end(input, idx)
125            {
126                return Some(idx..end);
127            }
128            idx += 1;
129        }
130
131        None
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    // A realistic JWT (HS256): header.payload.signature
140    const JWT_HS256: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
141
142    // RS256 JWT (longer signature)
143    const JWT_RS256: &str = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_5RU8VuLeas";
144
145    #[test]
146    fn id_should_return_jwt() {
147        let finder = Jwt::default();
148        assert_eq!("jwt", finder.id());
149    }
150
151    #[test]
152    fn find_should_extract_jwt() {
153        let finder = Jwt::default();
154        let input = format!("token: {}", JWT_HS256);
155        let range = finder.find(&input).unwrap();
156        assert_eq!(JWT_HS256, &input[range]);
157    }
158
159    #[test]
160    fn find_should_extract_jwt_at_start() {
161        let finder = Jwt::default();
162        let input = format!("{} is the token", JWT_HS256);
163        let range = finder.find(&input).unwrap();
164        assert_eq!(JWT_HS256, &input[range]);
165    }
166
167    #[test]
168    fn find_should_extract_rs256_jwt() {
169        let finder = Jwt::default();
170        let range = finder.find(JWT_RS256).unwrap();
171        assert_eq!(JWT_RS256, &JWT_RS256[range]);
172    }
173
174    #[test]
175    fn find_should_extract_jwt_in_text() {
176        let finder = Jwt::default();
177        let input = format!("Authorization: Bearer {} end", JWT_HS256);
178        let range = finder.find(&input).unwrap();
179        assert_eq!(JWT_HS256, &input[range]);
180    }
181
182    #[test]
183    fn find_should_reject_two_segments() {
184        let finder = Jwt::default();
185        assert!(
186            finder
187                .find("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0")
188                .is_none()
189        );
190    }
191
192    #[test]
193    fn find_should_reject_non_eyj_prefix() {
194        let finder = Jwt::default();
195        assert!(finder.find("abc.def.ghi").is_none());
196    }
197
198    #[test]
199    fn find_should_handle_empty_input() {
200        let finder = Jwt::default();
201        assert!(finder.find("").is_none());
202    }
203
204    #[test]
205    fn find_should_reject_preceded_by_alphanumeric() {
206        let finder = Jwt::default();
207        let input = format!("x{}", JWT_HS256);
208        assert!(finder.find(&input).is_none());
209    }
210
211    #[test]
212    fn find_should_extract_multiple_jwts_iteratively() {
213        let finder = Jwt::default();
214        let input = format!("{} and {}", JWT_HS256, JWT_RS256);
215
216        let mut results = Vec::new();
217        let mut idx = 0;
218        while idx < input.len() {
219            if let Some(range) = finder.find(&input[idx..]) {
220                results.push(input[idx + range.start..idx + range.end].to_string());
221                idx += range.end;
222            } else {
223                break;
224            }
225        }
226
227        assert_eq!(vec![JWT_HS256, JWT_RS256], results);
228    }
229
230    #[test]
231    fn find_should_reject_short_segments() {
232        let finder = Jwt::default();
233        assert!(finder.find("eyJ.ab.cd").is_none());
234    }
235
236    #[test]
237    fn try_at_valid_jwt() {
238        let finder = Jwt::default();
239        let input = JWT_HS256.as_bytes();
240        assert_eq!(finder.try_at(input, 0), Some(0..input.len()));
241    }
242
243    #[test]
244    fn try_at_preceded_by_base64() {
245        let finder = Jwt::default();
246        let input = format!("x{}", JWT_HS256);
247        assert!(finder.try_at(input.as_bytes(), 1).is_none());
248    }
249
250    #[test]
251    fn try_at_non_e() {
252        let finder = Jwt::default();
253        assert!(finder.try_at(b"abc", 0).is_none());
254    }
255
256    #[test]
257    fn try_at_single_e() {
258        let finder = Jwt::default();
259        assert!(finder.try_at(b"e", 0).is_none());
260    }
261
262    #[test]
263    fn try_at_eyj_only() {
264        let finder = Jwt::default();
265        assert!(finder.try_at(b"eyJ", 0).is_none());
266    }
267
268    #[test]
269    fn find_four_segments_with_non_json_tail_yields_none() {
270        // `JWT.extra`: the only inner candidate would use the original
271        // signature ("SflK...") as its payload, which cannot be a base64url
272        // JSON object (no leading 'e'), so nothing matches.
273        let finder = Jwt::default();
274        let input = format!("{}.extra", JWT_HS256);
275        assert!(finder.find(&input).is_none());
276    }
277
278    #[test]
279    fn find_four_eyj_segments_finds_inner_jwt() {
280        let finder = Jwt::default();
281        let input = "eyJab.eyJcd.eyJef.extra";
282        let range = finder.find(input).unwrap();
283        assert_eq!("eyJcd.eyJef.extra", &input[range]);
284    }
285
286    #[test]
287    fn find_should_extract_jwt_after_space() {
288        let finder = Jwt::default();
289        let input = format!(" {}", JWT_HS256);
290        let range = finder.find(&input).unwrap();
291        assert_eq!(JWT_HS256, &input[range]);
292    }
293}