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,
}
}
}
#[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
}
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 },
}
#[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())
);
}
}