1use std::future::Future;
8use std::pin::Pin;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Serialize, Deserialize)]
16#[serde(tag = "type")]
17pub enum Credential {
18 ApiKey {
20 key: String,
22 },
23 Bearer {
25 token: String,
27 #[serde(default)]
29 expires_at: Option<chrono::DateTime<chrono::Utc>>,
30 },
31 OAuth2 {
33 access_token: String,
35 refresh_token: Option<String>,
37 expires_at: Option<chrono::DateTime<chrono::Utc>>,
39 token_url: String,
41 client_id: String,
43 client_secret: Option<String>,
45 #[serde(default)]
47 scopes: Vec<String>,
48 },
49}
50
51impl std::fmt::Debug for Credential {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::ApiKey { .. } => f
55 .debug_struct("Credential::ApiKey")
56 .field("key", &"[REDACTED]")
57 .finish(),
58 Self::Bearer { expires_at, .. } => f
59 .debug_struct("Credential::Bearer")
60 .field("token", &"[REDACTED]")
61 .field("expires_at", expires_at)
62 .finish(),
63 Self::OAuth2 {
64 expires_at,
65 token_url,
66 client_id,
67 scopes,
68 ..
69 } => f
70 .debug_struct("Credential::OAuth2")
71 .field("access_token", &"[REDACTED]")
72 .field("refresh_token", &"[REDACTED]")
73 .field("expires_at", expires_at)
74 .field("token_url", token_url)
75 .field("client_id", client_id)
76 .field("client_secret", &"[REDACTED]")
77 .field("scopes", scopes)
78 .finish(),
79 }
80 }
81}
82
83impl Credential {
84 #[must_use]
86 pub const fn credential_type(&self) -> CredentialType {
87 match self {
88 Self::ApiKey { .. } => CredentialType::ApiKey,
89 Self::Bearer { .. } => CredentialType::Bearer,
90 Self::OAuth2 { .. } => CredentialType::OAuth2,
91 }
92 }
93}
94
95#[derive(Clone)]
102pub enum ResolvedCredential {
103 ApiKey(String),
105 Bearer(String),
107 OAuth2AccessToken(String),
109}
110
111impl std::fmt::Debug for ResolvedCredential {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::ApiKey(_) => f
115 .debug_tuple("ResolvedCredential::ApiKey")
116 .field(&"[REDACTED]")
117 .finish(),
118 Self::Bearer(_) => f
119 .debug_tuple("ResolvedCredential::Bearer")
120 .field(&"[REDACTED]")
121 .finish(),
122 Self::OAuth2AccessToken(_) => f
123 .debug_tuple("ResolvedCredential::OAuth2AccessToken")
124 .field(&"[REDACTED]")
125 .finish(),
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
137pub struct AuthConfig {
138 pub credential_key: String,
140 pub auth_scheme: AuthScheme,
142 pub credential_type: CredentialType,
144}
145
146#[derive(Debug, Clone)]
150pub enum AuthScheme {
151 BearerHeader,
153 ApiKeyHeader(String),
155 ApiKeyQuery(String),
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum CredentialType {
164 ApiKey,
166 Bearer,
168 OAuth2,
170}
171
172#[derive(Debug, thiserror::Error)]
179pub enum CredentialError {
180 #[error("credential not found: {key}")]
182 NotFound {
183 key: String,
185 },
186
187 #[error("credential expired: {key}")]
189 Expired {
190 key: String,
192 },
193
194 #[error("credential refresh failed for {key}: {reason}")]
196 RefreshFailed {
197 key: String,
199 reason: String,
201 },
202
203 #[error("credential type mismatch for {key}: expected {expected:?}, got {actual:?}")]
205 TypeMismatch {
206 key: String,
208 expected: CredentialType,
210 actual: CredentialType,
212 },
213
214 #[error("credential store error: {0}")]
216 StoreError(Box<dyn std::error::Error + Send + Sync>),
217
218 #[error("credential resolution timed out for {key}")]
220 Timeout {
221 key: String,
223 },
224}
225
226impl Clone for CredentialError {
227 fn clone(&self) -> Self {
228 match self {
229 Self::NotFound { key } => Self::NotFound { key: key.clone() },
230 Self::Expired { key } => Self::Expired { key: key.clone() },
231 Self::RefreshFailed { key, reason } => Self::RefreshFailed {
232 key: key.clone(),
233 reason: reason.clone(),
234 },
235 Self::TypeMismatch {
236 key,
237 expected,
238 actual,
239 } => Self::TypeMismatch {
240 key: key.clone(),
241 expected: *expected,
242 actual: *actual,
243 },
244 Self::StoreError(error) => {
245 Self::StoreError(Box::new(std::io::Error::other(error.to_string())))
246 }
247 Self::Timeout { key } => Self::Timeout { key: key.clone() },
248 }
249 }
250}
251
252pub type CredentialFuture<'a, T> =
254 Pin<Box<dyn Future<Output = Result<T, CredentialError>> + Send + 'a>>;
255
256pub trait CredentialStore: Send + Sync {
263 fn get(&self, key: &str) -> CredentialFuture<'_, Option<Credential>>;
265
266 fn set(&self, key: &str, credential: Credential) -> CredentialFuture<'_, ()>;
268
269 fn delete(&self, key: &str) -> CredentialFuture<'_, ()>;
271}
272
273pub trait CredentialResolver: Send + Sync {
278 fn resolve(&self, key: &str) -> CredentialFuture<'_, ResolvedCredential>;
281}
282
283#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
291 fn credential_serde_roundtrip_api_key() {
292 let cred = Credential::ApiKey {
293 key: "sk-test-123".into(),
294 };
295 let json = serde_json::to_string(&cred).unwrap();
296 let decoded: Credential = serde_json::from_str(&json).unwrap();
297 match decoded {
298 Credential::ApiKey { key } => assert_eq!(key, "sk-test-123"),
299 other => panic!("expected ApiKey, got {other:?}"),
300 }
301 }
302
303 #[test]
304 fn credential_serde_roundtrip_bearer() {
305 let cred = Credential::Bearer {
306 token: "tok-abc".into(),
307 expires_at: Some(chrono::Utc::now()),
308 };
309 let json = serde_json::to_string(&cred).unwrap();
310 let decoded: Credential = serde_json::from_str(&json).unwrap();
311 match decoded {
312 Credential::Bearer { token, expires_at } => {
313 assert_eq!(token, "tok-abc");
314 assert!(expires_at.is_some());
315 }
316 other => panic!("expected Bearer, got {other:?}"),
317 }
318 }
319
320 #[test]
321 fn credential_serde_roundtrip_oauth2() {
322 let cred = Credential::OAuth2 {
323 access_token: "access-123".into(),
324 refresh_token: Some("refresh-456".into()),
325 expires_at: None,
326 token_url: "https://auth.example.com/token".into(),
327 client_id: "client-1".into(),
328 client_secret: Some("secret".into()),
329 scopes: vec!["read".into(), "write".into()],
330 };
331 let json = serde_json::to_string(&cred).unwrap();
332 let decoded: Credential = serde_json::from_str(&json).unwrap();
333 match decoded {
334 Credential::OAuth2 {
335 access_token,
336 refresh_token,
337 client_id,
338 scopes,
339 ..
340 } => {
341 assert_eq!(access_token, "access-123");
342 assert_eq!(refresh_token.as_deref(), Some("refresh-456"));
343 assert_eq!(client_id, "client-1");
344 assert_eq!(scopes, vec!["read", "write"]);
345 }
346 other => panic!("expected OAuth2, got {other:?}"),
347 }
348 }
349
350 #[test]
352 fn credential_error_display_no_secrets() {
353 let errors = vec![
354 CredentialError::NotFound {
355 key: "my-key".into(),
356 },
357 CredentialError::Expired {
358 key: "my-key".into(),
359 },
360 CredentialError::RefreshFailed {
361 key: "my-key".into(),
362 reason: "bad response".into(),
363 },
364 CredentialError::TypeMismatch {
365 key: "my-key".into(),
366 expected: CredentialType::Bearer,
367 actual: CredentialType::ApiKey,
368 },
369 CredentialError::Timeout {
370 key: "my-key".into(),
371 },
372 ];
373
374 let secret_values = [
375 "sk-test-123",
376 "tok-abc",
377 "access-123",
378 "refresh-456",
379 "secret",
380 ];
381 for err in &errors {
382 let display = format!("{err}");
383 for secret in &secret_values {
384 assert!(
385 !display.contains(secret),
386 "Display of {err:?} leaks secret {secret}"
387 );
388 }
389 assert!(
391 display.contains("my-key"),
392 "Display of {err:?} should contain key name"
393 );
394 }
395 }
396
397 #[test]
399 fn credential_type_helper() {
400 let api_key = Credential::ApiKey { key: "k".into() };
401 assert_eq!(api_key.credential_type(), CredentialType::ApiKey);
402
403 let bearer = Credential::Bearer {
404 token: "t".into(),
405 expires_at: None,
406 };
407 assert_eq!(bearer.credential_type(), CredentialType::Bearer);
408
409 let oauth2 = Credential::OAuth2 {
410 access_token: "a".into(),
411 refresh_token: None,
412 expires_at: None,
413 token_url: "https://example.com/token".into(),
414 client_id: "c".into(),
415 client_secret: None,
416 scopes: vec![],
417 };
418 assert_eq!(oauth2.credential_type(), CredentialType::OAuth2);
419 }
420
421 #[test]
423 fn debug_impl_redacts_secrets() {
424 let cred = Credential::ApiKey {
425 key: "super-secret".into(),
426 };
427 let debug = format!("{cred:?}");
428 assert!(!debug.contains("super-secret"), "Debug leaks secret");
429 assert!(debug.contains("[REDACTED]"));
430
431 let resolved = ResolvedCredential::ApiKey("my-secret".into());
432 let debug = format!("{resolved:?}");
433 assert!(!debug.contains("my-secret"), "Debug leaks secret");
434 assert!(debug.contains("[REDACTED]"));
435 }
436}