1use std::collections::HashMap;
2
3use camel_api::Exchange;
4use camel_api::security_policy::CAMEL_HTTP_QUERY_HEADER;
5
6pub use camel_api::security_policy::CredentialSource;
11
12#[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
28fn 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
36pub 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
47pub 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
57pub 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
69pub 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
83pub 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
128pub 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
143fn 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
166fn 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
185pub 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
217fn 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#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn credential_source_reexport_path_stable() {
248 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 #[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\" }"); }
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 #[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 #[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 #[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 #[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 #[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 #[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:?}"); assert!(!debug.contains("super-secret-jwt-value"));
548 assert!(debug.contains("[REDACTED]"));
549 }
550}