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" ) }