Skip to main content

aptu_core/
auth.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Token provider abstraction for multi-platform credential resolution.
4//!
5//! This module defines the `TokenProvider` trait, which abstracts credential
6//! resolution across different platforms (CLI, iOS, etc.). Each platform
7//! implements this trait to provide GitHub and AI provider tokens from their
8//! respective credential sources.
9
10use secrecy::SecretString;
11
12/// Provides GitHub and AI provider credentials for API calls.
13///
14/// This trait abstracts credential resolution across platforms:
15/// - **CLI:** Resolves from environment variables, GitHub CLI, or system keyring
16/// - **iOS:** Resolves from iOS keychain via FFI
17///
18/// Implementations should handle credential lookup and return `None` if
19/// credentials are not available.
20pub trait TokenProvider: Send + Sync {
21    /// Retrieves the GitHub API token.
22    ///
23    /// Returns `None` if no token is available from any source.
24    fn github_token(&self) -> Option<SecretString>;
25
26    /// Retrieves an AI provider API key.
27    ///
28    /// # Arguments
29    /// * `provider` - The AI provider name (must match a registered provider in the registry)
30    ///
31    /// Returns `None` if no API key is available from any source.
32    fn ai_api_key(&self, provider: &str) -> Option<SecretString>;
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38    use crate::ai::registry::all_providers;
39    use secrecy::ExposeSecret;
40    use serial_test::serial;
41    use std::collections::HashMap;
42
43    /// Mock implementation for testing.
44    struct MockTokenProvider {
45        github_token: Option<SecretString>,
46        ai_keys: HashMap<String, SecretString>,
47    }
48
49    impl Drop for MockTokenProvider {
50        fn drop(&mut self) {
51            use zeroize::Zeroize;
52            if let Some(ref mut gh_token) = self.github_token {
53                gh_token.zeroize();
54            }
55            for ai_key in self.ai_keys.values_mut() {
56                ai_key.zeroize();
57            }
58        }
59    }
60
61    impl TokenProvider for MockTokenProvider {
62        fn github_token(&self) -> Option<SecretString> {
63            self.github_token.clone()
64        }
65
66        fn ai_api_key(&self, provider: &str) -> Option<SecretString> {
67            self.ai_keys.get(provider).cloned()
68        }
69    }
70
71    /// Reads tokens from environment variables at runtime.
72    ///
73    /// Always available (no cfg gate). On WASM, `std::env::var` returns
74    /// `Err(NotPresent)` for all variables, so both methods naturally
75    /// return `None` without needing platform gating.
76    pub struct EnvTokenProvider;
77
78    impl TokenProvider for EnvTokenProvider {
79        fn github_token(&self) -> Option<SecretString> {
80            std::env::var("GITHUB_TOKEN")
81                .or_else(|_| std::env::var("GH_TOKEN"))
82                .ok()
83                .map(SecretString::from)
84        }
85
86        fn ai_api_key(&self, provider: &str) -> Option<SecretString> {
87            let var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
88            std::env::var(&var).ok().map(SecretString::from)
89        }
90    }
91
92    #[test]
93    fn test_mock_provider_with_tokens() {
94        let mut ai_keys = HashMap::new();
95        for provider_config in all_providers() {
96            ai_keys.insert(
97                provider_config.name.to_string(),
98                SecretString::from(format!("{}_key", provider_config.name)),
99            );
100        }
101
102        let provider = MockTokenProvider {
103            github_token: Some(SecretString::from("gh_token")),
104            ai_keys,
105        };
106
107        assert!(provider.github_token().is_some());
108        for provider_config in all_providers() {
109            assert!(
110                provider.ai_api_key(provider_config.name).is_some(),
111                "Expected key for provider: {}",
112                provider_config.name
113            );
114        }
115    }
116
117    #[test]
118    fn test_mock_provider_without_tokens() {
119        let provider = MockTokenProvider {
120            github_token: None,
121            ai_keys: HashMap::new(),
122        };
123
124        assert!(provider.github_token().is_none());
125        for provider_config in all_providers() {
126            assert!(
127                provider.ai_api_key(provider_config.name).is_none(),
128                "Expected no key for provider: {}",
129                provider_config.name
130            );
131        }
132    }
133
134    #[test]
135    #[serial]
136    fn test_env_token_provider_github_token() {
137        // SAFETY: single-threaded test process; no concurrent env reads.
138        unsafe {
139            std::env::set_var("GITHUB_TOKEN", "test_gh_token_abc");
140        }
141        let provider = EnvTokenProvider;
142        let result = provider.github_token();
143        unsafe {
144            std::env::remove_var("GITHUB_TOKEN");
145        }
146        assert!(result.is_some());
147        assert_eq!(result.unwrap().expose_secret(), "test_gh_token_abc");
148    }
149
150    #[test]
151    #[serial]
152    fn test_env_token_provider_ai_api_key() {
153        // SAFETY: single-threaded test process; no concurrent env reads.
154        unsafe {
155            std::env::set_var("OPENAI_API_KEY", "sk-test-key");
156        }
157        let provider = EnvTokenProvider;
158        let result = provider.ai_api_key("openai");
159        unsafe {
160            std::env::remove_var("OPENAI_API_KEY");
161        }
162        assert!(result.is_some());
163        assert_eq!(result.unwrap().expose_secret(), "sk-test-key");
164    }
165
166    #[test]
167    #[serial]
168    fn test_env_token_provider_no_env() {
169        // Ensure GITHUB_TOKEN and GH_TOKEN are unset
170        unsafe {
171            std::env::remove_var("GITHUB_TOKEN");
172            std::env::remove_var("GH_TOKEN");
173        }
174        let provider = EnvTokenProvider;
175        assert!(provider.github_token().is_none());
176    }
177}