1use bitwarden::{
36 auth::login::AccessTokenLoginRequest,
37 secrets_manager::{
38 projects::ProjectsListRequest,
39 secrets::{SecretGetRequest, SecretIdentifiersByProjectRequest},
40 ClientProjectsExt, ClientSecretsExt,
41 },
42 Client,
43};
44use std::sync::Arc;
45
46use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue};
47use zeroize::Zeroizing;
48
49const BACKEND: &str = "bitwarden";
50
51fn backend_error(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> SecretError {
53 SecretError::Backend {
54 backend: BACKEND,
55 source: source.into(),
56 }
57}
58
59fn unavailable_error(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> SecretError {
61 SecretError::Unavailable {
62 backend: BACKEND,
63 source: source.into(),
64 }
65}
66
67fn secret_value_from_response(value: String) -> Result<SecretValue, SecretError> {
72 if value.is_empty() {
73 return Err(backend_error(
74 "Bitwarden returned an empty secret value",
75 ));
76 }
77 Ok(SecretValue::new(value.into_bytes()))
78}
79
80const SDK_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
84
85struct BitwardenSession {
91 client: Client,
92 #[allow(dead_code)]
95 org_id: uuid::Uuid,
96 #[allow(dead_code)]
99 project_id: uuid::Uuid,
100 secret_id: uuid::Uuid,
101}
102
103pub struct BitwardenBackend {
121 access_token: Zeroizing<String>,
122 project_name: String,
123 secret_name: String,
124 session: tokio::sync::RwLock<Option<BitwardenSession>>,
131}
132
133impl std::fmt::Debug for BitwardenBackend {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("BitwardenBackend")
136 .field("project_name", &self.project_name)
137 .field("secret_name", &self.secret_name)
138 .finish_non_exhaustive()
139 }
140}
141
142impl BitwardenBackend {
143 pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
155 Self::from_parsed_uri(&SecretUri::parse(uri)?)
156 }
157
158 pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
160 if parsed.backend() != BACKEND {
161 return Err(SecretError::InvalidUri(format!(
162 "expected backend `{BACKEND}`, got `{}`",
163 parsed.backend()
164 )));
165 }
166
167 let (project_name, secret_name) = parsed.path().split_once('/').ok_or_else(|| {
169 SecretError::InvalidUri(format!(
170 "bitwarden URI requires `<project-name>/<secret-name>`, got path: `{}`",
171 parsed.path()
172 ))
173 })?;
174
175 if project_name.is_empty() || secret_name.is_empty() {
176 return Err(SecretError::InvalidUri(
177 "bitwarden URI: project-name and secret-name must not be empty".into(),
178 ));
179 }
180 if secret_name.contains('/') {
185 return Err(SecretError::InvalidUri(
186 "bitwarden URI: secret-name must not contain '/' \
187 (only one '/' separator between project-name and secret-name is allowed)"
188 .into(),
189 ));
190 }
191
192 if parsed.param("field").is_some() {
196 return Err(SecretError::InvalidUri(
197 "bitwarden does not support ?field= (Bitwarden secret values are plain strings, \
198 not JSON objects); remove ?field= or use a backend that supports JSON field \
199 extraction (e.g. aws-sm)"
200 .into(),
201 ));
202 }
203
204 let access_token = std::env::var("BWS_ACCESS_TOKEN").map_err(|e| match e {
205 std::env::VarError::NotPresent => {
206 unavailable_error("BWS_ACCESS_TOKEN environment variable is not set")
207 }
208 std::env::VarError::NotUnicode(_) => {
209 unavailable_error("BWS_ACCESS_TOKEN environment variable contains non-UTF-8 bytes")
210 }
211 })?;
212 if access_token.is_empty() {
213 return Err(unavailable_error(
214 "BWS_ACCESS_TOKEN environment variable is set but empty",
215 ));
216 }
217
218 Ok(Self {
219 access_token: Zeroizing::new(access_token),
220 project_name: project_name.to_owned(),
221 secret_name: secret_name.to_owned(),
222 session: tokio::sync::RwLock::new(None),
223 })
224 }
225}
226
227async fn with_timeout<F, T>(fut: F) -> Result<T, SecretError>
230where
231 F: std::future::Future<Output = Result<T, SecretError>>,
232{
233 tokio::time::timeout(SDK_CALL_TIMEOUT, fut)
234 .await
235 .map_err(|_elapsed| {
236 unavailable_error(format!(
237 "SDK call timed out after {}s",
238 SDK_CALL_TIMEOUT.as_secs()
239 ))
240 })?
241}
242
243async fn build_authed_client(access_token: &str) -> Result<(Client, uuid::Uuid), SecretError> {
248 let client = Client::new(None);
249
250 let auth_resp = with_timeout(async {
251 client
252 .auth()
253 .login_access_token(&AccessTokenLoginRequest {
258 access_token: access_token.to_string(),
259 state_file: None,
260 })
261 .await
262 .map_err(unavailable_error)
263 })
264 .await?;
265
266 if !auth_resp.authenticated {
267 return Err(unavailable_error(
268 "access token login did not authenticate",
269 ));
270 }
271
272 let org_id: uuid::Uuid = client
289 .internal
290 .get_access_token_organization()
291 .ok_or_else(|| {
292 unavailable_error("could not determine organization ID from access token")
293 })?
294 .into();
295
296 Ok((client, org_id))
297}
298
299fn classify_bitwarden_sdk_error(e: impl std::error::Error + Send + Sync + 'static) -> SecretError {
317 let msg = e.to_string();
318 let transient_response =
321 msg.starts_with("Received error message from server: [5") || msg.contains("[429]");
322 let transient_network = msg.contains("error sending request")
324 || msg.contains("connection refused")
325 || msg.contains("connection reset")
326 || msg.contains("timed out")
327 || msg.contains("dns error")
328 || msg.contains("No such host")
329 || msg.contains("Name or service not known");
330 if transient_response || transient_network {
331 unavailable_error(e)
332 } else {
333 backend_error(e)
334 }
335}
336
337async fn resolve_project_id(
339 client: &Client,
340 org_id: uuid::Uuid,
341 project_name: &str,
342) -> Result<uuid::Uuid, SecretError> {
343 let resp = with_timeout(async {
344 client
345 .projects()
346 .list(&ProjectsListRequest {
347 organization_id: org_id,
348 })
349 .await
350 .map_err(classify_bitwarden_sdk_error)
351 })
352 .await?;
353
354 resp.data
355 .into_iter()
356 .find(|p| p.name == project_name)
357 .map(|p| p.id)
358 .ok_or(SecretError::NotFound)
359}
360
361async fn resolve_secret_id(
363 client: &Client,
364 project_id: uuid::Uuid,
365 secret_name: &str,
366) -> Result<uuid::Uuid, SecretError> {
367 let resp = with_timeout(async {
368 client
369 .secrets()
370 .list_by_project(&SecretIdentifiersByProjectRequest { project_id })
371 .await
372 .map_err(classify_bitwarden_sdk_error)
373 })
374 .await?;
375
376 resp.data
377 .into_iter()
378 .find(|s| s.key == secret_name)
379 .map(|s| s.id)
380 .ok_or(SecretError::NotFound)
381}
382
383impl BitwardenBackend {
384 async fn fetch(&self) -> Result<SecretValue, SecretError> {
391 {
393 let guard = self.session.read().await;
394 if let Some(session) = &*guard {
395 let secret_resp = with_timeout(async {
396 session
397 .client
398 .secrets()
399 .get(&SecretGetRequest {
400 id: session.secret_id,
401 })
402 .await
403 .map_err(classify_bitwarden_sdk_error)
404 })
405 .await?;
406 return secret_value_from_response(secret_resp.value);
407 }
408 }
409
410 let mut guard = self.session.write().await;
412 if guard.is_none() {
414 let (client, org_id) = build_authed_client(&self.access_token).await?;
415 let project_id =
416 resolve_project_id(&client, org_id, &self.project_name).await?;
417 let secret_id =
418 resolve_secret_id(&client, project_id, &self.secret_name).await?;
419 *guard = Some(BitwardenSession {
420 client,
421 org_id,
422 project_id,
423 secret_id,
424 });
425 }
426 let session = guard.as_ref().unwrap();
427 let secret_resp = with_timeout(async {
428 session
429 .client
430 .secrets()
431 .get(&SecretGetRequest {
432 id: session.secret_id,
433 })
434 .await
435 .map_err(classify_bitwarden_sdk_error)
436 })
437 .await?;
438 secret_value_from_response(secret_resp.value)
439 }
440}
441
442#[async_trait::async_trait]
443impl SecretStore for BitwardenBackend {
444 async fn get(&self) -> Result<SecretValue, SecretError> {
445 self.fetch().await
446 }
447
448 async fn refresh(&self) -> Result<SecretValue, SecretError> {
449 let (client, org_id) = build_authed_client(&self.access_token).await?;
451 let project_id = resolve_project_id(&client, org_id, &self.project_name).await?;
452 let secret_id = resolve_secret_id(&client, project_id, &self.secret_name).await?;
453
454 let secret_resp = with_timeout(async {
457 client
458 .secrets()
459 .get(&SecretGetRequest { id: secret_id })
460 .await
461 .map_err(classify_bitwarden_sdk_error)
462 })
463 .await?;
464
465 *self.session.write().await = Some(BitwardenSession {
468 client,
469 org_id,
470 project_id,
471 secret_id,
472 });
473
474 secret_value_from_response(secret_resp.value)
475 }
476}
477
478inventory::submit!(secretx_core::BackendRegistration::new(
479 "bitwarden",
480 |uri: &secretx_core::SecretUri| {
481 let b = BitwardenBackend::from_parsed_uri(uri)?;
482 Ok(Arc::new(b) as Arc<dyn secretx_core::SecretStore>)
483 },
484));
485
486#[cfg(test)]
489mod tests {
490 use super::*;
491 use std::sync::Mutex;
492
493 static ENV_LOCK: Mutex<()> = Mutex::new(());
503
504 #[test]
505 fn from_uri_wrong_backend() {
506 let _g = ENV_LOCK.lock().unwrap();
507 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
510 assert!(matches!(
511 BitwardenBackend::from_uri("secretx:env:FOO"),
512 Err(SecretError::InvalidUri(_))
513 ));
514 }
515
516 #[test]
517 fn from_uri_wrong_scheme() {
518 let _g = ENV_LOCK.lock().unwrap();
519 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
522 assert!(matches!(
523 BitwardenBackend::from_uri("https://bitwarden/proj/sec"),
524 Err(SecretError::InvalidUri(_))
525 ));
526 }
527
528 #[test]
529 fn from_uri_missing_secret_name() {
530 let _g = ENV_LOCK.lock().unwrap();
531 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
534 assert!(matches!(
535 BitwardenBackend::from_uri("secretx:bitwarden:only-project"),
536 Err(SecretError::InvalidUri(_))
537 ));
538 }
539
540 #[test]
541 fn from_uri_empty_project() {
542 let _g = ENV_LOCK.lock().unwrap();
543 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
546 assert!(matches!(
547 BitwardenBackend::from_uri("secretx:bitwarden:/secret-name"),
548 Err(SecretError::InvalidUri(_))
549 ));
550 }
551
552 #[test]
553 fn from_uri_missing_token() {
554 let _g = ENV_LOCK.lock().unwrap();
555 unsafe { std::env::remove_var("BWS_ACCESS_TOKEN") };
558 assert!(matches!(
559 BitwardenBackend::from_uri("secretx:bitwarden:proj/sec"),
560 Err(SecretError::Unavailable { .. })
561 ));
562 }
563
564 #[test]
565 fn from_uri_empty_token() {
566 let _g = ENV_LOCK.lock().unwrap();
567 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "") };
570 assert!(matches!(
571 BitwardenBackend::from_uri("secretx:bitwarden:proj/sec"),
572 Err(SecretError::Unavailable { .. })
573 ));
574 }
575
576 #[test]
577 fn from_uri_valid() {
578 let _g = ENV_LOCK.lock().unwrap();
579 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy-token") };
582 let backend = BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret");
583 assert!(backend.is_ok());
584 let b = backend.unwrap();
585 assert_eq!(b.project_name, "my-project");
586 assert_eq!(b.secret_name, "my-secret");
587 }
588
589 #[test]
590 fn from_uri_field_selector_rejected() {
591 let _g = ENV_LOCK.lock().unwrap();
595 unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy-token") };
598 let result =
599 BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret?field=password");
600 match result {
601 Err(SecretError::InvalidUri(msg)) => {
602 assert!(
603 msg.contains("bitwarden does not support ?field="),
604 "error must mention the limitation, got: {msg}"
605 );
606 }
607 Err(e) => panic!("expected InvalidUri, got: {e}"),
608 Ok(_) => panic!("expected InvalidUri, got Ok"),
609 }
610 }
611
612 #[derive(Debug)]
618 struct FakeError(String);
619
620 impl std::fmt::Display for FakeError {
621 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
622 f.write_str(&self.0)
623 }
624 }
625
626 impl std::error::Error for FakeError {}
627
628 fn is_unavailable(e: &SecretError) -> bool {
630 matches!(e, SecretError::Unavailable { .. })
631 }
632
633 fn is_backend(e: &SecretError) -> bool {
635 matches!(e, SecretError::Backend { .. })
636 }
637
638 #[test]
639 fn classify_5xx_server_error() {
640 let e = classify_bitwarden_sdk_error(FakeError(
641 "Received error message from server: [500] Internal Server Error".into(),
642 ));
643 assert!(is_unavailable(&e), "5xx should be transient: {e:?}");
644 }
645
646 #[test]
647 fn classify_503_server_error() {
648 let e = classify_bitwarden_sdk_error(FakeError(
649 "Received error message from server: [503] Service Unavailable".into(),
650 ));
651 assert!(is_unavailable(&e), "503 should be transient: {e:?}");
652 }
653
654 #[test]
655 fn classify_429_rate_limit() {
656 let e = classify_bitwarden_sdk_error(FakeError(
657 "Received error message from server: [429] Too Many Requests".into(),
658 ));
659 assert!(is_unavailable(&e), "429 should be transient: {e:?}");
660 }
661
662 #[test]
663 fn classify_4xx_permanent() {
664 let e = classify_bitwarden_sdk_error(FakeError(
665 "Received error message from server: [401] Unauthorized".into(),
666 ));
667 assert!(is_backend(&e), "401 should be permanent: {e:?}");
668 }
669
670 #[test]
671 fn classify_error_sending_request() {
672 let e = classify_bitwarden_sdk_error(FakeError(
673 "error sending request for url (https://example.com): connection refused".into(),
674 ));
675 assert!(is_unavailable(&e), "network send error should be transient: {e:?}");
676 }
677
678 #[test]
679 fn classify_connection_refused() {
680 let e = classify_bitwarden_sdk_error(FakeError("connection refused".into()));
681 assert!(is_unavailable(&e), "connection refused should be transient: {e:?}");
682 }
683
684 #[test]
685 fn classify_connection_reset() {
686 let e = classify_bitwarden_sdk_error(FakeError("connection reset by peer".into()));
687 assert!(is_unavailable(&e), "connection reset should be transient: {e:?}");
688 }
689
690 #[test]
691 fn classify_timed_out() {
692 let e = classify_bitwarden_sdk_error(FakeError("operation timed out".into()));
693 assert!(is_unavailable(&e), "timed out should be transient: {e:?}");
694 }
695
696 #[test]
697 fn classify_dns_error() {
698 let e = classify_bitwarden_sdk_error(FakeError(
699 "dns error: failed to lookup address information".into(),
700 ));
701 assert!(is_unavailable(&e), "dns error should be transient: {e:?}");
702 }
703
704 #[test]
705 fn classify_no_such_host() {
706 let e = classify_bitwarden_sdk_error(FakeError("No such host is known".into()));
707 assert!(is_unavailable(&e), "No such host should be transient: {e:?}");
708 }
709
710 #[test]
711 fn classify_name_or_service_not_known() {
712 let e = classify_bitwarden_sdk_error(FakeError(
713 "Name or service not known".into(),
714 ));
715 assert!(is_unavailable(&e), "Name or service not known should be transient: {e:?}");
716 }
717
718 #[test]
719 fn classify_unknown_error_is_permanent() {
720 let e = classify_bitwarden_sdk_error(FakeError(
721 "some unknown SDK error message".into(),
722 ));
723 assert!(is_backend(&e), "unrecognised error should be permanent: {e:?}");
724 }
725
726 #[test]
727 fn classify_validation_error_is_permanent() {
728 let e = classify_bitwarden_sdk_error(FakeError(
729 "Validation failed: field X is required".into(),
730 ));
731 assert!(is_backend(&e), "validation error should be permanent: {e:?}");
732 }
733
734 #[tokio::test]
738 async fn integration_get() {
739 if std::env::var("SECRETX_BWS_TEST").as_deref() != Ok("1") {
740 return;
741 }
742 let project = match std::env::var("SECRETX_BWS_TEST_PROJECT") {
743 Ok(p) => p,
744 Err(_) => return,
745 };
746 let secret = match std::env::var("SECRETX_BWS_TEST_SECRET") {
747 Ok(s) => s,
748 Err(_) => return,
749 };
750 let uri = format!("secretx:bitwarden:{project}/{secret}");
751 let store = BitwardenBackend::from_uri(&uri).unwrap();
752 let value = store.get().await.unwrap();
753 assert!(!value.as_bytes().is_empty());
754 }
755}