Skip to main content

api_scanner/scanner/
oauth_oidc.rs

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            // Skip analyze_openid_metadata if this host has already been processed
80            if let Some(host) = parsed.host_str() {
81                if self.checked_hosts.insert(host.to_string()) {
82                    // Host inserted successfully (first time), proceed with analysis
83                    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                // If we can't extract the host, proceed anyway as a fallback
90                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}