use bitwarden::{
auth::login::AccessTokenLoginRequest,
secrets_manager::{
projects::ProjectsListRequest,
secrets::{SecretGetRequest, SecretIdentifiersByProjectRequest},
ClientProjectsExt, ClientSecretsExt,
},
Client,
};
use std::sync::Arc;
use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue};
use zeroize::Zeroizing;
const BACKEND: &str = "bitwarden";
fn backend_error(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> SecretError {
SecretError::Backend {
backend: BACKEND,
source: source.into(),
}
}
fn unavailable_error(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> SecretError {
SecretError::Unavailable {
backend: BACKEND,
source: source.into(),
}
}
fn secret_value_from_response(value: String) -> Result<SecretValue, SecretError> {
if value.is_empty() {
return Err(backend_error(
"Bitwarden returned an empty secret value",
));
}
Ok(SecretValue::new(value.into_bytes()))
}
const SDK_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
struct BitwardenSession {
client: Client,
#[allow(dead_code)]
org_id: uuid::Uuid,
#[allow(dead_code)]
project_id: uuid::Uuid,
secret_id: uuid::Uuid,
}
pub struct BitwardenBackend {
access_token: Zeroizing<String>,
project_name: String,
secret_name: String,
session: tokio::sync::RwLock<Option<BitwardenSession>>,
}
impl std::fmt::Debug for BitwardenBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BitwardenBackend")
.field("project_name", &self.project_name)
.field("secret_name", &self.secret_name)
.finish_non_exhaustive()
}
}
impl BitwardenBackend {
pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
Self::from_parsed_uri(&SecretUri::parse(uri)?)
}
pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
if parsed.backend() != BACKEND {
return Err(SecretError::InvalidUri(format!(
"expected backend `{BACKEND}`, got `{}`",
parsed.backend()
)));
}
let (project_name, secret_name) = parsed.path().split_once('/').ok_or_else(|| {
SecretError::InvalidUri(format!(
"bitwarden URI requires `<project-name>/<secret-name>`, got path: `{}`",
parsed.path()
))
})?;
if project_name.is_empty() || secret_name.is_empty() {
return Err(SecretError::InvalidUri(
"bitwarden URI: project-name and secret-name must not be empty".into(),
));
}
if secret_name.contains('/') {
return Err(SecretError::InvalidUri(
"bitwarden URI: secret-name must not contain '/' \
(only one '/' separator between project-name and secret-name is allowed)"
.into(),
));
}
if parsed.param("field").is_some() {
return Err(SecretError::InvalidUri(
"bitwarden does not support ?field= (Bitwarden secret values are plain strings, \
not JSON objects); remove ?field= or use a backend that supports JSON field \
extraction (e.g. aws-sm)"
.into(),
));
}
let access_token = std::env::var("BWS_ACCESS_TOKEN").map_err(|e| match e {
std::env::VarError::NotPresent => {
unavailable_error("BWS_ACCESS_TOKEN environment variable is not set")
}
std::env::VarError::NotUnicode(_) => {
unavailable_error("BWS_ACCESS_TOKEN environment variable contains non-UTF-8 bytes")
}
})?;
if access_token.is_empty() {
return Err(unavailable_error(
"BWS_ACCESS_TOKEN environment variable is set but empty",
));
}
Ok(Self {
access_token: Zeroizing::new(access_token),
project_name: project_name.to_owned(),
secret_name: secret_name.to_owned(),
session: tokio::sync::RwLock::new(None),
})
}
}
async fn with_timeout<F, T>(fut: F) -> Result<T, SecretError>
where
F: std::future::Future<Output = Result<T, SecretError>>,
{
tokio::time::timeout(SDK_CALL_TIMEOUT, fut)
.await
.map_err(|_elapsed| {
unavailable_error(format!(
"SDK call timed out after {}s",
SDK_CALL_TIMEOUT.as_secs()
))
})?
}
async fn build_authed_client(access_token: &str) -> Result<(Client, uuid::Uuid), SecretError> {
let client = Client::new(None);
let auth_resp = with_timeout(async {
client
.auth()
.login_access_token(&AccessTokenLoginRequest {
access_token: access_token.to_string(),
state_file: None,
})
.await
.map_err(unavailable_error)
})
.await?;
if !auth_resp.authenticated {
return Err(unavailable_error(
"access token login did not authenticate",
));
}
let org_id: uuid::Uuid = client
.internal
.get_access_token_organization()
.ok_or_else(|| {
unavailable_error("could not determine organization ID from access token")
})?
.into();
Ok((client, org_id))
}
fn classify_bitwarden_sdk_error(e: impl std::error::Error + Send + Sync + 'static) -> SecretError {
let msg = e.to_string();
let transient_response =
msg.starts_with("Received error message from server: [5") || msg.contains("[429]");
let transient_network = msg.contains("error sending request")
|| msg.contains("connection refused")
|| msg.contains("connection reset")
|| msg.contains("timed out")
|| msg.contains("dns error")
|| msg.contains("No such host")
|| msg.contains("Name or service not known");
if transient_response || transient_network {
unavailable_error(e)
} else {
backend_error(e)
}
}
async fn resolve_project_id(
client: &Client,
org_id: uuid::Uuid,
project_name: &str,
) -> Result<uuid::Uuid, SecretError> {
let resp = with_timeout(async {
client
.projects()
.list(&ProjectsListRequest {
organization_id: org_id,
})
.await
.map_err(classify_bitwarden_sdk_error)
})
.await?;
resp.data
.into_iter()
.find(|p| p.name == project_name)
.map(|p| p.id)
.ok_or(SecretError::NotFound)
}
async fn resolve_secret_id(
client: &Client,
project_id: uuid::Uuid,
secret_name: &str,
) -> Result<uuid::Uuid, SecretError> {
let resp = with_timeout(async {
client
.secrets()
.list_by_project(&SecretIdentifiersByProjectRequest { project_id })
.await
.map_err(classify_bitwarden_sdk_error)
})
.await?;
resp.data
.into_iter()
.find(|s| s.key == secret_name)
.map(|s| s.id)
.ok_or(SecretError::NotFound)
}
impl BitwardenBackend {
async fn fetch(&self) -> Result<SecretValue, SecretError> {
{
let guard = self.session.read().await;
if let Some(session) = &*guard {
let secret_resp = with_timeout(async {
session
.client
.secrets()
.get(&SecretGetRequest {
id: session.secret_id,
})
.await
.map_err(classify_bitwarden_sdk_error)
})
.await?;
return secret_value_from_response(secret_resp.value);
}
}
let mut guard = self.session.write().await;
if guard.is_none() {
let (client, org_id) = build_authed_client(&self.access_token).await?;
let project_id =
resolve_project_id(&client, org_id, &self.project_name).await?;
let secret_id =
resolve_secret_id(&client, project_id, &self.secret_name).await?;
*guard = Some(BitwardenSession {
client,
org_id,
project_id,
secret_id,
});
}
let session = guard.as_ref().unwrap();
let secret_resp = with_timeout(async {
session
.client
.secrets()
.get(&SecretGetRequest {
id: session.secret_id,
})
.await
.map_err(classify_bitwarden_sdk_error)
})
.await?;
secret_value_from_response(secret_resp.value)
}
}
#[async_trait::async_trait]
impl SecretStore for BitwardenBackend {
async fn get(&self) -> Result<SecretValue, SecretError> {
self.fetch().await
}
async fn refresh(&self) -> Result<SecretValue, SecretError> {
let (client, org_id) = build_authed_client(&self.access_token).await?;
let project_id = resolve_project_id(&client, org_id, &self.project_name).await?;
let secret_id = resolve_secret_id(&client, project_id, &self.secret_name).await?;
let secret_resp = with_timeout(async {
client
.secrets()
.get(&SecretGetRequest { id: secret_id })
.await
.map_err(classify_bitwarden_sdk_error)
})
.await?;
*self.session.write().await = Some(BitwardenSession {
client,
org_id,
project_id,
secret_id,
});
secret_value_from_response(secret_resp.value)
}
}
inventory::submit!(secretx_core::BackendRegistration::new(
"bitwarden",
|uri: &secretx_core::SecretUri| {
let b = BitwardenBackend::from_parsed_uri(uri)?;
Ok(Arc::new(b) as Arc<dyn secretx_core::SecretStore>)
},
));
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn from_uri_wrong_backend() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
assert!(matches!(
BitwardenBackend::from_uri("secretx:env:FOO"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_wrong_scheme() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
assert!(matches!(
BitwardenBackend::from_uri("https://bitwarden/proj/sec"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_missing_secret_name() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
assert!(matches!(
BitwardenBackend::from_uri("secretx:bitwarden:only-project"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_empty_project() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy") };
assert!(matches!(
BitwardenBackend::from_uri("secretx:bitwarden:/secret-name"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_missing_token() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("BWS_ACCESS_TOKEN") };
assert!(matches!(
BitwardenBackend::from_uri("secretx:bitwarden:proj/sec"),
Err(SecretError::Unavailable { .. })
));
}
#[test]
fn from_uri_empty_token() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "") };
assert!(matches!(
BitwardenBackend::from_uri("secretx:bitwarden:proj/sec"),
Err(SecretError::Unavailable { .. })
));
}
#[test]
fn from_uri_valid() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy-token") };
let backend = BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret");
assert!(backend.is_ok());
let b = backend.unwrap();
assert_eq!(b.project_name, "my-project");
assert_eq!(b.secret_name, "my-secret");
}
#[test]
fn from_uri_field_selector_rejected() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("BWS_ACCESS_TOKEN", "dummy-token") };
let result =
BitwardenBackend::from_uri("secretx:bitwarden:my-project/my-secret?field=password");
match result {
Err(SecretError::InvalidUri(msg)) => {
assert!(
msg.contains("bitwarden does not support ?field="),
"error must mention the limitation, got: {msg}"
);
}
Err(e) => panic!("expected InvalidUri, got: {e}"),
Ok(_) => panic!("expected InvalidUri, got Ok"),
}
}
#[derive(Debug)]
struct FakeError(String);
impl std::fmt::Display for FakeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for FakeError {}
fn is_unavailable(e: &SecretError) -> bool {
matches!(e, SecretError::Unavailable { .. })
}
fn is_backend(e: &SecretError) -> bool {
matches!(e, SecretError::Backend { .. })
}
#[test]
fn classify_5xx_server_error() {
let e = classify_bitwarden_sdk_error(FakeError(
"Received error message from server: [500] Internal Server Error".into(),
));
assert!(is_unavailable(&e), "5xx should be transient: {e:?}");
}
#[test]
fn classify_503_server_error() {
let e = classify_bitwarden_sdk_error(FakeError(
"Received error message from server: [503] Service Unavailable".into(),
));
assert!(is_unavailable(&e), "503 should be transient: {e:?}");
}
#[test]
fn classify_429_rate_limit() {
let e = classify_bitwarden_sdk_error(FakeError(
"Received error message from server: [429] Too Many Requests".into(),
));
assert!(is_unavailable(&e), "429 should be transient: {e:?}");
}
#[test]
fn classify_4xx_permanent() {
let e = classify_bitwarden_sdk_error(FakeError(
"Received error message from server: [401] Unauthorized".into(),
));
assert!(is_backend(&e), "401 should be permanent: {e:?}");
}
#[test]
fn classify_error_sending_request() {
let e = classify_bitwarden_sdk_error(FakeError(
"error sending request for url (https://example.com): connection refused".into(),
));
assert!(is_unavailable(&e), "network send error should be transient: {e:?}");
}
#[test]
fn classify_connection_refused() {
let e = classify_bitwarden_sdk_error(FakeError("connection refused".into()));
assert!(is_unavailable(&e), "connection refused should be transient: {e:?}");
}
#[test]
fn classify_connection_reset() {
let e = classify_bitwarden_sdk_error(FakeError("connection reset by peer".into()));
assert!(is_unavailable(&e), "connection reset should be transient: {e:?}");
}
#[test]
fn classify_timed_out() {
let e = classify_bitwarden_sdk_error(FakeError("operation timed out".into()));
assert!(is_unavailable(&e), "timed out should be transient: {e:?}");
}
#[test]
fn classify_dns_error() {
let e = classify_bitwarden_sdk_error(FakeError(
"dns error: failed to lookup address information".into(),
));
assert!(is_unavailable(&e), "dns error should be transient: {e:?}");
}
#[test]
fn classify_no_such_host() {
let e = classify_bitwarden_sdk_error(FakeError("No such host is known".into()));
assert!(is_unavailable(&e), "No such host should be transient: {e:?}");
}
#[test]
fn classify_name_or_service_not_known() {
let e = classify_bitwarden_sdk_error(FakeError(
"Name or service not known".into(),
));
assert!(is_unavailable(&e), "Name or service not known should be transient: {e:?}");
}
#[test]
fn classify_unknown_error_is_permanent() {
let e = classify_bitwarden_sdk_error(FakeError(
"some unknown SDK error message".into(),
));
assert!(is_backend(&e), "unrecognised error should be permanent: {e:?}");
}
#[test]
fn classify_validation_error_is_permanent() {
let e = classify_bitwarden_sdk_error(FakeError(
"Validation failed: field X is required".into(),
));
assert!(is_backend(&e), "validation error should be permanent: {e:?}");
}
#[tokio::test]
async fn integration_get() {
if std::env::var("SECRETX_BWS_TEST").as_deref() != Ok("1") {
return;
}
let project = match std::env::var("SECRETX_BWS_TEST_PROJECT") {
Ok(p) => p,
Err(_) => return,
};
let secret = match std::env::var("SECRETX_BWS_TEST_SECRET") {
Ok(s) => s,
Err(_) => return,
};
let uri = format!("secretx:bitwarden:{project}/{secret}");
let store = BitwardenBackend::from_uri(&uri).unwrap();
let value = store.get().await.unwrap();
assert!(!value.as_bytes().is_empty());
}
}