1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use ahash::{AHashMap, AHashSet};
4use serde::Serialize;
5
6#[derive(Debug, Serialize)]
7pub struct AuthResult {
8 pub failures: Vec<AuthFailure>,
9 pub missing_auth_hosts: Vec<String>,
10 pub token_changes: Vec<TokenChange>,
11 pub refreshes: Vec<RefreshEvent>,
12}
13
14#[derive(Debug, Serialize)]
15pub struct AuthFailure {
16 pub host: String,
17 pub norm_path: String,
18 pub status: i64,
19 pub count: usize,
20 pub entry_ids: Vec<String>,
21}
22
23#[derive(Debug, Serialize)]
24pub struct TokenChange {
25 pub host: String,
26 pub distinct_tokens: usize,
27}
28
29#[derive(Debug, Serialize)]
30pub struct RefreshEvent {
31 pub id: String,
32 pub host: String,
33 pub status: i64,
34 pub success: bool,
35 pub concurrent: bool,
36 pub old_token_reused: bool,
37 pub reusing_ids: Vec<String>,
38}
39
40fn auth_value(e: &Entry) -> Option<&str> {
41 e.req_headers
42 .iter()
43 .find(|(n, _)| n.eq_ignore_ascii_case("authorization"))
44 .map(|(_, v)| v.as_str())
45}
46
47fn is_refresh(e: &Entry) -> bool {
48 let p = e.norm_path.to_ascii_lowercase();
49 let path_hit = p.contains("/token") || p.contains("/oauth") || p.contains("/auth/refresh");
50 let query_hit = e
51 .query
52 .iter()
53 .any(|(k, v)| k.eq_ignore_ascii_case("grant_type") && v == "refresh_token");
54 path_hit || query_hit
55}
56
57pub fn compute_auth(cap: &Capture, filter: &Filter, top: usize) -> AuthResult {
59 let entries: Vec<&Entry> = cap.entries.iter().filter(|e| filter.matches(e)).collect();
60
61 let mut fail_map: AHashMap<(String, String, i64), Vec<&Entry>> = AHashMap::new();
63 for e in &entries {
64 if e.status == 401 || e.status == 403 {
65 fail_map
66 .entry((e.host.clone(), e.norm_path.clone(), e.status))
67 .or_default()
68 .push(e);
69 }
70 }
71 let mut failures: Vec<AuthFailure> = fail_map
72 .into_iter()
73 .map(|((host, norm_path, status), g)| AuthFailure {
74 host,
75 norm_path,
76 status,
77 count: g.len(),
78 entry_ids: g.iter().map(|e| e.id.clone()).collect(),
79 })
80 .collect();
81 failures.sort_by(|a, b| {
82 b.count
83 .cmp(&a.count)
84 .then(a.host.cmp(&b.host))
85 .then(a.norm_path.cmp(&b.norm_path))
86 });
87 failures.truncate(top);
88
89 let mut host_has_auth: AHashMap<String, bool> = AHashMap::new();
91 let mut host_no_auth: AHashMap<String, bool> = AHashMap::new();
92 let mut host_tokens: AHashMap<String, AHashSet<String>> = AHashMap::new();
93 for e in &entries {
94 match auth_value(e) {
95 Some(a) => {
96 *host_has_auth.entry(e.host.clone()).or_default() = true;
97 host_tokens
98 .entry(e.host.clone())
99 .or_default()
100 .insert(a.to_string());
101 }
102 None => {
103 *host_no_auth.entry(e.host.clone()).or_default() = true;
104 }
105 }
106 }
107 let mut missing_auth_hosts: Vec<String> = host_has_auth
108 .keys()
109 .filter(|h| host_no_auth.get(*h).copied().unwrap_or(false))
110 .cloned()
111 .collect();
112 missing_auth_hosts.sort();
113
114 let mut token_changes: Vec<TokenChange> = host_tokens
115 .into_iter()
116 .filter(|(_, set)| set.len() > 1)
117 .map(|(host, set)| TokenChange {
118 host,
119 distinct_tokens: set.len(),
120 })
121 .collect();
122 token_changes.sort_by(|a, b| {
123 b.distinct_tokens
124 .cmp(&a.distinct_tokens)
125 .then(a.host.cmp(&b.host))
126 });
127
128 let mut auth_timeline: Vec<(f64, String, String)> = entries
131 .iter()
132 .filter_map(|e| auth_value(e).map(|a| (e.started_offset_ms, a.to_string(), e.id.clone())))
133 .collect();
134 auth_timeline.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
135
136 let refresh_entries: Vec<&&Entry> = entries.iter().filter(|e| is_refresh(e)).collect();
137 let mut refreshes: Vec<RefreshEvent> = Vec::new();
138 for rf in &refresh_entries {
139 let t = rf.started_offset_ms;
140 let success = (200..300).contains(&rf.status);
141
142 let pre: AHashSet<&String> = auth_timeline
143 .iter()
144 .filter(|(o, _, _)| *o < t)
145 .map(|(_, a, _)| a)
146 .collect();
147 let new_token_seen = auth_timeline
148 .iter()
149 .any(|(o, a, _)| *o > t && !pre.contains(a));
150 let reusing_ids: Vec<String> = auth_timeline
151 .iter()
152 .filter(|(o, a, _)| *o > t && pre.contains(a))
153 .map(|(_, _, id)| id.clone())
154 .collect();
155 let old_token_reused = success && new_token_seen && !reusing_ids.is_empty();
156
157 let concurrent = refresh_entries.iter().any(|other| {
158 other.id != rf.id && (other.started_offset_ms - t).abs() < rf.duration_ms.max(1.0)
159 });
160
161 refreshes.push(RefreshEvent {
162 id: rf.id.clone(),
163 host: rf.host.clone(),
164 status: rf.status,
165 success,
166 concurrent,
167 old_token_reused,
168 reusing_ids,
169 });
170 }
171 refreshes.sort_by(|a, b| a.id.cmp(&b.id));
172
173 AuthResult {
174 failures,
175 missing_auth_hosts,
176 token_changes,
177 refreshes,
178 }
179}
180
181pub fn render_auth_text(r: &AuthResult) -> String {
183 let mut out = String::new();
184 out.push_str("== wiretrail auth ==\n");
185 if !r.failures.is_empty() {
186 out.push_str("\nauth failures:\n");
187 for f in &r.failures {
188 out.push_str(&format!(
189 " {}x [{}] {} {}\n",
190 f.count, f.status, f.host, f.norm_path
191 ));
192 }
193 }
194 if !r.missing_auth_hosts.is_empty() {
195 out.push_str(&format!(
196 "\nhosts with inconsistent Authorization: {}\n",
197 r.missing_auth_hosts.join(", ")
198 ));
199 }
200 if !r.token_changes.is_empty() {
201 out.push_str("\ntoken rotation:\n");
202 for t in &r.token_changes {
203 out.push_str(&format!(
204 " {} ({} distinct tokens)\n",
205 t.host, t.distinct_tokens
206 ));
207 }
208 }
209 if !r.refreshes.is_empty() {
210 out.push_str("\ntoken refreshes:\n");
211 for rf in &r.refreshes {
212 let mut tags = Vec::new();
213 if !rf.success {
214 tags.push("failed".to_string());
215 }
216 if rf.old_token_reused {
217 tags.push("old-token-reused".to_string());
218 }
219 if rf.concurrent {
220 tags.push("concurrent".to_string());
221 }
222 let tagstr = if tags.is_empty() {
223 String::new()
224 } else {
225 format!(" [{}]", tags.join(", "))
226 };
227 out.push_str(&format!(
228 " {} {} [{}]{}\n",
229 rf.id, rf.host, rf.status, tagstr
230 ));
231 }
232 }
233 out
234}
235
236#[cfg(test)]
237mod tests {
238 use super::compute_auth;
239 use crate::filter::Filter;
240 use crate::model::{Entry, sample_capture, sample_entry};
241
242 fn with_auth(
243 index: usize,
244 host: &str,
245 path: &str,
246 status: i64,
247 auth: Option<&str>,
248 offset: f64,
249 ) -> Entry {
250 let mut e = sample_entry(index, host, "GET", path, status);
251 e.started_offset_ms = offset;
252 if let Some(a) = auth {
253 e.req_headers = vec![("Authorization".to_string(), a.to_string())];
254 } else {
255 e.req_headers = vec![];
256 }
257 e
258 }
259
260 #[test]
261 fn groups_401_failures() {
262 let cap = sample_capture(vec![
263 with_auth(0, "api.x", "/me", 401, Some("Bearer a"), 0.0),
264 with_auth(1, "api.x", "/me", 401, Some("Bearer a"), 10.0),
265 with_auth(2, "api.x", "/ok", 200, Some("Bearer a"), 20.0),
266 ]);
267 let r = compute_auth(&cap, &Filter::parse(&[]).unwrap(), 10);
268 let f = r.failures.iter().find(|f| f.norm_path == "/me").unwrap();
269 assert_eq!(f.count, 2);
270 assert_eq!(f.status, 401);
271 }
272
273 #[test]
274 fn flags_host_missing_auth_inconsistently() {
275 let cap = sample_capture(vec![
276 with_auth(0, "api.x", "/a", 200, Some("Bearer a"), 0.0),
277 with_auth(1, "api.x", "/b", 200, None, 10.0),
278 ]);
279 let r = compute_auth(&cap, &Filter::parse(&[]).unwrap(), 10);
280 assert!(r.missing_auth_hosts.contains(&"api.x".to_string()));
281 }
282
283 #[test]
284 fn detects_old_token_reuse_after_refresh() {
285 let cap = sample_capture(vec![
286 with_auth(0, "api.x", "/data", 200, Some("Bearer OLD"), 0.0),
287 {
289 let mut e = sample_entry(1, "auth.x", "POST", "/auth/v1/token", 200);
290 e.started_offset_ms = 100.0;
291 e.query = vec![("grant_type".to_string(), "refresh_token".to_string())];
292 e
293 },
294 with_auth(2, "api.x", "/data", 200, Some("Bearer OLD"), 200.0), with_auth(3, "api.x", "/data", 200, Some("Bearer NEW"), 300.0), ]);
297 let r = compute_auth(&cap, &Filter::parse(&[]).unwrap(), 10);
298 assert_eq!(r.refreshes.len(), 1);
299 let rf = &r.refreshes[0];
300 assert!(rf.success);
301 assert!(rf.old_token_reused);
302 assert!(rf.reusing_ids.contains(&"e000002".to_string()));
303 }
304}