use crate::provider::{Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize)]
struct OnePasswordItem {
fields: Vec<OnePasswordField>,
}
#[derive(Debug, Deserialize)]
struct OnePasswordField {
id: String,
#[serde(rename = "type")]
field_type: String,
label: Option<String>,
value: Option<String>,
}
#[derive(Debug, Serialize)]
struct OnePasswordItemTemplate {
title: String,
category: String,
fields: Vec<OnePasswordFieldTemplate>,
tags: Vec<String>,
}
#[derive(Debug, Serialize)]
struct OnePasswordFieldTemplate {
label: String,
#[serde(rename = "type")]
field_type: String,
value: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OnePasswordConfig {
pub account: Option<String>,
pub default_vault: Option<String>,
pub service_account_token: Option<String>,
pub folder_prefix: Option<String>,
}
impl TryFrom<&ProviderUrl> for OnePasswordConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
match scheme {
"1password" => {
return Err(SecretSpecError::ProviderOperationFailed(
"Invalid scheme '1password'. Use 'onepassword' instead (e.g., onepassword://vault/path)".to_string()
));
}
"onepassword" | "onepassword+token" => {}
_ => {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for OnePassword provider",
scheme
)));
}
}
let mut config = Self::default();
if let Some(host) = url.host()
&& host != "localhost"
{
let username = url.username();
if !username.is_empty() {
if scheme == "onepassword+token" {
if let Some(password) = url.password() {
config.service_account_token = Some(password);
} else {
config.service_account_token = Some(username);
}
} else {
config.account = Some(username);
}
config.default_vault = Some(host);
} else {
config.default_vault = Some(host);
}
}
Ok(config)
}
}
#[cfg(target_os = "linux")]
fn is_wsl2() -> bool {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|content| content.trim().ends_with("-microsoft-standard-WSL2"))
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
fn is_wsl2() -> bool {
false
}
pub struct OnePasswordProvider {
config: OnePasswordConfig,
op_command: String,
}
crate::register_provider! {
struct: OnePasswordProvider,
config: OnePasswordConfig,
name: "onepassword",
description: "OnePassword password manager",
schemes: ["onepassword", "onepassword+token"],
examples: ["onepassword://vault", "onepassword://work@Production", "onepassword+token://vault"],
preflight: check_auth,
}
impl OnePasswordProvider {
pub fn new(config: OnePasswordConfig) -> Self {
let op_command = std::env::var("SECRETSPEC_OPCLI_PATH").unwrap_or_else(|_| {
if is_wsl2() {
"op.exe".to_string()
} else {
"op".to_string()
}
});
Self { config, op_command }
}
fn execute_op_command(&self, args: &[&str], stdin_data: Option<&str>) -> Result<String> {
use std::io::Write;
use std::process::Stdio;
let mut cmd = Command::new(&self.op_command);
if let Some(token) = &self.config.service_account_token {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token);
}
if let Some(account) = &self.config.account {
cmd.arg("--account").arg(account);
}
cmd.args(args);
if stdin_data.is_some() {
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
}
let output = if let Some(data) = stdin_data {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(SecretSpecError::ProviderOperationFailed(
"OnePassword CLI (op) is not installed.\n\nTo install it:\n - macOS: brew install 1password-cli\n - Linux: Download from https://1password.com/downloads/command-line/\n - Windows: Download from https://1password.com/downloads/command-line/\n - NixOS: nix-env -iA nixpkgs.onepassword\n\nAfter installation, run 'eval $(op signin)' to authenticate.".to_string(),
));
}
Err(e) => return Err(e.into()),
};
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(data.as_bytes())?;
drop(stdin); }
child.wait_with_output()?
} else {
match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(SecretSpecError::ProviderOperationFailed(
"OnePassword CLI (op) is not installed.\n\nTo install it:\n - macOS: brew install 1password-cli\n - Linux: Download from https://1password.com/downloads/command-line/\n - Windows: Download from https://1password.com/downloads/command-line/\n - NixOS: nix-env -iA nixpkgs.onepassword\n\nAfter installation, run 'eval $(op signin)' to authenticate.".to_string(),
));
}
Err(e) => return Err(e.into()),
}
};
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
if error_msg.contains("not currently signed in")
|| error_msg.contains("no active session")
|| error_msg.contains("could not find session token")
{
return Err(SecretSpecError::ProviderOperationFailed(
"OnePassword authentication required. Please run 'eval $(op signin)' first."
.to_string(),
));
}
return Err(SecretSpecError::ProviderOperationFailed(
error_msg.to_string(),
));
}
String::from_utf8(output.stdout)
.map_err(|e| SecretSpecError::ProviderOperationFailed(e.to_string()))
}
fn whoami(&self) -> Result<bool> {
match self.execute_op_command(&["whoami"], None) {
Ok(_) => Ok(true),
Err(SecretSpecError::ProviderOperationFailed(msg))
if msg.contains("authentication required") || msg.contains("no account found") =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
fn get_vault_name(&self, _profile: &str) -> String {
self.config
.default_vault
.clone()
.unwrap_or_else(|| "Private".to_string())
}
fn find_item_id(&self, item_name: &str, vault: &str) -> Result<Option<String>> {
let args = vec!["item", "list", "--vault", vault, "--format", "json"];
let output = self.execute_op_command(&args, None)?;
#[derive(Deserialize)]
struct ListItem {
id: String,
title: String,
}
let items: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();
Ok(items
.into_iter()
.find(|item| item.title == item_name)
.map(|item| item.id))
}
fn format_item_name(&self, project: &str, key: &str, profile: &str) -> String {
let format_string = self
.config
.folder_prefix
.as_deref()
.unwrap_or("secretspec/{project}/{profile}/{key}");
format_string
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
fn create_item_template(
&self,
project: &str,
key: &str,
value: &SecretString,
profile: &str,
) -> OnePasswordItemTemplate {
OnePasswordItemTemplate {
title: self.format_item_name(project, key, profile),
category: "SECURE_NOTE".to_string(),
fields: vec![
OnePasswordFieldTemplate {
label: "project".to_string(),
field_type: "STRING".to_string(),
value: project.to_string(),
},
OnePasswordFieldTemplate {
label: "key".to_string(),
field_type: "STRING".to_string(),
value: key.to_string(),
},
OnePasswordFieldTemplate {
label: "value".to_string(),
field_type: "STRING".to_string(),
value: value.expose_secret().to_string(),
},
],
tags: vec!["automated".to_string(), project.to_string()],
}
}
fn extract_value_from_item(&self, output: &str) -> Result<Option<SecretString>> {
let item: OnePasswordItem = serde_json::from_str(output)?;
for field in &item.fields {
if field.label.as_deref() == Some("value") {
return Ok(field
.value
.as_ref()
.map(|v| SecretString::new(v.clone().into())));
}
}
for field in &item.fields {
if field.field_type == "CONCEALED" || field.id == "password" {
return Ok(field
.value
.as_ref()
.map(|v| SecretString::new(v.clone().into())));
}
}
Ok(None)
}
}
impl OnePasswordProvider {
pub(crate) fn check_auth(&self) -> Result<()> {
if self.whoami()? {
Ok(())
} else {
Err(SecretSpecError::ProviderOperationFailed(
"OnePassword authentication required. Please run 'eval $(op signin)' first."
.to_string(),
))
}
}
}
impl Provider for OnePasswordProvider {
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let scheme = if self.config.service_account_token.is_some() {
"onepassword+token"
} else {
"onepassword"
};
let mut uri = format!("{}://", scheme);
if self.config.service_account_token.is_some() {
if let Some(ref vault) = self.config.default_vault {
uri.push_str(&ProviderUrl::encode(vault));
}
} else {
if let Some(ref account) = self.config.account {
uri.push_str(&ProviderUrl::encode(account));
uri.push('@');
}
if let Some(ref vault) = self.config.default_vault {
uri.push_str(&ProviderUrl::encode(vault));
}
}
uri
}
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
let vault = self.get_vault_name(profile);
let item_name = self.format_item_name(project, key, profile);
let args = vec![
"item", "get", &item_name, "--vault", &vault, "--format", "json",
];
match self.execute_op_command(&args, None) {
Ok(output) => self.extract_value_from_item(&output),
Err(SecretSpecError::ProviderOperationFailed(msg)) if msg.contains("isn't an item") => {
Ok(None)
}
Err(SecretSpecError::ProviderOperationFailed(msg))
if msg.contains("More than one item") =>
{
if let Some(item_id) = self.find_item_id(&item_name, &vault)? {
let args = vec![
"item", "get", &item_id, "--vault", &vault, "--format", "json",
];
match self.execute_op_command(&args, None) {
Ok(output) => self.extract_value_from_item(&output),
Err(e) => Err(e),
}
} else {
Ok(None)
}
}
Err(e) => Err(e),
}
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
let vault = self.get_vault_name(profile);
let item_name = self.format_item_name(project, key, profile);
if let Some(item_id) = self.find_item_id(&item_name, &vault)? {
let field_assignment = format!("value={}", value.expose_secret());
let args = vec![
"item",
"edit",
&item_id,
"--vault",
&vault,
&field_assignment,
];
self.execute_op_command(&args, None)?;
} else {
let template = self.create_item_template(project, key, value, profile);
let template_json = serde_json::to_string(&template)?;
let args = vec!["item", "create", "--vault", &vault, "-"];
self.execute_op_command(&args, Some(&template_json))?;
}
Ok(())
}
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
use std::thread;
if keys.is_empty() {
return Ok(HashMap::new());
}
let vault = self.get_vault_name(profile);
let args = vec!["item", "list", "--vault", &vault, "--format", "json"];
let output = self.execute_op_command(&args, None)?;
#[derive(Deserialize)]
struct ListItem {
id: String,
title: String,
}
let items: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();
let item_map: HashMap<String, String> = items
.into_iter()
.map(|item| (item.title, item.id))
.collect();
let keys_to_fetch: Vec<(&str, String)> = keys
.iter()
.filter_map(|key| {
let item_name = self.format_item_name(project, key, profile);
item_map.get(&item_name).map(|id| (*key, id.clone()))
})
.collect();
let vault_clone = vault.clone();
let op_command = self.op_command.clone();
let service_token = self.config.service_account_token.clone();
let account = self.config.account.clone();
let handles: Vec<_> = keys_to_fetch
.into_iter()
.map(|(key, item_id)| {
let vault = vault_clone.clone();
let op_cmd = op_command.clone();
let token = service_token.clone();
let acct = account.clone();
let key_owned = key.to_string();
thread::spawn(move || {
let mut cmd = Command::new(&op_cmd);
if let Some(ref t) = token {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", t);
}
if let Some(ref a) = acct {
cmd.arg("--account").arg(a);
}
cmd.args([
"item", "get", &item_id, "--vault", &vault, "--format", "json",
]);
match cmd.output() {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(item) = serde_json::from_str::<OnePasswordItem>(&stdout) {
for field in &item.fields {
if field.label.as_deref() == Some("value")
&& let Some(ref v) = field.value
{
return Some((
key_owned,
SecretString::new(v.clone().into()),
));
}
}
for field in &item.fields {
if (field.field_type == "CONCEALED" || field.id == "password")
&& let Some(ref v) = field.value
{
return Some((
key_owned,
SecretString::new(v.clone().into()),
));
}
}
}
None
}
_ => None,
}
})
})
.collect();
let mut results = HashMap::new();
for handle in handles {
if let Ok(Some((key, value))) = handle.join() {
results.insert(key, value);
}
}
Ok(results)
}
}
impl Default for OnePasswordProvider {
fn default() -> Self {
Self::new(OnePasswordConfig::default())
}
}