bitbucket_cli/auth/
mod.rs

1pub mod api_key;
2pub mod file_store;
3pub mod keyring_store;
4pub mod oauth;
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9pub use api_key::*;
10pub use file_store::*;
11pub use keyring_store::*;
12pub use oauth::*;
13
14/// Credential types supported by the CLI
15/// OAuth2 is preferred, with API key as fallback
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub enum Credential {
18    /// OAuth 2.0 token (PREFERRED)
19    OAuth {
20        access_token: String,
21        refresh_token: Option<String>,
22        expires_at: Option<i64>,
23    },
24    /// Bitbucket API key (fallback for automation/CI)
25    /// Note: App passwords are deprecated by Atlassian
26    ApiKey { username: String, api_key: String },
27}
28
29impl Credential {
30    /// Get the authorization header value for API requests
31    #[inline]
32    pub fn auth_header(&self) -> String {
33        match self {
34            Credential::OAuth { access_token, .. } => {
35                // Pre-allocate: "Bearer " (7) + token length
36                let mut result = String::with_capacity(7 + access_token.len());
37                result.push_str("Bearer ");
38                result.push_str(access_token);
39                result
40            }
41            Credential::ApiKey { username, api_key } => {
42                use base64::Engine;
43                // Pre-calculate capacity: "Basic " (6) + base64 encoded length
44                // base64 length = ceil(input_len * 4/3)
45                let input_len = username.len() + 1 + api_key.len();
46                let base64_len = input_len.div_ceil(3) * 4;
47                let mut result = String::with_capacity(6 + base64_len);
48                result.push_str("Basic ");
49
50                // Encode directly into a buffer to avoid intermediate String
51                let mut credentials = Vec::with_capacity(input_len);
52                credentials.extend_from_slice(username.as_bytes());
53                credentials.push(b':');
54                credentials.extend_from_slice(api_key.as_bytes());
55
56                base64::engine::general_purpose::STANDARD.encode_string(&credentials, &mut result);
57                result
58            }
59        }
60    }
61
62    /// Get the credential type name for display
63    #[inline]
64    pub fn type_name(&self) -> &'static str {
65        match self {
66            Credential::OAuth { .. } => "OAuth 2.0",
67            Credential::ApiKey { .. } => "API Key",
68        }
69    }
70
71    /// Check if the credential needs refresh (for OAuth)
72    #[inline]
73    pub fn needs_refresh(&self) -> bool {
74        match self {
75            Credential::OAuth {
76                expires_at: Some(expires),
77                ..
78            } => {
79                // Refresh if expiring within 5 minutes (300 seconds)
80                *expires < chrono::Utc::now().timestamp() + 300
81            }
82            _ => false,
83        }
84    }
85
86    /// Get username if available
87    #[inline]
88    pub fn username(&self) -> Option<&str> {
89        match self {
90            Credential::ApiKey { username, .. } => Some(username),
91            Credential::OAuth { .. } => None,
92        }
93    }
94
95    /// Check if this is an OAuth credential
96    #[inline]
97    pub fn is_oauth(&self) -> bool {
98        matches!(self, Credential::OAuth { .. })
99    }
100
101    /// Check if this is an API key credential
102    #[inline]
103    pub fn is_api_key(&self) -> bool {
104        matches!(self, Credential::ApiKey { .. })
105    }
106}
107
108/// Credential storage backend
109enum StorageBackend {
110    Keyring(KeyringStore),
111    File(FileStore),
112}
113
114/// Authentication manager
115pub struct AuthManager {
116    backend: StorageBackend,
117    /// Fallback file store if keyring operations fail at runtime
118    file_fallback: Option<FileStore>,
119}
120
121impl AuthManager {
122    pub fn new() -> Result<Self> {
123        // Automatically detect if we should use file storage
124        let use_file_storage = Self::should_use_file_storage();
125
126        let (backend, file_fallback) = if use_file_storage {
127            // Use file storage silently - no need to warn the user
128            (StorageBackend::File(FileStore::new()?), None)
129        } else {
130            // Try keyring, but keep file fallback ready for runtime failures
131            match KeyringStore::new() {
132                Ok(keyring) => {
133                    // Pre-create file fallback in case keyring fails at runtime
134                    let fallback = FileStore::new().ok();
135                    (StorageBackend::Keyring(keyring), fallback)
136                }
137                Err(_) => (StorageBackend::File(FileStore::new()?), None),
138            }
139        };
140
141        Ok(Self {
142            backend,
143            file_fallback,
144        })
145    }
146
147    /// Determine if we should use file storage instead of keyring
148    fn should_use_file_storage() -> bool {
149        // Allow manual override
150        if std::env::var("BITBUCKET_USE_FILE_STORAGE").is_ok() {
151            return true;
152        }
153
154        // Detect WSL
155        if Self::is_wsl() {
156            return true;
157        }
158
159        // Detect if in a container
160        if Self::is_container() {
161            return true;
162        }
163
164        // Test if keyring actually works
165        !Self::test_keyring()
166    }
167
168    /// Check if running in WSL
169    fn is_wsl() -> bool {
170        // Check for WSL in /proc/version
171        if let Ok(version) = std::fs::read_to_string("/proc/version") {
172            if version.to_lowercase().contains("microsoft")
173                || version.to_lowercase().contains("wsl")
174            {
175                return true;
176            }
177        }
178
179        // Check WSL environment variables
180        std::env::var("WSL_DISTRO_NAME").is_ok() || std::env::var("WSL_INTEROP").is_ok()
181    }
182
183    /// Check if running in a container
184    fn is_container() -> bool {
185        // Check for /.dockerenv file
186        if std::path::Path::new("/.dockerenv").exists() {
187            return true;
188        }
189
190        // Check for container in /proc/1/cgroup
191        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
192            if cgroup.contains("docker") || cgroup.contains("lxc") || cgroup.contains("kubepods") {
193                return true;
194            }
195        }
196
197        false
198    }
199
200    /// Test if keyring is actually available and working
201    /// Uses the same service name as KeyringStore to ensure consistent behavior
202    fn test_keyring() -> bool {
203        // Use the same service name as KeyringStore, but a test-specific key
204        // This ensures we're testing the actual service permissions
205        match keyring::Entry::new("bitbucket-cli", "test-probe") {
206            Ok(entry) => {
207                // Try to set and get a test value
208                if entry.set_password("test").is_ok() {
209                    let can_read = entry.get_password().is_ok();
210                    let _ = entry.delete_credential(); // Clean up
211                    can_read
212                } else {
213                    false
214                }
215            }
216            Err(_) => false,
217        }
218    }
219
220    /// Get stored credentials
221    /// Also checks file fallback if keyring returns nothing (for credentials stored during fallback)
222    pub fn get_credentials(&self) -> Result<Option<Credential>> {
223        match &self.backend {
224            StorageBackend::Keyring(store) => {
225                match store.get_credential() {
226                    Ok(Some(cred)) => Ok(Some(cred)),
227                    Ok(None) | Err(_) => {
228                        // Check file fallback in case credentials were stored there
229                        if let Some(ref file_store) = self.file_fallback {
230                            file_store.get_credential()
231                        } else {
232                            Ok(None)
233                        }
234                    }
235                }
236            }
237            StorageBackend::File(store) => store.get_credential(),
238        }
239    }
240
241    /// Store credentials
242    /// Falls back to file storage if keyring fails at runtime
243    pub fn store_credentials(&self, credential: &Credential) -> Result<()> {
244        match &self.backend {
245            StorageBackend::Keyring(store) => {
246                match store.store_credential(credential) {
247                    Ok(()) => Ok(()),
248                    Err(e) => {
249                        // Keyring failed at runtime - try file fallback
250                        if let Some(ref file_store) = self.file_fallback {
251                            eprintln!(
252                                "⚠️  Keyring storage failed ({}), falling back to file storage",
253                                e
254                            );
255                            file_store.store_credential(credential)
256                        } else {
257                            Err(e)
258                        }
259                    }
260                }
261            }
262            StorageBackend::File(store) => store.store_credential(credential),
263        }
264    }
265
266    /// Clear stored credentials from all storage locations
267    pub fn clear_credentials(&self) -> Result<()> {
268        // Clear from primary backend
269        let primary_result = match &self.backend {
270            StorageBackend::Keyring(store) => store.delete_credential(),
271            StorageBackend::File(store) => store.delete_credential(),
272        };
273
274        // Also clear file fallback if it exists (in case credentials were stored there)
275        if let Some(ref file_store) = self.file_fallback {
276            let _ = file_store.delete_credential(); // Ignore errors for fallback cleanup
277        }
278
279        primary_result
280    }
281
282    /// Check if authenticated
283    pub fn is_authenticated(&self) -> bool {
284        self.get_credentials().map(|c| c.is_some()).unwrap_or(false)
285    }
286}
287
288impl Default for AuthManager {
289    fn default() -> Self {
290        Self::new().expect("Failed to create auth manager")
291    }
292}