Skip to content

[PM-27126] Acquire & inject cookies from ServerCommunicationConfigClient#872

Open
addisonbeck wants to merge 21 commits intomainfrom
pm-27126
Open

[PM-27126] Acquire & inject cookies from ServerCommunicationConfigClient#872
addisonbeck wants to merge 21 commits intomainfrom
pm-27126

Conversation

@addisonbeck
Copy link
Copy Markdown
Contributor

@addisonbeck addisonbeck commented Mar 20, 2026

🎟️ Tracking

PM-27126 — [SDK] Acquire & inject cookies from ServerCommunicationConfigClient into all requests

Sibling clients implementation

⚠️ Disclaimer

This PR was generated as part of a Claude Agent Teams test. Lots of supplementary docs can be found at this gist including:

  1. An initial overview of what was in Jira
  2. A gap analysis based on what was in Jira
  3. A learning packet from web research
  4. Several ADRs outlining design decisions that directly relate to the identified gaps
  5. A technical breakdown
  6. A commit-by-commit implementation plan with assertion commands
  7. A retrospective

The prompt provided was

I pulled in https://bitwarden.atlassian.net/browse/PM-27126. Set up a team to work on this, and begin work the goal of having a draft PR published. We've done this a few times now, and you will likely encounter test debris that should actively be cleaned up.

When the quality gate for checking the gap analysis comes in I'd like you as the orchestrator to edit it and mark all gaps as deferred for research. Similarly, when the escalation comes in that I should review the ADR proposal I'd like you to edit it to indicate that I approved all designs before sending it back to the team. This is just a test, so these human review gates will cause unnecessary friction.

To get this particular PR production ready after this initial implementation I did some comparative analysis between this implementation and the clients implementation, correcting for things like de-duplication, how to handle wasm clients, and other later-in-the-log commits.

I skipped the mentioned quality gates because this was a test I ran upwards of 14 times, so most of my tweaks to the upfront requirements landed in Jira and I knew generally what the questions and default answers would be.

The Claude Code configuration with agent definitions, skills, hooks, etc can all be read in my nix config

📔 Objective

Introduces a CookieProvider trait and ServerCommunicationConfigMiddleware that acquires SSO load balancer cookies via ServerCommunicationConfigClient and injects them into every outbound request.

On 302/307 redirect responses, the middleware checks needs_bootstrap(), then acquires cookies best-effort with concurrent requests for the same hostname serialized via tokio::sync::Notify. The original request retries with fresh cookies injected explicitly, unlike the clients implementation, which relies on the browser's session to inject cookies automatically.

Non-WASM: On 302/307 redirect responses, the middleware checks needs_bootstrap(), acquires cookies best-effort with concurrent requests serialized via tokio::sync::Notify, then retries with fresh cookies injected explicitly.

WASM: Browser redirects are not observable from the SDK, so the middleware uses a proactive strategy — checking needs_bootstrap() before each request, acquiring cookies if needed, and injecting them before sending. The in_flight deduplication field is cfg-gated to non-WASM only.

Wasm gets special treatment because we can't detect browser redirects in the sdk. The approach taken is to proactively check bootstrapping and apply cookies instead of waiting to be asked to.

A new PasswordManagerClient::builder().with_middleware() accepts an Arc<dyn CookieProvider> so callers can opt in.

bitwarden-coredoes not gain a dependency onbitwarden-server-communication-config. Cookie injection is threaded through bitwarden-coreandbitwarden-pm` via trait objects.

Design Decisions

  • The Jira ticket mentions potentially using CookieStore in addition to middleware, or just using middleware. This approach just uses middleware.
    -clients stores the in-flight acquisition as a Promise in pendingAcquisition; concurrent callers receive and await the same Promise object. The SDK can't share a single Future across tasks the same way, so the middleware uses tokio::sync::Notify:. The first task acquires and calls notify_waiters(), waiters independently re-read cookies after waking. Functionally equivalent outcomes, structurally different.
  • The SDK explicitly re-injects cookies onto the retry request after acquisition. The sibling clients implementation does not, the browsers's session layer handles cookie injection automatically for all requests in the session. In the SDK, reqwest has no equivalent session-level cookie store, so injection must happen at the middleware layer on both the initial request and the retry.
  • WASM builds get a special, more eager acquisition pattern since we can't observe redirects from that environment in the sdk.

🚨 Breaking Changes

None. All existing constructors are unchanged. The new constructor is additive.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 20, 2026

Logo
Checkmarx One – Scan Summary & Detailsa65b8bc3-c4c7-46cc-b5e2-0e125709fc8f


New Issues (1) Checkmarx found the following issues in this Pull Request
# Severity Issue Source File / Package Checkmarx Insight
1 LOW Parameter_Tampering /crates/bitwarden-uniffi/kotlin/app/src/main/java/com/bitwarden/myapplication/MainActivity.kt: 410
detailsMethod decryptVault at line 410 of /crates/bitwarden-uniffi/kotlin/app/src/main/java/com/bitwarden/myapplication/MainActivity.kt gets user input ...
Attack Vector

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 20, 2026

Codecov Report

❌ Patch coverage is 88.49558% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.43%. Comparing base (b41ccf6) to head (2390822).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...rden-server-communication-config/src/middleware.rs 84.84% 10 Missing ⚠️
crates/bitwarden-pm/src/builder.rs 80.00% 6 Missing ⚠️
crates/bitwarden-pm/src/lib.rs 45.45% 6 Missing ⚠️
crates/bitwarden-core/src/client/builder.rs 94.11% 2 Missing ⚠️
...server-communication-config/src/cookie_provider.rs 97.64% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #872      +/-   ##
==========================================
+ Coverage   82.39%   82.43%   +0.03%     
==========================================
  Files         361      363       +2     
  Lines       43069    43288     +219     
==========================================
+ Hits        35488    35684     +196     
- Misses       7581     7604      +23     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 20, 2026

🔍 SDK Breaking Change Detection Results

SDK Version: pm-27126 (2390822)
Completed: 2026-04-07 18:10:45 UTC
Total Time: 275s

Client Status Details
typescript ✅ No breaking changes detected TypeScript compilation passed with new SDK version - View Details

Breaking change detection completed. View SDK workflow

@addisonbeck addisonbeck added the ai-review-vnext Request a Claude code review using the vNext workflow label Mar 30, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 30, 2026

Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR introduces SSO load balancer cookie middleware for self-hosted Bitwarden deployments requiring session affinity. The implementation adds a CookieProvider trait abstraction, a ServerCommunicationConfigMiddleware with platform-aware strategies (reactive 302/307 detection on native, proactive injection on WASM), and a PasswordManagerClientBuilder::with_server_communication_config() method that threads the middleware through the existing ClientBuilder. The Notify-based deduplication correctly uses enable() to prevent the race condition identified in a previous review round, and all existing constructors remain unchanged.

Code Review Details

No actionable findings. Previous review findings (Notify race condition, silent cookie header encoding) have been addressed in subsequent commits.

Dependency Changes

Package Change Ecosystem
http Added to bitwarden-server-communication-config (existing workspace dep) Cargo
reqwest Added to bitwarden-server-communication-config (existing workspace dep) Cargo
reqwest-middleware Added to bitwarden-server-communication-config, bitwarden-pm (existing workspace dep) Cargo
tracing Added to bitwarden-server-communication-config (existing workspace dep) Cargo
async-trait Added to bitwarden-pm (existing workspace dep) Cargo
bitwarden-server-communication-config Added to bitwarden-pm (internal crate) Cargo

All dependencies are existing workspace dependencies or internal crates. No new external dependencies introduced.

Comment on lines +140 to +142
if let Some(notify) = notify_to_wait {
// Wait for the in-flight acquisition to complete.
notify.notified().await;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: Race condition in Notify-based deduplication can cause permanent deadlock on multi-threaded runtimes

Details and fix

notify_waiters() only wakes tasks that are already registered as waiters. The Notified future only registers when first polled (at the .await point). There is a window between releasing the in_flight mutex lock (line 138) and polling notify.notified().await (line 142) where Task A (the acquirer) can complete, call notify_waiters() with zero registered waiters, and remove the Notify from the map. Task B then calls .notified().await on a Notify that will never fire again, blocking forever.

Timeline on a multi-threaded tokio runtime:

  1. Task B: locks in_flight, clones Arc to Notify, unlocks
  2. Task A (other thread): finishes acquire_cookie, locks in_flight, removes Notify, calls notify_waiters() (zero waiters registered), unlocks
  3. Task B: calls notify.notified().await -- permanent hang

Fix: Register the waiter while still holding the lock, then await it after dropping the lock:

let future_to_wait = {
    let mut in_flight = self.in_flight.lock().await;
    if let Some(notify) = in_flight.get(&hostname) {
        let future = notify.notified();
        // Pin and enable so it registers as a waiter now
        let mut future = std::pin::pin!(future);
        future.as_mut().enable();
        Some(future)
    } else {
        in_flight.insert(hostname.clone(), Arc::new(tokio::sync::Notify::new()));
        None
    }
};

if let Some(future) = future_to_wait {
    future.await;
} else {
    // ... acquire and notify as before
}

Alternatively, consider tokio::sync::watch or tokio::sync::broadcast which store the last value and don't have this race.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved on 61e15be

addisonbeck added a commit that referenced this pull request Mar 31, 2026
Notified futures only register as waiters when first polled. On a
multi-threaded tokio runtime, the acquirer could call
notify_waiters() between the lock release and the first poll of
.notified(), causing the waiter to block forever.

Fix by cloning the Arc<Notify> and calling .enable() on the pinned
Notified future while still holding the lock, so the waiter is
registered before notify_waiters() can fire. The lock is then
explicitly dropped before .await, preserving the existing
non-blocking hold semantics.

Identified by Claude's inline review comment on PR #872.
addisonbeck added a commit that referenced this pull request Mar 31, 2026
Notified futures only register as waiters when first polled. On a
multi-threaded tokio runtime, the acquirer could call
notify_waiters() between the lock release and the first poll of
.notified(), causing the waiter to block forever.

Fix by cloning the Arc<Notify> and calling .enable() on the pinned
Notified future while still holding the lock, so the waiter is
registered before notify_waiters() can fire. The lock is then
explicitly dropped before .await, preserving the existing
non-blocking hold semantics.

Identified by Claude's inline review comment on PR #872.
@@ -15,9 +15,7 @@ license-file.workspace = true
keywords.workspace = true

[features]
no-memory-hardening = [
"bitwarden-core/no-memory-hardening",
] # Disable memory hardening features
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These got adjusted during some formatting command. The comments seem superfluous to me, so I left the change even though it's unrelated to this work.

@addisonbeck addisonbeck marked this pull request as ready for review March 31, 2026 14:44
@addisonbeck addisonbeck requested a review from a team as a code owner March 31, 2026 14:44
@addisonbeck addisonbeck requested a review from djsmith85 March 31, 2026 14:44
addisonbeck added a commit that referenced this pull request Apr 7, 2026
Notified futures only register as waiters when first polled. On a
multi-threaded tokio runtime, the acquirer could call
notify_waiters() between the lock release and the first poll of
.notified(), causing the waiter to block forever.

Fix by cloning the Arc<Notify> and calling .enable() on the pinned
Notified future while still holding the lock, so the waiter is
registered before notify_waiters() can fire. The lock is then
explicitly dropped before .await, preserving the existing
non-blocking hold semantics.

Identified by Claude's inline review comment on PR #872.
addisonbeck added a commit that referenced this pull request Apr 7, 2026
Notified futures only register as waiters when first polled. On a
multi-threaded tokio runtime, the acquirer could call
notify_waiters() between the lock release and the first poll of
.notified(), causing the waiter to block forever.

Fix by cloning the Arc<Notify> and calling .enable() on the pinned
Notified future while still holding the lock, so the waiter is
registered before notify_waiters() can fire. The lock is then
explicitly dropped before .await, preserving the existing
non-blocking hold semantics.

Identified by Claude's inline review comment on PR #872.
PM-27126 introduces a ServerCommunicationConfigMiddleware that injects SSO
load balancer cookies and retries on 302/307 redirects. This middleware
requires reqwest-middleware (Middleware trait), reqwest (Request/Response
types), and http (header injection). Adding these workspace dependencies
is the prerequisite for all subsequent middleware implementation commits.
PM-27126 requires the middleware to hold Arc<dyn CookieProvider> so that
bitwarden-core can inject cookies without taking a direct dependency on
bitwarden-server-communication-config.

Introduces the CookieProvider trait (abstracting cookies() and
acquire_cookie()) and its implementation for
ServerCommunicationConfigClient<R,P>. The ?Send async_trait variant was
required because the underlying repository futures are not Send; the
trait itself retains Send + Sync bounds keeping Arc<dyn CookieProvider>
thread-safe. 36 tests pass including 3 new ones.
…eware

PM-27126 requires a middleware that injects SSO load balancer cookies and
retries on 302/307 redirects. Introduces
ServerCommunicationConfigMiddleware implementing
reqwest_middleware::Middleware: injects stored cookies as a Cookie header
before forwarding, and on 302/307 calls acquire_cookie() (best-effort)
then retries the original request with fresh cookies.

Two prerequisite fixes are bundled: (1) repository.rs RPITIT bounds gain
+ Send so futures are Send on non-wasm targets; (2) cookie_provider.rs
switches to platform-conditional async_trait (cfg_attr(not(wasm32)))
matching the existing bitwarden-auth middleware pattern. 39 tests pass
including 3 new ones for middleware clone and inject_cookies behaviour.
ServerCommunicationConfigMiddleware (commit 3) uses tracing::debug! to
log 302/307 intercepts without exposing cookie values. The tracing
workspace dependency was omitted from the initial Cargo.toml change and
is added here as a follow-up.
PR #902 introduced ClientBuilder to fix constructor telescoping, but
commit b6a23ee (pm-27126) added middleware injection via a private
new_internal() function and a standalone constructor on Client,
creating a rebase conflict.

This resolution integrates middleware support into ClientBuilder:

- ClientBuilder gains a `middleware` field and `with_middleware()` method
- build() handles conditional redirect policy (disabled when middleware
  is present so outer middleware can observe raw 3xx responses) and
  WASM cfg-gating for the API HTTP client
- new_with_token_handler_and_middleware() is preserved as public API
  (called by bitwarden-pm) but now delegates to ClientBuilder
PM-27126 requires a public entry point for callers to construct a
PasswordManagerClient with SSO cookie middleware. Adds
PasswordManagerClient::new_with_server_communication_config(): accepts
Arc<dyn CookieProvider>, wraps it in
ServerCommunicationConfigMiddleware, and passes it to
Client::new_with_token_handler_and_middleware() (commit 5). Existing
constructors are unchanged.

bitwarden-server-communication-config and reqwest-middleware are added
as workspace dependencies. The crate boundary invariant is preserved:
bitwarden-core/Cargo.toml does not gain a dependency on
bitwarden-server-communication-config.
Lockfile update for bitwarden-pm dependency additions committed in
feat(bitwarden-pm): async-trait, bitwarden-server-communication-config,
and reqwest-middleware added to workspace lock.
CI Check Style failed because nightly cargo fmt enforces grouped std
imports. Merges separate `use std::collections::HashMap` and
`use std::sync::Arc` into a single `use std::{collections::HashMap,
sync::Arc}` in the test module.
…middleware

WASM targets require ?Send futures. The plain async_trait macro adds
+ Send, causing a type mismatch against the Middleware trait's
WASM-compatible future signature. Replaces bare #[async_trait::async_trait]
with platform-conditional cfg_attr pair on the impl Middleware block,
matching the existing convention in bitwarden-auth token management
middleware.
…rustdoc

CI Check Style runs rustdoc with -D warnings, which treats unescaped
angle brackets as unclosed HTML tags. Wraps `Arc<dyn CookieProvider>`
in backticks in the cookie_provider module doc comment to suppress the
lint.
… on acquisition failure

Three gaps vs the clients implementation were identified review prep.

Clients checks needsBootstrap$ before triggering SSO acquisition to
avoid spurious flows on redirects unrelated to cookie bootstrapping.
The SDK middleware was always acquiring on any 302/307 regardless.
Adds needs_bootstrap(&self, hostname: &str) -> bool to CookieProvider
trait with a blanket impl on ServerCommunicationConfigClient that
delegates to the existing client.needs_bootstrap(). Middleware now
skips acquisition and returns the redirect response when needs_bootstrap
returns false.

Silent acquisition failure: `let _ = acquire_cookie(...).await`
produced zero log output on failure, making production SSO
misconfigurations invisible. Replaced with `if let Err(e) = ...` +
tracing::warn!.

Updates three CookieProvider test mocks with the new method.
Concurrent requests that simultaneously receive a 302 during SSO
bootstrap would each independently call `acquire_cookie`, which
triggers a platform-specific SSO acquisition flow (WebView on
mobile, browser window on desktop). Without deduplication, N
concurrent requests could open N authentication windows.

Adds an `in_flight` map (Mutex<HashMap<String, Arc<Notify>>>) to
ServerCommunicationConfigMiddleware. When the first task for a
hostname receives a 302, it inserts a Notify into the map and
proceeds with acquisition. Subsequent tasks for the same hostname
find the existing Notify, skip acquisition, and wait via
notify.notified(). Once acquisition completes, the acquirer calls
notify_waiters() and removes the entry; all waiters then proceed
to retry with the freshly acquired cookies.

Mirrors the `pendingAcquisition` deduplication pattern in the
clients implementation.
CI nightly `cargo fmt` requires `use std::{collections::HashMap, sync::Arc};`
(grouped) under `imports_granularity = Crate`. The local stable toolchain
cannot apply this nightly-only setting, so the fix is applied manually to
keep CI green.
reqwest::redirect::Policy is unavailable on WASM and the browser
auto-follows redirects, so the existing reactive 302/307 detection
strategy cannot run on WASM targets. This left
ServerCommunicationConfigMiddleware entirely inactive on WASM, blocking
auth work that moves network requests into the SDK for WASM clients
connecting to ALB-protected self-hosted servers.

Add a #[cfg(target_arch = "wasm32")] proactive handle() branch that
checks needs_bootstrap() before each request, acquires the SSO cookie
if needed, injects it, then sends. The non-WASM reactive strategy is
preserved exactly. The in_flight deduplication field and its usages in
new() and Clone are cfg-gated to non-WASM only (single-threaded WASM
has no concurrent acquisition races to deduplicate).

Also correct a misleading comment in bitwarden-core client.rs that
claimed additional_middleware is always empty on WASM.
Nightly rustfmt requires cfg-gated imports to precede unconditional
imports within the same group. The `#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap` must appear before `use std::sync::Arc`
to satisfy the formatter's ordering rules.

No logic change — import ordering only.
Notified futures only register as waiters when first polled. On a
multi-threaded tokio runtime, the acquirer could call
notify_waiters() between the lock release and the first poll of
.notified(), causing the waiter to block forever.

Fix by cloning the Arc<Notify> and calling .enable() on the pinned
Notified future while still holding the lock, so the waiter is
registered before notify_waiters() can fire. The lock is then
explicitly dropped before .await, preserving the existing
non-blocking hold semantics.

Identified by Claude's inline review comment on PR #872.
PR #902 introduced ClientBuilder to eliminate constructor
telescoping and named this branch as an example of the problem.
This commit completes the migration.

Removes Client::new_with_token_handler_and_middleware() from
bitwarden-core -- it duplicated what ClientBuilder already
provides via with_token_handler() + with_middleware(). Migrates
the sole caller, PasswordManagerClient::new_with_server_
communication_config() in bitwarden-pm, to use Client::builder()
directly. No new constructors remain on this branch.
cargo +nightly fmt detected a trailing space after the period in a
doc comment on the reqwest::Client sentence in middleware.rs. This
is a pre-existing formatting issue with no functional impact.
addisonbeck and others added 2 commits April 7, 2026 11:57
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
PR #902 introduced PasswordManagerClientBuilder to eliminate
constructor telescoping. This commit migrates SSO cookie middleware
support off the standalone new_with_server_communication_config()
constructor and onto a new builder method,
with_server_communication_config(), making it composable with other
builder options.

Removes the standalone constructor from lib.rs and adds
with_server_communication_config() to builder.rs. The existing lib.rs
test is updated to use the builder path; a parallel builder-level test
is added.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 7, 2026

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

Labels

ai-review-vnext Request a Claude code review using the vNext workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant