1use std::collections::HashMap;
7use std::path::Path;
8
9use crate::error::{AuthError, CloudError, Result};
10
11#[derive(Debug, Clone)]
13pub enum Credentials {
14 None,
16
17 ApiKey {
19 key: String,
21 },
22
23 AccessKey {
25 access_key: String,
27 secret_key: String,
29 session_token: Option<String>,
31 },
32
33 OAuth2 {
35 access_token: String,
37 refresh_token: Option<String>,
39 expires_at: Option<chrono::DateTime<chrono::Utc>>,
41 },
42
43 ServiceAccount {
45 key_json: String,
47 project_id: Option<String>,
49 },
50
51 SasToken {
53 token: String,
55 expires_at: Option<chrono::DateTime<chrono::Utc>>,
57 },
58
59 IamRole {
61 role_arn: String,
63 session_name: String,
65 },
66
67 Custom {
69 data: HashMap<String, String>,
71 },
72}
73
74impl Credentials {
75 #[must_use]
77 pub fn api_key(key: impl Into<String>) -> Self {
78 Self::ApiKey { key: key.into() }
79 }
80
81 #[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 #[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 #[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 #[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 pub fn service_account_from_json(json: impl Into<String>) -> Result<Self> {
130 let json_str = json.into();
131
132 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 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 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 #[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 #[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 #[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 #[must_use]
200 pub fn needs_refresh(&self) -> bool {
201 let now = chrono::Utc::now();
202 let buffer = chrono::Duration::minutes(5); 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 #[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#[cfg(feature = "async")]
239#[async_trait::async_trait]
240pub trait CredentialProvider: Send + Sync {
241 async fn load(&self) -> Result<Credentials>;
243
244 async fn refresh(&self, _credentials: &Credentials) -> Result<Credentials> {
246 self.load().await
248 }
249}
250
251pub struct EnvCredentialProvider {
253 credential_type: CredentialType,
255}
256
257#[derive(Debug, Clone, Copy)]
259pub enum CredentialType {
260 Aws,
262 Azure,
264 Gcp,
266 ApiKey,
268}
269
270impl EnvCredentialProvider {
271 #[must_use]
273 pub const fn new(credential_type: CredentialType) -> Self {
274 Self { credential_type }
275 }
276
277 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 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 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 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 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
365pub struct FileCredentialProvider {
367 path: std::path::PathBuf,
369}
370
371impl FileCredentialProvider {
372 #[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
389pub struct ChainCredentialProvider {
391 providers: Vec<Box<dyn CredentialProvider>>,
393}
394
395impl ChainCredentialProvider {
396 #[must_use]
398 pub fn new() -> Self {
399 Self {
400 providers: Vec::new(),
401 }
402 }
403
404 #[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); 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}