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#[async_trait]
104pub trait AuthProvider: Send + Sync + std::fmt::Debug {
105 async fn credential(&self) -> Result<Credential, FaucetError>;
107
108 async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
116 self.credential().await
117 }
118
119 fn provider_name(&self) -> &'static str;
121}
122
123pub type SharedAuthProvider = Arc<dyn AuthProvider>;
126
127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130#[serde(deny_unknown_fields)]
131pub struct AuthReference {
132 #[serde(rename = "ref")]
134 pub name: String,
135}
136
137#[derive(Debug, Clone, Serialize, JsonSchema)]
144#[serde(untagged)]
145pub enum AuthSpec<A> {
146 Inline(A),
148 Reference(AuthReference),
150}
151
152impl<A: Default> Default for AuthSpec<A> {
153 fn default() -> Self {
154 AuthSpec::Inline(A::default())
155 }
156}
157
158impl<A> AuthSpec<A> {
159 pub fn inline(&self) -> Option<&A> {
161 match self {
162 AuthSpec::Inline(a) => Some(a),
163 AuthSpec::Reference(_) => None,
164 }
165 }
166
167 pub fn reference_name(&self) -> Option<&str> {
169 match self {
170 AuthSpec::Reference(r) => Some(&r.name),
171 AuthSpec::Inline(_) => None,
172 }
173 }
174}
175
176impl<'de, A> Deserialize<'de> for AuthSpec<A>
179where
180 A: serde::de::DeserializeOwned,
181{
182 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183 where
184 D: Deserializer<'de>,
185 {
186 let value = serde_json::Value::deserialize(deserializer)?;
187 let has_ref = value.get("ref").is_some();
188 if has_ref {
189 let has_other = value
190 .as_object()
191 .map(|o| o.keys().any(|k| k != "ref"))
192 .unwrap_or(false);
193 if has_other {
194 return Err(serde::de::Error::custom(
195 "auth: `ref` cannot be combined with inline auth fields (type/config)",
196 ));
197 }
198 let r: AuthReference =
199 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
200 return Ok(AuthSpec::Reference(r));
201 }
202 let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
203 Ok(AuthSpec::Inline(inner))
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[derive(Debug, Deserialize, PartialEq)]
212 #[serde(tag = "type", content = "config", rename_all = "snake_case")]
213 enum StubAuth {
214 None,
215 Bearer { token: String },
216 }
217
218 #[test]
219 fn credential_authorization_value() {
220 assert_eq!(
221 Credential::Bearer("abc".into()).authorization_value(),
222 Some("Bearer abc".to_string())
223 );
224 assert_eq!(
225 Credential::Token("Custom xyz".into()).authorization_value(),
226 Some("Custom xyz".to_string())
227 );
228 assert_eq!(
229 Credential::Basic {
230 username: "u".into(),
231 password: "p".into()
232 }
233 .authorization_value(),
234 None
235 );
236 assert_eq!(
237 Credential::Header {
238 name: "X-Api-Key".into(),
239 value: "k".into()
240 }
241 .authorization_value(),
242 None
243 );
244 }
245
246 #[test]
247 fn authspec_parses_inline() {
248 let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
249 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
250 match s {
251 AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
252 other => panic!("expected inline bearer, got {other:?}"),
253 }
254 }
255
256 #[test]
257 fn authspec_parses_inline_unit_variant() {
258 let j = serde_json::json!({"type": "none"});
259 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
260 assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
261 }
262
263 #[test]
264 fn authspec_parses_ref() {
265 let j = serde_json::json!({"ref": "sf"});
266 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
267 assert_eq!(s.reference_name(), Some("sf"));
268 }
269
270 #[test]
271 fn authspec_rejects_ref_plus_inline() {
272 let j = serde_json::json!({"ref": "sf", "type": "bearer"});
273 let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
274 assert!(r.is_err(), "ref + inline must be rejected");
275 }
276
277 #[derive(Debug)]
278 struct Fixed(Credential);
279
280 #[async_trait]
281 impl AuthProvider for Fixed {
282 async fn credential(&self) -> Result<Credential, FaucetError> {
283 Ok(self.0.clone())
284 }
285 fn provider_name(&self) -> &'static str {
286 "fixed"
287 }
288 }
289
290 #[test]
291 fn credential_debug_redacts_secrets() {
292 let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
294 assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
295 assert!(b.contains("***"), "bearer token not masked: {b}");
296
297 let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
298 assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
299 assert!(t.contains("***"), "raw token not masked: {t}");
300
301 let basic = format!(
303 "{:?}",
304 Credential::Basic {
305 username: "alice".into(),
306 password: "hunter2secret".into(),
307 }
308 );
309 assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
310 assert!(
311 basic.contains("alice"),
312 "username should stay visible for diagnostics: {basic}"
313 );
314
315 let header = format!(
317 "{:?}",
318 Credential::Header {
319 name: "X-Api-Key".into(),
320 value: "secretkeyvalue".into(),
321 }
322 );
323 assert!(
324 !header.contains("secretkeyvalue"),
325 "header value leaked: {header}"
326 );
327 assert!(
328 header.contains("X-Api-Key"),
329 "header name should stay visible for diagnostics: {header}"
330 );
331 }
332
333 #[tokio::test]
334 async fn auth_provider_default_invalidate_returns_current() {
335 let p = Fixed(Credential::Bearer("x".into()));
336 assert_eq!(
337 p.credential().await.unwrap(),
338 Credential::Bearer("x".into())
339 );
340 assert_eq!(
342 p.invalidate(&Credential::Bearer("old".into()))
343 .await
344 .unwrap(),
345 Credential::Bearer("x".into())
346 );
347 }
348}