codineer_runtime/credentials/
mod.rs1mod 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#[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#[derive(Debug)]
36pub enum CredentialError {
37 NoCredentials {
39 provider: &'static str,
40 tried: Vec<String>,
41 },
42 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
78pub trait CredentialResolver: fmt::Debug + Send + Sync {
83 fn id(&self) -> &str;
85
86 fn display_name(&self) -> &str;
88
89 fn priority(&self) -> u16;
94
95 fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError>;
98
99 fn supports_login(&self) -> bool {
101 false
102 }
103
104 fn login(&self) -> Result<(), Box<dyn std::error::Error>> {
106 Err("login not supported by this credential source".into())
107 }
108
109 fn logout(&self) -> Result<(), Box<dyn std::error::Error>> {
111 Err("logout not supported by this credential source".into())
112 }
113}
114
115#[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
124pub 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 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 #[must_use]
157 pub fn empty(provider_name: &'static str) -> Self {
158 Self {
159 provider_name,
160 resolvers: Vec::new(),
161 }
162 }
163
164 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 #[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 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 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 pub fn resolver_ids(&self) -> impl Iterator<Item = &str> {
215 self.resolvers.iter().map(|r| r.id())
216 }
217
218 #[must_use]
220 pub fn provider_name(&self) -> &str {
221 self.provider_name
222 }
223
224 #[must_use]
226 pub fn len(&self) -> usize {
227 self.resolvers.len()
228 }
229
230 #[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 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}