Skip to content

feat: add 1Password vault provider integration#113

Open
yaniv-golan wants to merge 1 commit into
onecli:mainfrom
yaniv-golan:feat/onepassword-vault-provider
Open

feat: add 1Password vault provider integration#113
yaniv-golan wants to merge 1 commit into
onecli:mainfrom
yaniv-golan:feat/onepassword-vault-provider

Conversation

@yaniv-golan

Copy link
Copy Markdown

Summary

  • Add OnePasswordVaultProvider implementing the existing VaultProvider trait with three auth modes: Service Account, CLI (desktop app), and Connect Server
  • v1 ships with explicit mappings only (hostname → item_id + vault_id); domain-based URL search deferred to v2
  • Concurrent provider lookup via tokio::JoinSet with 500ms grace period (Bitwarden preferred)
  • SSRF-hardened Connect client: custom DNS resolver, IPv4+IPv6 blocklist, no_proxy(), no redirects, configurable allowlist
  • Tokens encrypted at rest via CryptoService (AES-256-GCM), decrypted in memory per session
  • CORS origin allowlist replaces permissive mirror_request()
  • CLI mode gated behind ONEPASSWORD_CLI_MODE=true env var
  • Opt-in op CLI in Docker (multi-arch: amd64/arm64)

New endpoints

  • GET /api/vault/onepassword/info — capabilities and available modes (unauthenticated)
  • POST /api/vault/onepassword/discover/accounts — list signed-in 1Password accounts (CLI mode)
  • POST /api/vault/onepassword/discover/vaults — list vaults with temporary credentials
  • GET /api/vault/{provider}/vaults — list vaults for paired account
  • GET /api/vault/{provider}/vaults/{vault_id}/items — list items in a vault
  • GET /api/vault/{provider}/mappings — list explicit mappings
  • PUT /api/vault/{provider}/mappings — create/update mapping (validates item exists)
  • DELETE /api/vault/{provider}/mappings/{hostname} — delete mapping

Test plan

  • 123 unit tests + 5 integration tests pass
  • cargo clippy -- -D warnings clean
  • cargo fmt --check clean
  • End-to-end smoke test with live op CLI (v2.32.1) against real 1Password vaults
  • Verified: discover accounts, discover vaults, pair (CLI mode), status, list items, create mapping, credential injection via CONNECT proxy, delete mapping, disconnect
  • Service Account mode (needs a service account token)
  • Connect Server mode (needs a 1Password Connect deployment)

@johnnyfish johnnyfish requested a review from guyb1 March 26, 2026 07:19
@johnnyfish johnnyfish added the feature New feature or request label Mar 26, 2026
@johnnyfish

Copy link
Copy Markdown
Contributor

Hey @yaniv-golan, thanks for putting this together, appreciate the contribution. this is a pretty substantial PR so we'll need a bit of time to go through it properly. we'll review it soon and follow up with any feedback. thanks again 🙏

@guyb1 🔌

@johnnyfish

johnnyfish commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Great work on this @yaniv-golan, the SSRF protection, encrypted token storage, credential caching, and the concurrent provider lookup with grace period are all really solid. The test coverage is thorough too.

Before we build the UI for this, I want to share some thoughts on simplifying the setup experience. I looked at how Anchor Browser handles their 1Password integration, and I think there's a simpler model we should consider.

Their approach: one field, paste a Service Account Token, done. Then use op://vault/item/field references to point at secrets. No mode picker, no vault browser, no item picker.

What I'm thinking for us:

  1. Single auth mode for v1: Service Account Token only (the mainstream path). CLI mode and Connect Server can come later as advanced options.
  2. Use op:// references instead of raw vault_id + item_id for mappings. Users already know these from their CI/CD pipelines and .env files. Instead of building a vault/item
    browser UI, the user just types:
    api.anthropic.com → op://API Keys/Anthropic/credential
    And we resolve it with op read at proxy time.
  3. This preserves most of your backend work: the encrypted token storage, SSRF protection, caching, error cooldown, and the provider trait extension all stay. The main change is
    swapping the mapping format from (vault_id, item_id) to op:// references and simplifying pair() to just accept a token.

The Bitwarden setup is one step (paste a code). I'd like 1Password to feel equally simple: paste a token, then add hostname→op:// mappings as needed.

What do you think? Happy to hop on a call if it's easier to discuss live.

@yaniv-golan

Copy link
Copy Markdown
Author

Thanks Jonathan, really appreciate the thoughtful feedback - and the Anchor Browser reference, you're right, that's a clean model.

Fully agree on simplifying to SA-token-only for v1. The op:// reference approach is better UX - users already know these from their CI/CD pipelines and .env files, so there's zero learning curve. And op read is simpler than op item get + field extraction on onecli side too.

A few notes on the implementation:

What stays as-is: The encrypted token storage, credential caching, concurrent provider lookup with grace period, CORS allowlist, and the provider trait extension — none of that changes.

What simplifies:

  • pair() becomes: validate token via op whoami, encrypt, store. ~30 lines instead of ~270.
  • Mappings become hostname → op://vault/item/field (a single string instead of vault_id + item_id)
  • request_credential() calls op read "op://..." — one command, plaintext out, no field extraction
  • Discovery endpoints (accounts, vaults, items) get removed — not needed when users type op:// references directly

One question on credential shape: Currently the proxy injects username + password pairs. With op://, each reference resolves to a single value. I'm thinking each mapping points to the specific field the proxy needs (e.g., op://API Keys/Anthropic/credential for an API key). The proxy injects that as the password/token. Does that match what you had in mind?
Probably best to keep the CLI and Connect code in the branch for when we want to add advanced modes later.

@johnnyfish

johnnyfish commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Yes, single op:// reference per hostname is the right model for v1. Our proxy primarily injects API keys/tokens as Authorization headers, which is always one value. For basic auth (username+password), we can add a second optional reference field later.

One thing I noticed while testing with the op CLI: LOGIN items have a top-level urls field (e.g., {"href": "woohoo.com"}). We could optionally scan items and pre-populate mappings from those URLs, so users don't have to type every hostname manually. Not required for v1 but worth keeping in mind for v2.

Really appreciate how quickly you aligned on the simplification, and the implementation breakdown you laid out is exactly right. Excited to get this shipped.

For next steps:

  1. You push an updated PR with the simplified backend
    (SA-token-only pair(), op:// mappings, op read for resolution)
  2. Once the API is finalized, we build the dashboard UI on top:
    service account token input + hostname/op:// mapping table
  3. We sync on any edge cases (credential shape, error handling) as they come up

Sound good?

@yaniv-golan

yaniv-golan commented Mar 31, 2026

Copy link
Copy Markdown
Author

macOS TCC impact on CLI mode

I just spent a few hours debugging this exact issue on a project that spawns op from a Node.js process (via execSync). Sharing findings that are directly relevant to the CLI mode in this PR.

The problem

On macOS Sequoia+, Apple extended TCC (kTCCServiceSystemPolicyAppData) to protect Group Containers. The op CLI probes ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/ (Unix sockets for IPC with the desktop app) during initialization, regardless of auth method - even when OP_SERVICE_ACCOUNT_TOKEN is set.

This means any process that spawns op as a child will trigger a macOS dialog:

"[parent binary]" would like to access data from other apps

The "responsible process" is the parent (OneCLI in this case), not op itself. The permission is per-process-lifetime - it doesn't persist across restarts.

What doesn't work

  • OP_BIOMETRIC_UNLOCK_ENABLED=false - tested today, the prompt still appears. op still probes the Group Container before checking this flag.
  • op daemon disable - the 1Password desktop app can re-enable it automatically.

Impact on this PR

  • Docker users: unaffected (no desktop app in container)
  • macOS CLI mode users: will see TCC prompt on every OneCLI process start. If they click "Don't Allow", op gets killed and the integration fails silently or crashes.
  • Service Account mode: also affected if it uses op CLI under the hood

Suggestion

Consider adding SDK-based auth as an alternative to CLI mode. The 1Password SDKs (available in Rust, JS, Python, Go) authenticate via WASM+HTTPS directly - they never touch the filesystem or Group Containers, so zero TCC prompts. For a Rust project, the onepassword crate would be a natural fit.

References

@johnnyfish

johnnyfish commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Good catch on the TCC issue. I did some research and verified the core problem: on macOS Sequoia+, op probes ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/ during initialization regardless of auth mode, and macOS attributes the access to the parent process (OneCLI), not op itself. So users get a "would like to access data from other apps"
dialog.

That said, I don't think this changes our v1 plan:

  1. Docker/Linux (production): unaffected, no desktop app in container
  2. macOS local dev: users can grant Full Disk Access to their terminal once and it persists. Not ideal, but workable for v1
  3. We already agreed to ship SA-token-only with op read, which keeps the surface area small

On the SDK suggestion: the Go, JS, and Python SDKs exist, but there's no official Rust crate. The internal Rust core isn't published as a standalone package, so "the onepassword crate would be a natural fit" isn't an option today. For a Rust gateway, the realistic SDK paths would be FFI to the Go SDK or a JS sidecar, both of which add significant complexity.

For v2, the cleaner path is probably calling the 1Password Connect REST API directly for service account resolution (HTTPS only, zero filesystem access, zero TCC). The Connect infrastructure is already partially built in your PR. But that's a separate discussion.

For now, let's ship v1 with op read + SA token, document the macOS FDA requirement, and revisit the SDK/Connect path once we have real user feedback. wdyt?

@guyb1 pls also take a look 🙏

@yaniv-golan

Copy link
Copy Markdown
Author

@johnnyfish I stand corrected on the Rust SDK - there's no official one.

On shipping v1 with op read - the TCC concern is real for macOS users running OneCLI as a service (no terminal to inherit FDA, Homebrew upgrades reset the grant). But I looked deeper into alternatives and you're right that there isn't a simpler path today: SA tokens require SRP + client-side decryption (no REST API), and Connect requires self-hosted Docker infrastructure per user - heavier than op, not lighter.

Worth documenting that macOS service users need FDA on the binary and that it resets on upgrades.

One thing worth tracking: there's an unofficial Rust SDK wrapping the same libop_uniffi_core that all official SDKs use. If it matures (or 1Password publishes an official Rust crate), that'd be the cleanest path - single async call, no CLI, no TCC.

@yaniv-golan

Copy link
Copy Markdown
Author

One more thing on the FDA-resets-on-upgrade problem. I initially thought we could create a stable wrapper binary at a fixed path (e.g., /usr/local/bin/onecli-launcher) that just spawns the real binary. Grant FDA to the wrapper once, and since its path never changes, the grant survives upgrades.

That doesn't work. TCC checks the actual binary performing the file access, not its parent. A non-bundled parent's FDA does not propagate to child processes.

But - an app bundle wrapper does work. macOS has a "responsible process" concept — when an app bundle spawns child processes, its TCC grants cover them. This is the same mechanism that makes Terminal.app's FDA cover all commands run within it. The key difference: app bundles are identified by CFBundleIdentifier (stable across upgrades), not by binary path (which changes with every Homebrew upgrade).

So if OneCLI shipped as a minimal .app bundle:

  /Applications/OneCLI.app/                                                                    
    Contents/                                                                            
      Info.plist        # stable CFBundleIdentifier, e.g. com.onecli.proxy                     
      MacOS/                                                                                   
        onecli-launcher # spawns the real onecli binary as a child                             
 

Users grant FDA once to OneCLI.app. The grant survives Homebrew upgrades because TCC keys it to the bundle ID, not the Cellar path. Child processes (op, etc.) inherit the FDA through the responsible process chain.

Not suggesting this for v1 - just documenting the pattern in case the upgrade friction becomes a real issue.

@yaniv-golan yaniv-golan force-pushed the feat/onepassword-vault-provider branch from a346411 to 2647b92 Compare March 31, 2026 11:40
@yaniv-golan

Copy link
Copy Markdown
Author

Updated the PR with the simplified implementation:

  • SA-token only - pair() takes service_account_token, validates via op whoami, encrypts, done
  • op:// mappings - PUT /mappings with {"hostname": "api.anthropic.com", "op_ref": "op://vault/item/field"}
  • op read at proxy time - single command, plaintext out, cached 60s
  • Discovery endpoints removed, vault_info is fully static (no op invocation / no TCC prompt)
  • macOS FDA doc added

Tested end-to-end with a live SA token - pair, mapping, credential injection via CONNECT proxy all working.

@yaniv-golan yaniv-golan force-pushed the feat/onepassword-vault-provider branch from 2647b92 to 7cb3918 Compare March 31, 2026 12:46
…/ mappings

Add OnePasswordVaultProvider implementing the VaultProvider trait:
- Service Account Token authentication (paste token, done)
- op:// secret reference mappings (hostname -> op://vault/item/field)
- Credential resolution via `op read` at proxy time
- Encrypted token storage via CryptoService (AES-256-GCM)
- Concurrent provider lookup via JoinSet with 500ms grace period
- Credential caching (60s positive, 30s negative TTL)
- Error cooldown (60s) with stale mapping tracking
- CORS origin allowlist (replaces permissive mirror)
- VaultError enum with typed HTTP status responses
- Opt-in op CLI installation in Docker (multi-arch)
- macOS TCC/FDA setup documentation
@johnnyfish johnnyfish force-pushed the feat/onepassword-vault-provider branch from 7cb3918 to 6f7077f Compare April 1, 2026 12:01
@johnnyfish

Copy link
Copy Markdown
Contributor

Reviewed the updated PR, the implementation looks great. SA-token-only pair(), op:// mappings with op read, and the macOS FDA doc are all exactly what we discussed.

We ran a local e2e test against a live SA token: pair, add mappings, status check, invalid reference rejection, hostname validation, delete mapping, all working. Also confirmed with fs_usage that op 2.33.1 does still probe the Group Container settings.json even in SA mode, so your TCC documentation is spot on.

@guyb1 and I are going to run it through more testing this week (credential injection via the CONNECT proxy, cache behavior, error/cooldown paths) and I want him to review the code as well. We'll follow up with our thoughts once we're done, then merge and start on the dashboard UI.

Really clean work on the simplification, the diff went from ~2400 lines to ~1400 and the API surface is much simpler to build a UI on top of.

@mbravorus

Copy link
Copy Markdown
Contributor

super excited about this one, will test ASAP when merged :D

@guyb1

guyb1 commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Hey @yaniv-golan , thanks for the patience on this. The implementation is solid and we've been testing it.

One thing I'm still working through is that I have some concerns about bundling the op CLI binary inside our Docker image. It means we're shipping and maintaining a third-party binary inside our image (also for users without 1Password), and spawning a process every time we need to resolve a secret. I'm exploring whether there's a cleaner path (Connect Server sidecar, the unofficial Rust FFI crate, etc.) that avoids embedding the CLI.

Still thinking through this, wanted to let you know why it's taking a bit longer on our end!

@yaniv-golan

Copy link
Copy Markdown
Author

Hey @guyb1, that's a helpful pushback actually- you're right to question the op CLI dependency.

Let me walk through the constraints so you have the full picture, and where I landed:

The current state: INSTALL_OP_CLI defaults to false in the Dockerfile, but publish.yml passes true, so the published image ships op to all users regardless of whether they use 1Password. That's not great.

Quick fix - separate image tags (onecli:latest without op, onecli:1password with it) would address the "shipping to non-1P users" concern, but doesn't solve the deeper issue of shelling out to a third-party binary per secret resolution.

Why alternatives are constrained:

  • 1Password Connect Server REST API - eliminates the CLI, but requires users to self-host two additional containers (connect-api + connect-sync). That's heavier infrastructure than the CLI, not lighter. @johnnyfish flagged this earlier in the thread as a v2 option.
  • Official Rust SDK - doesn't exist. The internal Rust core isn't published as a standalone crate.
  • Unofficial Rust FFI crate - too risky for production.
  • Service Account tokens via direct REST API - not possible. SA auth involves SRP + client-side vault decryption, which is why the SDKs exist.

What does exist: an official JS/TS SDK (@1password/sdk) that resolves op:// references natively via client.secrets.resolve("op://vault/item/field"). No CLI, no filesystem access, no TCC issues.

Proposed approach: Move secret resolution from the Rust gateway to the Node.js web app layer, following the same pattern as Bitwarden - where the gateway just decrypts and injects stored credentials, and doesn't talk to the vault backend directly.

The flow would be:

  1. User pairs with SA token (validated via JS SDK, stored encrypted - same UX as today)
  2. User adds hostname → op:// mappings (same API)
  3. Web app resolves op:// references via JS SDK, stores encrypted resolved values in DB
  4. Gateway reads encrypted values from DB, decrypts, injects - no op needed
  5. Background job periodically re-resolves to handle secret rotation

This removes the op binary entirely, uses an officially supported SDK, and keeps the gateway lean. The tradeoff is a freshness lag on rotation (minutes instead of seconds), but the current implementation already caches for 60s, so comparable in practice.

This is a bigger change than what's in the PR today - wanted to check if this aligns with what you had in mind before going down that path.

@guyb1

guyb1 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Hey @yaniv-golan, thanks again for the detailed breakdown and for thinking through the alternatives!

One thing we want to avoid is storing resolved secrets from vaults in our DB. We treat the vault as the single source of truth ,and keeping those values in our storage adds a breach surface we'd rather not have. The op:// references are safe to store tho.

What about this flow:

  1. User pairs by pasting their SA token, stored encrypted in VaultConnection.connectionData
  2. User adds hostname → op:// mappings, stored in DB (references only, not secrets)
  3. At request time, the gateway calls the web app via an internal endpoint (POST /api/internal/vault/resolve) with { accountId, opRef, and of course, some authentication method of the agent ( i think agent token would be available at this context and could fit) }
  4. Web app creates or reuses a cached JS SDK client for that account's SA token, calls client.secrets.resolve(opRef), returns the value
  5. Gateway injects it into the request. The resolved value lives in memory only, never written to the DB
  6. SDK clients are LRU-cached in the web app process (e.g. keep 50 most recent, evict after 30 min idle)

This removes the op CLI entirely, uses the official JS SDK, and keeps secrets out of our storage. The tradeoff is the gateway now depends on the web app for vault resolution, which is not great.

I think this is still far from optimal and we should probably try to get in touch with someone from the 1Password team to understand what they'd recommend for this kind of integration. But curious what you think of this direction before we commit to it.

Again, thank you for your time!

@natevick

Copy link
Copy Markdown

I want to say how excited I am for a 1Password integration! If there is someway I can join in and help move this forward I will.

@mbravorus

Copy link
Copy Markdown
Contributor

it's been a hot minute since this was last discussed, any chance it is still in the pipeline?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants