Skip to main content

swink_agent/
credential.rs

1//! Credential types, traits, and error types for tool authentication.
2//!
3//! Tools declare authentication requirements via [`AuthConfig`]; the framework
4//! resolves credentials from a pluggable [`CredentialStore`] and delivers the
5//! resolved secret to `execute()` as an [`Option<ResolvedCredential>`].
6
7use std::future::Future;
8use std::pin::Pin;
9
10use serde::{Deserialize, Serialize};
11
12// ─── Credential ─────────────────────────────────────────────────────────────
13
14/// A secret value with type information for tool authentication.
15#[derive(Clone, Serialize, Deserialize)]
16#[serde(tag = "type")]
17pub enum Credential {
18    /// A single secret API key string.
19    ApiKey {
20        /// The API key value.
21        key: String,
22    },
23    /// A bearer token with optional expiry.
24    Bearer {
25        /// The bearer token value.
26        token: String,
27        /// When the token expires (if known).
28        #[serde(default)]
29        expires_at: Option<chrono::DateTime<chrono::Utc>>,
30    },
31    /// A full `OAuth2` token set with refresh capability.
32    OAuth2 {
33        /// The current access token.
34        access_token: String,
35        /// Optional refresh token for automatic renewal.
36        refresh_token: Option<String>,
37        /// When the access token expires (if known).
38        expires_at: Option<chrono::DateTime<chrono::Utc>>,
39        /// Token endpoint URL for refresh requests.
40        token_url: String,
41        /// `OAuth2` client identifier.
42        client_id: String,
43        /// `OAuth2` client secret (optional for public clients).
44        client_secret: Option<String>,
45        /// Requested scopes.
46        #[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    /// Returns the [`CredentialType`] discriminant for this credential.
85    #[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// ─── ResolvedCredential ─────────────────────────────────────────────────────
96
97/// Minimal secret value delivered to a tool after credential resolution.
98///
99/// Does NOT contain refresh tokens, client secrets, or token endpoints.
100/// Tools receive only the secret they need for the authenticated request.
101#[derive(Clone)]
102pub enum ResolvedCredential {
103    /// A resolved API key.
104    ApiKey(String),
105    /// A resolved bearer token.
106    Bearer(String),
107    /// A resolved (possibly refreshed) `OAuth2` access token.
108    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// ─── AuthConfig ─────────────────────────────────────────────────────────────
131
132/// Per-tool declaration of authentication requirements.
133///
134/// Returned by [`AgentTool::auth_config()`](crate::AgentTool::auth_config) to
135/// declare that a tool needs credentials resolved before execution.
136#[derive(Debug, Clone)]
137pub struct AuthConfig {
138    /// Key to look up in the credential store.
139    pub credential_key: String,
140    /// How to attach the credential to the outbound request.
141    pub auth_scheme: AuthScheme,
142    /// Expected credential type (for mismatch checking).
143    pub credential_type: CredentialType,
144}
145
146// ─── AuthScheme ─────────────────────────────────────────────────────────────
147
148/// How a resolved credential is attached to the outbound request.
149#[derive(Debug, Clone)]
150pub enum AuthScheme {
151    /// `Authorization: Bearer {token}`
152    BearerHeader,
153    /// `{header_name}: {key}`
154    ApiKeyHeader(String),
155    /// `?{param_name}={key}`
156    ApiKeyQuery(String),
157}
158
159// ─── CredentialType ─────────────────────────────────────────────────────────
160
161/// Credential type discriminant for mismatch checking (FR-018).
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum CredentialType {
164    /// Expects an API key credential.
165    ApiKey,
166    /// Expects a bearer token.
167    Bearer,
168    /// Expects an `OAuth2` token set.
169    OAuth2,
170}
171
172// ─── CredentialError ────────────────────────────────────────────────────────
173
174/// Errors from credential resolution.
175///
176/// All variants include the credential key for diagnostics but NEVER include
177/// secret values (FR-016).
178#[derive(Debug, thiserror::Error)]
179pub enum CredentialError {
180    /// Credential not found in the store.
181    #[error("credential not found: {key}")]
182    NotFound {
183        /// The credential key that was looked up.
184        key: String,
185    },
186
187    /// Credential has expired and cannot be refreshed.
188    #[error("credential expired: {key}")]
189    Expired {
190        /// The credential key that expired.
191        key: String,
192    },
193
194    /// `OAuth2` token refresh failed.
195    #[error("credential refresh failed for {key}: {reason}")]
196    RefreshFailed {
197        /// The credential key whose refresh failed.
198        key: String,
199        /// Human-readable reason (no secrets).
200        reason: String,
201    },
202
203    /// Credential type doesn't match what the tool expects.
204    #[error("credential type mismatch for {key}: expected {expected:?}, got {actual:?}")]
205    TypeMismatch {
206        /// The credential key.
207        key: String,
208        /// The type the tool declared.
209        expected: CredentialType,
210        /// The type found in the store.
211        actual: CredentialType,
212    },
213
214    /// Generic credential store error.
215    #[error("credential store error: {0}")]
216    StoreError(Box<dyn std::error::Error + Send + Sync>),
217
218    /// Credential resolution timed out.
219    #[error("credential resolution timed out for {key}")]
220    Timeout {
221        /// The credential key.
222        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
252/// Boxed async result used by credential traits.
253pub type CredentialFuture<'a, T> =
254    Pin<Box<dyn Future<Output = Result<T, CredentialError>> + Send + 'a>>;
255
256// ─── CredentialStore trait ──────────────────────────────────────────────────
257
258/// Pluggable credential storage abstraction.
259///
260/// Thread-safe for concurrent tool executions. Implementations must be
261/// `Send + Sync` to allow sharing across `tokio::spawn` boundaries.
262pub trait CredentialStore: Send + Sync {
263    /// Retrieve a credential by key.
264    fn get(&self, key: &str) -> CredentialFuture<'_, Option<Credential>>;
265
266    /// Store or update a credential by key.
267    fn set(&self, key: &str, credential: Credential) -> CredentialFuture<'_, ()>;
268
269    /// Delete a credential by key.
270    fn delete(&self, key: &str) -> CredentialFuture<'_, ()>;
271}
272
273// ─── CredentialResolver trait ───────────────────────────────────────────────
274
275/// Orchestrator for credential resolution — checks validity, triggers
276/// refresh, deduplicates concurrent requests.
277pub trait CredentialResolver: Send + Sync {
278    /// Resolve a credential by key. Returns the minimal secret value
279    /// needed for the authenticated request.
280    fn resolve(&self, key: &str) -> CredentialFuture<'_, ResolvedCredential>;
281}
282
283// ─── Tests ──────────────────────────────────────────────────────────────────
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // T023: Credential serde roundtrip
290    #[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    // T024: CredentialError Display contains no secrets
351    #[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            // Should contain the key name for diagnostics
390            assert!(
391                display.contains("my-key"),
392                "Display of {err:?} should contain key name"
393            );
394        }
395    }
396
397    // T011: credential_type helper
398    #[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    // T023 additional: Debug impl redacts secrets
422    #[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}