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(
267 &self,
268 _method: &str,
269 _url: &str,
270 _query: &std::collections::BTreeMap<String, String>,
271 ) -> Result<Option<Credential>, FaucetError> {
272 Ok(None)
273 }
274
275 async fn request_auth(
291 &self,
292 _method: &str,
293 _url: &str,
294 _query: &std::collections::BTreeMap<String, String>,
295 ) -> Result<RequestAuth, FaucetError> {
296 Ok(RequestAuth::new())
297 }
298
299 fn reauth_statuses(&self) -> &[u16] {
306 &[]
307 }
308
309 fn provider_name(&self) -> &'static str;
311}
312
313pub type SharedAuthProvider = Arc<dyn AuthProvider>;
316
317pub fn rejects_credential(err: &FaucetError, provider: &dyn AuthProvider) -> bool {
321 matches!(
322 err,
323 FaucetError::HttpStatus { status, .. }
324 if *status == 401 || provider.reauth_statuses().contains(status)
325 )
326}
327
328pub async fn send_with_reauth<T, F, Fut>(
340 provider: Option<&SharedAuthProvider>,
341 mut send: F,
342) -> Result<T, FaucetError>
343where
344 F: FnMut(Option<Credential>) -> Fut,
345 Fut: std::future::Future<Output = Result<T, FaucetError>>,
346{
347 let Some(provider) = provider else {
348 return send(None).await;
349 };
350 let sent = provider.credential().await?;
351 match send(Some(sent.clone())).await {
352 Err(e) if rejects_credential(&e, provider.as_ref()) => {
353 let name = provider.provider_name();
354 tracing::warn!(
355 provider = name,
356 "the server rejected the shared credential; re-authenticating and retrying once"
357 );
358 let fresh = provider.invalidate(&sent).await?;
359 send(Some(fresh)).await
360 }
361 other => other,
362 }
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
368#[serde(deny_unknown_fields)]
369pub struct AuthReference {
370 #[serde(rename = "ref")]
372 pub name: String,
373}
374
375#[derive(Debug, Clone, Serialize, JsonSchema)]
382#[serde(untagged)]
383pub enum AuthSpec<A> {
384 Inline(A),
386 Reference(AuthReference),
388}
389
390impl<A: Default> Default for AuthSpec<A> {
391 fn default() -> Self {
392 AuthSpec::Inline(A::default())
393 }
394}
395
396impl<A> AuthSpec<A> {
397 pub fn inline(&self) -> Option<&A> {
399 match self {
400 AuthSpec::Inline(a) => Some(a),
401 AuthSpec::Reference(_) => None,
402 }
403 }
404
405 pub fn reference_name(&self) -> Option<&str> {
407 match self {
408 AuthSpec::Reference(r) => Some(&r.name),
409 AuthSpec::Inline(_) => None,
410 }
411 }
412}
413
414impl<'de, A> Deserialize<'de> for AuthSpec<A>
417where
418 A: serde::de::DeserializeOwned,
419{
420 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
421 where
422 D: Deserializer<'de>,
423 {
424 let value = serde_json::Value::deserialize(deserializer)?;
425 let has_ref = value.get("ref").is_some();
426 if has_ref {
427 let has_other = value
428 .as_object()
429 .map(|o| o.keys().any(|k| k != "ref"))
430 .unwrap_or(false);
431 if has_other {
432 return Err(serde::de::Error::custom(
433 "auth: `ref` cannot be combined with inline auth fields (type/config)",
434 ));
435 }
436 let r: AuthReference =
437 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
438 return Ok(AuthSpec::Reference(r));
439 }
440 let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
441 Ok(AuthSpec::Inline(inner))
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[derive(Debug, Deserialize, PartialEq)]
450 #[serde(tag = "type", content = "config", rename_all = "snake_case")]
451 enum StubAuth {
452 None,
453 Bearer { token: String },
454 }
455
456 #[derive(Debug, Default)]
459 struct RotatingProvider {
460 token: std::sync::Mutex<u32>,
461 refreshes: std::sync::atomic::AtomicU32,
462 }
463
464 #[async_trait]
465 impl AuthProvider for RotatingProvider {
466 async fn credential(&self) -> Result<Credential, FaucetError> {
467 Ok(Credential::Bearer(format!(
468 "t{}",
469 self.token.lock().unwrap()
470 )))
471 }
472 async fn invalidate(&self, stale: &Credential) -> Result<Credential, FaucetError> {
473 let mut t = self.token.lock().unwrap();
474 if *stale == Credential::Bearer(format!("t{t}")) {
475 *t += 1;
476 self.refreshes
477 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
478 }
479 Ok(Credential::Bearer(format!("t{t}")))
480 }
481 fn reauth_statuses(&self) -> &[u16] {
482 &[419]
483 }
484 fn provider_name(&self) -> &'static str {
485 "rotating"
486 }
487 }
488
489 fn rejected(status: u16) -> FaucetError {
490 FaucetError::HttpStatus {
491 status,
492 url: "https://api".into(),
493 body: String::new(),
494 }
495 }
496
497 #[tokio::test]
498 async fn a_rejected_credential_is_refreshed_and_the_request_retried_once() {
499 let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
500 let mut seen = Vec::new();
501 let out = send_with_reauth(Some(&provider), |cred| {
502 seen.push(cred.clone());
503 let ok = cred == Some(Credential::Bearer("t1".into()));
504 async move { if ok { Ok("data") } else { Err(rejected(401)) } }
505 })
506 .await
507 .unwrap();
508 assert_eq!(out, "data");
509 assert_eq!(
510 seen,
511 vec![
512 Some(Credential::Bearer("t0".into())),
513 Some(Credential::Bearer("t1".into()))
514 ]
515 );
516 }
517
518 #[tokio::test]
519 async fn a_declared_reauth_status_also_refreshes_but_only_once() {
520 let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
521 let mut attempts = 0;
522 let err = send_with_reauth(Some(&provider), |_| {
523 attempts += 1;
524 async { Err::<(), _>(rejected(419)) }
525 })
526 .await
527 .unwrap_err();
528 assert!(matches!(err, FaucetError::HttpStatus { status: 419, .. }));
529 assert_eq!(attempts, 2, "one retry, never a loop");
530 }
531
532 #[tokio::test]
533 async fn other_failures_and_providerless_requests_are_not_retried() {
534 let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
535 let mut attempts = 0;
536 let err = send_with_reauth(Some(&provider), |_| {
537 attempts += 1;
538 async { Err::<(), _>(rejected(500)) }
539 })
540 .await
541 .unwrap_err();
542 assert!(matches!(err, FaucetError::HttpStatus { status: 500, .. }));
543 assert_eq!(attempts, 1);
544
545 let mut got = None;
546 send_with_reauth(None, |cred| {
547 got = Some(cred);
548 async { Ok::<_, FaucetError>(()) }
549 })
550 .await
551 .unwrap();
552 assert_eq!(
553 got,
554 Some(None),
555 "no provider → the request gets no credential"
556 );
557 }
558
559 #[derive(Debug)]
560 struct MinimalProvider;
561
562 #[async_trait]
563 impl AuthProvider for MinimalProvider {
564 async fn credential(&self) -> Result<Credential, FaucetError> {
565 Ok(Credential::Bearer("t".into()))
566 }
567 fn provider_name(&self) -> &'static str {
568 "minimal"
569 }
570 }
571
572 #[test]
573 fn credential_placement_debug_redacts_value_keeps_name() {
574 let cases = [
575 CredentialPlacement::Header {
576 name: "X-Tok".into(),
577 value: "secret".into(),
578 },
579 CredentialPlacement::Query {
580 name: "access_token".into(),
581 value: "secret".into(),
582 },
583 CredentialPlacement::Cookie {
584 name: "sid".into(),
585 value: "secret".into(),
586 },
587 CredentialPlacement::BodyField {
588 name: "auth".into(),
589 value: "secret".into(),
590 },
591 ];
592 for p in cases {
593 let s = format!("{p:?}");
594 assert!(s.contains("***"), "value must be redacted: {s}");
595 assert!(!s.contains("secret"), "secret leaked: {s}");
596 }
597 }
598
599 #[test]
600 fn request_auth_builders_and_is_empty() {
601 let empty = RequestAuth::new();
602 assert!(empty.is_empty());
603
604 let ra = RequestAuth::new()
605 .with_placement(CredentialPlacement::Query {
606 name: "t".into(),
607 value: "v".into(),
608 })
609 .with_base_url("https://host");
610 assert!(!ra.is_empty());
611 assert_eq!(ra.placements.len(), 1);
612 assert_eq!(ra.base_url.as_deref(), Some("https://host"));
613
614 assert!(!RequestAuth::new().with_base_url("https://h").is_empty());
616 assert!(RequestAuth::default().is_empty());
617
618 let mut cap = std::collections::BTreeMap::new();
621 cap.insert("session_id".to_string(), "SID".to_string());
622 let ra = RequestAuth::new().with_captured(cap);
623 assert!(!ra.is_empty());
624 assert_eq!(
625 ra.captured.get("session_id").map(String::as_str),
626 Some("SID")
627 );
628 }
629
630 #[test]
631 fn request_auth_debug_redacts_placements_and_base_url() {
632 let ra = RequestAuth::new()
633 .with_placement(CredentialPlacement::Header {
634 name: "Authorization".into(),
635 value: "topsecret".into(),
636 })
637 .with_base_url("https://user:pw@host/path");
638 let s = format!("{ra:?}");
639 assert!(!s.contains("topsecret"), "placement value leaked: {s}");
640 assert!(!s.contains("pw"), "base-url userinfo leaked: {s}");
641 }
642
643 #[tokio::test]
644 async fn default_request_auth_is_empty_and_reauth_is_empty() {
645 let p = MinimalProvider;
646 assert!(matches!(
647 p.credential().await.unwrap(),
648 Credential::Bearer(_)
649 ));
650 assert_eq!(p.provider_name(), "minimal");
651 let ra = p
652 .request_auth("GET", "https://x", &std::collections::BTreeMap::new())
653 .await
654 .unwrap();
655 assert!(ra.is_empty());
656 assert!(p.reauth_statuses().is_empty());
657 assert!(
659 p.sign_request("GET", "https://x", &std::collections::BTreeMap::new())
660 .await
661 .unwrap()
662 .is_none()
663 );
664 }
665
666 #[test]
667 fn credential_authorization_value() {
668 assert_eq!(
669 Credential::Bearer("abc".into()).authorization_value(),
670 Some("Bearer abc".to_string())
671 );
672 assert_eq!(
673 Credential::Token("Custom xyz".into()).authorization_value(),
674 Some("Custom xyz".to_string())
675 );
676 assert_eq!(
677 Credential::Basic {
678 username: "u".into(),
679 password: "p".into()
680 }
681 .authorization_value(),
682 None
683 );
684 assert_eq!(
685 Credential::Header {
686 name: "X-Api-Key".into(),
687 value: "k".into()
688 }
689 .authorization_value(),
690 None
691 );
692 }
693
694 #[test]
695 fn authspec_parses_inline() {
696 let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
697 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
698 match s {
699 AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
700 other => panic!("expected inline bearer, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn authspec_parses_inline_unit_variant() {
706 let j = serde_json::json!({"type": "none"});
707 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
708 assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
709 }
710
711 #[test]
712 fn authspec_parses_ref() {
713 let j = serde_json::json!({"ref": "sf"});
714 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
715 assert_eq!(s.reference_name(), Some("sf"));
716 }
717
718 #[test]
719 fn authspec_rejects_ref_plus_inline() {
720 let j = serde_json::json!({"ref": "sf", "type": "bearer"});
721 let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
722 assert!(r.is_err(), "ref + inline must be rejected");
723 }
724
725 #[derive(Debug)]
726 struct Fixed(Credential);
727
728 #[async_trait]
729 impl AuthProvider for Fixed {
730 async fn credential(&self) -> Result<Credential, FaucetError> {
731 Ok(self.0.clone())
732 }
733 fn provider_name(&self) -> &'static str {
734 "fixed"
735 }
736 }
737
738 #[test]
739 fn credential_debug_redacts_secrets() {
740 let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
742 assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
743 assert!(b.contains("***"), "bearer token not masked: {b}");
744
745 let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
746 assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
747 assert!(t.contains("***"), "raw token not masked: {t}");
748
749 let basic = format!(
751 "{:?}",
752 Credential::Basic {
753 username: "alice".into(),
754 password: "hunter2secret".into(),
755 }
756 );
757 assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
758 assert!(
759 basic.contains("alice"),
760 "username should stay visible for diagnostics: {basic}"
761 );
762
763 let header = format!(
765 "{:?}",
766 Credential::Header {
767 name: "X-Api-Key".into(),
768 value: "secretkeyvalue".into(),
769 }
770 );
771 assert!(
772 !header.contains("secretkeyvalue"),
773 "header value leaked: {header}"
774 );
775 assert!(
776 header.contains("X-Api-Key"),
777 "header name should stay visible for diagnostics: {header}"
778 );
779 }
780
781 #[tokio::test]
782 async fn auth_provider_default_invalidate_returns_current() {
783 let p = Fixed(Credential::Bearer("x".into()));
784 assert_eq!(
785 p.credential().await.unwrap(),
786 Credential::Bearer("x".into())
787 );
788 assert_eq!(
790 p.invalidate(&Credential::Bearer("old".into()))
791 .await
792 .unwrap(),
793 Credential::Bearer("x".into())
794 );
795 }
796}