Skip to main content

oxicode_ai/
provider_registry.rs

1//! Provider authentication registry
2//!
3//! Manages authentication for multiple providers with a unified interface.
4//! Supports API keys, OAuth tokens, and ambient credentials (AWS IAM, Google ADC).
5
6use crate::env_api_keys;
7use chrono::Utc;
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Information about an OAuth token
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct OAuthTokenInfo {
15    /// Access token
16    pub access_token: String,
17    /// Refresh token (if available)
18    pub refresh_token: Option<String>,
19    /// Expiration timestamp (Unix epoch seconds)
20    pub expires_at: i64,
21    /// Token type (usually "Bearer")
22    pub token_type: String,
23}
24
25impl OAuthTokenInfo {
26    /// Check if the token is expired
27    pub fn is_expired(&self) -> bool {
28        let now = Utc::now().timestamp();
29        now >= self.expires_at
30    }
31
32    /// Check if the token needs refresh (within 5 minutes of expiration)
33    pub fn needs_refresh(&self) -> bool {
34        let now = Utc::now().timestamp();
35        now >= (self.expires_at - 300) // 5 minutes buffer
36    }
37
38    /// Create a new OAuth token info
39    pub fn new(access_token: String, refresh_token: Option<String>, expires_in_secs: i64) -> Self {
40        let now = Utc::now().timestamp();
41        Self {
42            access_token,
43            refresh_token,
44            expires_at: now + expires_in_secs,
45            token_type: "Bearer".to_string(),
46        }
47    }
48}
49
50/// Trait for provider-specific authentication
51pub trait ProviderAuth: Send + Sync {
52    /// Get the provider name
53    fn provider_name(&self) -> &str;
54
55    /// Check if auth is configured (API key, OAuth, or ambient)
56    fn is_configured(&self) -> bool;
57
58    /// Get the current API key (may involve refresh for OAuth)
59    fn get_api_key(&self) -> Option<String>;
60
61    /// Check if OAuth is configured and needs refresh
62    fn needs_oauth_refresh(&self) -> bool;
63
64    /// Get OAuth token info
65    fn get_oauth_token(&self) -> Option<OAuthTokenInfo>;
66
67    /// Set OAuth token
68    fn set_oauth_token(&mut self, token: OAuthTokenInfo);
69
70    /// Set API key directly
71    fn set_api_key(&mut self, api_key: String);
72}
73
74/// API key based authentication
75#[derive(Debug, Clone)]
76pub struct ApiKeyAuth {
77    api_key: Option<String>,
78    source: AuthSource,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82/// AuthSource.
83pub enum AuthSource {
84    /// stored variant.
85    Stored,
86    /// runtime variant.
87    Runtime,
88    /// environment variant.
89    Environment,
90    /// ambient variant.
91    Ambient,
92}
93
94impl ApiKeyAuth {
95    /// Create a new API key auth with the given key and source.
96    pub fn new(api_key: Option<String>, source: AuthSource) -> Self {
97        Self { api_key, source }
98    }
99}
100
101impl ProviderAuth for ApiKeyAuth {
102    fn provider_name(&self) -> &str {
103        "api_key"
104    }
105
106    fn is_configured(&self) -> bool {
107        self.api_key.is_some()
108    }
109
110    fn get_api_key(&self) -> Option<String> {
111        self.api_key.clone()
112    }
113
114    fn needs_oauth_refresh(&self) -> bool {
115        false
116    }
117
118    fn get_oauth_token(&self) -> Option<OAuthTokenInfo> {
119        None
120    }
121
122    fn set_oauth_token(&mut self, _token: OAuthTokenInfo) {
123        // Not applicable for API key auth
124    }
125
126    fn set_api_key(&mut self, api_key: String) {
127        self.api_key = Some(api_key);
128        self.source = AuthSource::Stored;
129    }
130}
131
132/// OAuth-based authentication with auto-refresh support
133pub struct OAuthAuth {
134    provider_name: String,
135    token: Option<OAuthTokenInfo>,
136    #[allow(clippy::type_complexity)]
137    on_refresh: Option<Box<dyn Fn(&OAuthTokenInfo) + Send + Sync>>,
138}
139
140impl OAuthAuth {
141    /// Create a new OAuth auth without an existing token.
142    pub fn new(provider_name: &str) -> Self {
143        Self {
144            provider_name: provider_name.to_string(),
145            token: None,
146            on_refresh: None,
147        }
148    }
149
150    /// Create a new OAuth auth with an existing token.
151    pub fn with_token(provider_name: &str, token: OAuthTokenInfo) -> Self {
152        Self {
153            provider_name: provider_name.to_string(),
154            token: Some(token),
155            on_refresh: None,
156        }
157    }
158
159    /// Set a callback to be called when the token is refreshed
160    pub fn on_token_refresh<F>(&mut self, callback: F)
161    where
162        F: Fn(&OAuthTokenInfo) + Send + Sync + 'static,
163    {
164        self.on_refresh = Some(Box::new(callback));
165    }
166}
167
168impl ProviderAuth for OAuthAuth {
169    fn provider_name(&self) -> &str {
170        &self.provider_name
171    }
172
173    fn is_configured(&self) -> bool {
174        self.token.is_some()
175    }
176
177    fn get_api_key(&self) -> Option<String> {
178        self.token.as_ref().map(|t| t.access_token.clone())
179    }
180
181    fn needs_oauth_refresh(&self) -> bool {
182        self.token
183            .as_ref()
184            .map(|t| t.needs_refresh())
185            .unwrap_or(true)
186    }
187
188    fn get_oauth_token(&self) -> Option<OAuthTokenInfo> {
189        self.token.clone()
190    }
191
192    fn set_oauth_token(&mut self, token: OAuthTokenInfo) {
193        if let Some(ref callback) = self.on_refresh {
194            callback(&token);
195        }
196        self.token = Some(token);
197    }
198
199    fn set_api_key(&mut self, _api_key: String) {
200        // OAuth auth doesn't use direct API keys
201    }
202}
203
204/// Ambient credential authentication (AWS IAM, Google ADC, etc.)
205pub struct AmbientAuth {
206    provider_name: String,
207    check_fn: Box<dyn Fn() -> bool + Send + Sync>,
208}
209
210impl AmbientAuth {
211    /// Create a new ambient auth with a custom check function.
212    pub fn new<F>(provider_name: &str, check_fn: F) -> Self
213    where
214        F: Fn() -> bool + Send + Sync + 'static,
215    {
216        Self {
217            provider_name: provider_name.to_string(),
218            check_fn: Box::new(check_fn),
219        }
220    }
221}
222
223impl ProviderAuth for AmbientAuth {
224    fn provider_name(&self) -> &str {
225        &self.provider_name
226    }
227
228    fn is_configured(&self) -> bool {
229        (self.check_fn)()
230    }
231
232    fn get_api_key(&self) -> Option<String> {
233        if (self.check_fn)() {
234            Some("<authenticated>".to_string())
235        } else {
236            None
237        }
238    }
239
240    fn needs_oauth_refresh(&self) -> bool {
241        false
242    }
243
244    fn get_oauth_token(&self) -> Option<OAuthTokenInfo> {
245        None
246    }
247
248    fn set_oauth_token(&mut self, _token: OAuthTokenInfo) {
249        // Not applicable for ambient auth
250    }
251
252    fn set_api_key(&mut self, _api_key: String) {
253        // Ambient auth doesn't use direct API keys
254    }
255}
256
257/// Provider authentication registry
258///
259/// Manages authentication for multiple providers with priority-based resolution.
260/// All resolution is file-based — no environment variables are used.
261///
262/// Priority:
263/// 1. Runtime override (CLI --api-key)
264/// 2. Stored credential (auth.json) ← Primary source
265/// 3. OAuth token (with auto-refresh)
266/// 4. Ambient credentials (AWS IAM via ~/.aws, Google ADC via ~/.config/gcloud)
267/// 5. Fallback resolver (custom provider config from models.json)
268pub struct ProviderAuthRegistry {
269    providers: HashMap<String, Box<dyn ProviderAuth>>,
270    runtime_overrides: RwLock<HashMap<String, String>>,
271    #[allow(clippy::type_complexity)]
272    fallback_resolver: RwLock<Option<Box<dyn Fn(&str) -> Option<String> + Send + Sync>>>,
273}
274
275impl Default for ProviderAuthRegistry {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281impl ProviderAuthRegistry {
282    /// Create a new empty registry
283    pub fn new() -> Self {
284        Self {
285            providers: HashMap::new(),
286            runtime_overrides: RwLock::new(HashMap::new()),
287            fallback_resolver: RwLock::new(None),
288        }
289    }
290
291    /// Create a registry with default providers pre-registered
292    pub fn with_defaults() -> Self {
293        let mut registry = Self::new();
294        registry.register_defaults();
295        registry
296    }
297
298    /// Register default providers
299    pub fn register_defaults(&mut self) {
300        // Register ambient auth providers
301        self.register_ambient("vertex", env_api_keys::has_vertex_adc_full);
302        self.register_ambient("google-vertex", env_api_keys::has_vertex_adc_full);
303        self.register_ambient("bedrock", env_api_keys::has_bedrock_creds);
304        self.register_ambient("amazon-bedrock", env_api_keys::has_bedrock_creds);
305        self.register_ambient("aws-bedrock", env_api_keys::has_bedrock_creds);
306    }
307
308    /// Register an API key provider
309    pub fn register_api_key(&mut self, provider: &str, api_key: Option<String>) {
310        self.providers.insert(
311            provider.to_string(),
312            Box::new(ApiKeyAuth::new(api_key, AuthSource::Stored)),
313        );
314    }
315
316    /// Register an OAuth provider
317    pub fn register_oauth(&mut self, provider: &str, token: OAuthTokenInfo) {
318        self.providers.insert(
319            provider.to_string(),
320            Box::new(OAuthAuth::with_token(provider, token)),
321        );
322    }
323
324    /// Register an ambient credential provider
325    pub fn register_ambient<F>(&mut self, provider: &str, check_fn: F)
326    where
327        F: Fn() -> bool + Send + Sync + 'static,
328    {
329        self.providers.insert(
330            provider.to_string(),
331            Box::new(AmbientAuth::new(provider, check_fn)),
332        );
333    }
334
335    /// Register a custom provider auth implementation
336    pub fn register<P: ProviderAuth + 'static>(&mut self, provider: &str, auth: P) {
337        self.providers.insert(provider.to_string(), Box::new(auth));
338    }
339
340    /// Set a runtime API key override
341    pub fn set_runtime_key(&self, provider: &str, api_key: String) {
342        self.runtime_overrides
343            .write()
344            .insert(provider.to_string(), api_key);
345    }
346
347    /// Remove a runtime override
348    pub fn remove_runtime_key(&self, provider: &str) {
349        self.runtime_overrides.write().remove(provider);
350    }
351
352    /// Set a fallback resolver for providers not in the registry
353    pub fn set_fallback_resolver<F>(&self, resolver: F)
354    where
355        F: Fn(&str) -> Option<String> + Send + Sync + 'static,
356    {
357        *self.fallback_resolver.write() = Some(Box::new(resolver));
358    }
359
360    /// Remove fallback resolver
361    pub fn clear_fallback_resolver(&self) {
362        *self.fallback_resolver.write() = None;
363    }
364
365    /// Get API key for a provider using the resolution chain
366    ///
367    /// Priority:
368    /// 1. Runtime override (--api-key)
369    /// 2. Stored credential (auth.json)
370    /// 3. OAuth token
371    /// 4. Ambient credentials (AWS IAM, Google ADC via SDK/filesystem)
372    /// 5. Fallback resolver (custom provider config from models.json)
373    /// 6. Environment variable (last resort for CI/CD)
374    pub fn get_api_key(&self, provider: &str) -> Option<String> {
375        // 1. Runtime override takes highest priority
376        {
377            let overrides = self.runtime_overrides.read();
378            if let Some(key) = overrides.get(provider) {
379                return Some(key.clone());
380            }
381        }
382
383        // 2. Check registered provider (stored API key or OAuth)
384        if let Some(auth) = self.providers.get(provider)
385            && let Some(key) = auth.get_api_key()
386        {
387            return Some(key);
388        }
389
390        // 3. Fallback resolver (custom provider config from models.json)
391        {
392            let resolver = self.fallback_resolver.read();
393            if let Some(ref fallback) = *resolver
394                && let Some(key) = fallback(provider)
395            {
396                return Some(key);
397            }
398        }
399
400        // 4. Environment variable (last resort)
401        env_api_keys::get_env_api_key(provider)
402    }
403
404    /// Check if any auth is configured for a provider
405    ///
406    /// Note: Environment variables don't count as "configured" —
407    /// they are a last-resort fallback. Use `oxicode setup` to store
408    /// credentials properly in auth.json for persistent authentication.
409    pub fn has_auth(&self, provider: &str) -> bool {
410        // Check runtime override
411        if self.runtime_overrides.read().contains_key(provider) {
412            return true;
413        }
414
415        // Check registered provider
416        if let Some(auth) = self.providers.get(provider)
417            && auth.is_configured()
418        {
419            return true;
420        }
421
422        // Environment variables do NOT count as configured auth.
423        // They are a last-resort fallback for CI/CD, not a primary auth source.
424
425        false
426    }
427
428    /// Check if an OAuth provider needs refresh
429    pub fn needs_oauth_refresh(&self, provider: &str) -> bool {
430        self.providers
431            .get(provider)
432            .map(|auth| auth.needs_oauth_refresh())
433            .unwrap_or(false)
434    }
435
436    /// Update OAuth token for a provider
437    pub fn set_oauth_token(&mut self, provider: &str, token: OAuthTokenInfo) {
438        if let Some(auth) = self.providers.get_mut(provider) {
439            auth.set_oauth_token(token);
440        }
441    }
442
443    /// Update API key directly
444    pub fn set_api_key(&mut self, provider: &str, api_key: String) {
445        if let Some(auth) = self.providers.get_mut(provider) {
446            auth.set_api_key(api_key);
447        } else {
448            // Create new API key auth
449            self.register_api_key(provider, Some(api_key));
450        }
451    }
452
453    /// List all providers with configured auth
454    pub fn list_providers(&self) -> Vec<String> {
455        let mut providers: Vec<String> = self
456            .providers
457            .iter()
458            .filter(|(_, auth)| auth.is_configured())
459            .map(|(name, _)| name.clone())
460            .collect();
461
462        // Add runtime overrides
463        let overrides: Vec<String> = self.runtime_overrides.read().keys().cloned().collect();
464
465        for key in overrides {
466            if !providers.contains(&key) {
467                providers.push(key);
468            }
469        }
470
471        providers.sort();
472        providers.dedup();
473        providers
474    }
475
476    /// Get auth status for a provider
477    pub fn get_auth_status(&self, provider: &str) -> AuthStatus {
478        if self.runtime_overrides.read().contains_key(provider) {
479            return AuthStatus {
480                configured: true,
481                source: AuthSource::Runtime,
482                label: Some("--api-key".to_string()),
483            };
484        }
485
486        if let Some(auth) = self.providers.get(provider)
487            && auth.is_configured()
488        {
489            return AuthStatus {
490                configured: true,
491                source: AuthSource::Stored,
492                label: None,
493            };
494        }
495
496        // Check environment variables
497        if env_api_keys::has_env_key(provider) {
498            return AuthStatus {
499                configured: false, // Env vars don't count as "configured"
500                source: AuthSource::Environment,
501                label: None,
502            };
503        }
504
505        AuthStatus {
506            configured: false,
507            source: AuthSource::Stored,
508            label: Some("run 'oxicode setup' to configure".to_string()),
509        }
510    }
511}
512
513/// Authentication status
514#[derive(Debug, Clone)]
515pub struct AuthStatus {
516    /// Whether authentication is configured.
517    pub configured: bool,
518    /// The source of the authentication credential.
519    pub source: AuthSource,
520    /// Optional human-readable label for the auth method.
521    pub label: Option<String>,
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_oauth_token_info_expired() {
530        // Create token that expires immediately
531        let token = OAuthTokenInfo::new(
532            "access".to_string(),
533            Some("refresh".to_string()),
534            -1, // Already expired
535        );
536
537        assert!(token.is_expired());
538        assert!(token.needs_refresh());
539    }
540
541    #[test]
542    fn test_oauth_token_info_valid() {
543        // Create token that expires in 1 hour
544        let token = OAuthTokenInfo::new("access".to_string(), Some("refresh".to_string()), 3600);
545
546        assert!(!token.is_expired());
547        assert!(!token.needs_refresh());
548    }
549
550    #[test]
551    fn test_oauth_token_info_needs_refresh_buffer() {
552        // Create token that expires in 2 minutes (within 5 min buffer)
553        let token = OAuthTokenInfo::new("access".to_string(), Some("refresh".to_string()), 120);
554
555        assert!(!token.is_expired());
556        assert!(token.needs_refresh());
557    }
558
559    #[test]
560    fn test_api_key_auth() {
561        let auth = ApiKeyAuth::new(Some("sk-test".to_string()), AuthSource::Stored);
562
563        assert!(auth.is_configured());
564        assert_eq!(auth.get_api_key(), Some("sk-test".to_string()));
565        assert!(!auth.needs_oauth_refresh());
566    }
567
568    #[test]
569    fn test_api_key_auth_not_configured() {
570        let auth = ApiKeyAuth::new(None, AuthSource::Environment);
571
572        assert!(!auth.is_configured());
573        assert!(auth.get_api_key().is_none());
574    }
575
576    #[test]
577    fn test_oauth_auth() {
578        let token = OAuthTokenInfo::new(
579            "access_token".to_string(),
580            Some("refresh_token".to_string()),
581            3600,
582        );
583        let auth = OAuthAuth::with_token("anthropic", token);
584
585        assert!(auth.is_configured());
586        assert_eq!(auth.get_api_key(), Some("access_token".to_string()));
587        assert_eq!(auth.provider_name(), "anthropic");
588    }
589
590    #[test]
591    fn test_oauth_auth_refresh_callback() {
592        let mut auth = OAuthAuth::new("anthropic");
593
594        let refreshed = std::sync::Arc::new(std::sync::Mutex::new(false));
595        let refreshed_clone = refreshed.clone();
596        auth.on_token_refresh(move |_| {
597            *refreshed_clone.lock().unwrap() = true;
598        });
599
600        let new_token = OAuthTokenInfo::new("new_access".to_string(), None, 3600);
601        auth.set_oauth_token(new_token);
602
603        assert!(*refreshed.lock().unwrap());
604        assert_eq!(auth.get_api_key(), Some("new_access".to_string()));
605    }
606
607    #[test]
608    fn test_ambient_auth() {
609        let auth = AmbientAuth::new("bedrock", || true);
610
611        assert!(auth.is_configured());
612        assert_eq!(auth.get_api_key(), Some("<authenticated>".to_string()));
613    }
614
615    #[test]
616    fn test_ambient_auth_not_configured() {
617        let auth = AmbientAuth::new("bedrock", || false);
618
619        assert!(!auth.is_configured());
620        assert!(auth.get_api_key().is_none());
621    }
622
623    #[test]
624    fn test_registry_new() {
625        let registry = ProviderAuthRegistry::new();
626
627        assert!(registry.list_providers().is_empty());
628        assert!(!registry.has_auth("openai"));
629    }
630
631    #[test]
632    fn test_registry_with_defaults() {
633        let registry = ProviderAuthRegistry::with_defaults();
634
635        // Ambient providers should be registered
636        assert!(registry.providers.contains_key("bedrock"));
637        assert!(registry.providers.contains_key("vertex"));
638    }
639
640    #[test]
641    fn test_registry_runtime_override() {
642        let registry = ProviderAuthRegistry::new();
643
644        // Set runtime override
645        registry.set_runtime_key("openai", "sk-runtime".to_string());
646
647        // Runtime should take priority
648        assert_eq!(
649            registry.get_api_key("openai"),
650            Some("sk-runtime".to_string())
651        );
652        assert!(registry.has_auth("openai"));
653    }
654
655    #[test]
656    fn test_registry_remove_runtime_key() {
657        let registry = ProviderAuthRegistry::new();
658
659        registry.set_runtime_key("openai", "sk-runtime".to_string());
660        assert_eq!(
661            registry.get_api_key("openai"),
662            Some("sk-runtime".to_string())
663        );
664
665        registry.remove_runtime_key("openai");
666        assert!(registry.get_api_key("openai").is_none());
667    }
668
669    #[test]
670    fn test_registry_register_api_key() {
671        let mut registry = ProviderAuthRegistry::new();
672        registry.register_api_key("anthropic", Some("sk-stored".to_string()));
673
674        assert!(registry.has_auth("anthropic"));
675        assert_eq!(
676            registry.get_api_key("anthropic"),
677            Some("sk-stored".to_string())
678        );
679    }
680
681    #[test]
682    fn test_registry_register_oauth() {
683        let mut registry = ProviderAuthRegistry::new();
684        let token = OAuthTokenInfo::new(
685            "oauth-access".to_string(),
686            Some("refresh".to_string()),
687            3600,
688        );
689        registry.register_oauth("anthropic", token);
690
691        assert!(registry.has_auth("anthropic"));
692        assert_eq!(
693            registry.get_api_key("anthropic"),
694            Some("oauth-access".to_string())
695        );
696    }
697
698    #[test]
699    fn test_registry_env_key_fallback() {
700        unsafe { std::env::set_var("OPENAI_API_KEY", "sk-env-key") };
701
702        let registry = ProviderAuthRegistry::new();
703        // No explicit provider registered
704
705        // Should fall back to environment
706        assert_eq!(
707            registry.get_api_key("openai"),
708            Some("sk-env-key".to_string())
709        );
710
711        unsafe { std::env::remove_var("OPENAI_API_KEY") };
712    }
713
714    #[test]
715    fn test_registry_fallback_resolver() {
716        let registry = ProviderAuthRegistry::new();
717
718        registry.set_fallback_resolver(|provider| {
719            if provider == "custom" {
720                Some("custom-key".to_string())
721            } else {
722                None
723            }
724        });
725
726        assert_eq!(
727            registry.get_api_key("custom"),
728            Some("custom-key".to_string())
729        );
730        assert!(registry.get_api_key("unknown").is_none());
731    }
732
733    #[test]
734    fn test_registry_priority() {
735        unsafe { std::env::set_var("ANTHROPIC_API_KEY", "sk-env") };
736
737        let mut registry = ProviderAuthRegistry::new();
738
739        // Register stored key
740        registry.register_api_key("anthropic", Some("sk-stored".to_string()));
741        registry.set_runtime_key("anthropic", "sk-runtime".to_string());
742
743        // Runtime should win
744        assert_eq!(
745            registry.get_api_key("anthropic"),
746            Some("sk-runtime".to_string())
747        );
748
749        // Remove runtime
750        registry.remove_runtime_key("anthropic");
751
752        // Stored should win
753        assert_eq!(
754            registry.get_api_key("anthropic"),
755            Some("sk-stored".to_string())
756        );
757
758        unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
759    }
760
761    #[test]
762    fn test_registry_list_providers() {
763        let mut registry = ProviderAuthRegistry::new();
764
765        registry.register_api_key("openai", Some("key1".to_string()));
766        registry.register_oauth(
767            "anthropic",
768            OAuthTokenInfo::new("access".to_string(), None, 3600),
769        );
770        registry.set_runtime_key("google", "runtime-key".to_string());
771
772        let providers = registry.list_providers();
773        assert!(providers.contains(&"openai".to_string()));
774        assert!(providers.contains(&"anthropic".to_string()));
775        assert!(providers.contains(&"google".to_string()));
776    }
777
778    #[test]
779    fn test_registry_get_auth_status() {
780        let registry = ProviderAuthRegistry::new();
781
782        registry.set_runtime_key("openai", "key".to_string());
783
784        let status = registry.get_auth_status("openai");
785        assert!(status.configured);
786        assert_eq!(status.source, AuthSource::Runtime);
787        assert_eq!(status.label, Some("--api-key".to_string()));
788    }
789
790    #[test]
791    fn test_registry_env_source_status() {
792        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "sk-test") };
793
794        let registry = ProviderAuthRegistry::new();
795
796        let status = registry.get_auth_status("deepseek");
797        assert!(!status.configured); // Env vars don't count as "configured"
798        assert_eq!(status.source, AuthSource::Environment);
799
800        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
801    }
802
803    #[test]
804    fn test_registry_update_oauth_token() {
805        let mut registry = ProviderAuthRegistry::new();
806        let token = OAuthTokenInfo::new("old".to_string(), None, 0);
807        registry.register_oauth("anthropic", token);
808
809        // Token should be expired
810        assert!(registry.needs_oauth_refresh("anthropic"));
811
812        // Update with fresh token
813        let new_token = OAuthTokenInfo::new("new".to_string(), None, 3600);
814        registry.set_oauth_token("anthropic", new_token);
815
816        assert!(!registry.needs_oauth_refresh("anthropic"));
817        assert_eq!(registry.get_api_key("anthropic"), Some("new".to_string()));
818    }
819}