1use async_trait::async_trait;
2use dashmap::DashSet;
3use rand::{distributions::Alphanumeric, seq::SliceRandom, Rng};
4use serde_json::Value;
5use std::sync::Arc;
6use url::Url;
7
8use crate::{
9 config::Config,
10 error::CapturedError,
11 http_client::{HttpClient, HttpResponse},
12 reports::{Finding, Severity},
13};
14
15use super::{common::http_utils::is_json_response, Scanner};
16
17pub struct OAuthOidcScanner {
18 checked_hosts: Arc<DashSet<String>>,
19}
20
21impl OAuthOidcScanner {
22 pub fn new(_config: &Config) -> Self {
23 Self {
24 checked_hosts: Arc::new(DashSet::new()),
25 }
26 }
27}
28
29static OAUTH_HINTS: &[&str] = &[
30 "/oauth",
31 "/oauth2",
32 "/oidc",
33 "/authorize",
34 "/token",
35 "/connect",
36 "/.well-known/openid-configuration",
37];
38
39#[async_trait]
40impl Scanner for OAuthOidcScanner {
41 fn name(&self) -> &'static str {
42 "oauth_oidc"
43 }
44
45 async fn scan(
46 &self,
47 url: &str,
48 client: &HttpClient,
49 config: &Config,
50 ) -> (Vec<Finding>, Vec<CapturedError>) {
51 if !config.active_checks {
52 return (Vec::new(), Vec::new());
53 }
54
55 let mut findings = Vec::new();
56 let mut errors = Vec::new();
57
58 let parsed = match Url::parse(url) {
59 Ok(u) => u,
60 Err(_) => return (findings, errors),
61 };
62
63 if !matches!(parsed.scheme(), "http" | "https") {
64 return (findings, errors);
65 }
66
67 let path = parsed.path().to_ascii_lowercase();
68 if !looks_oauth_related(&path) {
69 return (findings, errors);
70 }
71
72 if is_authorize_like_path(&path) {
73 let (mut f, mut e) = probe_authorize_redirect(url, client).await;
74 findings.append(&mut f);
75 errors.append(&mut e);
76 }
77
78 if let Some(well_known_url) = openid_well_known_url(&parsed) {
79 if let Some(host) = parsed.host_str() {
81 if self.checked_hosts.insert(host.to_string()) {
82 let (mut f, mut e) =
84 analyze_openid_metadata(url, &well_known_url, client).await;
85 findings.append(&mut f);
86 errors.append(&mut e);
87 }
88 } else {
89 let (mut f, mut e) = analyze_openid_metadata(url, &well_known_url, client).await;
91 findings.append(&mut f);
92 errors.append(&mut e);
93 }
94 }
95
96 (findings, errors)
97 }
98}
99
100fn looks_oauth_related(path: &str) -> bool {
101 OAUTH_HINTS.iter().any(|hint| path.contains(hint))
102}
103
104fn is_authorize_like_path(path: &str) -> bool {
105 path.contains("authorize") || path.ends_with("/auth")
106}
107
108fn openid_well_known_url(parsed: &Url) -> Option<String> {
109 let host = parsed.host_str()?;
110 let mut base = format!("{}://{}", parsed.scheme(), host);
111 if let Some(port) = parsed.port() {
112 base.push(':');
113 base.push_str(&port.to_string());
114 }
115 Some(format!("{base}/.well-known/openid-configuration"))
116}
117
118fn random_probe_token(len: usize) -> String {
119 rand::thread_rng()
120 .sample_iter(&Alphanumeric)
121 .map(char::from)
122 .map(|c| c.to_ascii_lowercase())
123 .take(len)
124 .collect()
125}
126
127fn random_redirect_probe() -> String {
128 const PROBES: &[&str] = &[
129 "https://app.example.net/callback",
130 "https://cdn.example.net/oauth/callback",
131 "https://portal.example.org/auth/callback",
132 ];
133 let mut rng = rand::thread_rng();
134 PROBES
135 .choose(&mut rng)
136 .copied()
137 .unwrap_or("https://app.example.net/callback")
138 .to_string()
139}
140
141async fn probe_authorize_redirect(
142 target_url: &str,
143 client: &HttpClient,
144) -> (Vec<Finding>, Vec<CapturedError>) {
145 let mut findings = Vec::new();
146 let mut errors = Vec::new();
147
148 let mut probe = match Url::parse(target_url) {
149 Ok(u) => u,
150 Err(_) => return (findings, errors),
151 };
152 probe.set_query(None);
153 probe.set_fragment(None);
154
155 let state_probe = format!("st_{}", random_probe_token(10));
156 let client_probe = format!("apihunter-{}", random_probe_token(8));
157 let redirect_probe = random_redirect_probe();
158
159 probe
160 .query_pairs_mut()
161 .append_pair("response_type", "code")
162 .append_pair("client_id", &client_probe)
163 .append_pair("redirect_uri", &redirect_probe)
164 .append_pair("scope", "openid profile")
165 .append_pair("state", &state_probe);
166
167 let resp = match authorize_probe_without_redirects(client, &probe).await {
168 Ok(r) => r,
169 Err(e) => {
170 errors.push(e);
171 return (findings, errors);
172 }
173 };
174
175 let Some(location) = resp.header("location") else {
176 return (findings, errors);
177 };
178 let location_l = location.to_ascii_lowercase();
179 let redirect_probe_l = redirect_probe.to_ascii_lowercase();
180
181 if !location_l.contains(&format!("state={state_probe}")) {
182 findings.push(
183 Finding::new(
184 target_url,
185 "oauth/state-not-returned",
186 "OAuth state parameter may not be round-tripped",
187 Severity::Medium,
188 "Authorization redirect did not include the supplied state value.",
189 "oauth_oidc",
190 )
191 .with_evidence(format!(
192 "GET {}\nStatus: {}\nLocation: {}",
193 probe, resp.status, location
194 ))
195 .with_remediation(
196 "Ensure the authorization server preserves and returns the exact state value.",
197 ),
198 );
199 }
200
201 if location_l.starts_with(&redirect_probe_l) {
202 findings.push(
203 Finding::new(
204 target_url,
205 "oauth/redirect-uri-not-validated",
206 "OAuth authorize endpoint may accept attacker redirect_uri",
207 Severity::High,
208 "Authorization flow redirected to an attacker-controlled redirect_uri.",
209 "oauth_oidc",
210 )
211 .with_evidence(format!(
212 "GET {}\nStatus: {}\nLocation: {}",
213 probe, resp.status, location
214 ))
215 .with_remediation(
216 "Require exact redirect_uri matching per client registration and reject unregistered callbacks.",
217 ),
218 );
219 }
220
221 (findings, errors)
222}
223
224async fn authorize_probe_without_redirects(
225 client: &HttpClient,
226 probe: &Url,
227) -> Result<HttpResponse, CapturedError> {
228 client
229 .get_with_headers_no_redirect(probe.as_str(), &[])
230 .await
231 .map_err(|mut e| {
232 e.context = "oauth/authorize-probe".to_string();
233 e.url = Some(probe.to_string());
234 e
235 })
236}
237
238async fn analyze_openid_metadata(
239 source_url: &str,
240 metadata_url: &str,
241 client: &HttpClient,
242) -> (Vec<Finding>, Vec<CapturedError>) {
243 let mut findings = Vec::new();
244 let mut errors = Vec::new();
245
246 let body = if let Some(cached) = client.get_cached_spec(metadata_url) {
247 cached
248 } else {
249 let resp = match client.get(metadata_url).await {
250 Ok(r) => r,
251 Err(e) => {
252 errors.push(e);
253 return (findings, errors);
254 }
255 };
256
257 if resp.status >= 400 || !is_json_response(&resp.headers, &resp.body) {
258 return (findings, errors);
259 }
260
261 client.cache_spec(metadata_url, &resp.body);
262 resp.body
263 };
264
265 let parsed: Value = match serde_json::from_str(&body) {
266 Ok(v) => v,
267 Err(e) => {
268 errors.push(CapturedError::new(
269 "oauth/openid-metadata-parse",
270 Some(metadata_url.to_string()),
271 &e,
272 ));
273 return (findings, errors);
274 }
275 };
276
277 let pkce_methods = get_string_array(&parsed, "code_challenge_methods_supported");
278 if pkce_methods.is_empty() {
279 findings.push(
280 Finding::new(
281 source_url,
282 "oauth/pkce-metadata-missing",
283 "OIDC metadata missing PKCE methods",
284 Severity::Medium,
285 "OpenID metadata does not declare code_challenge_methods_supported.",
286 "oauth_oidc",
287 )
288 .with_evidence(format!("GET {metadata_url}"))
289 .with_remediation(
290 "Publish code_challenge_methods_supported and enforce PKCE with S256 for public clients.",
291 ),
292 );
293 } else {
294 let has_s256 = pkce_methods.iter().any(|m| m == "s256");
295 let has_plain = pkce_methods.iter().any(|m| m == "plain");
296
297 if !has_s256 {
298 findings.push(
299 Finding::new(
300 source_url,
301 "oauth/pkce-s256-not-supported",
302 "OIDC metadata does not advertise PKCE S256",
303 Severity::High,
304 "Authorization server metadata does not include S256 in supported PKCE methods.",
305 "oauth_oidc",
306 )
307 .with_evidence(format!(
308 "GET {metadata_url}\ncode_challenge_methods_supported: {}",
309 pkce_methods.join(", ")
310 ))
311 .with_remediation(
312 "Support and require PKCE S256 for authorization-code flows.",
313 ),
314 );
315 } else if has_plain {
316 findings.push(
317 Finding::new(
318 source_url,
319 "oauth/pkce-plain-supported",
320 "OIDC metadata allows weak PKCE plain method",
321 Severity::Medium,
322 "Authorization server metadata includes the weak PKCE plain method.",
323 "oauth_oidc",
324 )
325 .with_evidence(format!(
326 "GET {metadata_url}\ncode_challenge_methods_supported: {}",
327 pkce_methods.join(", ")
328 ))
329 .with_remediation("Disable PKCE plain and enforce S256 only."),
330 );
331 }
332 }
333
334 let response_types = get_string_array(&parsed, "response_types_supported");
335 if response_types
336 .iter()
337 .any(|t| t.split_whitespace().any(|p| p == "token"))
338 {
339 findings.push(
340 Finding::new(
341 source_url,
342 "oauth/implicit-flow-enabled",
343 "OIDC metadata indicates implicit or hybrid token response types",
344 Severity::Medium,
345 "response_types_supported includes token-bearing flows.",
346 "oauth_oidc",
347 )
348 .with_evidence(format!(
349 "GET {metadata_url}\nresponse_types_supported: {}",
350 response_types.join(", ")
351 ))
352 .with_remediation(
353 "Prefer authorization-code + PKCE and disable implicit/hybrid token response types when possible.",
354 ),
355 );
356 }
357
358 let grant_types = get_string_array(&parsed, "grant_types_supported");
359 if grant_types.iter().any(|g| g == "password") {
360 findings.push(
361 Finding::new(
362 source_url,
363 "oauth/ropc-grant-enabled",
364 "OIDC metadata advertises password grant",
365 Severity::Medium,
366 "grant_types_supported includes Resource Owner Password Credentials.",
367 "oauth_oidc",
368 )
369 .with_evidence(format!(
370 "GET {metadata_url}\ngrant_types_supported: {}",
371 grant_types.join(", ")
372 ))
373 .with_remediation(
374 "Avoid password grant and migrate clients to authorization-code + PKCE.",
375 ),
376 );
377 }
378
379 (findings, errors)
380}
381
382fn get_string_array(v: &Value, key: &str) -> Vec<String> {
383 v.get(key)
384 .and_then(|x| x.as_array())
385 .map(|arr| {
386 arr.iter()
387 .filter_map(|x| x.as_str())
388 .map(|s| s.to_ascii_lowercase())
389 .collect::<Vec<_>>()
390 })
391 .unwrap_or_default()
392}