1use crate::FaucetError;
21use async_trait::async_trait;
22use schemars::JsonSchema;
23use serde::{Deserialize, Deserializer, Serialize};
24use std::sync::Arc;
25
26#[derive(Clone, PartialEq, Eq)]
34pub enum Credential {
35 Bearer(String),
37 Header {
39 name: String,
41 value: String,
43 },
44 Basic {
46 username: String,
48 password: String,
50 },
51 Token(String),
54}
55
56impl std::fmt::Debug for Credential {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Credential::Bearer(_) => f.debug_tuple("Bearer").field(&"***").finish(),
66 Credential::Header { name, .. } => f
67 .debug_struct("Header")
68 .field("name", name)
69 .field("value", &"***")
70 .finish(),
71 Credential::Basic { username, .. } => f
72 .debug_struct("Basic")
73 .field("username", username)
74 .field("password", &"***")
75 .finish(),
76 Credential::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
77 }
78 }
79}
80
81impl Credential {
82 pub fn authorization_value(&self) -> Option<String> {
87 match self {
88 Credential::Bearer(t) => Some(format!("Bearer {t}")),
89 Credential::Token(t) => Some(t.clone()),
90 Credential::Header { .. } | Credential::Basic { .. } => None,
91 }
92 }
93}
94
95#[derive(Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum CredentialPlacement {
104 Header {
106 name: String,
108 value: String,
110 },
111 Query {
113 name: String,
115 value: String,
117 },
118 Cookie {
120 name: String,
122 value: String,
124 },
125 BodyField {
127 name: String,
129 value: String,
131 },
132}
133
134impl std::fmt::Debug for CredentialPlacement {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 let (kind, name) = match self {
139 CredentialPlacement::Header { name, .. } => ("Header", name),
140 CredentialPlacement::Query { name, .. } => ("Query", name),
141 CredentialPlacement::Cookie { name, .. } => ("Cookie", name),
142 CredentialPlacement::BodyField { name, .. } => ("BodyField", name),
143 };
144 f.debug_struct(kind)
145 .field("name", name)
146 .field("value", &"***")
147 .finish()
148 }
149}
150
151#[derive(Clone, Default)]
161#[non_exhaustive]
162pub struct RequestAuth {
163 pub placements: Vec<CredentialPlacement>,
165 pub base_url: Option<String>,
167 pub captured: std::collections::BTreeMap<String, String>,
174}
175
176impl RequestAuth {
177 pub fn new() -> Self {
179 Self::default()
180 }
181
182 #[must_use]
184 pub fn with_placement(mut self, placement: CredentialPlacement) -> Self {
185 self.placements.push(placement);
186 self
187 }
188
189 #[must_use]
191 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
192 self.base_url = Some(base_url.into());
193 self
194 }
195
196 #[must_use]
199 pub fn with_captured(mut self, captured: std::collections::BTreeMap<String, String>) -> Self {
200 self.captured = captured;
201 self
202 }
203
204 pub fn is_empty(&self) -> bool {
207 self.placements.is_empty() && self.base_url.is_none() && self.captured.is_empty()
208 }
209}
210
211impl std::fmt::Debug for RequestAuth {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("RequestAuth")
216 .field("placements", &self.placements)
217 .field(
218 "base_url",
219 &self
220 .base_url
221 .as_deref()
222 .map(crate::util::redact_uri_credentials),
223 )
224 .finish()
225 }
226}
227
228#[async_trait]
237pub trait AuthProvider: Send + Sync + std::fmt::Debug {
238 async fn credential(&self) -> Result<Credential, FaucetError>;
240
241 async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
249 self.credential().await
250 }
251
252 async fn sign_request(
269 &self,
270 _method: &str,
271 _url: &str,
272 _query: &std::collections::BTreeMap<String, String>,
273 ) -> Result<Option<Credential>, FaucetError> {
274 Ok(None)
275 }
276
277 async fn request_auth(
293 &self,
294 _method: &str,
295 _url: &str,
296 _query: &std::collections::BTreeMap<String, String>,
297 ) -> Result<RequestAuth, FaucetError> {
298 Ok(RequestAuth::new())
299 }
300
301 fn reauth_statuses(&self) -> &[u16] {
308 &[]
309 }
310
311 fn provider_name(&self) -> &'static str;
313}
314
315pub type SharedAuthProvider = Arc<dyn AuthProvider>;
318
319pub fn rejects_credential(err: &FaucetError, provider: &dyn AuthProvider) -> bool {
323 matches!(
324 err,
325 FaucetError::HttpStatus { status, .. }
326 if *status == 401 || provider.reauth_statuses().contains(status)
327 )
328}
329
330pub async fn send_with_reauth<T, F, Fut>(
342 provider: Option<&SharedAuthProvider>,
343 mut send: F,
344) -> Result<T, FaucetError>
345where
346 F: FnMut(Option<Credential>) -> Fut,
347 Fut: std::future::Future<Output = Result<T, FaucetError>>,
348{
349 let Some(provider) = provider else {
350 return send(None).await;
351 };
352 let sent = provider.credential().await?;
353 match send(Some(sent.clone())).await {
354 Err(e) if rejects_credential(&e, provider.as_ref()) => {
355 let name = provider.provider_name();
356 tracing::warn!(
357 provider = name,
358 "the server rejected the shared credential; re-authenticating and retrying once"
359 );
360 let fresh = provider.invalidate(&sent).await?;
361 send(Some(fresh)).await
362 }
363 other => other,
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
370#[serde(deny_unknown_fields)]
371pub struct AuthReference {
372 #[serde(rename = "ref")]
374 pub name: String,
375}
376
377#[derive(Debug, Clone, Serialize, JsonSchema)]
384#[serde(untagged)]
385pub enum AuthSpec<A> {
386 Inline(A),
388 Reference(AuthReference),
390}
391
392impl<A: Default> Default for AuthSpec<A> {
393 fn default() -> Self {
394 AuthSpec::Inline(A::default())
395 }
396}
397
398impl<A> AuthSpec<A> {
399 pub fn inline(&self) -> Option<&A> {
401 match self {
402 AuthSpec::Inline(a) => Some(a),
403 AuthSpec::Reference(_) => None,
404 }
405 }
406
407 pub fn reference_name(&self) -> Option<&str> {
409 match self {
410 AuthSpec::Reference(r) => Some(&r.name),
411 AuthSpec::Inline(_) => None,
412 }
413 }
414}
415
416impl<'de, A> Deserialize<'de> for AuthSpec<A>
419where
420 A: serde::de::DeserializeOwned,
421{
422 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
423 where
424 D: Deserializer<'de>,
425 {
426 let value = serde_json::Value::deserialize(deserializer)?;
427 let has_ref = value.get("ref").is_some();
428 if has_ref {
429 let has_other = value
430 .as_object()
431 .map(|o| o.keys().any(|k| k != "ref"))
432 .unwrap_or(false);
433 if has_other {
434 return Err(serde::de::Error::custom(
435 "auth: `ref` cannot be combined with inline auth fields (type/config)",
436 ));
437 }
438 let r: AuthReference =
439 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
440 return Ok(AuthSpec::Reference(r));
441 }
442 let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
443 Ok(AuthSpec::Inline(inner))
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[derive(Debug, Deserialize, PartialEq)]
452 #[serde(tag = "type", content = "config", rename_all = "snake_case")]
453 enum StubAuth {
454 None,
455 Bearer { token: String },
456 }
457
458 #[derive(Debug, Default)]
461 struct RotatingProvider {
462 token: std::sync::Mutex<u32>,
463 refreshes: std::sync::atomic::AtomicU32,
464 }
465
466 #[async_trait]
467 impl AuthProvider for RotatingProvider {
468 async fn credential(&self) -> Result<Credential, FaucetError> {
469 Ok(Credential::Bearer(format!(
470 "t{}",
471 self.token.lock().unwrap()
472 )))
473 }
474 async fn invalidate(&self, stale: &Credential) -> Result<Credential, FaucetError> {
475 let mut t = self.token.lock().unwrap();
476 if *stale == Credential::Bearer(format!("t{t}")) {
477 *t += 1;
478 self.refreshes
479 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
480 }
481 Ok(Credential::Bearer(format!("t{t}")))
482 }
483 fn reauth_statuses(&self) -> &[u16] {
484 &[419]
485 }
486 fn provider_name(&self) -> &'static str {
487 "rotating"
488 }
489 }
490
491 fn rejected(status: u16) -> FaucetError {
492 FaucetError::HttpStatus {
493 status,
494 url: "https://api".into(),
495 body: String::new(),
496 }
497 }
498
499 #[tokio::test]
500 async fn a_rejected_credential_is_refreshed_and_the_request_retried_once() {
501 let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
502 let mut seen = Vec::new();
503 let out = send_with_reauth(Some(&provider), |cred| {
504 seen.push(cred.clone());
505 let ok = cred == Some(Credential::Bearer("t1".into()));
506 async move { if ok { Ok("data") } else { Err(rejected(401)) } }
507 })
508 .await
509 .unwrap();
510 assert_eq!(out, "data");
511 assert_eq!(
512 seen,
513 vec![
514 Some(Credential::Bearer("t0".into())),
515 Some(Credential::Bearer("t1".into()))
516 ]
517 );
518 }
519
520 #[tokio::test]
521 async fn a_declared_reauth_status_also_refreshes_but_only_once() {
522 let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
523 let mut attempts = 0;
524 let err = send_with_reauth(Some(&provider), |_| {
525 attempts += 1;
526 async { Err::<(), _>(rejected(419)) }
527 })
528 .await
529 .unwrap_err();
530 assert!(matches!(err, FaucetError::HttpStatus { status: 419, .. }));
531 assert_eq!(attempts, 2, "one retry, never a loop");
532 }
533
534 #[tokio::test]
535 async fn other_failures_and_providerless_requests_are_not_retried() {
536 let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
537 let mut attempts = 0;
538 let err = send_with_reauth(Some(&provider), |_| {
539 attempts += 1;
540 async { Err::<(), _>(rejected(500)) }
541 })
542 .await
543 .unwrap_err();
544 assert!(matches!(err, FaucetError::HttpStatus { status: 500, .. }));
545 assert_eq!(attempts, 1);
546
547 let mut got = None;
548 send_with_reauth(None, |cred| {
549 got = Some(cred);
550 async { Ok::<_, FaucetError>(()) }
551 })
552 .await
553 .unwrap();
554 assert_eq!(
555 got,
556 Some(None),
557 "no provider → the request gets no credential"
558 );
559 }
560
561 #[derive(Debug)]
562 struct MinimalProvider;
563
564 #[async_trait]
565 impl AuthProvider for MinimalProvider {
566 async fn credential(&self) -> Result<Credential, FaucetError> {
567 Ok(Credential::Bearer("t".into()))
568 }
569 fn provider_name(&self) -> &'static str {
570 "minimal"
571 }
572 }
573
574 #[test]
575 fn credential_placement_debug_redacts_value_keeps_name() {
576 let cases = [
577 CredentialPlacement::Header {
578 name: "X-Tok".into(),
579 value: "secret".into(),
580 },
581 CredentialPlacement::Query {
582 name: "access_token".into(),
583 value: "secret".into(),
584 },
585 CredentialPlacement::Cookie {
586 name: "sid".into(),
587 value: "secret".into(),
588 },
589 CredentialPlacement::BodyField {
590 name: "auth".into(),
591 value: "secret".into(),
592 },
593 ];
594 for p in cases {
595 let s = format!("{p:?}");
596 assert!(s.contains("***"), "value must be redacted: {s}");
597 assert!(!s.contains("secret"), "secret leaked: {s}");
598 }
599 }
600
601 #[test]
602 fn request_auth_builders_and_is_empty() {
603 let empty = RequestAuth::new();
604 assert!(empty.is_empty());
605
606 let ra = RequestAuth::new()
607 .with_placement(CredentialPlacement::Query {
608 name: "t".into(),
609 value: "v".into(),
610 })
611 .with_base_url("https://host");
612 assert!(!ra.is_empty());
613 assert_eq!(ra.placements.len(), 1);
614 assert_eq!(ra.base_url.as_deref(), Some("https://host"));
615
616 assert!(!RequestAuth::new().with_base_url("https://h").is_empty());
618 assert!(RequestAuth::default().is_empty());
619
620 let mut cap = std::collections::BTreeMap::new();
623 cap.insert("session_id".to_string(), "SID".to_string());
624 let ra = RequestAuth::new().with_captured(cap);
625 assert!(!ra.is_empty());
626 assert_eq!(
627 ra.captured.get("session_id").map(String::as_str),
628 Some("SID")
629 );
630 }
631
632 #[test]
633 fn request_auth_debug_redacts_placements_and_base_url() {
634 let ra = RequestAuth::new()
635 .with_placement(CredentialPlacement::Header {
636 name: "Authorization".into(),
637 value: "topsecret".into(),
638 })
639 .with_base_url("https://user:pw@host/path");
640 let s = format!("{ra:?}");
641 assert!(!s.contains("topsecret"), "placement value leaked: {s}");
642 assert!(!s.contains("pw"), "base-url userinfo leaked: {s}");
643 }
644
645 #[tokio::test]
646 async fn default_request_auth_is_empty_and_reauth_is_empty() {
647 let p = MinimalProvider;
648 assert!(matches!(
649 p.credential().await.unwrap(),
650 Credential::Bearer(_)
651 ));
652 assert_eq!(p.provider_name(), "minimal");
653 let ra = p
654 .request_auth("GET", "https://x", &std::collections::BTreeMap::new())
655 .await
656 .unwrap();
657 assert!(ra.is_empty());
658 assert!(p.reauth_statuses().is_empty());
659 assert!(
661 p.sign_request("GET", "https://x", &std::collections::BTreeMap::new())
662 .await
663 .unwrap()
664 .is_none()
665 );
666 }
667
668 #[test]
669 fn credential_authorization_value() {
670 assert_eq!(
671 Credential::Bearer("abc".into()).authorization_value(),
672 Some("Bearer abc".to_string())
673 );
674 assert_eq!(
675 Credential::Token("Custom xyz".into()).authorization_value(),
676 Some("Custom xyz".to_string())
677 );
678 assert_eq!(
679 Credential::Basic {
680 username: "u".into(),
681 password: "p".into()
682 }
683 .authorization_value(),
684 None
685 );
686 assert_eq!(
687 Credential::Header {
688 name: "X-Api-Key".into(),
689 value: "k".into()
690 }
691 .authorization_value(),
692 None
693 );
694 }
695
696 #[test]
697 fn authspec_parses_inline() {
698 let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
699 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
700 match s {
701 AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
702 other => panic!("expected inline bearer, got {other:?}"),
703 }
704 }
705
706 #[test]
707 fn authspec_parses_inline_unit_variant() {
708 let j = serde_json::json!({"type": "none"});
709 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
710 assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
711 }
712
713 #[test]
714 fn authspec_parses_ref() {
715 let j = serde_json::json!({"ref": "sf"});
716 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
717 assert_eq!(s.reference_name(), Some("sf"));
718 }
719
720 #[test]
721 fn authspec_rejects_ref_plus_inline() {
722 let j = serde_json::json!({"ref": "sf", "type": "bearer"});
723 let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
724 assert!(r.is_err(), "ref + inline must be rejected");
725 }
726
727 #[derive(Debug)]
728 struct Fixed(Credential);
729
730 #[async_trait]
731 impl AuthProvider for Fixed {
732 async fn credential(&self) -> Result<Credential, FaucetError> {
733 Ok(self.0.clone())
734 }
735 fn provider_name(&self) -> &'static str {
736 "fixed"
737 }
738 }
739
740 #[test]
741 fn credential_debug_redacts_secrets() {
742 let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
744 assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
745 assert!(b.contains("***"), "bearer token not masked: {b}");
746
747 let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
748 assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
749 assert!(t.contains("***"), "raw token not masked: {t}");
750
751 let basic = format!(
753 "{:?}",
754 Credential::Basic {
755 username: "alice".into(),
756 password: "hunter2secret".into(),
757 }
758 );
759 assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
760 assert!(
761 basic.contains("alice"),
762 "username should stay visible for diagnostics: {basic}"
763 );
764
765 let header = format!(
767 "{:?}",
768 Credential::Header {
769 name: "X-Api-Key".into(),
770 value: "secretkeyvalue".into(),
771 }
772 );
773 assert!(
774 !header.contains("secretkeyvalue"),
775 "header value leaked: {header}"
776 );
777 assert!(
778 header.contains("X-Api-Key"),
779 "header name should stay visible for diagnostics: {header}"
780 );
781 }
782
783 #[tokio::test]
784 async fn auth_provider_default_invalidate_returns_current() {
785 let p = Fixed(Credential::Bearer("x".into()));
786 assert_eq!(
787 p.credential().await.unwrap(),
788 Credential::Bearer("x".into())
789 );
790 assert_eq!(
792 p.invalidate(&Credential::Bearer("old".into()))
793 .await
794 .unwrap(),
795 Credential::Bearer("x".into())
796 );
797 }
798}