parlov_analysis/aggregation/
auth_classifier.rs1use parlov_core::{ProbeDefinition, RequestAuthState, ResponseSurface};
15
16use super::auth_parsers::{parse_auth_error_body, parse_www_authenticate};
17use super::auth_types::{
18 AuthBlockConfidence, AuthBlockFamily, AuthBlockSignature, AuthChallenge, AuthErrorBodySignal,
19 CredentialBlockKind,
20};
21use super::login_redirect::is_login_redirect;
22
23#[allow(clippy::similar_names)] #[must_use]
36pub fn classify_auth_block(
37 req: &ProbeDefinition,
38 res: &ResponseSurface,
39) -> Option<AuthBlockSignature> {
40 let status = res.status.as_u16();
41 let auth_state = RequestAuthState::from_request(req);
42 if status == 401 {
43 return Some(classify_401(res, auth_state));
44 }
45 if status == 407 {
46 return Some(classify_407(res));
47 }
48 if status == 511 {
49 return Some(classify_511());
50 }
51 if status == 403 {
52 return classify_403_auth_block(req, res);
53 }
54 if let Some(sig) = classify_www_authenticate_non_401(res, auth_state) {
55 return Some(sig);
56 }
57 if let Some(sig) = classify_login_redirect(res) {
58 return Some(sig);
59 }
60 classify_body_envelope(res)
61}
62
63fn classify_401(res: &ResponseSurface, auth_state: RequestAuthState) -> AuthBlockSignature {
64 let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE);
65 let confidence = strong_if_present(challenge.as_ref());
66 let credential_state = credential_state_from_challenge(auth_state, challenge.as_ref());
67 AuthBlockSignature {
68 family: AuthBlockFamily::OriginAuthentication,
69 confidence,
70 status: 401,
71 credential_state,
72 challenge,
73 body_signal: parse_body_signal(res),
74 login_redirect: None,
75 }
76}
77
78fn classify_407(res: &ResponseSurface) -> AuthBlockSignature {
79 let challenge = parse_challenge_header(res, http::header::PROXY_AUTHENTICATE);
80 let confidence = strong_if_present(challenge.as_ref());
81 AuthBlockSignature {
82 family: AuthBlockFamily::ProxyAuthentication,
83 confidence,
84 status: 407,
85 credential_state: CredentialBlockKind::NoCredential,
86 challenge,
87 body_signal: parse_body_signal(res),
88 login_redirect: None,
89 }
90}
91
92fn classify_511() -> AuthBlockSignature {
93 AuthBlockSignature {
94 family: AuthBlockFamily::NetworkAuthentication,
95 confidence: AuthBlockConfidence::Strong,
96 status: 511,
97 credential_state: CredentialBlockKind::NotApplicable,
98 challenge: None,
99 body_signal: None,
100 login_redirect: None,
101 }
102}
103
104fn classify_www_authenticate_non_401(
105 res: &ResponseSurface,
106 auth_state: RequestAuthState,
107) -> Option<AuthBlockSignature> {
108 let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE)?;
109 Some(AuthBlockSignature {
110 family: AuthBlockFamily::OriginAuthentication,
111 confidence: AuthBlockConfidence::Medium,
112 status: res.status.as_u16(),
113 credential_state: credential_state_from_challenge(auth_state, Some(&challenge)),
114 challenge: Some(challenge),
115 body_signal: parse_body_signal(res),
116 login_redirect: None,
117 })
118}
119
120#[allow(clippy::similar_names)] #[must_use]
125pub fn classify_403_auth_block(
126 req: &ProbeDefinition,
127 res: &ResponseSurface,
128) -> Option<AuthBlockSignature> {
129 let auth_state = RequestAuthState::from_request(req);
130 let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE);
131 let body_signal = parse_body_signal(res);
132 let has_strong_body = body_signal
133 .as_ref()
134 .is_some_and(|b| b.confidence == AuthBlockConfidence::Strong);
135 if challenge.is_none() && !has_strong_body {
136 return None;
137 }
138 let credential_state =
139 credential_state_403(auth_state, challenge.as_ref(), body_signal.as_ref());
140 let confidence = strong_if_present(challenge.as_ref());
141 Some(AuthBlockSignature {
142 family: AuthBlockFamily::OriginAuthorization,
143 confidence,
144 status: 403,
145 credential_state,
146 challenge,
147 body_signal,
148 login_redirect: None,
149 })
150}
151
152fn classify_login_redirect(res: &ResponseSurface) -> Option<AuthBlockSignature> {
153 let signal = is_login_redirect(res)?;
154 Some(AuthBlockSignature {
155 family: AuthBlockFamily::LoginRedirect,
156 confidence: signal.confidence,
157 status: res.status.as_u16(),
158 credential_state: CredentialBlockKind::NoCredential,
159 challenge: None,
160 body_signal: None,
161 login_redirect: Some(signal),
162 })
163}
164
165fn classify_body_envelope(res: &ResponseSurface) -> Option<AuthBlockSignature> {
166 let body_signal = parse_body_signal(res)?;
167 Some(AuthBlockSignature {
168 family: AuthBlockFamily::AuthErrorEnvelope,
169 confidence: body_signal.confidence,
170 status: res.status.as_u16(),
171 credential_state: CredentialBlockKind::UnknownAuthFailure,
172 challenge: None,
173 body_signal: Some(body_signal),
174 login_redirect: None,
175 })
176}
177
178fn parse_challenge_header(res: &ResponseSurface, name: http::HeaderName) -> Option<AuthChallenge> {
179 res.headers
180 .get(name)
181 .and_then(|v| v.to_str().ok())
182 .and_then(parse_www_authenticate)
183}
184
185fn parse_body_signal(res: &ResponseSurface) -> Option<AuthErrorBodySignal> {
186 let ct = res
187 .headers
188 .get(http::header::CONTENT_TYPE)
189 .and_then(|v| v.to_str().ok());
190 parse_auth_error_body(ct, &res.body)
191}
192
193fn strong_if_present<T>(opt: Option<&T>) -> AuthBlockConfidence {
194 if opt.is_some() {
195 AuthBlockConfidence::Strong
196 } else {
197 AuthBlockConfidence::Medium
198 }
199}
200
201fn credential_state_403(
202 auth_state: RequestAuthState,
203 challenge: Option<&AuthChallenge>,
204 body_signal: Option<&AuthErrorBodySignal>,
205) -> CredentialBlockKind {
206 let challenge_error = challenge.and_then(|c| c.error.as_deref());
207 let body_code = body_signal.map(|b| b.code.as_str());
208 let signals_insufficient = matches_token(challenge_error, "insufficient_scope")
209 || matches_token(body_code, "insufficient_scope");
210 let signals_rejected = matches_token(challenge_error, "invalid_token")
211 || matches_token(body_code, "invalid_token");
212 match (auth_state, signals_insufficient, signals_rejected) {
213 (RequestAuthState::Present, true, _) => CredentialBlockKind::InsufficientScope,
214 (RequestAuthState::Present, _, true) => CredentialBlockKind::CredentialRejected,
215 (RequestAuthState::Absent, _, _) if challenge.is_some() => {
216 CredentialBlockKind::NoCredential
217 }
218 _ => CredentialBlockKind::UnknownAuthFailure,
219 }
220}
221
222fn credential_state_from_challenge(
223 auth_state: RequestAuthState,
224 challenge: Option<&AuthChallenge>,
225) -> CredentialBlockKind {
226 let error = challenge.and_then(|c| c.error.as_deref());
227 if matches_token(error, "insufficient_scope") {
228 return CredentialBlockKind::InsufficientScope;
229 }
230 if matches_token(error, "invalid_token") || matches_token(error, "expired_token") {
231 return CredentialBlockKind::CredentialRejected;
232 }
233 match auth_state {
234 RequestAuthState::Absent => CredentialBlockKind::NoCredential,
235 RequestAuthState::Present => CredentialBlockKind::UnknownAuthFailure,
236 }
237}
238
239fn matches_token(actual: Option<&str>, expected: &str) -> bool {
240 actual.is_some_and(|s| s.eq_ignore_ascii_case(expected))
241}
242
243#[cfg(test)]
244#[path = "auth_classifier_tests.rs"]
245mod tests;