Skip to main content

oxigdal_cloud/
auth.rs

1//! Authentication strategies for cloud storage backends
2//!
3//! This module provides various authentication methods for cloud providers,
4//! including OAuth 2.0, service accounts, API keys, SAS tokens, and IAM roles.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use crate::error::{AuthError, CloudError, Result};
10
11/// Authentication credentials
12#[derive(Debug, Clone)]
13pub enum Credentials {
14    /// No authentication
15    None,
16
17    /// API key authentication
18    ApiKey {
19        /// API key
20        key: String,
21    },
22
23    /// Access key and secret key (AWS-style)
24    AccessKey {
25        /// Access key ID
26        access_key: String,
27        /// Secret access key
28        secret_key: String,
29        /// Optional session token
30        session_token: Option<String>,
31    },
32
33    /// OAuth 2.0 token
34    OAuth2 {
35        /// Access token
36        access_token: String,
37        /// Optional refresh token
38        refresh_token: Option<String>,
39        /// Token expiration time
40        expires_at: Option<chrono::DateTime<chrono::Utc>>,
41    },
42
43    /// Service account key (GCP-style JSON)
44    ServiceAccount {
45        /// Service account key JSON
46        key_json: String,
47        /// Project ID
48        project_id: Option<String>,
49    },
50
51    /// Shared Access Signature token (Azure-style)
52    SasToken {
53        /// SAS token
54        token: String,
55        /// Token expiration time
56        expires_at: Option<chrono::DateTime<chrono::Utc>>,
57    },
58
59    /// IAM role credentials
60    IamRole {
61        /// Role ARN
62        role_arn: String,
63        /// Session name
64        session_name: String,
65    },
66
67    /// Custom credentials with arbitrary key-value pairs
68    Custom {
69        /// Credential data
70        data: HashMap<String, String>,
71    },
72}
73
74impl Credentials {
75    /// Creates API key credentials
76    #[must_use]
77    pub fn api_key(key: impl Into<String>) -> Self {
78        Self::ApiKey { key: key.into() }
79    }
80
81    /// Creates access key credentials
82    #[must_use]
83    pub fn access_key(access_key: impl Into<String>, secret_key: impl Into<String>) -> Self {
84        Self::AccessKey {
85            access_key: access_key.into(),
86            secret_key: secret_key.into(),
87            session_token: None,
88        }
89    }
90
91    /// Creates access key credentials with session token
92    #[must_use]
93    pub fn access_key_with_session(
94        access_key: impl Into<String>,
95        secret_key: impl Into<String>,
96        session_token: impl Into<String>,
97    ) -> Self {
98        Self::AccessKey {
99            access_key: access_key.into(),
100            secret_key: secret_key.into(),
101            session_token: Some(session_token.into()),
102        }
103    }
104
105    /// Creates OAuth 2.0 credentials
106    #[must_use]
107    pub fn oauth2(access_token: impl Into<String>) -> Self {
108        Self::OAuth2 {
109            access_token: access_token.into(),
110            refresh_token: None,
111            expires_at: None,
112        }
113    }
114
115    /// Creates OAuth 2.0 credentials with refresh token
116    #[must_use]
117    pub fn oauth2_with_refresh(
118        access_token: impl Into<String>,
119        refresh_token: impl Into<String>,
120    ) -> Self {
121        Self::OAuth2 {
122            access_token: access_token.into(),
123            refresh_token: Some(refresh_token.into()),
124            expires_at: None,
125        }
126    }
127
128    /// Creates service account credentials from JSON
129    pub fn service_account_from_json(json: impl Into<String>) -> Result<Self> {
130        let json_str = json.into();
131
132        // Try to parse JSON to validate
133        let parsed: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
134            CloudError::Auth(AuthError::ServiceAccountKey {
135                message: format!("Invalid JSON: {e}"),
136            })
137        })?;
138
139        // Extract project ID if available
140        let project_id = parsed
141            .get("project_id")
142            .and_then(|v| v.as_str())
143            .map(|s| s.to_string());
144
145        Ok(Self::ServiceAccount {
146            key_json: json_str,
147            project_id,
148        })
149    }
150
151    /// Creates service account credentials from file
152    pub fn service_account_from_file(path: impl AsRef<Path>) -> Result<Self> {
153        let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
154            CloudError::Auth(AuthError::ServiceAccountKey {
155                message: format!("Failed to read service account key file: {e}"),
156            })
157        })?;
158
159        Self::service_account_from_json(content)
160    }
161
162    /// Creates SAS token credentials
163    #[must_use]
164    pub fn sas_token(token: impl Into<String>) -> Self {
165        Self::SasToken {
166            token: token.into(),
167            expires_at: None,
168        }
169    }
170
171    /// Creates IAM role credentials
172    #[must_use]
173    pub fn iam_role(role_arn: impl Into<String>, session_name: impl Into<String>) -> Self {
174        Self::IamRole {
175            role_arn: role_arn.into(),
176            session_name: session_name.into(),
177        }
178    }
179
180    /// Checks if credentials are expired
181    #[must_use]
182    pub fn is_expired(&self) -> bool {
183        let now = chrono::Utc::now();
184
185        match self {
186            Self::OAuth2 {
187                expires_at: Some(expiry),
188                ..
189            } => *expiry <= now,
190            Self::SasToken {
191                expires_at: Some(expiry),
192                ..
193            } => *expiry <= now,
194            _ => false,
195        }
196    }
197
198    /// Returns true if credentials need refresh
199    #[must_use]
200    pub fn needs_refresh(&self) -> bool {
201        let now = chrono::Utc::now();
202        let buffer = chrono::Duration::minutes(5); // Refresh 5 minutes before expiry
203
204        match self {
205            Self::OAuth2 {
206                expires_at: Some(expiry),
207                ..
208            } => *expiry <= now + buffer,
209            Self::SasToken {
210                expires_at: Some(expiry),
211                ..
212            } => *expiry <= now + buffer,
213            _ => false,
214        }
215    }
216
217    /// Returns a short, secret-free name for the credentials variant.
218    ///
219    /// Intended for diagnostics/error messages where the full `Debug`
220    /// representation would risk leaking secret material (access keys,
221    /// tokens, service account JSON, etc.).
222    #[must_use]
223    pub fn variant_name(&self) -> &'static str {
224        match self {
225            Self::None => "None",
226            Self::ApiKey { .. } => "ApiKey",
227            Self::AccessKey { .. } => "AccessKey",
228            Self::OAuth2 { .. } => "OAuth2",
229            Self::ServiceAccount { .. } => "ServiceAccount",
230            Self::SasToken { .. } => "SasToken",
231            Self::IamRole { .. } => "IamRole",
232            Self::Custom { .. } => "Custom",
233        }
234    }
235}
236
237/// Credential provider trait for dynamic credential loading
238#[cfg(feature = "async")]
239#[async_trait::async_trait]
240pub trait CredentialProvider: Send + Sync {
241    /// Loads credentials
242    async fn load(&self) -> Result<Credentials>;
243
244    /// Refreshes credentials if needed
245    async fn refresh(&self, _credentials: &Credentials) -> Result<Credentials> {
246        // Default implementation: just reload
247        self.load().await
248    }
249}
250
251/// Environment variable credential provider
252pub struct EnvCredentialProvider {
253    /// Credential type
254    credential_type: CredentialType,
255}
256
257/// Supported credential types for environment variable provider
258#[derive(Debug, Clone, Copy)]
259pub enum CredentialType {
260    /// AWS access key credentials
261    Aws,
262    /// Azure storage credentials
263    Azure,
264    /// GCP service account credentials
265    Gcp,
266    /// Generic API key
267    ApiKey,
268}
269
270impl EnvCredentialProvider {
271    /// Creates a new environment variable credential provider
272    #[must_use]
273    pub const fn new(credential_type: CredentialType) -> Self {
274        Self { credential_type }
275    }
276
277    /// Loads AWS credentials from environment variables
278    fn load_aws() -> Result<Credentials> {
279        let access_key = std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| {
280            CloudError::Auth(AuthError::CredentialsNotFound {
281                message: "AWS_ACCESS_KEY_ID not found".to_string(),
282            })
283        })?;
284
285        let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY").map_err(|_| {
286            CloudError::Auth(AuthError::CredentialsNotFound {
287                message: "AWS_SECRET_ACCESS_KEY not found".to_string(),
288            })
289        })?;
290
291        let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
292
293        Ok(Credentials::AccessKey {
294            access_key,
295            secret_key,
296            session_token,
297        })
298    }
299
300    /// Loads Azure credentials from environment variables
301    fn load_azure() -> Result<Credentials> {
302        let account_name = std::env::var("AZURE_STORAGE_ACCOUNT").map_err(|_| {
303            CloudError::Auth(AuthError::CredentialsNotFound {
304                message: "AZURE_STORAGE_ACCOUNT not found".to_string(),
305            })
306        })?;
307
308        // Try account key first, then SAS token
309        if let Ok(account_key) = std::env::var("AZURE_STORAGE_KEY") {
310            let mut data = HashMap::new();
311            data.insert("account_name".to_string(), account_name);
312            data.insert("account_key".to_string(), account_key);
313
314            Ok(Credentials::Custom { data })
315        } else if let Ok(sas_token) = std::env::var("AZURE_STORAGE_SAS_TOKEN") {
316            Ok(Credentials::SasToken {
317                token: sas_token,
318                expires_at: None,
319            })
320        } else {
321            Err(CloudError::Auth(AuthError::CredentialsNotFound {
322                message: "Neither AZURE_STORAGE_KEY nor AZURE_STORAGE_SAS_TOKEN found".to_string(),
323            }))
324        }
325    }
326
327    /// Loads GCP credentials from environment variables
328    fn load_gcp() -> Result<Credentials> {
329        let key_file = std::env::var("GOOGLE_APPLICATION_CREDENTIALS").map_err(|_| {
330            CloudError::Auth(AuthError::CredentialsNotFound {
331                message: "GOOGLE_APPLICATION_CREDENTIALS not found".to_string(),
332            })
333        })?;
334
335        Credentials::service_account_from_file(&key_file)
336    }
337
338    /// Loads API key from environment variables
339    fn load_api_key() -> Result<Credentials> {
340        let key = std::env::var("API_KEY")
341            .or_else(|_| std::env::var("APIKEY"))
342            .map_err(|_| {
343                CloudError::Auth(AuthError::CredentialsNotFound {
344                    message: "API_KEY or APIKEY not found".to_string(),
345                })
346            })?;
347
348        Ok(Credentials::ApiKey { key })
349    }
350}
351
352#[cfg(feature = "async")]
353#[async_trait::async_trait]
354impl CredentialProvider for EnvCredentialProvider {
355    async fn load(&self) -> Result<Credentials> {
356        match self.credential_type {
357            CredentialType::Aws => Self::load_aws(),
358            CredentialType::Azure => Self::load_azure(),
359            CredentialType::Gcp => Self::load_gcp(),
360            CredentialType::ApiKey => Self::load_api_key(),
361        }
362    }
363}
364
365/// File-based credential provider
366pub struct FileCredentialProvider {
367    /// Path to credentials file
368    path: std::path::PathBuf,
369}
370
371impl FileCredentialProvider {
372    /// Creates a new file credential provider
373    #[must_use]
374    pub fn new(path: impl AsRef<Path>) -> Self {
375        Self {
376            path: path.as_ref().to_path_buf(),
377        }
378    }
379}
380
381#[cfg(feature = "async")]
382#[async_trait::async_trait]
383impl CredentialProvider for FileCredentialProvider {
384    async fn load(&self) -> Result<Credentials> {
385        Credentials::service_account_from_file(&self.path)
386    }
387}
388
389/// Chain credential provider that tries multiple providers in order
390pub struct ChainCredentialProvider {
391    /// List of credential providers
392    providers: Vec<Box<dyn CredentialProvider>>,
393}
394
395impl ChainCredentialProvider {
396    /// Creates a new chain credential provider
397    #[must_use]
398    pub fn new() -> Self {
399        Self {
400            providers: Vec::new(),
401        }
402    }
403
404    /// Adds a credential provider to the chain
405    #[must_use]
406    pub fn with_provider(mut self, provider: Box<dyn CredentialProvider>) -> Self {
407        self.providers.push(provider);
408        self
409    }
410}
411
412impl Default for ChainCredentialProvider {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418#[cfg(feature = "async")]
419#[async_trait::async_trait]
420impl CredentialProvider for ChainCredentialProvider {
421    async fn load(&self) -> Result<Credentials> {
422        for provider in &self.providers {
423            if let Ok(credentials) = provider.load().await {
424                return Ok(credentials);
425            }
426        }
427
428        Err(CloudError::Auth(AuthError::CredentialsNotFound {
429            message: "No credential provider succeeded".to_string(),
430        }))
431    }
432}
433
434#[cfg(test)]
435#[allow(clippy::panic)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_credentials_api_key() {
441        let creds = Credentials::api_key("test-key");
442        match creds {
443            Credentials::ApiKey { key } => assert_eq!(key, "test-key"),
444            _ => panic!("Expected ApiKey credentials"),
445        }
446    }
447
448    #[test]
449    fn test_credentials_access_key() {
450        let creds = Credentials::access_key("access", "secret");
451        match creds {
452            Credentials::AccessKey {
453                access_key,
454                secret_key,
455                session_token,
456            } => {
457                assert_eq!(access_key, "access");
458                assert_eq!(secret_key, "secret");
459                assert!(session_token.is_none());
460            }
461            _ => panic!("Expected AccessKey credentials"),
462        }
463    }
464
465    #[test]
466    fn test_credentials_oauth2() {
467        let creds = Credentials::oauth2("token");
468        match creds {
469            Credentials::OAuth2 { access_token, .. } => assert_eq!(access_token, "token"),
470            _ => panic!("Expected OAuth2 credentials"),
471        }
472    }
473
474    #[test]
475    fn test_credentials_sas_token() {
476        let creds = Credentials::sas_token("token");
477        match creds {
478            Credentials::SasToken { token, .. } => assert_eq!(token, "token"),
479            _ => panic!("Expected SasToken credentials"),
480        }
481    }
482
483    #[test]
484    fn test_credentials_iam_role() {
485        let creds = Credentials::iam_role("arn:aws:iam::123:role/test", "session");
486        match creds {
487            Credentials::IamRole {
488                role_arn,
489                session_name,
490            } => {
491                assert_eq!(role_arn, "arn:aws:iam::123:role/test");
492                assert_eq!(session_name, "session");
493            }
494            _ => panic!("Expected IamRole credentials"),
495        }
496    }
497
498    #[test]
499    fn test_credentials_service_account_from_json() {
500        let json = r#"{"type":"service_account","project_id":"test-project"}"#;
501        let creds = Credentials::service_account_from_json(json);
502        assert!(creds.is_ok());
503
504        match creds.ok() {
505            Some(Credentials::ServiceAccount {
506                project_id: Some(project_id),
507                ..
508            }) => {
509                assert_eq!(project_id, "test-project");
510            }
511            _ => panic!("Expected ServiceAccount credentials with project_id"),
512        }
513    }
514
515    #[test]
516    fn test_credentials_is_expired() {
517        let now = chrono::Utc::now();
518        let past = now - chrono::Duration::hours(1);
519        let future = now + chrono::Duration::hours(1);
520
521        let expired = Credentials::OAuth2 {
522            access_token: "token".to_string(),
523            refresh_token: None,
524            expires_at: Some(past),
525        };
526        assert!(expired.is_expired());
527
528        let valid = Credentials::OAuth2 {
529            access_token: "token".to_string(),
530            refresh_token: None,
531            expires_at: Some(future),
532        };
533        assert!(!valid.is_expired());
534    }
535
536    #[test]
537    fn test_credentials_needs_refresh() {
538        let now = chrono::Utc::now();
539        let soon = now + chrono::Duration::minutes(3); // Within 5-minute buffer
540        let later = now + chrono::Duration::hours(1);
541
542        let needs_refresh = Credentials::OAuth2 {
543            access_token: "token".to_string(),
544            refresh_token: None,
545            expires_at: Some(soon),
546        };
547        assert!(needs_refresh.needs_refresh());
548
549        let valid = Credentials::OAuth2 {
550            access_token: "token".to_string(),
551            refresh_token: None,
552            expires_at: Some(later),
553        };
554        assert!(!valid.needs_refresh());
555    }
556}