From aa25c221e99bc10e45d35a276fc9a867dcb54d1b Mon Sep 17 00:00:00 2001 From: nathanmoreton <52756156+nathanmoreton@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:43:49 +0000 Subject: [PATCH] Document the SSO cookie vendor and fix clippy on Rust 1.98 Bring the documentation this feature ships with in line with the Google developer documentation style guide, and clear the one lint the toolchain bump surfaced. No behavior changes. Documentation: * Add a module header to sso_cookie_vendor.rs explaining the flow, why it exists, and that the route is registered only when the feature is on. Rewrite the item docs in descriptive third person and give each fallible function an `# Errors` section naming every status code and the condition behind it. Replace the comments that restated the code with ones that explain why the cookie map is built, why the length cap is applied to the finished link, and why shard order is deterministic. * Comment the two additions in api/core/mod.rs, restoring the upstream bitwarden/server#6892 reference that was dropped along with the `"communication": null` placeholder. * Rewrite the admin-panel help text and .env.template entries so they say when an operator needs the setting, not just what it is. * Rewrite docs/sso-cookie-vendor.md: sentence-case headings, a settings table, numbered procedures, explained placeholders. It now states that Cloudflare Access is the only proxy verified in production rather than implying the others are tested. Fixes: * `cookie.value().to_string()` twice becomes `to_owned()`, which clippy::str_to_string began rejecting after the toolchain moved to 1.98. * Rename test_uri_too_long_returns_400 to test_oversize_cookie_exceeds_uri_limit. It asserts that the built URI exceeds the cap; the 400 comes from the handler, which the test never calls. * The validation error now names `SSO_COOKIE_VENDOR_ENABLED` instead of describing it in prose, so the reader knows which flag to unset. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YXT28rZFFFahdfKXJScPHy --- .env.template | 24 ++- docs/sso-cookie-vendor.md | 328 ++++++++++++++++-------------- src/api/core/mod.rs | 6 + src/api/core/sso_cookie_vendor.rs | 105 +++++++--- src/config.rs | 16 +- 5 files changed, 283 insertions(+), 196 deletions(-) diff --git a/.env.template b/.env.template index 43adf52d..3af9c15d 100644 --- a/.env.template +++ b/.env.template @@ -564,22 +564,26 @@ ### SSO Cookie Vendor settings ### ########################################## -## Enable the SSO cookie vendor endpoint. This allows native Bitwarden apps -## (mobile/desktop) to work when Vaultwarden is behind an authenticating reverse -## proxy such as Cloudflare Access. The proxy sets an auth cookie on the request, -## and this endpoint reads it and redirects the client with the cookie value -## embedded in a bitwarden:// deep link. +## Serve the SSO cookie vendor endpoint. Set this when Vaultwarden sits behind a +## reverse proxy that authenticates every request, such as Cloudflare Access, +## Authentik, Authelia, or oauth2-proxy. Without it the Bitwarden mobile and +## desktop apps cannot finish logging in, because the proxy answers their API +## calls with a browser redirect they cannot follow. The endpoint reads the +## cookie the proxy set and redirects the browser to a bitwarden:// deep link +## carrying that cookie, which the app then sends with every later request. +## The three settings below are required when this is true. +## See docs/sso-cookie-vendor.md for the full setup guide. # SSO_COOKIE_VENDOR_ENABLED=false -## The IdP login URL the client should navigate to for authentication -## (e.g. the Cloudflare Access login URL for your Vaultwarden application) +## URL the app opens in a browser to authenticate. For Cloudflare Access this is +## the Access Login URL shown on the application's details page. # SSO_COOKIE_VENDOR_IDP_LOGIN_URL=https://example.cloudflareaccess.com/cdn-cgi/access/login/vault.example.com -## The name of the cookie set by the authenticating reverse proxy -## (e.g. CF_Authorization for Cloudflare Access) +## Name of the cookie the proxy sets on authenticated requests. Cloudflare Access +## always uses CF_Authorization. # SSO_COOKIE_VENDOR_COOKIE_NAME=CF_Authorization -## The domain scope of the proxy auth cookie (e.g. vault.example.com) +## Domain scope of the proxy auth cookie, which is the domain the proxy protects. # SSO_COOKIE_VENDOR_COOKIE_DOMAIN=vault.example.com ######################## diff --git a/docs/sso-cookie-vendor.md b/docs/sso-cookie-vendor.md index f2ec9c58..2cba5bbc 100644 --- a/docs/sso-cookie-vendor.md +++ b/docs/sso-cookie-vendor.md @@ -1,91 +1,98 @@ -# SSO Cookie Vendor — Native App Support Behind Authenticating Reverse Proxies +# SSO cookie vendor + +Lets the Bitwarden mobile and desktop apps log in when Vaultwarden sits behind a +reverse proxy that authenticates every request, such as Cloudflare Access, +Authentik, Authelia, or oauth2-proxy. ## Background -Users of Vaultwarden frequently put it behind an authenticating reverse proxy — -most commonly **Cloudflare Access** or similar Zero Trust gateways — so that -only authenticated users can reach the vault at all. This is a strong defensive -layer: bots can't crawl the endpoint, credential-stuffing never reaches the -login form, and the attack surface drops to "whoever passes my IdP." - -The problem is that when the proxy sits in front of the API, the **native -Bitwarden clients (mobile, desktop)** can no longer complete their login flow. -The proxy expects a browser with a cookie jar and OAuth redirect support; the -native apps' HTTP clients have neither. After the browser-assisted IdP step, -the client is stuck — requests to the API come back as HTML login pages from -the proxy instead of JSON from Vaultwarden. - -Bitwarden's upstream server solved this in February 2026 with a flow they call -**SSO cookie vending**: the server advertises, via `/api/config`, that it lives -behind an authenticating proxy, and exposes an endpoint (`/api/sso-cookie-vendor`) -that reads the proxy's auth cookie after the user authenticates in a browser -and hands it back to the native app via a `bitwarden://` deep link. The app -then attaches that cookie to every subsequent API request, and the proxy lets -those requests through. - -See the upstream PRs: [bitwarden/server#6880][pr-6880], -[bitwarden/server#6892][pr-6892], [bitwarden/server#6903][pr-6903], -[bitwarden/clients#18476][pr-18476], [bitwarden/clients#19392][pr-19392]. - -Vaultwarden shipped the web-vault connector page (from -`bitwarden/clients#18476`) as part of v2026.2.0, but the server-side pieces -(`/api/sso-cookie-vendor` and the `communication.bootstrap` advertisement in -`/api/config`) were missing. Native apps would detect the web-vault connector, -open a browser, complete the Access auth, and then 404 when they tried to -hand the cookie off. This change adds the two missing server pieces. - -## What this change does - -Four things: - -1. **Adds a new config section** `sso_cookie_vendor` with four fields: - - `SSO_COOKIE_VENDOR_ENABLED` — master switch (default `false`) - - `SSO_COOKIE_VENDOR_IDP_LOGIN_URL` — the URL the app should navigate to - in a browser for IdP authentication (e.g. the Cloudflare Access login - URL for your Vaultwarden application) - - `SSO_COOKIE_VENDOR_COOKIE_NAME` — the name of the cookie the proxy sets - on authenticated requests (e.g. `CF_Authorization` for Cloudflare Access) - - `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` — the cookie's domain scope -2. **Advertises the configuration** in the `/api/config` response as a - `communication.bootstrap` object, matching the shape Bitwarden's clients - already expect from `bitwarden/server#6892`. -3. **Adds the `/api/sso-cookie-vendor` endpoint** that reads the proxy cookie - from the incoming request and 302-redirects to - `bitwarden://sso-cookie-vendor?=&d=1`. -4. **Validates config at startup**: if `SSO_COOKIE_VENDOR_ENABLED=true` but - any of the three string fields is empty, Vaultwarden refuses to start with - a clear error message. - -The endpoint is only registered when the feature is enabled, so disabled -installs behave exactly as before — no new attack surface. - -### Sharded cookie support - -Cloudflare Access can split its auth JWT across multiple cookies when the JWT -grows past browser size limits (`CF_Authorization-0`, `CF_Authorization-1`, -…). The endpoint checks for up to 20 shards (`{name}-0` through `{name}-19`) -and forwards all present shards in a single deep link. A non-sharded cookie, -if present, takes precedence (matching upstream Bitwarden's semantics). - -### Why this belongs in the server and not in a reverse-proxy shim - -The original workaround for Cloudflare Access users was a small Cloudflare -Worker that intercepted `/api/config` and `/api/sso-cookie-vendor` and -injected the same behavior. That works, but: +Putting Vaultwarden behind an authenticating proxy means only users who pass +your identity provider (IdP) reach the vault at all. Bots cannot crawl the +endpoint, credential stuffing never reaches the login form, and the exposed +surface shrinks to the proxy. + +The cost is that the Bitwarden mobile and desktop apps can no longer finish +logging in. The proxy expects a browser with a cookie jar and OAuth 2.0 redirect +support, and the apps' HTTP clients have neither. After the browser step, the +apps receive the proxy's HTML login page where they expect JSON from +Vaultwarden, and login stalls. + +Bitwarden solved this in their own server in February 2026 with a flow called +SSO cookie vending: + +1. The server states in `/api/config` that it sits behind an authenticating + proxy, and names the IdP login URL and the cookie to look for. +2. The app opens a system browser at that IdP login URL. +3. After the user authenticates, the proxy sets its cookie and the browser + reaches `/api/sso-cookie-vendor`. +4. The server reads the cookie and redirects the browser to a `bitwarden://` + deep link carrying it. +5. The app attaches that cookie to every later API request, and the proxy lets + those requests through. + +Vaultwarden 2026.2.0 shipped the web-vault connector page from +[bitwarden/clients#18476][pr-18476], but not the two server-side pieces the flow +needs: the `/api/sso-cookie-vendor` endpoint and the `communication.bootstrap` +block in `/api/config`. Without them the apps detect the connector page, open a +browser, complete the proxy's authentication, and then get a 404 when they try +to collect the cookie. This change adds both pieces. + +## What this adds + +A configuration section, `sso_cookie_vendor`, holding four settings: + +| Setting | Purpose | +|---|---| +| `SSO_COOKIE_VENDOR_ENABLED` | Turns the feature on. Defaults to `false`. | +| `SSO_COOKIE_VENDOR_IDP_LOGIN_URL` | URL the app opens in a browser to authenticate. | +| `SSO_COOKIE_VENDOR_COOKIE_NAME` | Name of the cookie the proxy sets on authenticated requests. | +| `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` | Domain scope of that cookie. | + +On top of those settings, this change: + +- Publishes the three string settings in `/api/config` as a + `communication.bootstrap` object, in the shape Bitwarden's clients already + read from [bitwarden/server#6892][pr-6892]. +- Serves `GET /api/sso-cookie-vendor`, which reads the proxy's cookie from the + request and returns a 302 to + `bitwarden://sso-cookie-vendor?COOKIE_NAME=COOKIE_VALUE&d=1`. Replace + `COOKIE_NAME` and `COOKIE_VALUE` with your configured cookie name and its + percent-encoded value; `d=1` is the sentinel the clients look for. +- Refuses to start when `SSO_COOKIE_VENDOR_ENABLED` is `true` and any of the + three string settings is empty, and reports which ones to set. + +The route is registered only when the feature is on. With +`SSO_COOKIE_VENDOR_ENABLED=false`, Vaultwarden serves exactly the routes it +served before. + +### Sharded cookies + +Cloudflare Access splits its auth JWT across numbered cookies, such as +`CF_Authorization-0` and `CF_Authorization-1`, when the token outgrows the +per-cookie size limit. The endpoint looks for up to 20 shards, `-0` through +`-19`, and forwards every shard it finds in one deep link, in ascending order, +so the app can reassemble the token. An unsuffixed cookie takes precedence over +any shards, matching the Bitwarden server. + +### Why this belongs in the server + +The existing workaround for Cloudflare Access users is a Cloudflare Worker that +intercepts `/api/config` and `/api/sso-cookie-vendor` and supplies the same +behavior. That works, with three drawbacks: - Every user behind Cloudflare Access has to deploy and maintain a Worker. -- A Worker only helps Cloudflare Access users — Authentik, Authelia, - oauth2-proxy, and any other authenticating proxy that drops a cookie can - use the exact same flow, but each would need its own shim. -- The `communication.bootstrap` block is a first-class feature of Bitwarden's - `/api/config` contract — it should come from the server, not a proxy layer. +- A Worker helps only Cloudflare Access users. Authentik, Authelia, + oauth2-proxy, and any other authenticating proxy that sets a cookie can use + the same flow, but each one needs its own shim. +- `communication.bootstrap` is part of Bitwarden's `/api/config` contract, so it + belongs in the server rather than in a proxy layer. -Putting the logic in Vaultwarden makes any authenticating proxy work with -native clients just by flipping four env vars. +Implementing it in Vaultwarden makes any authenticating proxy work with the +mobile and desktop apps once you set four environment variables. -## How to enable it +## Configure the endpoint -In your `.env` (or `config.json`, or the admin UI): +Set the four settings in `.env`, in `config.json`, or through the admin panel: ```bash SSO_COOKIE_VENDOR_ENABLED=true @@ -94,84 +101,97 @@ SSO_COOKIE_VENDOR_COOKIE_NAME=CF_Authorization SSO_COOKIE_VENDOR_COOKIE_DOMAIN=vault.example.com ``` -### Cloudflare Access specifics - -`SSO_COOKIE_VENDOR_IDP_LOGIN_URL` is the "Access Login URL" shown on the -application's details page (format: -`https://.cloudflareaccess.com/cdn-cgi/access/login/`). -`SSO_COOKIE_VENDOR_COOKIE_NAME` is always `CF_Authorization` for Cloudflare -Access. `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` is the domain your Access -application protects. - -### Other proxies (Authentik, Authelia, oauth2-proxy, …) - -Any reverse proxy that (a) redirects unauthenticated requests to a -browser-based IdP flow, and (b) sets a cookie on the authenticated response, -will work. Set `SSO_COOKIE_VENDOR_IDP_LOGIN_URL` to the proxy's login URL -and `SSO_COOKIE_VENDOR_COOKIE_NAME` / `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` to -the cookie your proxy sets on authenticated sessions. - -## End-to-end flow (what the user sees) - -1. User opens the Bitwarden app and points it at their Vaultwarden server. -2. App fetches `/api/config`, sees `communication.bootstrap.type == "ssoCookieVendor"`, - and knows to use the cookie-vending flow. -3. App shows a "sync your browser" prompt and opens the system browser at - `idpLoginUrl`. -4. Browser is redirected through the IdP (Google, GitHub, Okta, …). User - authenticates. -5. Proxy sets its auth cookie on the response and redirects the browser to +### Cloudflare Access + +- `SSO_COOKIE_VENDOR_IDP_LOGIN_URL` is the Access Login URL on the + application's details page. It takes the form + `https://TEAM.cloudflareaccess.com/cdn-cgi/access/login/YOUR_DOMAIN`, where + `TEAM` is your Cloudflare Zero Trust team name and `YOUR_DOMAIN` is the + hostname the Access application protects. +- `SSO_COOKIE_VENDOR_COOKIE_NAME` is always `CF_Authorization`. +- `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` is the domain the Access application + protects. + +### Other authenticating proxies + +Any proxy works that redirects unauthenticated requests to a browser-based IdP +flow and sets a cookie on the authenticated response. Set +`SSO_COOKIE_VENDOR_IDP_LOGIN_URL` to the proxy's login URL, and set +`SSO_COOKIE_VENDOR_COOKIE_NAME` and `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` to the +cookie the proxy sets on authenticated sessions. + +Note: Cloudflare Access is the only proxy this has run against in production. +The others meet the requirements above, but no one has reported a tested +configuration for them yet. + +## How the login flow runs + +From the user's side, with the feature configured: + +1. The user opens the Bitwarden app and points it at their Vaultwarden server. +2. The app reads `/api/config`, finds + `communication.bootstrap.type` set to `ssoCookieVendor`, and switches to the + cookie vending flow. +3. The app prompts the user to sign in through their browser and opens the + system browser at `SSO_COOKIE_VENDOR_IDP_LOGIN_URL`. +4. The browser follows the proxy to the IdP. The user authenticates. +5. The proxy sets its auth cookie and sends the browser to `/api/sso-cookie-vendor`. -6. Vaultwarden receives the request, pulls the cookie out of the jar, and - 302-redirects the browser to - `bitwarden://sso-cookie-vendor?CF_Authorization=&d=1`. -7. The OS hands the deep link back to the Bitwarden app. -8. App stores the cookie value and attaches it to every subsequent API - request. The proxy sees the cookie, lets the request through, and the app - continues with the normal Bitwarden master-password unlock. - -No app-side modifications are required — this uses the cookie-vending support -Bitwarden's clients already ship. - -## Security considerations - -- The endpoint is only registered when `SSO_COOKIE_VENDOR_ENABLED=true`. - Default-off installs are byte-identical to current behavior. -- The endpoint **reads the cookie from an already-authenticated request** — - the proxy has already validated the IdP session before the request ever - reaches Vaultwarden. No new authentication boundary is introduced. -- The deep-link response never crosses a trust boundary the browser wasn't - already on: the browser holds the same cookie, the app holds the same - cookie, the proxy validates the same cookie. -- Vaultwarden's own authentication (master password) is still required after - the proxy gate — this feature does not weaken the vault. -- Deep-link length is capped at 8192 bytes to match the upstream Bitwarden - limit; oversize requests return HTTP 400 with the standard error page. -- Missing/empty cookie returns HTTP 404 with the upstream-compatible error - page telling the user to return to the app. - -## Testing - -Unit tests live inline in `src/api/core/sso_cookie_vendor.rs` under the usual -`#[cfg(test)] mod tests` pattern. They cover: - -- Single-cookie happy path -- Sharded cookies (ordered 0..19) -- Single cookie takes precedence over shards when both are present -- Missing cookie → 404 -- URL-encoding of cookie values with spaces and special characters -- Oversize URI handling -- Error-page HTML matches the upstream Bitwarden format - -Run with `cargo test --features sqlite -- sso_cookie_vendor`. +6. Vaultwarden reads the cookie off the request and redirects the browser to + `bitwarden://sso-cookie-vendor?CF_Authorization=COOKIE_VALUE&d=1`. +7. The operating system hands the deep link to the Bitwarden app. +8. The app stores the cookie and sends it with every later API request. The + proxy recognizes the cookie, lets the request through, and the app continues + to the usual master password unlock. + +The apps need no changes. This uses the cookie vending support Bitwarden's +clients already ship. + +## Security notes + +- The endpoint exists only when `SSO_COOKIE_VENDOR_ENABLED` is `true`. An + install that leaves the feature off serves the same routes it served before. +- The endpoint reads a cookie from a request the proxy has already + authenticated. The proxy validates the IdP session before the request reaches + Vaultwarden, so this adds no authentication boundary of its own. +- The redirect moves the cookie between two parties that both already hold it: + the browser that received it and the app on the same device. The proxy + validates the same cookie in either case. +- Vaultwarden's own authentication still applies. The user unlocks the vault + with their master password after the proxy gate, so this does not weaken the + vault. +- The deep link is capped at 8192 bytes, matching the Bitwarden server. A + request that would exceed the cap gets a 400 and an HTML error page. +- A request carrying neither the cookie nor any shard gets a 404 and the same + error page, which tells the user to return to the app. + +## Test the change + +Unit tests live in `src/api/core/sso_cookie_vendor.rs` under `#[cfg(test)] mod +tests`. Run them with: + +```bash +cargo test --features sqlite -- sso_cookie_vendor +``` + +They cover: + +- A single cookie, the common case. +- Sharded cookies, forwarded in suffix order regardless of map iteration order. +- An unsuffixed cookie taking precedence when shards are also present. +- A missing cookie producing a 404. +- Percent-encoding of values holding spaces and reserved characters. +- An oversize cookie producing a link past the 8192-byte cap. +- The error page matching the Bitwarden server's HTML. ## References -- [bitwarden/server#6880][pr-6880] — Config infrastructure -- [bitwarden/server#6892][pr-6892] — Expose config in `/api/config` -- [bitwarden/server#6903][pr-6903] — Endpoint implementation -- [bitwarden/clients#18476][pr-18476] — Web-vault connector page (already in Vaultwarden v2026.2.0) -- [bitwarden/clients#19392][pr-19392] — Client-side cookie acquisition +- [bitwarden/server#6880][pr-6880]: configuration infrastructure. +- [bitwarden/server#6892][pr-6892]: exposing the configuration in `/api/config`. +- [bitwarden/server#6903][pr-6903]: the endpoint implementation. +- [bitwarden/clients#18476][pr-18476]: the web-vault connector page, shipped in + Vaultwarden 2026.2.0. +- [bitwarden/clients#19392][pr-19392]: client-side cookie acquisition. [pr-6880]: https://github.com/bitwarden/server/pull/6880 [pr-6892]: https://github.com/bitwarden/server/pull/6892 diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index a7ecc54e..aa51e249 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -52,6 +52,8 @@ pub fn routes() -> Vec { routes.append(&mut hibp_routes); routes.append(&mut meta_routes); + // Mounted only when the feature is on, so that installs without an authenticating proxy in + // front of them keep answering /api/sso-cookie-vendor with the standard 404. if CONFIG.sso_cookie_vendor_enabled() { routes.append(&mut sso_cookie_vendor::routes()); } @@ -226,6 +228,10 @@ fn config() -> Json { ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Tells clients whether reaching this server takes extra work beyond a plain HTTPS request. + // A populated bootstrap block sends the Bitwarden apps through the SSO cookie vending flow in + // api::core::sso_cookie_vendor; null means the server is reachable directly. + // See: https://github.com/bitwarden/server/pull/6892 let communication = if CONFIG.sso_cookie_vendor_enabled() { json!({ "bootstrap": { diff --git a/src/api/core/sso_cookie_vendor.rs b/src/api/core/sso_cookie_vendor.rs index 2b547bb5..a0f61eac 100644 --- a/src/api/core/sso_cookie_vendor.rs +++ b/src/api/core/sso_cookie_vendor.rs @@ -1,3 +1,23 @@ +//! SSO cookie vending for the Bitwarden mobile and desktop apps behind an authenticating proxy. +//! +//! When Vaultwarden runs behind a reverse proxy that gates every request on an identity provider +//! (Cloudflare Access, Authentik, Authelia, oauth2-proxy), the Bitwarden mobile and desktop apps +//! cannot finish logging in. The proxy answers their API calls with a browser redirect to the +//! identity provider, and their HTTP clients have no cookie jar or browser to follow it with. +//! +//! Bitwarden's answer is cookie vending. The server advertises the flow through the +//! `communication.bootstrap` object in `/api/config`, the app opens a system browser at the +//! identity provider, and the browser lands on the route in this module once the proxy has set its +//! auth cookie. The route hands that cookie back to the app as a `bitwarden://` deep link, and the +//! app attaches it to every later API request so the proxy lets those requests through. +//! +//! Vaultwarden authenticates nobody here. The proxy is the gate, and the vault's own master +//! password unlock still runs afterwards. The route is registered only when +//! `SSO_COOKIE_VENDOR_ENABLED` is true, so installs that have not opted in are unaffected. +//! +//! This is the server half of bitwarden/server#6880, #6892, and #6903. For operator-facing setup, +//! see `docs/sso-cookie-vendor.md`. + use std::collections::HashMap; use rocket::{ @@ -8,18 +28,30 @@ use rocket::{ use crate::CONFIG; -/// Maximum allowed length for the redirect URI. -/// Matches the official Bitwarden server limit. +/// Maximum length of the `bitwarden://` deep link, in bytes. +/// +/// Matches the limit the Bitwarden server enforces, so an oversize token fails the same way on +/// both servers instead of producing a link the app or the operating system truncates silently. const MAX_REDIRECT_URI_LENGTH: usize = 8192; -/// Maximum number of sharded cookie suffixes to check (0 through 19). +/// Number of sharded cookie suffixes to look for, `-0` through `-19`. +/// +/// Cloudflare Access splits its auth JWT across numbered cookies when the token outgrows the +/// per-cookie size limit, so reading only the unsuffixed name would miss the token entirely. const MAX_SHARD_COUNT: usize = 20; +/// Returns the routes this module serves. +/// +/// The caller in `api::core::routes` invokes this only when `SSO_COOKIE_VENDOR_ENABLED` is true, +/// so the endpoint does not exist on installs that leave the feature off. pub fn routes() -> Vec { routes![sso_cookie_vendor] } -/// Error HTML response matching the official Bitwarden server format. +/// Returns the HTML error page for `status_code`, in the format the Bitwarden server uses. +/// +/// A browser renders this response, not an API client, so the page tells the user to return to the +/// app rather than describing why the lookup failed. fn error_html(status_code: u16) -> Html { Html(format!( "Error\ @@ -27,13 +59,22 @@ fn error_html(status_code: u16) -> Html { )) } -/// GET /sso-cookie-vendor +/// Vends the reverse proxy's auth cookie to the calling app as a `bitwarden://` deep link. +/// +/// The browser arrives at `GET /api/sso-cookie-vendor` after the proxy has authenticated the user, +/// so the request already carries the proxy's cookie. The response is a 302 to +/// `bitwarden://sso-cookie-vendor?=&d=1`, which the operating system hands to +/// the Bitwarden app. The `d=1` parameter is the sentinel Bitwarden's clients look for. /// -/// This endpoint is called after the user authenticates through the reverse proxy. -/// It reads the proxy auth cookie from the request and redirects the native client -/// to a bitwarden:// deep link containing the cookie value. +/// # Errors /// -/// No Bitwarden authentication is required — the proxy handles auth. +/// Every failure renders an HTML page rather than a JSON body, because a browser displays it: +/// +/// - 500 when `SSO_COOKIE_VENDOR_COOKIE_NAME` is empty. Config validation rejects that combination +/// at startup and on admin-panel updates, so reaching it means the config was bypassed. +/// - 404 when the request carries neither the cookie nor any of its shards. This is the response +/// an unconfigured Bitwarden server gives, and the clients already handle it. +/// - 400 when the deep link would exceed `MAX_REDIRECT_URI_LENGTH`. #[get("/sso-cookie-vendor")] fn sso_cookie_vendor(cookies: &CookieJar<'_>) -> Result)> { let cookie_name = CONFIG.sso_cookie_vendor_cookie_name(); @@ -42,22 +83,23 @@ fn sso_cookie_vendor(cookies: &CookieJar<'_>) -> Result MAX_REDIRECT_URI_LENGTH { return Err((Status::BadRequest, error_html(400))); } @@ -65,18 +107,21 @@ fn sso_cookie_vendor(cookies: &CookieJar<'_>) -> Result) -> Result)> { - // Check for the single (non-sharded) cookie — takes precedence over shards if let Some(value) = cookies.get(cookie_name) { let encoded_value = url_encode(value); return Ok(format!("bitwarden://sso-cookie-vendor?{cookie_name}={encoded_value}&d=1")); } - // Check for sharded cookies: {name}-0, {name}-1, ..., {name}-19 let mut shards: Vec<(String, String)> = Vec::new(); for i in 0..MAX_SHARD_COUNT { let shard_name = format!("{cookie_name}-{i}"); @@ -93,13 +138,20 @@ fn build_redirect_uri(cookie_name: &str, cookies: &HashMap) -> R Ok(format!("bitwarden://sso-cookie-vendor?{}&d=1", params.join("&"))) } -/// URL-encode a cookie value using percent-encoding for the query string. +/// Percent-encodes a cookie value for the deep link's query string. +/// +/// Uses `application/x-www-form-urlencoded` serialization so a value containing `&` or `=` cannot +/// be read by the receiving app as an extra query parameter. fn url_encode(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() } #[cfg(test)] mod tests { + //! The tests drive `build_redirect_uri` directly rather than the route, because the route needs a + //! live `CookieJar` and the configured cookie name. The status codes the route maps these + //! results to are documented on `sso_cookie_vendor`. + use super::*; #[test] @@ -167,7 +219,6 @@ mod tests { #[test] fn test_single_cookie_preferred_over_shards() { let mut cookies = HashMap::new(); - // Add both single and sharded cookies cookies.insert("CF_Authorization".to_string(), "single_value".to_string()); cookies.insert("CF_Authorization-0".to_string(), "shard0".to_string()); cookies.insert("CF_Authorization-1".to_string(), "shard1".to_string()); @@ -175,7 +226,7 @@ mod tests { let result = build_redirect_uri("CF_Authorization", &cookies); assert!(result.is_ok()); let uri = result.unwrap(); - // Single cookie should take precedence — no shards in the URI + // The unsuffixed cookie wins, and no shard reaches the link. assert_eq!(uri, "bitwarden://sso-cookie-vendor?CF_Authorization=single_value&d=1"); assert!(!uri.contains("CF_Authorization-0")); } @@ -192,16 +243,16 @@ mod tests { } #[test] - fn test_uri_too_long_returns_400() { + fn test_oversize_cookie_exceeds_uri_limit() { let mut cookies = HashMap::new(); - // Create a very long cookie value that will exceed MAX_REDIRECT_URI_LENGTH + // A cookie long enough to push the finished link past the cap. let long_value = "x".repeat(MAX_REDIRECT_URI_LENGTH + 1); cookies.insert("CF_Authorization".to_string(), long_value); let result = build_redirect_uri("CF_Authorization", &cookies); assert!(result.is_ok()); let uri = result.unwrap(); - // The URI exceeds the limit — the caller (sso_cookie_vendor handler) checks this + // Building succeeds. The 400 comes from `sso_cookie_vendor`, which applies the cap. assert!(uri.len() > MAX_REDIRECT_URI_LENGTH); } @@ -220,7 +271,7 @@ mod tests { #[test] fn test_sharded_cookies_ordered() { let mut cookies = HashMap::new(); - // Insert in non-sequential order to verify ordering + // Inserted out of order: the link must still come back in suffix order. cookies.insert("CF_Authorization-2".to_string(), "part2".to_string()); cookies.insert("CF_Authorization-0".to_string(), "part0".to_string()); cookies.insert("CF_Authorization-1".to_string(), "part1".to_string()); @@ -228,7 +279,7 @@ mod tests { let result = build_redirect_uri("CF_Authorization", &cookies); assert!(result.is_ok()); let uri = result.unwrap(); - // Shards should appear in order 0, 1, 2 regardless of insertion order + // Shards appear as 0, 1, 2 whatever order the map yields them in. let q = uri.find("CF_Authorization-0").unwrap(); let r = uri.find("CF_Authorization-1").unwrap(); let s = uri.find("CF_Authorization-2").unwrap(); diff --git a/src/config.rs b/src/config.rs index 8ad59707..2907dc07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -851,13 +851,17 @@ make_config! { /// SSO Cookie Vendor settings sso_cookie_vendor { - /// Enabled |> Enable the SSO cookie vendor endpoint for native app support behind authenticating reverse proxies + /// Enabled |> Serve `/api/sso-cookie-vendor` and advertise the flow in `/api/config` + /// Set this when Vaultwarden sits behind a reverse proxy that authenticates every request, such as + /// Cloudflare Access, Authentik, Authelia, or oauth2-proxy. Without it the Bitwarden mobile and desktop + /// apps cannot finish logging in, because the proxy answers their API calls with a browser redirect they + /// cannot follow. The three settings below are required while this is enabled. sso_cookie_vendor_enabled: bool, true, def, false; - /// IdP Login URL |> The URL the client should navigate to for IdP authentication (e.g. Cloudflare Access login URL) + /// IdP Login URL |> URL the app opens in a browser to authenticate, for example, the Cloudflare Access login URL for this vault sso_cookie_vendor_idp_login_url: String, true, def, String::new(); - /// Cookie Name |> The name of the cookie set by the authenticating reverse proxy (e.g. CF_Authorization) + /// Cookie Name |> Name of the cookie the proxy sets on authenticated requests, for example, `CF_Authorization` sso_cookie_vendor_cookie_name: String, true, def, String::new(); - /// Cookie Domain |> The domain scope of the proxy auth cookie (e.g. vault.example.com) + /// Cookie Domain |> Domain scope of the proxy auth cookie, for example, `vault.example.com` sso_cookie_vendor_cookie_domain: String, true, def, String::new(); }, @@ -1128,13 +1132,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } + // Refuse a half-configured cookie vendor: with any of these blank the endpoint would hand + // clients a bootstrap block they cannot act on, or answer with a 500. if cfg.sso_cookie_vendor_enabled && (cfg.sso_cookie_vendor_idp_login_url.is_empty() || cfg.sso_cookie_vendor_cookie_name.is_empty() || cfg.sso_cookie_vendor_cookie_domain.is_empty()) { err!( - "`SSO_COOKIE_VENDOR_IDP_LOGIN_URL`, `SSO_COOKIE_VENDOR_COOKIE_NAME` and `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` must be set when SSO cookie vendor is enabled" + "`SSO_COOKIE_VENDOR_IDP_LOGIN_URL`, `SSO_COOKIE_VENDOR_COOKIE_NAME`, and `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` must be set when `SSO_COOKIE_VENDOR_ENABLED` is true" ) }