Skip to main content

camel_auth/
credential_source.rs

1use std::collections::HashMap;
2
3use camel_api::Exchange;
4use camel_api::security_policy::CAMEL_HTTP_QUERY_HEADER;
5
6/// Re-export of the `CredentialSource` contract type, which now lives in
7/// camel-api so camel-core and camel-dsl can reference it without depending
8/// on this crate. Keeps every existing `camel_auth::CredentialSource` path
9/// (e.g. `camel-ws`, `camel-component-api`) compiling unchanged.
10pub use camel_api::security_policy::CredentialSource;
11
12/// A token extracted from a specific source.
13#[derive(Clone)]
14pub struct ExtractedToken {
15    pub token: String,
16    pub source: CredentialSource,
17}
18
19impl std::fmt::Debug for ExtractedToken {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.debug_struct("ExtractedToken")
22            .field("token", &"[REDACTED]")
23            .field("source", &self.source)
24            .finish()
25    }
26}
27
28/// Percent-decode a string, returning the original on failure.
29fn percent_decode_str(s: &str) -> String {
30    percent_encoding::percent_decode_str(s)
31        .decode_utf8()
32        .unwrap_or_else(|_| s.into())
33        .into_owned()
34}
35
36/// Extract a bearer token from the `Authorization` header.
37///
38/// Delegates to `crate::extract_bearer_token`. Returns `None` if the header
39/// is absent; propagates errors as `None` (the caller should log separately).
40pub fn extract_token_from_header(headers: &http::HeaderMap) -> Option<String> {
41    let value = headers.get(http::header::AUTHORIZATION)?;
42    crate::extract_bearer_token(value.to_str().ok()?)
43        .ok()
44        .flatten()
45}
46
47/// Extract a token from the query string of a URI.
48///
49/// Parses the query portion of `uri`, looks for `param`, and percent-decodes
50/// the value.
51pub fn extract_token_from_query(uri: &http::Uri, param: &str) -> Option<String> {
52    let query = uri.query()?;
53    let pairs = parse_query_string(query);
54    pairs.get(param).map(|v| percent_decode_str(v))
55}
56
57/// Extract a token from a named cookie in the `Cookie` header.
58///
59/// Parses the `Cookie` header value (semicolon-separated `name=value` pairs)
60/// and returns the value for `cookie_name`, percent-decoded.
61pub fn extract_token_from_cookie(headers: &http::HeaderMap, cookie_name: &str) -> Option<String> {
62    let cookie_header = headers.get(http::header::COOKIE)?;
63    let cookie_str = cookie_header.to_str().ok()?;
64    parse_cookie_header(cookie_str)
65        .get(cookie_name)
66        .map(|v| percent_decode_str(v))
67}
68
69/// Extract a token from a named request header.
70///
71/// Looks up `name` case-insensitively, consistent with `Message::header_ic`.
72/// The whole header value is the token (no scheme prefix). Returns `None` if
73/// the header is absent, its value is not valid UTF-8, or `name` is not a
74/// valid HTTP header token.
75pub fn extract_token_from_named_header(headers: &http::HeaderMap, name: &str) -> Option<String> {
76    let header_name = http::header::HeaderName::try_from(name).ok()?;
77    headers
78        .get(&header_name)
79        .and_then(|v| v.to_str().ok())
80        .map(|s| s.to_string())
81}
82
83/// Try each source in order, returning the first successful extraction.
84pub fn extract_token_multi(
85    headers: &http::HeaderMap,
86    uri: &http::Uri,
87    sources: &[CredentialSource],
88) -> Option<ExtractedToken> {
89    for source in sources {
90        match source {
91            CredentialSource::AuthorizationHeader => {
92                if let Some(token) = extract_token_from_header(headers) {
93                    return Some(ExtractedToken {
94                        token,
95                        source: source.clone(),
96                    });
97                }
98            }
99            CredentialSource::QueryParam { param } => {
100                if let Some(token) = extract_token_from_query(uri, param) {
101                    return Some(ExtractedToken {
102                        token,
103                        source: source.clone(),
104                    });
105                }
106            }
107            CredentialSource::Cookie { name } => {
108                if let Some(token) = extract_token_from_cookie(headers, name) {
109                    return Some(ExtractedToken {
110                        token,
111                        source: source.clone(),
112                    });
113                }
114            }
115            CredentialSource::Header { name } => {
116                if let Some(token) = extract_token_from_named_header(headers, name) {
117                    return Some(ExtractedToken {
118                        token,
119                        source: source.clone(),
120                    });
121                }
122            }
123        }
124    }
125    None
126}
127
128/// Extract a token from the Exchange input using the route-declared sources.
129///
130/// Builds an `http::HeaderMap` view from the camel-api input headers, reads the
131/// raw query string from the `CAMEL_HTTP_QUERY_HEADER` input header, and
132/// delegates to `extract_token_multi`. A missing or malformed query header
133/// yields no query pairs. An absent source is never fatal (ADR-0032).
134pub fn extract_token_from_exchange(
135    exchange: &Exchange,
136    sources: &[CredentialSource],
137) -> Option<ExtractedToken> {
138    let headers = header_map_from_exchange(exchange);
139    let uri = query_uri_from_exchange(exchange);
140    extract_token_multi(&headers, &uri, sources)
141}
142
143/// Build an `http::HeaderMap` view from the camel-api input headers.
144///
145/// Only header entries whose name and value survive the `http` crate's
146/// validation become HTTP headers. Non-string `serde_json` values and names or
147/// values rejected by `HeaderName`/`HeaderValue` parsing are skipped — the
148/// source is treated as absent, never fatal.
149fn header_map_from_exchange(exchange: &Exchange) -> http::HeaderMap {
150    let mut headers = http::HeaderMap::new();
151    for (name, value) in &exchange.input.headers {
152        let Ok(header_name) = http::header::HeaderName::try_from(name.as_str()) else {
153            continue;
154        };
155        let Some(value_str) = value.as_str() else {
156            continue;
157        };
158        let Ok(header_value) = http::header::HeaderValue::from_str(value_str) else {
159            continue;
160        };
161        headers.append(header_name, header_value);
162    }
163    headers
164}
165
166/// Build a synthetic URI carrying the raw query string from the input header.
167///
168/// `http::Uri` requires a path, so a minimal `/` is prepended. A missing, empty,
169/// or malformed query header yields a query-less URI (no query pairs).
170fn query_uri_from_exchange(exchange: &Exchange) -> http::Uri {
171    let Some(query) = exchange
172        .input
173        .header_ic(CAMEL_HTTP_QUERY_HEADER)
174        .and_then(|v| v.as_str())
175        .filter(|q| !q.is_empty())
176    else {
177        return http::Uri::from_static("/");
178    };
179    let query = query.strip_prefix('?').unwrap_or(query);
180    let raw = format!("/?{query}");
181    raw.parse::<http::Uri>()
182        .unwrap_or_else(|_| http::Uri::from_static("/"))
183}
184
185/// Replace sensitive query parameter values with `[REDACTED]`.
186///
187/// Returns the full URI as a string with matching param values replaced.
188pub fn redact_query_params(uri: &http::Uri, sensitive_params: &[&str]) -> String {
189    let query = match uri.query() {
190        Some(q) => q,
191        None => return uri.to_string(),
192    };
193
194    let redacted: Vec<String> = query
195        .split('&')
196        .map(|pair| {
197            if let Some((key, _value)) = pair.split_once('=') {
198                if sensitive_params.iter().any(|s| s == &key) {
199                    format!("{}=[REDACTED]", key)
200                } else {
201                    pair.to_string()
202                }
203            } else {
204                pair.to_string()
205            }
206        })
207        .collect();
208
209    let base = uri.path();
210    if redacted.is_empty() {
211        base.to_string()
212    } else {
213        format!("{}?{}", base, redacted.join("&"))
214    }
215}
216
217// --- Internal helpers ---
218
219fn parse_query_string(query: &str) -> HashMap<String, String> {
220    let mut map = HashMap::new();
221    for pair in query.split('&') {
222        if let Some((key, value)) = pair.split_once('=') {
223            map.insert(key.to_string(), value.to_string());
224        }
225    }
226    map
227}
228
229fn parse_cookie_header(cookie_str: &str) -> HashMap<String, String> {
230    let mut map = HashMap::new();
231    for pair in cookie_str.split(';') {
232        let pair = pair.trim();
233        if let Some((key, value)) = pair.split_once('=') {
234            map.insert(key.trim().to_string(), value.trim().to_string());
235        }
236    }
237    map
238}
239
240// --- Tests ---
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn credential_source_reexport_path_stable() {
248        // The re-export keeps `camel_auth::CredentialSource` resolving to the
249        // camel-api contract type (pure move — same type identity).
250        let via_reexport = CredentialSource::AuthorizationHeader;
251        let canonical = camel_api::security_policy::CredentialSource::AuthorizationHeader;
252        assert_eq!(via_reexport, canonical);
253        assert_eq!(via_reexport.variant_name(), "AuthorizationHeader");
254    }
255
256    // --- CredentialSource Debug ---
257
258    #[test]
259    fn debug_authorization_header_shows_variant_name() {
260        let source = CredentialSource::AuthorizationHeader;
261        assert_eq!(format!("{:?}", source), "AuthorizationHeader");
262    }
263
264    #[test]
265    fn debug_query_param_shows_param_name() {
266        let source = CredentialSource::QueryParam {
267            param: "token".to_string(),
268        };
269        assert_eq!(format!("{:?}", source), "QueryParam { param: \"token\" }"); // allow-secret
270    }
271
272    #[test]
273    fn debug_cookie_shows_cookie_name() {
274        let source = CredentialSource::Cookie {
275            name: "session".to_string(),
276        };
277        assert_eq!(format!("{:?}", source), "Cookie { name: \"session\" }");
278    }
279
280    #[test]
281    fn credential_source_clone() {
282        let original = CredentialSource::QueryParam {
283            param: "access_token".to_string(),
284        };
285        let cloned = original.clone();
286        assert_eq!(format!("{:?}", original), format!("{:?}", cloned));
287    }
288
289    // --- extract_token_from_header ---
290
291    #[test]
292    fn extract_header_valid_bearer() {
293        let mut headers = http::HeaderMap::new();
294        headers.insert(
295            http::header::AUTHORIZATION,
296            "Bearer mytoken123".parse().unwrap(),
297        );
298        let token = extract_token_from_header(&headers);
299        assert_eq!(token, Some("mytoken123".to_string()));
300    }
301
302    #[test]
303    fn extract_header_missing_returns_none() {
304        let headers = http::HeaderMap::new();
305        let token = extract_token_from_header(&headers);
306        assert!(token.is_none());
307    }
308
309    #[test]
310    fn extract_header_non_bearer_returns_none() {
311        let mut headers = http::HeaderMap::new();
312        headers.insert(http::header::AUTHORIZATION, "Basic abc123".parse().unwrap());
313        let token = extract_token_from_header(&headers);
314        assert!(token.is_none());
315    }
316
317    // --- extract_token_from_query ---
318
319    #[test]
320    fn extract_query_valid_token() {
321        let uri: http::Uri = "/ws?token=abc123".parse().unwrap();
322        let token = extract_token_from_query(&uri, "token");
323        assert_eq!(token, Some("abc123".to_string()));
324    }
325
326    #[test]
327    fn extract_query_missing_param_returns_none() {
328        let uri: http::Uri = "/ws?other=value".parse().unwrap();
329        let token = extract_token_from_query(&uri, "token");
330        assert!(token.is_none());
331    }
332
333    #[test]
334    fn extract_query_no_query_string_returns_none() {
335        let uri: http::Uri = "/ws".parse().unwrap();
336        let token = extract_token_from_query(&uri, "token");
337        assert!(token.is_none());
338    }
339
340    #[test]
341    fn extract_query_percent_encoded() {
342        let uri: http::Uri = "/ws?token=abc%2Bdef".parse().unwrap();
343        let token = extract_token_from_query(&uri, "token");
344        assert_eq!(token, Some("abc+def".to_string()));
345    }
346
347    #[test]
348    fn extract_query_multiple_params() {
349        let uri: http::Uri = "/ws?foo=bar&token=secret&baz=qux".parse().unwrap();
350        let token = extract_token_from_query(&uri, "token");
351        assert_eq!(token, Some("secret".to_string()));
352    }
353
354    // --- extract_token_from_cookie ---
355
356    #[test]
357    fn extract_cookie_valid() {
358        let mut headers = http::HeaderMap::new();
359        headers.insert(
360            http::header::COOKIE,
361            "session=cookie_token_123".parse().unwrap(),
362        );
363        let token = extract_token_from_cookie(&headers, "session");
364        assert_eq!(token, Some("cookie_token_123".to_string()));
365    }
366
367    #[test]
368    fn extract_cookie_missing_returns_none() {
369        let mut headers = http::HeaderMap::new();
370        headers.insert(http::header::COOKIE, "other=value".parse().unwrap());
371        let token = extract_token_from_cookie(&headers, "session");
372        assert!(token.is_none());
373    }
374
375    #[test]
376    fn extract_cookie_no_cookie_header_returns_none() {
377        let headers = http::HeaderMap::new();
378        let token = extract_token_from_cookie(&headers, "session");
379        assert!(token.is_none());
380    }
381
382    #[test]
383    fn extract_cookie_multiple_cookies() {
384        let mut headers = http::HeaderMap::new();
385        headers.insert(
386            http::header::COOKIE,
387            "foo=bar; auth_token=mycookie; baz=qux".parse().unwrap(),
388        );
389        let token = extract_token_from_cookie(&headers, "auth_token");
390        assert_eq!(token, Some("mycookie".to_string()));
391    }
392
393    #[test]
394    fn extract_cookie_with_spaces() {
395        let mut headers = http::HeaderMap::new();
396        headers.insert(
397            http::header::COOKIE,
398            "foo=bar;  auth_token=spaced_token  ; baz=qux"
399                .parse()
400                .unwrap(),
401        );
402        let token = extract_token_from_cookie(&headers, "auth_token");
403        assert_eq!(token, Some("spaced_token".to_string()));
404    }
405
406    // --- extract_token_multi ---
407
408    #[test]
409    fn multi_falls_back_from_header_to_query() {
410        let headers = http::HeaderMap::new();
411        let uri: http::Uri = "/ws?token=query_token".parse().unwrap();
412        let sources = vec![
413            CredentialSource::AuthorizationHeader,
414            CredentialSource::QueryParam {
415                param: "token".to_string(),
416            },
417        ];
418        let result = extract_token_multi(&headers, &uri, &sources);
419        assert!(result.is_some());
420        let extracted = result.unwrap();
421        assert_eq!(extracted.token, "query_token");
422        assert!(matches!(
423            extracted.source,
424            CredentialSource::QueryParam { .. }
425        ));
426    }
427
428    #[test]
429    fn multi_prefers_first_matching_source() {
430        let mut headers = http::HeaderMap::new();
431        headers.insert(
432            http::header::AUTHORIZATION,
433            "Bearer header_token".parse().unwrap(),
434        );
435        let uri: http::Uri = "/ws?token=query_token".parse().unwrap();
436        let sources = vec![
437            CredentialSource::AuthorizationHeader,
438            CredentialSource::QueryParam {
439                param: "token".to_string(),
440            },
441        ];
442        let result = extract_token_multi(&headers, &uri, &sources);
443        assert!(result.is_some());
444        let extracted = result.unwrap();
445        assert_eq!(extracted.token, "header_token");
446        assert!(matches!(
447            extracted.source,
448            CredentialSource::AuthorizationHeader
449        ));
450    }
451
452    #[test]
453    fn multi_falls_back_to_cookie() {
454        let mut headers = http::HeaderMap::new();
455        headers.insert(
456            http::header::COOKIE,
457            "session=cookie_token".parse().unwrap(),
458        );
459        let uri: http::Uri = "/ws".parse().unwrap();
460        let sources = vec![
461            CredentialSource::AuthorizationHeader,
462            CredentialSource::QueryParam {
463                param: "token".to_string(),
464            },
465            CredentialSource::Cookie {
466                name: "session".to_string(),
467            },
468        ];
469        let result = extract_token_multi(&headers, &uri, &sources);
470        assert!(result.is_some());
471        let extracted = result.unwrap();
472        assert_eq!(extracted.token, "cookie_token");
473        assert!(matches!(extracted.source, CredentialSource::Cookie { .. }));
474    }
475
476    #[test]
477    fn multi_returns_none_when_all_fail() {
478        let headers = http::HeaderMap::new();
479        let uri: http::Uri = "/ws".parse().unwrap();
480        let sources = vec![
481            CredentialSource::AuthorizationHeader,
482            CredentialSource::QueryParam {
483                param: "token".to_string(),
484            },
485            CredentialSource::Cookie {
486                name: "session".to_string(),
487            },
488        ];
489        let result = extract_token_multi(&headers, &uri, &sources);
490        assert!(result.is_none());
491    }
492
493    // --- redact_query_params ---
494
495    #[test]
496    fn redact_single_sensitive_param() {
497        let uri: http::Uri = "/ws?token=secret&foo=bar".parse().unwrap();
498        let redacted = redact_query_params(&uri, &["token"]);
499        assert_eq!(redacted, "/ws?token=[REDACTED]&foo=bar");
500    }
501
502    #[test]
503    fn redact_multiple_sensitive_params() {
504        let uri: http::Uri = "/ws?token=secret&password=pass123&foo=bar".parse().unwrap();
505        let redacted = redact_query_params(&uri, &["token", "password"]);
506        assert_eq!(redacted, "/ws?token=[REDACTED]&password=[REDACTED]&foo=bar");
507    }
508
509    #[test]
510    fn redact_no_sensitive_params_in_uri() {
511        let uri: http::Uri = "/ws?foo=bar&baz=qux".parse().unwrap();
512        let redacted = redact_query_params(&uri, &["token"]);
513        assert_eq!(redacted, "/ws?foo=bar&baz=qux");
514    }
515
516    #[test]
517    fn redact_no_query_string_returns_uri_as_is() {
518        let uri: http::Uri = "/ws".parse().unwrap();
519        let redacted = redact_query_params(&uri, &["token"]);
520        assert_eq!(redacted, "/ws");
521    }
522
523    // --- percent_decode_str ---
524
525    #[test]
526    fn percent_decode_plus_sign() {
527        assert_eq!(percent_decode_str("hello%2Bworld"), "hello+world");
528    }
529
530    #[test]
531    fn percent_decode_space() {
532        assert_eq!(percent_decode_str("hello%20world"), "hello world");
533    }
534
535    #[test]
536    fn percent_decode_no_encoding_returns_original() {
537        assert_eq!(percent_decode_str("plaintext"), "plaintext");
538    }
539
540    #[test]
541    fn extracted_token_debug_redacts_token() {
542        let token = ExtractedToken {
543            token: "super-secret-jwt-value".to_string(),
544            source: CredentialSource::AuthorizationHeader,
545        };
546        let debug = format!("{token:?}"); // allow-secret
547        assert!(!debug.contains("super-secret-jwt-value"));
548        assert!(debug.contains("[REDACTED]"));
549    }
550}