Skip to main content

codineer_runtime/credentials/
mod.rs

1mod claude_code;
2mod env_resolver;
3pub mod oauth_resolver;
4
5pub use claude_code::ClaudeCodeResolver;
6pub use env_resolver::EnvVarResolver;
7pub use oauth_resolver::CodineerOAuthResolver;
8
9use std::fmt;
10
11/// A successfully resolved credential ready for use in API requests.
12#[derive(Clone, PartialEq, Eq)]
13pub enum ResolvedCredential {
14    ApiKey(String),
15    BearerToken(String),
16    ApiKeyAndBearer {
17        api_key: String,
18        bearer_token: String,
19    },
20}
21
22impl fmt::Debug for ResolvedCredential {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::ApiKey(_) => write!(f, "ResolvedCredential::ApiKey(***)"),
26            Self::BearerToken(_) => write!(f, "ResolvedCredential::BearerToken(***)"),
27            Self::ApiKeyAndBearer { .. } => {
28                write!(f, "ResolvedCredential::ApiKeyAndBearer(***)")
29            }
30        }
31    }
32}
33
34/// Error type for credential resolution.
35#[derive(Debug)]
36pub enum CredentialError {
37    /// No resolver in the chain produced a credential.
38    NoCredentials {
39        provider: &'static str,
40        tried: Vec<String>,
41    },
42    /// An individual resolver encountered a fatal error.
43    ResolverFailed {
44        resolver_id: String,
45        source: Box<dyn std::error::Error + Send + Sync>,
46    },
47}
48
49impl fmt::Display for CredentialError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::NoCredentials { provider, tried } => {
53                write!(
54                    f,
55                    "no credentials found for {provider} (tried: {})",
56                    tried.join(", ")
57                )
58            }
59            Self::ResolverFailed {
60                resolver_id,
61                source,
62            } => {
63                write!(f, "credential resolver '{resolver_id}' failed: {source}")
64            }
65        }
66    }
67}
68
69impl std::error::Error for CredentialError {
70    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71        match self {
72            Self::ResolverFailed { source, .. } => Some(source.as_ref()),
73            _ => None,
74        }
75    }
76}
77
78/// Trait for pluggable credential sources.
79///
80/// Each implementation represents one way to obtain API credentials
81/// (e.g. environment variables, saved OAuth tokens, external tool discovery).
82pub trait CredentialResolver: fmt::Debug + Send + Sync {
83    /// Unique identifier for this resolver (e.g. `"env"`, `"codineer-oauth"`, `"claude-code"`).
84    fn id(&self) -> &str;
85
86    /// Human-readable name shown in UI (e.g. `"Environment Variables"`).
87    fn display_name(&self) -> &str;
88
89    /// Lower priority values are tried first. Convention:
90    /// - 100: environment variables
91    /// - 200: Codineer OAuth
92    /// - 300: external tool discovery (Claude Code, etc.)
93    fn priority(&self) -> u16;
94
95    /// Attempt to resolve credentials. Returns `Ok(None)` if this source
96    /// has no credentials (and the chain should try the next resolver).
97    fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError>;
98
99    /// Whether this resolver supports interactive `login()`.
100    fn supports_login(&self) -> bool {
101        false
102    }
103
104    /// Run an interactive login flow. Only called when `supports_login()` is true.
105    fn login(&self) -> Result<(), Box<dyn std::error::Error>> {
106        Err("login not supported by this credential source".into())
107    }
108
109    /// Clear saved credentials. Only called when `supports_login()` is true.
110    fn logout(&self) -> Result<(), Box<dyn std::error::Error>> {
111        Err("logout not supported by this credential source".into())
112    }
113}
114
115/// Status snapshot of a single resolver in the chain.
116#[derive(Debug, Clone)]
117pub struct CredentialStatus {
118    pub id: String,
119    pub display_name: String,
120    pub available: bool,
121    pub supports_login: bool,
122}
123
124/// An ordered chain of credential resolvers for a single provider.
125///
126/// Resolvers are tried in priority order (lowest first). The first resolver
127/// that returns `Some(credential)` wins.
128pub struct CredentialChain {
129    provider_name: &'static str,
130    resolvers: Vec<Box<dyn CredentialResolver>>,
131}
132
133impl fmt::Debug for CredentialChain {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.debug_struct("CredentialChain")
136            .field("provider", &self.provider_name)
137            .field("resolvers", &self.resolvers.len())
138            .finish()
139    }
140}
141
142impl CredentialChain {
143    /// Build a chain for the given provider. Resolvers are sorted by priority.
144    pub fn new(
145        provider_name: &'static str,
146        mut resolvers: Vec<Box<dyn CredentialResolver>>,
147    ) -> Self {
148        resolvers.sort_by_key(|r| r.priority());
149        Self {
150            provider_name,
151            resolvers,
152        }
153    }
154
155    /// Build an empty chain (always returns `NoCredentials`).
156    #[must_use]
157    pub fn empty(provider_name: &'static str) -> Self {
158        Self {
159            provider_name,
160            resolvers: Vec::new(),
161        }
162    }
163
164    /// Resolve credentials by walking the chain in priority order.
165    pub fn resolve(&self) -> Result<ResolvedCredential, CredentialError> {
166        let mut tried = Vec::new();
167        for resolver in &self.resolvers {
168            tried.push(resolver.display_name().to_string());
169            match resolver.resolve() {
170                Ok(Some(credential)) => return Ok(credential),
171                Ok(None) => continue,
172                Err(CredentialError::ResolverFailed { .. }) => continue,
173                Err(error) => return Err(error),
174            }
175        }
176        Err(CredentialError::NoCredentials {
177            provider: self.provider_name,
178            tried,
179        })
180    }
181
182    /// Return status of each resolver in the chain.
183    #[must_use]
184    pub fn status(&self) -> Vec<CredentialStatus> {
185        self.resolvers
186            .iter()
187            .map(|r| CredentialStatus {
188                id: r.id().to_string(),
189                display_name: r.display_name().to_string(),
190                available: matches!(r.resolve(), Ok(Some(_))),
191                supports_login: r.supports_login(),
192            })
193            .collect()
194    }
195
196    /// Return resolvers that support interactive login.
197    pub fn login_sources(&self) -> Vec<&dyn CredentialResolver> {
198        self.resolvers
199            .iter()
200            .filter(|r| r.supports_login())
201            .map(|r| r.as_ref())
202            .collect()
203    }
204
205    /// Find a resolver by id.
206    pub fn get_resolver(&self, id: &str) -> Option<&dyn CredentialResolver> {
207        self.resolvers
208            .iter()
209            .find(|r| r.id() == id)
210            .map(|r| r.as_ref())
211    }
212
213    /// Iterate over all resolver IDs in priority order.
214    pub fn resolver_ids(&self) -> impl Iterator<Item = &str> {
215        self.resolvers.iter().map(|r| r.id())
216    }
217
218    /// Provider name this chain serves.
219    #[must_use]
220    pub fn provider_name(&self) -> &str {
221        self.provider_name
222    }
223
224    /// Number of resolvers in the chain.
225    #[must_use]
226    pub fn len(&self) -> usize {
227        self.resolvers.len()
228    }
229
230    /// Whether the chain has no resolvers.
231    #[must_use]
232    pub fn is_empty(&self) -> bool {
233        self.resolvers.is_empty()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[derive(Debug)]
242    struct StubResolver {
243        id: &'static str,
244        priority: u16,
245        credential: Option<ResolvedCredential>,
246        login_supported: bool,
247    }
248
249    impl CredentialResolver for StubResolver {
250        fn id(&self) -> &str {
251            self.id
252        }
253        fn display_name(&self) -> &str {
254            self.id
255        }
256        fn priority(&self) -> u16 {
257            self.priority
258        }
259        fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError> {
260            Ok(self.credential.clone())
261        }
262        fn supports_login(&self) -> bool {
263            self.login_supported
264        }
265    }
266
267    #[test]
268    fn chain_resolves_first_available() {
269        let chain = CredentialChain::new(
270            "test",
271            vec![
272                Box::new(StubResolver {
273                    id: "a",
274                    priority: 200,
275                    credential: Some(ResolvedCredential::ApiKey("key-a".into())),
276                    login_supported: false,
277                }),
278                Box::new(StubResolver {
279                    id: "b",
280                    priority: 100,
281                    credential: None,
282                    login_supported: false,
283                }),
284            ],
285        );
286        // "b" has lower priority (tried first) but returns None, so "a" wins
287        let cred = chain.resolve().expect("should resolve");
288        assert_eq!(cred, ResolvedCredential::ApiKey("key-a".into()));
289    }
290
291    #[test]
292    fn chain_sorts_by_priority() {
293        let chain = CredentialChain::new(
294            "test",
295            vec![
296                Box::new(StubResolver {
297                    id: "high",
298                    priority: 300,
299                    credential: Some(ResolvedCredential::BearerToken("tok-high".into())),
300                    login_supported: false,
301                }),
302                Box::new(StubResolver {
303                    id: "low",
304                    priority: 100,
305                    credential: Some(ResolvedCredential::BearerToken("tok-low".into())),
306                    login_supported: false,
307                }),
308            ],
309        );
310        let cred = chain.resolve().expect("should resolve");
311        assert_eq!(cred, ResolvedCredential::BearerToken("tok-low".into()));
312    }
313
314    #[test]
315    fn empty_chain_returns_no_credentials() {
316        let chain = CredentialChain::empty("test");
317        let err = chain.resolve().unwrap_err();
318        assert!(matches!(err, CredentialError::NoCredentials { .. }));
319        assert!(chain.is_empty());
320    }
321
322    #[test]
323    fn chain_skips_none_resolvers() {
324        let chain = CredentialChain::new(
325            "test",
326            vec![
327                Box::new(StubResolver {
328                    id: "empty1",
329                    priority: 100,
330                    credential: None,
331                    login_supported: false,
332                }),
333                Box::new(StubResolver {
334                    id: "empty2",
335                    priority: 200,
336                    credential: None,
337                    login_supported: false,
338                }),
339                Box::new(StubResolver {
340                    id: "found",
341                    priority: 300,
342                    credential: Some(ResolvedCredential::ApiKey("k".into())),
343                    login_supported: false,
344                }),
345            ],
346        );
347        let cred = chain.resolve().expect("should resolve");
348        assert_eq!(cred, ResolvedCredential::ApiKey("k".into()));
349    }
350
351    #[test]
352    fn status_reports_all_resolvers() {
353        let chain = CredentialChain::new(
354            "test",
355            vec![
356                Box::new(StubResolver {
357                    id: "env",
358                    priority: 100,
359                    credential: Some(ResolvedCredential::ApiKey("k".into())),
360                    login_supported: false,
361                }),
362                Box::new(StubResolver {
363                    id: "oauth",
364                    priority: 200,
365                    credential: None,
366                    login_supported: true,
367                }),
368            ],
369        );
370        let statuses = chain.status();
371        assert_eq!(statuses.len(), 2);
372        assert!(statuses[0].available);
373        assert!(!statuses[0].supports_login);
374        assert!(!statuses[1].available);
375        assert!(statuses[1].supports_login);
376    }
377
378    #[test]
379    fn login_sources_filters_correctly() {
380        let chain = CredentialChain::new(
381            "test",
382            vec![
383                Box::new(StubResolver {
384                    id: "env",
385                    priority: 100,
386                    credential: None,
387                    login_supported: false,
388                }),
389                Box::new(StubResolver {
390                    id: "oauth",
391                    priority: 200,
392                    credential: None,
393                    login_supported: true,
394                }),
395            ],
396        );
397        let sources = chain.login_sources();
398        assert_eq!(sources.len(), 1);
399        assert_eq!(sources[0].id(), "oauth");
400    }
401
402    #[test]
403    fn get_resolver_finds_by_id() {
404        let chain = CredentialChain::new(
405            "test",
406            vec![Box::new(StubResolver {
407                id: "env",
408                priority: 100,
409                credential: None,
410                login_supported: false,
411            })],
412        );
413        assert!(chain.get_resolver("env").is_some());
414        assert!(chain.get_resolver("nonexistent").is_none());
415    }
416
417    #[test]
418    fn resolved_credential_debug_redacts() {
419        let key = ResolvedCredential::ApiKey("secret".into());
420        let debug = format!("{key:?}");
421        assert!(!debug.contains("secret"));
422        assert!(debug.contains("***"));
423    }
424
425    #[test]
426    fn credential_error_display() {
427        let err = CredentialError::NoCredentials {
428            provider: "Anthropic",
429            tried: vec!["env".into(), "oauth".into()],
430        };
431        let msg = err.to_string();
432        assert!(msg.contains("Anthropic"));
433        assert!(msg.contains("env, oauth"));
434    }
435}