Skip to main content

har/analysis/
auth.rs

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
57/// Analyze auth failures, missing/rotating auth, and token-refresh flows.
58pub 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    // --- failures (401/403) ---
62    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    // --- per-host auth presence + distinct tokens ---
90    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    // --- token refresh flows ---
129    // Global timeline of (offset, auth value, id) for reuse analysis.
130    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
181/// Render auth analysis as deterministic terminal text.
182pub 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            // refresh call (path contains /token + grant_type query)
288            {
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), // reuses old
295            with_auth(3, "api.x", "/data", 200, Some("Bearer NEW"), 300.0), // new token seen
296        ]);
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}