use crate::provider::{Address, 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)]
pub struct SecretReference {
pub item: String,
pub section: Option<String>,
pub field: 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)".to_string()
));
}
"onepassword" | "onepassword+token" | "op" => {}
_ => {
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);
}
}
let path = url.path();
let path = path.trim_matches('/');
if !path.is_empty() || scheme == "op" {
let vault = config.default_vault.as_deref().unwrap_or("<vault>");
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let hint = match segments.as_slice() {
[item, field] => {
crate::config::ref_table_hint(Some(vault), item, None, Some(field))
}
[item, section, field] => {
crate::config::ref_table_hint(Some(vault), item, Some(section), Some(field))
}
_ => crate::config::ref_table_hint(Some(vault), "<item>", None, Some("<field>")),
};
return Err(SecretSpecError::ProviderOperationFailed(format!(
"1Password items are addressed with a secret's `ref`, not in the provider URI: \
use providers = [\"onepassword://{vault}\"] with {hint}"
)));
}
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
}
const OP_NOT_INSTALLED_HELP: &str = "OnePassword CLI (op) is not installed.\n\n\
To 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\n\
Then enable desktop integration in the 1Password app under\n \
Settings → Developer → \"Integrate with 1Password CLI\".";
const AUTH_REQUIRED_HELP: &str = "OnePassword authentication required.\n\n\
Recommended: enable desktop integration in the 1Password app under\n \
Settings → Developer → \"Integrate with 1Password CLI\", then unlock the app.\n\n\
Alternatives:\n \
- Service account (CI): set OP_SERVICE_ACCOUNT_TOKEN or use the onepassword+token:// scheme\n \
- Manual signin: run 'eval $(op signin)' (session expires after 30 minutes of inactivity)";
fn strip_op_session_env(cmd: &mut Command) {
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("OP_SESSION_") {
cmd.env_remove(&key);
}
}
}
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", "op"],
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);
strip_op_session_env(&mut cmd);
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(
OP_NOT_INSTALLED_HELP.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(
OP_NOT_INSTALLED_HELP.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")
|| error_msg.contains("account is not signed in")
{
return Err(SecretSpecError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
));
}
return Err(SecretSpecError::ProviderOperationFailed(
error_msg.to_string(),
));
}
String::from_utf8(output.stdout)
.map_err(|e| SecretSpecError::ProviderOperationFailed(e.to_string()))
}
fn is_authenticated(&self) -> Result<bool> {
match self.execute_op_command(&["vault", "list", "--format", "json"], 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) -> String {
self.config
.default_vault
.clone()
.unwrap_or_else(|| "Private".to_string())
}
fn reference_uri(vault: &str, reference: &SecretReference) -> String {
match &reference.section {
Some(section) => format!(
"op://{}/{}/{}/{}",
vault, reference.item, section, reference.field
),
None => format!("op://{}/{}/{}", vault, reference.item, reference.field),
}
}
fn read_reference(
&self,
vault: &str,
reference: &SecretReference,
) -> Result<Option<SecretString>> {
let reference_uri = Self::reference_uri(vault, reference);
match self.execute_op_command(&["read", "--no-newline", &reference_uri], None) {
Ok(output) => Ok(Some(SecretString::new(output.into()))),
Err(SecretSpecError::ProviderOperationFailed(msg))
if msg.contains("isn't an item") || msg.contains("doesn't have a field") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
fn set_reference(
&self,
vault: &str,
reference: &SecretReference,
value: &SecretString,
) -> Result<()> {
let assignment = format!(
"{}={}",
Self::assignment_target(reference),
value.expose_secret()
);
let args = vec![
"item",
"edit",
&reference.item,
"--vault",
vault,
&assignment,
];
self.execute_op_command(&args, None)?;
Ok(())
}
fn native_reference(
&self,
native: &crate::config::NativeAddress,
) -> Result<(String, Option<SecretReference>)> {
let vault = native
.vault
.clone()
.unwrap_or_else(|| self.get_vault_name());
let reference = match &native.field {
Some(field) => Some(SecretReference {
item: native.item.clone(),
section: native.section.clone(),
field: field.clone(),
}),
None => {
if native.section.is_some() {
return Err(SecretSpecError::ProviderOperationFailed(
"onepassword references with a `section` also need a `field`".to_string(),
));
}
None
}
};
Ok((vault, reference))
}
fn read_item(&self, vault: &str, item_name: &str) -> Result<Option<SecretString>> {
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 assignment_target(reference: &SecretReference) -> String {
let escape = |s: &str| s.replace('.', "\\.");
match &reference.section {
Some(section) => format!("{}.{}", escape(section), escape(&reference.field)),
None => escape(&reference.field),
}
}
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<()> {
match self.is_authenticated() {
Ok(true) => Ok(()),
Ok(false) => Err(SecretSpecError::ProviderOperationFailed(
AUTH_REQUIRED_HELP.to_string(),
)),
Err(e) => Err(e),
}
}
}
impl Provider for OnePasswordProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: self.format_item_name(project, key, profile),
vault: Some(self.get_vault_name()),
..Default::default()
})
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field", "vault", "section"]
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn auth_scope_key(&self) -> Option<String> {
Some(format!(
"{:?}",
(
&self.config.account,
&self.config.service_account_token,
&self.op_command
)
))
}
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, addr: Address<'_>) -> Result<Option<SecretString>> {
let coords = self.resolve_coords(addr)?;
let (vault, reference) = self.native_reference(&coords)?;
match reference {
Some(reference) => self.read_reference(&vault, &reference),
None => self.read_item(&vault, &coords.item),
}
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let (project, profile, key) = match addr {
Address::Native(native) => {
let coords = self.resolve_coords(addr)?;
let (vault, reference) = self.native_reference(&coords)?;
let reference = reference.unwrap_or_else(|| SecretReference {
item: native.item.clone(),
section: None,
field: "value".to_string(),
});
return self.set_reference(&vault, &reference, value);
}
Address::Convention {
project,
profile,
key,
} => (project, profile, key),
};
let vault = self.get_vault_name();
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_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
if requests.is_empty() {
return Ok(HashMap::new());
}
let mut whole_items: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut field_refs: Vec<(&str, Address<'_>)> = Vec::new();
for (name, addr) in requests {
let coords = self.resolve_coords(*addr)?;
let (vault, reference) = self.native_reference(&coords)?;
match reference {
Some(_) => field_refs.push((name, *addr)),
None => whole_items
.entry(vault)
.or_default()
.push((name.to_string(), coords.item.clone())),
}
}
let mut results = HashMap::new();
for (vault, items) in whole_items {
results.extend(self.get_items_batch(&vault, items)?);
}
results.extend(super::get_each(self, &field_refs)?);
Ok(results)
}
}
impl OnePasswordProvider {
fn get_items_batch(
&self,
vault: &str,
items: Vec<(String, String)>,
) -> Result<HashMap<String, SecretString>> {
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 listed: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();
let item_map: HashMap<String, String> = listed
.into_iter()
.map(|item| (item.title, item.id))
.collect();
let to_fetch: Vec<(String, String)> = items
.into_iter()
.filter_map(|(name, title)| item_map.get(&title).map(|id| (name, id.clone())))
.collect();
let fetched: Vec<(String, Result<Option<SecretString>>)> = std::thread::scope(|scope| {
let handles: Vec<_> = to_fetch
.into_iter()
.map(|(name, item_id)| (name, scope.spawn(move || self.read_item(vault, &item_id))))
.collect();
handles
.into_iter()
.map(|(name, handle)| (name, handle.join().expect("op item get thread panicked")))
.collect()
});
let mut results = HashMap::new();
for (name, result) in fetched {
if let Some(value) = result? {
results.insert(name, value);
}
}
Ok(results)
}
}
impl Default for OnePasswordProvider {
fn default() -> Self {
Self::new(OnePasswordConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
fn config(s: &str) -> OnePasswordConfig {
OnePasswordConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap()
}
#[test]
fn try_from_parses_account_and_vault() {
let c = config("onepassword://work@Production");
assert_eq!(c.account.as_deref(), Some("work"));
assert_eq!(c.default_vault.as_deref(), Some("Production"));
assert_eq!(c.service_account_token, None);
}
#[test]
fn try_from_parses_vault_only() {
let c = config("onepassword://Production");
assert_eq!(c.account, None);
assert_eq!(c.default_vault.as_deref(), Some("Production"));
}
#[test]
fn try_from_token_scheme_captures_token_from_username() {
let c = config("onepassword+token://ops_tok@Private");
assert_eq!(c.service_account_token.as_deref(), Some("ops_tok"));
assert_eq!(c.default_vault.as_deref(), Some("Private"));
assert_eq!(c.account, None);
}
#[test]
fn try_from_token_scheme_captures_token_from_password() {
let c = config("onepassword+token://acct:ops_tok@Private");
assert_eq!(c.service_account_token.as_deref(), Some("ops_tok"));
}
#[test]
fn try_from_ignores_localhost_host() {
let c = config("onepassword://localhost");
assert_eq!(c.default_vault, None);
assert_eq!(c.account, None);
}
#[test]
fn try_from_rejects_unknown_scheme() {
let err =
OnePasswordConfig::try_from(&ProviderUrl::new(Url::parse("keyring://vault").unwrap()))
.unwrap_err();
assert!(err.to_string().contains("Invalid scheme"));
}
#[test]
fn get_vault_name_defaults_to_private() {
let default = OnePasswordProvider::new(OnePasswordConfig::default());
assert_eq!(default.get_vault_name(), "Private");
let configured = OnePasswordProvider::new(config("onepassword://Production"));
assert_eq!(configured.get_vault_name(), "Production");
}
#[test]
fn format_item_name_default_and_custom() {
let default = OnePasswordProvider::new(OnePasswordConfig::default());
assert_eq!(
default.format_item_name("proj", "KEY", "prod"),
"secretspec/proj/prod/KEY"
);
let custom = OnePasswordProvider::new(OnePasswordConfig {
folder_prefix: Some("{project}-{key}".to_string()),
..Default::default()
});
assert_eq!(custom.format_item_name("proj", "KEY", "prod"), "proj-KEY");
}
#[test]
fn uri_for_account_round_trips() {
let provider = OnePasswordProvider::new(config("onepassword://work@Production"));
assert_eq!(provider.uri(), "onepassword://work@Production");
}
#[test]
fn uri_for_token_does_not_leak_secret() {
let provider =
OnePasswordProvider::new(config("onepassword+token://ops_secret_tok@Private"));
let uri = provider.uri();
assert_eq!(uri, "onepassword+token://Private");
assert!(!uri.contains("ops_secret_tok"));
}
fn config_err(s: &str) -> SecretSpecError {
OnePasswordConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap_err()
}
#[test]
fn item_paths_are_rejected_with_ref_hint() {
let err = config_err("op://Infra/db/password");
assert!(
err.to_string()
.contains("ref = { vault = \"Infra\", item = \"db\", field = \"password\" }"),
"{err}"
);
let err = config_err("op://Infra");
assert!(
err.to_string().contains("addressed with a secret's `ref`"),
"{err}"
);
let err = config_err("onepassword://vault/Production");
assert!(
err.to_string().contains("addressed with a secret's `ref`"),
"{err}"
);
let err = config_err("op://Infra/a/b/c/d");
assert!(
err.to_string().contains("addressed with a secret's `ref`"),
"{err}"
);
}
#[test]
fn assignment_target_escapes_dots() {
let reference = SecretReference {
item: "db".to_string(),
section: Some("api.keys".to_string()),
field: "connection.url".to_string(),
};
assert_eq!(
OnePasswordProvider::assignment_target(&reference),
"api\\.keys.connection\\.url"
);
let reference = SecretReference {
section: None,
..reference
};
assert_eq!(
OnePasswordProvider::assignment_target(&reference),
"connection\\.url"
);
}
#[test]
fn pasted_reference_hint_preserves_spaces() {
let Err(err) = Box::<dyn Provider>::try_from("op://Prod Vault/My Item/field") else {
panic!("op:// provider spec must be rejected");
};
assert!(
err.to_string().contains(
"ref = { vault = \"Prod Vault\", item = \"My Item\", field = \"field\" }"
),
"{err}"
);
}
#[test]
fn native_address_maps_coordinates_with_vault_override() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
field: Some("password".into()),
section: Some("api".into()),
vault: Some("Production".into()),
..Default::default()
};
let (vault, reference) = provider.native_reference(&addr).unwrap();
assert_eq!(vault, "Production");
let reference = reference.expect("field-addressed reference");
assert_eq!(
OnePasswordProvider::reference_uri(&vault, &reference),
"op://Production/db/api/password"
);
}
#[test]
fn native_address_vault_defaults_to_store_vault() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
field: Some("password".into()),
..Default::default()
};
let (vault, _) = provider.native_reference(&addr).unwrap();
assert_eq!(vault, "Personal");
}
#[test]
fn native_address_without_field_names_the_whole_item() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "My API Item".into(),
..Default::default()
};
let (_, reference) = provider.native_reference(&addr).unwrap();
assert!(reference.is_none());
}
#[test]
fn native_address_rejects_version() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
version: Some("3".into()),
..Default::default()
};
let err = provider.resolve_coords(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`version`"), "{err}");
}
#[test]
fn native_address_section_requires_field() {
let provider = OnePasswordProvider::new(config("onepassword://Personal"));
let addr = crate::config::NativeAddress {
item: "db".into(),
section: Some("api".into()),
..Default::default()
};
let err = provider.native_reference(&addr).unwrap_err();
assert!(err.to_string().contains("need a `field`"), "{err}");
}
}