Skip to content

refactor(auth): split VaultBaseAuth.__authToken into separate promise/token fields - #123

Merged
kurok merged 4 commits into
masterfrom
refactor/120-split-auth-token-fields
Aug 3, 2026
Merged

refactor(auth): split VaultBaseAuth.__authToken into separate promise/token fields#123
kurok merged 4 commits into
masterfrom
refactor/120-split-auth-token-fields

Conversation

@kurok

@kurok kurok commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #120 — the one "consider"-tier item from the 2026-07 audit backlog (#111) that was intentionally left out of #119.

VaultBaseAuth.__authToken held either a Promise (login in flight) or a resolved AuthToken, discriminated at each use site with instanceof. Overloading one field with two types made the auth state machine hard to read and easy to get subtly wrong when touched.

It is now two clearly-typed fields:

field type meaning
__pendingLogin Promise<AuthToken> | null a login is in flight
__authToken AuthToken | null the resolved token

getAuthToken() reads as a straight state machine: pending login → cached token → expiry/reauth → start a login. No instanceof discrimination anywhere.

No behavior change — public API and semantics are identical. Specifically preserved:

  • Single-flight login — concurrent callers still await one in-flight login (previously implicit in Promise.resolve(this.__authToken) unwrapping a stored promise; now an explicit early return).
  • Renewal-timer wiring__setupTokenRefreshTimer / __renewToken untouched.
  • Failure reset — a failed login clears both fields, so a later call retries.
  • Expired-token + reauth-disallowed still rejects with AuthTokenExpiredError.

The only observable difference is one debug log line: the in-flight case now logs login already in flight instead of token already exist, which is what was actually happening.

Changes

  • src/auth/VaultBaseAuth.js — split the field, restructure getAuthToken() into explicit early returns, document both fields with JSDoc types.
  • test/auth.base.test.mjs — two new tests pinning the invariants the refactor had to keep: concurrent callers coalesce onto a single login (and the pending slot clears on resolve), and a failed login resets both fields.
  • CHANGELOG.md — entry under # Unreleased.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • CI / tooling

Checklist

  • Tests added or updated
  • npm run lint && npm test passes locally — 308 unit tests pass, lint clean, npm run coverage gate green (VaultBaseAuth.js 96.96% stmts / 97.05% branches). E2E not run (needs a live Vault).
  • User-facing changes recorded under # Unreleased in CHANGELOG.md — internal-refactor entry, matching the convention used for Refactor: unify the 4x copy-pasted request pipeline in VaultClient #110
  • All commits have a Signed-off-by: trailer (git commit -s)

@kurok
kurok requested review from m2broth and wRLSS as code owners July 28, 2026 08:27
kurok added 2 commits July 31, 2026 12:40
…ields

`VaultBaseAuth.__authToken` held either a Promise (login in flight) or a
resolved AuthToken, discriminated at each use site with `instanceof`.
Overloading one field with two types made the auth state machine hard to
read and easy to break when touched.

Split it into two clearly-typed fields:

- `__pendingLogin: Promise<AuthToken>|null` — the in-flight login
- `__authToken: AuthToken|null` — the resolved token

`getAuthToken()` now checks the pending login first (preserving the
single-flight behaviour for concurrent callers), then the cached-token
and expiry paths, so no `instanceof` discrimination is needed. The
renewal-timer wiring is unchanged.

No behaviour change: public API and semantics are identical. Adds tests
pinning the single-flight invariant and the post-failure state reset.

Closes #120

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
…nreleased

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
@kurok
kurok force-pushed the refactor/120-split-auth-token-fields branch from 118f023 to 7768999 Compare July 31, 2026 11:42
kurok added 2 commits July 31, 2026 13:27
…leased

The 'Update branch' merge of master (9403563) auto-merged without a conflict
but placed this note inside the '# 2.1.1 Release notes (2026-07-31)' section.
2.1.1 is a dependency-only release and does not contain this refactor, so the
note is restored to a fresh '# Unreleased' heading above it.

Signed-off-by: yuriyryabikov <22548029+kurok@users.noreply.github.com>
@kurok

kurok commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Local verification: 119 differential cases, this branch vs published 2.1.0

I verified this against the published npm artifact, not just the branch base.

Baseline first: the src/ shipped in node-vault-client@2.1.0 (and @2.1.1) is byte-identical to the v2.1.0 tag and to this PR's merge base 7a863b7 — so "PR vs 2.1.0" and "PR vs master" are literally the same comparison. I installed 2.1.0 the way a consumer does (npm install, fresh dependency resolve) and pointed the probes at node_modules/node-vault-client/src.

Matrix: Node 26.5.1 (current), 24.18.1 (LTS), 22.22.3 · Vault 1.13.3 in Docker (KV v1 + KV v2).

Evidence

Suite Cases vs published 2.1.0
Auth state machine (mocked transport, fake clocks) 82 5 behaviour diffs, 8 internal-field-only
End-to-end against live Vault 1.13.3 10 0 diffs
Consumer use cases (modelled on real downstream code) 27 0 diffs

Each suite is a single file run unchanged against both trees, emitting structured JSON per case; the two outputs are diffed mechanically, with observable behaviour recorded separately from private-field shape. State-machine suites ran on Node 26/24 (the first batch also on 22), consumer suite on 26/24, live-Vault suite on 26 — identical results on every combination.

All 5 behaviour diffs, in full:

  • F1/F2/F3_authenticate() resolving something that is not an AuthToken. Swapping instanceof AuthToken for !== null means expiry is now evaluated on whatever was returned: a duck-typed object with no isExpired() throws TypeError, and a duck-typed expired object now triggers reauth instead of being served forever. Unreachable through the public API — __getAuthProvider is a closed switch over four types, and all four resolve via _getTokenEntityAuthToken.fromResponse. Where it is reachable (someone subclassing VaultBaseAuth directly), this branch is the more correct of the two.
  • E8/E11 — the one genuine change. When the renewal timer completes while a re-login is in flight, 2.1.0 has the timer's this.__authToken = authToken write clobber the in-flight promise held in that same field, so a caller arriving afterwards is served the renewed token instead of joining the login. With the fields split, __pendingLogin is untouched and single-flight holds. If the re-login never settles, 2.1.0 resolves that caller from the renewal while this branch keeps waiting on the login. Reachable only with a renewable token whose renew request outlives half its TTL, and both versions hand the caller a valid token.

So "No behavior change" is very slightly too strong — worth one line in the description about the renew-during-re-login case, which this branch arguably improves, since single-flight is now genuinely preserved rather than accidentally broken by a timer write.

The new tests earn their place

Mutation testing — each mutation applied to the source, then that tree's own test:unit run:

mutation 2.1.0 suite (306 tests) this branch (308 tests)
remove single-flight SURVIVED caught
drop the failure reset caught caught (2 tests)
never clear __pendingLogin caught (3 tests)
restore instanceof AuthToken survived

2.1.0 had no test pinning single-flight; the new coalesces concurrent callers onto a single in-flight login test catches its removal. The one surviving mutation is exactly the instanceof!== null guard, i.e. the F1–F3 edge that nothing reachable can produce.

Both new tests also pass against 2.1.0 once their four __pendingLogin/__authToken assertions are stripped — so they pin the new representation and a behaviour that was already there, rather than testing the refactor into existence.

Downstream consumers

I catalogued every internal repository that imports this library (20 call sites across 17 repos) and wrote 27 cases modelled directly on that code rather than invented:

  • the boot() / get() / clear() instance registry, including clear() cancelling an armed refresh timer;
  • fillNodeConfig() — by far the dominant entry point — including the reject-not-hang path that consumers wire to process.exit(1);
  • concurrent Promise.all read/list bursts on a freshly constructed client, plus the nested list-then-read-every-key shape, plus a detached bound read handed straight to .map() (so it receives (path, index, array));
  • read('sys/mounts') for KV-version detection, and the permission-denied fallback where a 403 there must leave auth usable for subsequent reads;
  • KV2 via both engines and kv.autoDetect, and the two monkey-patched-read idioms found in the wild (Object.create(lease) prototype delegation, and a bare { getData } replacement);
  • all four auth types as they are actually configured, including iam with statically supplied credentials, a custom mount and iam_server_id_header_value, and kubernetes reading its JWT from tokenPath;
  • Lease.getValue / getData / isRenewable, dynamic leased secrets, and renewal firing during a long-lived process.

27/27 identical between 2.1.0 and this branch. Every burst still performs exactly one login, fillNodeConfig populates identically, and renewal still extends expiry mid-process while reads keep succeeding.

Repo gates

npm run lint clean, test:unit 306 → 308 passing, coverage gate green with VaultBaseAuth.js improving (96.7 → 96.96 statements, 96.87 → 97.05 branches), test:e2e:kv2 10/10 — all on both trees × 3 Node versions.

One pre-existing failure, identical on both trees over 5 fresh-container runs each: test:e2eshould renew token if needed. That test's token has a 2s TTL, and on my machine Node's global fetch stalls ~2.5s on a request issued after a ~1s idle gap, so renew-self reaches Vault after the token has expired and gets a 403. It reproduces against a plain node:http server with no Docker and no Vault involved, on Node 22/24/26 alike, while curl is always ~4ms — environmental, not this library. Separately, that suite is not idempotent against a reused dev-mode Vault (it asserts the full key list of secret/ and re-mounts ssh/), which is worth its own issue.

Verdict

No regression reachable from any consumer pattern in use; the single-flight invariant is now actually pinned by tests where it previously was not; coverage up. The only substantive note is the E8/E11 wording in the description.

@kurok
kurok merged commit 04607d5 into master Aug 3, 2026
18 checks passed
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.

Refactor: split VaultBaseAuth.__authToken into separate promise/token fields

1 participant