use crate::error::{Error, Result};
use crate::ffi::{load_library, SdkClient};
use crate::secret::{SecretMap, SecretReference};
use base64::Engine;
use secrecy::SecretString;
use std::sync::Arc;
use tokio::sync::Mutex;
const TOKEN_PREFIX: &str = "ops_";
fn validate_token(token: &str) -> Result<()> {
if !token.starts_with(TOKEN_PREFIX) {
return Err(Error::InvalidToken);
}
let payload = &token[TOKEN_PREFIX.len()..];
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| Error::InvalidToken)?;
let json: serde_json::Value =
serde_json::from_slice(&decoded).map_err(|_| Error::InvalidToken)?;
const REQUIRED_FIELDS: &[&str] = &["signInAddress", "email", "deviceUuid"];
for field in REQUIRED_FIELDS {
if json.get(*field).is_none() {
return Err(Error::InvalidToken);
}
}
Ok(())
}
pub struct OnePasswordBuilder {
token: SecretString,
integration_name: Option<String>,
integration_version: Option<String>,
}
impl OnePasswordBuilder {
fn new(token: impl Into<String>) -> Self {
Self {
token: SecretString::from(token.into()),
integration_name: None,
integration_version: None,
}
}
pub fn integration(mut self, name: &str, version: &str) -> Self {
self.integration_name = Some(name.to_string());
self.integration_version = Some(version.to_string());
self
}
pub async fn connect(self) -> Result<OnePassword> {
let integration_name = self
.integration_name
.unwrap_or_else(|| "corteq-onepassword".to_string());
let integration_version = self
.integration_version
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
#[cfg(feature = "tracing")]
tracing::debug!(
integration_name = %integration_name,
integration_version = %integration_version,
"connecting to 1Password"
);
let library = load_library()?;
let client = SdkClient::init(
library,
&self.token,
&integration_name,
&integration_version,
)?;
#[cfg(feature = "tracing")]
tracing::info!("connected to 1Password");
Ok(OnePassword {
client: Arc::new(Mutex::new(client)),
})
}
#[cfg(feature = "blocking")]
pub fn connect_blocking(self) -> Result<OnePassword> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Error::SdkError {
message: format!("failed to create runtime: {e}"),
})?
.block_on(self.connect())
}
}
pub struct OnePassword {
client: Arc<Mutex<SdkClient>>,
}
unsafe impl Send for OnePassword {}
unsafe impl Sync for OnePassword {}
impl OnePassword {
#[must_use = "builder must be used to connect"]
pub fn from_env() -> Result<OnePasswordBuilder> {
let token =
std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| Error::MissingAuthToken)?;
if token.is_empty() {
return Err(Error::MissingAuthToken);
}
validate_token(&token)?;
Ok(OnePasswordBuilder::new(token))
}
#[must_use = "builder must be used to connect"]
pub fn from_token(token: impl Into<String>) -> Result<OnePasswordBuilder> {
let token = token.into();
validate_token(&token)?;
Ok(OnePasswordBuilder::new(token))
}
pub async fn secret(&self, reference: &str) -> Result<SecretString> {
SecretReference::parse(reference)?;
#[cfg(feature = "tracing")]
tracing::debug!(reference = %reference, "resolving secret");
let client = self.client.lock().await;
let result = client.resolve_secret(reference);
#[cfg(feature = "tracing")]
if result.is_ok() {
tracing::debug!(reference = %reference, "secret resolved successfully");
}
result
}
pub async fn secrets(&self, references: &[&str]) -> Result<Vec<SecretString>> {
for reference in references {
SecretReference::parse(reference)?;
}
if references.is_empty() {
return Ok(Vec::new());
}
#[cfg(feature = "tracing")]
tracing::debug!(count = references.len(), "resolving batch of secrets");
let client = self.client.lock().await;
client.resolve_secrets_batch(references)
}
pub async fn secrets_named(&self, mappings: &[(&str, &str)]) -> Result<SecretMap> {
let references: Vec<&str> = mappings.iter().map(|(_, r)| *r).collect();
let names: Vec<&str> = mappings.iter().map(|(n, _)| *n).collect();
let secrets = self.secrets(&references).await?;
Ok(SecretMap::from_pairs(names.into_iter().zip(secrets)))
}
}
impl std::fmt::Debug for OnePassword {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OnePassword")
.field("connected", &true)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_onepassword_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<OnePassword>();
}
#[test]
fn test_builder_is_send() {
fn assert_send<T: Send>() {}
assert_send::<OnePasswordBuilder>();
}
#[test]
#[serial]
fn test_from_env_missing_var() {
let original = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").ok();
unsafe {
std::env::remove_var("OP_SERVICE_ACCOUNT_TOKEN");
}
let result = OnePassword::from_env();
assert!(matches!(result, Err(Error::MissingAuthToken)));
if let Some(val) = original {
unsafe {
std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", val);
}
}
}
#[test]
#[serial]
fn test_from_env_empty_var() {
let original = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").ok();
unsafe {
std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", "");
}
let result = OnePassword::from_env();
assert!(matches!(result, Err(Error::MissingAuthToken)));
unsafe {
match original {
Some(val) => std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", val),
None => std::env::remove_var("OP_SERVICE_ACCOUNT_TOKEN"),
}
}
}
#[test]
fn test_from_token_rejects_invalid_token() {
let result = OnePassword::from_token("test-token");
assert!(matches!(result, Err(Error::InvalidToken)));
}
#[test]
fn test_from_token_rejects_invalid_prefix() {
let result = OnePassword::from_token("opp_test");
assert!(matches!(result, Err(Error::InvalidToken)));
}
#[test]
fn test_from_token_rejects_invalid_base64() {
let result = OnePassword::from_token("ops_not-valid-base64!!!");
assert!(matches!(result, Err(Error::InvalidToken)));
}
#[test]
fn test_from_token_rejects_invalid_json() {
let result = OnePassword::from_token("ops_aGVsbG8gd29ybGQ");
assert!(matches!(result, Err(Error::InvalidToken)));
}
#[test]
fn test_from_token_rejects_missing_fields() {
let result = OnePassword::from_token("ops_eyJmb28iOiAiYmFyIn0");
assert!(matches!(result, Err(Error::InvalidToken)));
}
#[test]
fn test_from_token_accepts_valid_format() {
let valid_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
r#"{"signInAddress":"example.com","email":"test@test.com","deviceUuid":"123"}"#,
);
let token = format!("ops_{valid_payload}");
let result = OnePassword::from_token(&token);
assert!(result.is_ok());
}
#[test]
fn test_builder_integration_chaining() {
let valid_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
r#"{"signInAddress":"example.com","email":"test@test.com","deviceUuid":"123"}"#,
);
let token = format!("ops_{valid_payload}");
let builder = OnePassword::from_token(&token)
.expect("valid token should be accepted")
.integration("my-app", "2.0.0");
assert!(builder.integration_name.is_some());
assert!(builder.integration_version.is_some());
}
}