use crate::FaucetError;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use std::sync::Arc;
#[derive(Clone, PartialEq, Eq)]
pub enum Credential {
Bearer(String),
Header {
name: String,
value: String,
},
Basic {
username: String,
password: String,
},
Token(String),
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Credential::Bearer(_) => f.debug_tuple("Bearer").field(&"***").finish(),
Credential::Header { name, .. } => f
.debug_struct("Header")
.field("name", name)
.field("value", &"***")
.finish(),
Credential::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.field("password", &"***")
.finish(),
Credential::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
}
}
}
impl Credential {
pub fn authorization_value(&self) -> Option<String> {
match self {
Credential::Bearer(t) => Some(format!("Bearer {t}")),
Credential::Token(t) => Some(t.clone()),
Credential::Header { .. } | Credential::Basic { .. } => None,
}
}
}
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredentialPlacement {
Header {
name: String,
value: String,
},
Query {
name: String,
value: String,
},
Cookie {
name: String,
value: String,
},
BodyField {
name: String,
value: String,
},
}
impl std::fmt::Debug for CredentialPlacement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (kind, name) = match self {
CredentialPlacement::Header { name, .. } => ("Header", name),
CredentialPlacement::Query { name, .. } => ("Query", name),
CredentialPlacement::Cookie { name, .. } => ("Cookie", name),
CredentialPlacement::BodyField { name, .. } => ("BodyField", name),
};
f.debug_struct(kind)
.field("name", name)
.field("value", &"***")
.finish()
}
}
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct RequestAuth {
pub placements: Vec<CredentialPlacement>,
pub base_url: Option<String>,
pub captured: std::collections::BTreeMap<String, String>,
}
impl RequestAuth {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_placement(mut self, placement: CredentialPlacement) -> Self {
self.placements.push(placement);
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
#[must_use]
pub fn with_captured(mut self, captured: std::collections::BTreeMap<String, String>) -> Self {
self.captured = captured;
self
}
pub fn is_empty(&self) -> bool {
self.placements.is_empty() && self.base_url.is_none() && self.captured.is_empty()
}
}
impl std::fmt::Debug for RequestAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RequestAuth")
.field("placements", &self.placements)
.field(
"base_url",
&self
.base_url
.as_deref()
.map(crate::util::redact_uri_credentials),
)
.finish()
}
}
#[async_trait]
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
async fn credential(&self) -> Result<Credential, FaucetError>;
async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
self.credential().await
}
async fn sign_request(
&self,
_method: &str,
_url: &str,
_query: &std::collections::BTreeMap<String, String>,
) -> Result<Option<Credential>, FaucetError> {
Ok(None)
}
async fn request_auth(
&self,
_method: &str,
_url: &str,
_query: &std::collections::BTreeMap<String, String>,
) -> Result<RequestAuth, FaucetError> {
Ok(RequestAuth::new())
}
fn reauth_statuses(&self) -> &[u16] {
&[]
}
fn provider_name(&self) -> &'static str;
}
pub type SharedAuthProvider = Arc<dyn AuthProvider>;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AuthReference {
#[serde(rename = "ref")]
pub name: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum AuthSpec<A> {
Inline(A),
Reference(AuthReference),
}
impl<A: Default> Default for AuthSpec<A> {
fn default() -> Self {
AuthSpec::Inline(A::default())
}
}
impl<A> AuthSpec<A> {
pub fn inline(&self) -> Option<&A> {
match self {
AuthSpec::Inline(a) => Some(a),
AuthSpec::Reference(_) => None,
}
}
pub fn reference_name(&self) -> Option<&str> {
match self {
AuthSpec::Reference(r) => Some(&r.name),
AuthSpec::Inline(_) => None,
}
}
}
impl<'de, A> Deserialize<'de> for AuthSpec<A>
where
A: serde::de::DeserializeOwned,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let has_ref = value.get("ref").is_some();
if has_ref {
let has_other = value
.as_object()
.map(|o| o.keys().any(|k| k != "ref"))
.unwrap_or(false);
if has_other {
return Err(serde::de::Error::custom(
"auth: `ref` cannot be combined with inline auth fields (type/config)",
));
}
let r: AuthReference =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
return Ok(AuthSpec::Reference(r));
}
let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
Ok(AuthSpec::Inline(inner))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Deserialize, PartialEq)]
#[serde(tag = "type", content = "config", rename_all = "snake_case")]
enum StubAuth {
None,
Bearer { token: String },
}
#[derive(Debug)]
struct MinimalProvider;
#[async_trait]
impl AuthProvider for MinimalProvider {
async fn credential(&self) -> Result<Credential, FaucetError> {
Ok(Credential::Bearer("t".into()))
}
fn provider_name(&self) -> &'static str {
"minimal"
}
}
#[test]
fn credential_placement_debug_redacts_value_keeps_name() {
let cases = [
CredentialPlacement::Header {
name: "X-Tok".into(),
value: "secret".into(),
},
CredentialPlacement::Query {
name: "access_token".into(),
value: "secret".into(),
},
CredentialPlacement::Cookie {
name: "sid".into(),
value: "secret".into(),
},
CredentialPlacement::BodyField {
name: "auth".into(),
value: "secret".into(),
},
];
for p in cases {
let s = format!("{p:?}");
assert!(s.contains("***"), "value must be redacted: {s}");
assert!(!s.contains("secret"), "secret leaked: {s}");
}
}
#[test]
fn request_auth_builders_and_is_empty() {
let empty = RequestAuth::new();
assert!(empty.is_empty());
let ra = RequestAuth::new()
.with_placement(CredentialPlacement::Query {
name: "t".into(),
value: "v".into(),
})
.with_base_url("https://host");
assert!(!ra.is_empty());
assert_eq!(ra.placements.len(), 1);
assert_eq!(ra.base_url.as_deref(), Some("https://host"));
assert!(!RequestAuth::new().with_base_url("https://h").is_empty());
assert!(RequestAuth::default().is_empty());
let mut cap = std::collections::BTreeMap::new();
cap.insert("session_id".to_string(), "SID".to_string());
let ra = RequestAuth::new().with_captured(cap);
assert!(!ra.is_empty());
assert_eq!(
ra.captured.get("session_id").map(String::as_str),
Some("SID")
);
}
#[test]
fn request_auth_debug_redacts_placements_and_base_url() {
let ra = RequestAuth::new()
.with_placement(CredentialPlacement::Header {
name: "Authorization".into(),
value: "topsecret".into(),
})
.with_base_url("https://user:pw@host/path");
let s = format!("{ra:?}");
assert!(!s.contains("topsecret"), "placement value leaked: {s}");
assert!(!s.contains("pw"), "base-url userinfo leaked: {s}");
}
#[tokio::test]
async fn default_request_auth_is_empty_and_reauth_is_empty() {
let p = MinimalProvider;
assert!(matches!(
p.credential().await.unwrap(),
Credential::Bearer(_)
));
assert_eq!(p.provider_name(), "minimal");
let ra = p
.request_auth("GET", "https://x", &std::collections::BTreeMap::new())
.await
.unwrap();
assert!(ra.is_empty());
assert!(p.reauth_statuses().is_empty());
assert!(
p.sign_request("GET", "https://x", &std::collections::BTreeMap::new())
.await
.unwrap()
.is_none()
);
}
#[test]
fn credential_authorization_value() {
assert_eq!(
Credential::Bearer("abc".into()).authorization_value(),
Some("Bearer abc".to_string())
);
assert_eq!(
Credential::Token("Custom xyz".into()).authorization_value(),
Some("Custom xyz".to_string())
);
assert_eq!(
Credential::Basic {
username: "u".into(),
password: "p".into()
}
.authorization_value(),
None
);
assert_eq!(
Credential::Header {
name: "X-Api-Key".into(),
value: "k".into()
}
.authorization_value(),
None
);
}
#[test]
fn authspec_parses_inline() {
let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
match s {
AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
other => panic!("expected inline bearer, got {other:?}"),
}
}
#[test]
fn authspec_parses_inline_unit_variant() {
let j = serde_json::json!({"type": "none"});
let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
}
#[test]
fn authspec_parses_ref() {
let j = serde_json::json!({"ref": "sf"});
let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
assert_eq!(s.reference_name(), Some("sf"));
}
#[test]
fn authspec_rejects_ref_plus_inline() {
let j = serde_json::json!({"ref": "sf", "type": "bearer"});
let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
assert!(r.is_err(), "ref + inline must be rejected");
}
#[derive(Debug)]
struct Fixed(Credential);
#[async_trait]
impl AuthProvider for Fixed {
async fn credential(&self) -> Result<Credential, FaucetError> {
Ok(self.0.clone())
}
fn provider_name(&self) -> &'static str {
"fixed"
}
}
#[test]
fn credential_debug_redacts_secrets() {
let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
assert!(b.contains("***"), "bearer token not masked: {b}");
let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
assert!(t.contains("***"), "raw token not masked: {t}");
let basic = format!(
"{:?}",
Credential::Basic {
username: "alice".into(),
password: "hunter2secret".into(),
}
);
assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
assert!(
basic.contains("alice"),
"username should stay visible for diagnostics: {basic}"
);
let header = format!(
"{:?}",
Credential::Header {
name: "X-Api-Key".into(),
value: "secretkeyvalue".into(),
}
);
assert!(
!header.contains("secretkeyvalue"),
"header value leaked: {header}"
);
assert!(
header.contains("X-Api-Key"),
"header name should stay visible for diagnostics: {header}"
);
}
#[tokio::test]
async fn auth_provider_default_invalidate_returns_current() {
let p = Fixed(Credential::Bearer("x".into()));
assert_eq!(
p.credential().await.unwrap(),
Credential::Bearer("x".into())
);
assert_eq!(
p.invalidate(&Credential::Bearer("old".into()))
.await
.unwrap(),
Credential::Bearer("x".into())
);
}
}