use anyhow::{Context, Result, anyhow, bail};
use secrecy::{ExposeSecret, SecretString};
use crate::secrets::resolver::SecretResolver;
use crate::secrets::resolvers::{ERROR_BODY_MAX_LEN, truncate_body};
fn validate_op_segment(value: &str, field_name: &str) -> Result<()> {
if value.is_empty() {
bail!("{field_name} must not be empty");
}
for ch in value.chars() {
if ch == '/' || ch == '?' || ch == '#' || ch.is_control() {
bail!(
"{field_name} contains invalid character '{}' — \
must not contain '/', '?', '#', or control characters",
ch.escape_debug()
);
}
}
Ok(())
}
#[derive(Debug)]
struct OpReference {
vault: String,
item: String,
field: String,
}
impl OpReference {
fn parse(reference: &str) -> Result<Self> {
let path = reference
.strip_prefix("op://")
.ok_or_else(|| anyhow!("invalid 1Password reference: must start with op://"))?;
let segments: Vec<&str> = path.split('/').collect();
if segments.iter().any(|s| s.is_empty()) {
bail!(
"invalid 1Password reference: contains empty path segments \
(double slash or trailing slash) in {reference}"
);
}
match segments.len() {
3 => {
let vault = segments[0].to_string();
let item = segments[1].to_string();
let field = segments[2].to_string();
validate_op_segment(&vault, "vault name")?;
validate_op_segment(&item, "item name")?;
validate_op_segment(&field, "field name")?;
Ok(Self { vault, item, field })
}
4 => {
let vault = segments[0].to_string();
let item = segments[1].to_string();
let section = segments[2];
let field = segments[3];
validate_op_segment(&vault, "vault name")?;
validate_op_segment(&item, "item name")?;
validate_op_segment(section, "section name")?;
validate_op_segment(field, "field name")?;
Ok(Self {
vault,
item,
field: format!("{section}/{field}"),
})
}
_ => {
bail!(
"invalid 1Password reference: expected op://vault/item/field \
or op://vault/item/section/field, got: {}",
reference
);
}
}
}
}
const SCIM_UNSAFE_CHARS: &[char] = &['"', '\\'];
pub struct OpResolver;
impl OpResolver {
pub fn new() -> Self {
Self
}
}
impl Default for OpResolver {
fn default() -> Self {
Self::new()
}
}
struct OpAuth {
host: String,
token: SecretString,
}
impl OpAuth {
fn from_env() -> Result<Self> {
let token = std::env::var("OP_CONNECT_TOKEN")
.ok()
.filter(|t| !t.is_empty());
let host = std::env::var("OP_CONNECT_HOST")
.ok()
.filter(|h| !h.is_empty());
match (token, host) {
(Some(token), Some(host)) => {
let parsed = reqwest::Url::parse(&host)
.with_context(|| format!("OP_CONNECT_HOST is not a valid URL: {host}"))?;
match parsed.scheme() {
"https" => {}
"http" => {
tracing::warn!(
"OP_CONNECT_HOST uses plain HTTP — the bearer token will be \
transmitted in cleartext. Use HTTPS in production."
);
}
other => {
bail!("OP_CONNECT_HOST must use http:// or https:// scheme, got: {other}")
}
}
if !parsed.path().is_empty() && parsed.path() != "/" {
bail!("OP_CONNECT_HOST must not include a path, got: {host}");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!(
"OP_CONNECT_HOST must not include userinfo (username/password), got: {host}"
);
}
if parsed.query().is_some() {
bail!("OP_CONNECT_HOST must not include a query string, got: {host}");
}
if parsed.fragment().is_some() {
bail!("OP_CONNECT_HOST must not include a fragment, got: {host}");
}
Ok(Self {
host: host.trim_end_matches('/').to_string(),
token: SecretString::from(token),
})
}
_ => bail!(
"1Password Connect Server requires OP_CONNECT_TOKEN + OP_CONNECT_HOST \
environment variables. See https://developer.1password.com/docs/connect/"
),
}
}
}
fn is_op_uuid(s: &str) -> bool {
s.len() == 26
&& s.chars()
.all(|c| c.is_ascii_uppercase() || matches!(c, '2'..='7'))
}
fn validate_scim_value(value: &str, field_name: &str) -> Result<()> {
for ch in value.chars() {
if SCIM_UNSAFE_CHARS.contains(&ch) || ch.is_control() {
bail!(
"{field_name} contains invalid character '{}' — \
must not contain '\"', '\\', or control characters (SCIM filter injection risk)",
ch.escape_debug()
);
}
}
Ok(())
}
impl SecretResolver for OpResolver {
fn scheme(&self) -> &str {
"op"
}
fn resolve(&self, reference: &str) -> Result<SecretString> {
let op_ref = OpReference::parse(reference)?;
let canonical_ref = format!("op://{}/{}/{}", op_ref.vault, op_ref.item, op_ref.field);
match OpAuth::from_env() {
Ok(auth) => resolve_via_connect(&op_ref, &auth),
Err(_) => resolve_via_cli(&canonical_ref),
}
}
}
fn resolve_via_connect(op_ref: &OpReference, auth: &OpAuth) -> Result<SecretString> {
validate_scim_value(&op_ref.vault, "vault name")?;
validate_scim_value(&op_ref.item, "item name")?;
let base = auth.host.trim_end_matches('/');
let token = auth.token.expose_secret();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.context("failed to build HTTP client for 1Password")?;
let vault_id = resolve_vault_id(&client, base, token, &op_ref.vault)?;
let item_id = resolve_item_id(&client, base, token, &vault_id, &op_ref.item)?;
let item_url = format!("{base}/v1/vaults/{vault_id}/items/{item_id}");
let request = client
.get(&item_url)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/json")
.build()
.context("failed to build 1Password item request")?;
let response = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(client.execute(request))
})
.context("1Password API request failed")?;
let status = response.status();
if !status.is_success() {
let body = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(response.text())
})
.unwrap_or_default();
bail!(
"1Password API returned HTTP {}: {}",
status.as_u16(),
truncate_body(&body, ERROR_BODY_MAX_LEN)
);
}
let body: serde_json::Value =
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(response.json()))
.context("failed to parse 1Password API response")?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow!("1Password API response missing 'fields' array"))?;
let (expected_section, expected_field) = if op_ref.field.contains('/') {
let (s, f) = op_ref.field.split_once('/').unwrap();
(Some(s), f)
} else {
(None, op_ref.field.as_str())
};
let field_value = fields
.iter()
.find(|f| {
let label_matches = f["label"].as_str() == Some(expected_field)
|| f["id"].as_str() == Some(expected_field);
if !label_matches {
return false;
}
match expected_section {
Some(section) => {
f["section"]["label"].as_str() == Some(section)
|| f["section"]["id"].as_str() == Some(section)
}
None => true,
}
})
.and_then(|f| f["value"].as_str())
.ok_or_else(|| {
anyhow!(
"field '{}' not found in 1Password item '{}/{}'. \
Verify the field label and section name (if specified) are correct.",
op_ref.field,
op_ref.vault,
op_ref.item,
)
})?;
Ok(SecretString::from(field_value.to_string()))
}
fn resolve_via_cli(reference: &str) -> Result<SecretString> {
let mut child = match std::process::Command::new("op")
.args(["read", "--no-newline", reference])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!(
"1Password secret resolution requires either:\n \
1. Connect Server: set OP_CONNECT_TOKEN + OP_CONNECT_HOST\n \
2. CLI: install `op` (https://developer.1password.com/docs/cli/) \
and set OP_SERVICE_ACCOUNT_TOKEN or run `op signin`"
);
}
Err(err) => {
bail!("failed to execute 1Password CLI `op read`: {err}");
}
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if std::time::Instant::now() >= deadline {
child.kill().ok();
child.wait().ok();
bail!(
"1Password CLI `op read` did not complete within 30 seconds. \
The CLI may be waiting for interactive authentication (biometric/GUI). \
For non-interactive use, set OP_SERVICE_ACCOUNT_TOKEN or use Connect Server."
);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(err) => bail!("failed to wait for 1Password CLI: {err}"),
}
}
let result = child
.wait_with_output()
.context("failed to read 1Password CLI output")?;
if result.status.success() {
let value =
String::from_utf8(result.stdout).context("1Password CLI returned non-UTF-8 output")?;
Ok(SecretString::from(value))
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
bail!(
"1Password CLI `op read` failed (exit {}): {}\n\n\
Ensure you are authenticated via one of:\n \
- OP_SERVICE_ACCOUNT_TOKEN env var (CI/CD)\n \
- `op signin` interactive session (developer workstation)\n \
- Connect Server: set OP_CONNECT_TOKEN + OP_CONNECT_HOST",
result.status.code().unwrap_or(-1),
stderr.trim()
);
}
}
fn resolve_vault_id(
client: &reqwest::Client,
base: &str,
token: &str,
name_or_id: &str,
) -> Result<String> {
if is_op_uuid(name_or_id) {
return Ok(name_or_id.to_string());
}
let request = client
.get(format!("{base}/v1/vaults"))
.query(&[("filter", format!("name eq \"{name_or_id}\""))])
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/json")
.build()
.context("failed to build 1Password vault lookup request")?;
let response = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(client.execute(request))
})
.context("1Password vault lookup request failed")?;
let status = response.status();
if !status.is_success() {
let body = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(response.text())
})
.unwrap_or_default();
bail!(
"1Password vault lookup returned HTTP {}: {}",
status.as_u16(),
truncate_body(&body, ERROR_BODY_MAX_LEN)
);
}
let vaults: Vec<serde_json::Value> =
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(response.json()))
.context("failed to parse 1Password vault lookup response")?;
if vaults.is_empty() {
bail!("1Password vault '{}' not found", name_or_id);
}
if vaults.len() > 1 {
bail!(
"1Password vault name '{}' is ambiguous — {} vaults matched. \
Use the vault UUID instead to resolve the ambiguity.",
name_or_id,
vaults.len()
);
}
let id = vaults[0]["id"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("1Password vault response missing 'id' field"))?;
validate_op_segment(&id, "vault ID from API")?;
Ok(id)
}
fn resolve_item_id(
client: &reqwest::Client,
base: &str,
token: &str,
vault_id: &str,
name_or_id: &str,
) -> Result<String> {
if is_op_uuid(name_or_id) {
return Ok(name_or_id.to_string());
}
let request = client
.get(format!("{base}/v1/vaults/{vault_id}/items"))
.query(&[("filter", format!("title eq \"{name_or_id}\""))])
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/json")
.build()
.context("failed to build 1Password item lookup request")?;
let response = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(client.execute(request))
})
.context("1Password item lookup request failed")?;
let status = response.status();
if !status.is_success() {
let body = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(response.text())
})
.unwrap_or_default();
bail!(
"1Password item lookup returned HTTP {}: {}",
status.as_u16(),
truncate_body(&body, ERROR_BODY_MAX_LEN)
);
}
let items: Vec<serde_json::Value> =
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(response.json()))
.context("failed to parse 1Password item lookup response")?;
if items.is_empty() {
bail!("1Password item '{}' not found in vault", name_or_id);
}
if items.len() > 1 {
bail!(
"1Password item title '{}' is ambiguous — {} items matched in vault. \
Use the item UUID instead to resolve the ambiguity.",
name_or_id,
items.len()
);
}
let id = items[0]["id"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("1Password item response missing 'id' field"))?;
validate_op_segment(&id, "item ID from API")?;
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn three_segment_reference_sets_vault() {
let r = OpReference::parse("op://my-vault/my-item/password").unwrap();
assert_eq!(r.vault, "my-vault");
}
#[test]
fn three_segment_reference_sets_item() {
let r = OpReference::parse("op://my-vault/my-item/password").unwrap();
assert_eq!(r.item, "my-item");
}
#[test]
fn three_segment_reference_sets_field() {
let r = OpReference::parse("op://my-vault/my-item/password").unwrap();
assert_eq!(r.field, "password");
}
#[test]
fn four_segment_reference_joins_section_and_field_with_slash() {
let r = OpReference::parse("op://my-vault/my-item/login/password").unwrap();
assert_eq!(r.field, "login/password");
}
#[test]
fn parse_rejects_too_few_segments() {
assert!(OpReference::parse("op://vault/item").is_err());
}
#[test]
fn parse_rejects_too_many_segments() {
assert!(OpReference::parse("op://vault/item/a/b/c").is_err());
}
#[test]
fn parse_rejects_empty_path() {
assert!(OpReference::parse("op://").is_err());
}
#[test]
fn parse_rejects_wrong_scheme() {
assert!(OpReference::parse("vault://a/b/c").is_err());
}
#[test]
fn parse_rejects_control_char_in_vault() {
assert!(OpReference::parse("op://my\x00vault/item/field").is_err());
}
#[test]
fn parse_rejects_question_mark_in_item() {
assert!(OpReference::parse("op://vault/item?q=1/field").is_err());
}
#[test]
fn parse_rejects_hash_in_field() {
assert!(OpReference::parse("op://vault/item/field#frag").is_err());
}
#[test]
fn parse_accepts_spaces_in_vault() {
let r = OpReference::parse("op://My Vault/My Item/password").unwrap();
assert_eq!(r.vault, "My Vault");
}
#[test]
fn scim_validation_rejects_quotes() {
assert!(validate_scim_value("my\"vault", "vault name").is_err());
}
#[test]
fn scim_validation_rejects_backslash() {
assert!(validate_scim_value("my\\vault", "vault name").is_err());
}
#[test]
fn scim_validation_accepts_hyphenated_name() {
validate_scim_value("my-vault", "vault name").unwrap();
}
#[test]
fn scim_validation_accepts_name_with_spaces_and_digits() {
validate_scim_value("My Vault 123", "vault name").unwrap();
}
#[test]
fn connect_auth_requires_both_vars() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
assert!(OpAuth::from_env().is_err());
}
#[test]
fn connect_auth_rejects_host_with_userinfo() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
unsafe { std::env::set_var("OP_CONNECT_TOKEN", "tok") };
unsafe { std::env::set_var("OP_CONNECT_HOST", "https://user:pass@op.example.com") };
assert!(OpAuth::from_env().is_err());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
}
#[test]
fn connect_auth_rejects_host_with_query() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
unsafe { std::env::set_var("OP_CONNECT_TOKEN", "tok") };
unsafe { std::env::set_var("OP_CONNECT_HOST", "https://op.example.com?foo=bar") };
assert!(OpAuth::from_env().is_err());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
}
#[test]
fn connect_auth_rejects_host_with_fragment() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
unsafe { std::env::set_var("OP_CONNECT_TOKEN", "tok") };
unsafe { std::env::set_var("OP_CONNECT_HOST", "https://op.example.com#section") };
assert!(OpAuth::from_env().is_err());
unsafe { std::env::remove_var("OP_CONNECT_TOKEN") };
unsafe { std::env::remove_var("OP_CONNECT_HOST") };
}
#[test]
fn limit_inside_multibyte_char_walks_back_to_char_boundary() {
let s = "ab😀cd";
let truncated = truncate_body(s, 3);
assert_eq!(truncated, "ab");
}
#[test]
fn input_at_exact_limit_is_returned_unchanged() {
assert_eq!(truncate_body("hello", 5), "hello");
}
#[test]
fn input_exceeding_limit_is_cut_at_limit() {
assert_eq!(truncate_body("hello", 3), "hel");
}
}