Skip to content

Clear the 1Password service-account token from the op-js global after each call - #1574

Open
GeiserX wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
GeiserX:fix/onepassword-token-lifetime
Open

Clear the 1Password service-account token from the op-js global after each call#1574
GeiserX wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
GeiserX:fix/onepassword-token-lifetime

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 13, 2026

Copy link
Copy Markdown

TL;DR

Hygiene, not a security fix — and I want to be precise about which, because my first draft of this description overstated it.

@1password/op-js keeps the service-account token on a module-level global (cli.serviceAccountToken) and reads it when it spawns op. The CLI backend set that global before each call and never cleared it, so one reachable reference to the token stayed live for the rest of the process. This clears it once the call that needed it is done.

What this does not do: it does not reduce what an attacker can reach. See "Why this is smaller than it looks" below. I'd rather say that up front than have you find it in review.


The change

makeCliService sets the global before each call. Now it also clears it afterwards, on success, failure and interruption alike, via Effect.ensuring inside the existing semaphore.

Authentication is unaffected: every read and write of that global already happens inside cliAuthLock, so the next operation re-sets the token before it spawns anything.

Why this is smaller than it looks

Three reasons a reviewer should weigh before spending time on this:

  1. No unrelated op child ever received the stale token. @1password/op-js is imported at exactly one site in the repo, and every call routes through wrapSync, which sets the correct token (or blanks it for desktop auth) inside the semaphore immediately before invoking. So the only op children that ever saw a token were the ones whose own call supplied it. op-js also builds a fresh per-spawn env rather than mutating process.env, so no other subprocess inherited it.
  2. The token is deliberately persisted in plaintext anyway. saveConfig JSON-stringifies config.auth — token included — into the config blob, which makeFumaBlobStore.put writes as a plain value column. That is documented in types.ts.
  3. Clearing one reference does not zero the others. Every resolve/list re-decodes the stored token into a fresh immutable JS string, and JS strings cannot be zeroed, so unreachable copies linger in the heap regardless.

So: this removes a long-lived reachable reference, which is what the library's own API shape implies you should do, and it is cheap and tested. It is not a boundary change.

The larger thing this sits next to

The comment on ServiceAccountAuth.token explains the plaintext storage as "v1 stored this behind a separate secret id; v2 has no secrets table, so the plugin-owned config row carries it directly."

That rationale looks stale: v2 does have a credential-provider abstraction, and a long-lived service-account token is exactly the kind of value it exists for. Moving it there would let a sealed or hardware-backed store hold it instead of a plaintext blob column — a real boundary change, unlike this PR.

I have not written that change, because it is a design decision that is yours to make and it would need a migration for existing configs. Happy to open it if you want it.

Tests

Two tests in service.test.ts, pinning both halves deliberately: the token must be gone afterwards, and it must still be set while the call runs. Clearing it too early would satisfy a "no longer parked" assertion and silently break authentication.

Mutation-checked, each mutation verified to have landed in the file, with an unmutated control before and after:

mutation result
remove the clear (i.e. revert this PR) killed
never set the token at all killed
clear on success only, not on failure killed (exactly the failure-path test)

Package: 10 passed. tsgo --noEmit, oxlint --deny-warnings and oxfmt --check clean on the changed files.

…t is done

op-js keeps the service-account token on a module-level CLI instance and reads
it when spawning `op`. The CLI backend set that global before each call and
nothing cleared it, so a token handed over for one secret resolution stayed
readable for the rest of the process's life, with nothing left to read it. The
account-name branch blanks it, but only if a differently-authenticated call
comes next -- which in a service-account-only deployment never happens.

Clear it on success, failure and interruption alike. Every read and write of
that global already happens inside the backend's semaphore, so the next
operation re-sets the token before it spawns anything.
@GeiserX GeiserX changed the title Clear the 1Password service-account token once the call that needed it is done Clear the 1Password service-account token from the op-js global after each call Aug 13, 2026
@GeiserX

GeiserX commented Aug 13, 2026

Copy link
Copy Markdown
Author

Context for this one: #1585 explains why this PR and twelve others exist — they came out of a single pass over credential handling, asking for each credential where it ends up, how long it stays, and who can read it once it's there.

This PR stands alone and doesn't depend on any of the others.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant