diff --git a/.env.template b/.env.template index 5f6f374c..3af9c15d 100644 --- a/.env.template +++ b/.env.template @@ -560,6 +560,32 @@ ## Log all the tokens, LOG_LEVEL=debug is required # SSO_DEBUG_TOKENS=false +########################################## +### SSO Cookie Vendor settings ### +########################################## + +## 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 + +## 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 + +## Name of the cookie the proxy sets on authenticated requests. Cloudflare Access +## always uses CF_Authorization. +# SSO_COOKIE_VENDOR_COOKIE_NAME=CF_Authorization + +## Domain scope of the proxy auth cookie, which is the domain the proxy protects. +# SSO_COOKIE_VENDOR_COOKIE_DOMAIN=vault.example.com + ######################## ### MFA/2FA settings ### ######################## diff --git a/docs/sso-cookie-vendor.md b/docs/sso-cookie-vendor.md new file mode 100644 index 00000000..2cba5bbc --- /dev/null +++ b/docs/sso-cookie-vendor.md @@ -0,0 +1,200 @@ +# 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 + +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 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. + +Implementing it in Vaultwarden makes any authenticating proxy work with the +mobile and desktop apps once you set four environment variables. + +## Configure the endpoint + +Set the four settings in `.env`, in `config.json`, or through the admin panel: + +```bash +SSO_COOKIE_VENDOR_ENABLED=true +SSO_COOKIE_VENDOR_IDP_LOGIN_URL=https://example.cloudflareaccess.com/cdn-cgi/access/login/vault.example.com +SSO_COOKIE_VENDOR_COOKIE_NAME=CF_Authorization +SSO_COOKIE_VENDOR_COOKIE_DOMAIN=vault.example.com +``` + +### 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 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]: 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 +[pr-6903]: https://github.com/bitwarden/server/pull/6903 +[pr-18476]: https://github.com/bitwarden/clients/pull/18476 +[pr-19392]: https://github.com/bitwarden/clients/pull/19392 diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..aa51e249 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -8,6 +8,7 @@ mod folders; mod organizations; mod public; mod sends; +mod sso_cookie_vendor; pub use accounts::purge_auth_requests; pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers}; @@ -51,6 +52,12 @@ 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()); + } + routes } @@ -221,6 +228,23 @@ 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": { + "type": "ssoCookieVendor", + "idpLoginUrl": CONFIG.sso_cookie_vendor_idp_login_url(), + "cookieName": CONFIG.sso_cookie_vendor_cookie_name(), + "cookieDomain": CONFIG.sso_cookie_vendor_cookie_domain(), + } + }) + } else { + json!(null) + }; + Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns // This means they expect a version that closely matches the Bitwarden server version @@ -248,16 +272,13 @@ fn config() -> Json { "sso": "", "cloudRegion": null, }, + "communication": communication, // Bitwarden uses this for the self-hosted servers to indicate the default push technology "push": { "pushTechnology": 0, "vapidPublicKey": null }, "featureStates": feature_states, - // Not supported right now - // Used for by clients to learn if the server requires extra work to establish a connection. - // See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0 - "communication": null, "object": "config", })) } diff --git a/src/api/core/sso_cookie_vendor.rs b/src/api/core/sso_cookie_vendor.rs new file mode 100644 index 00000000..a0f61eac --- /dev/null +++ b/src/api/core/sso_cookie_vendor.rs @@ -0,0 +1,299 @@ +//! 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::{ + Route, + http::{CookieJar, Status}, + response::{Redirect, content::RawHtml as Html}, +}; + +use crate::CONFIG; + +/// 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; + +/// 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] +} + +/// 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\ +

Error code {status_code}. Please return to the Bitwarden app and try again.

" + )) +} + +/// 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. +/// +/// # Errors +/// +/// 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(); + + if cookie_name.is_empty() { + return Err((Status::InternalServerError, error_html(500))); + } + + // Copy the relevant cookies out of the jar so that link building stays a plain function over a + // map, which the tests below can drive without standing up a request. + let mut cookie_map = HashMap::new(); + if let Some(cookie) = cookies.get(&cookie_name) { + cookie_map.insert(cookie_name.clone(), cookie.value().to_owned()); + } + for i in 0..MAX_SHARD_COUNT { + let shard_name = format!("{cookie_name}-{i}"); + if let Some(cookie) = cookies.get(&shard_name) { + cookie_map.insert(shard_name, cookie.value().to_owned()); + } + } + + let redirect_uri = build_redirect_uri(&cookie_name, &cookie_map)?; + + // Measured on the finished link rather than on the raw cookie, because percent-encoding and + // the shard names both count against what the app has to receive. + if redirect_uri.len() > MAX_REDIRECT_URI_LENGTH { + return Err((Status::BadRequest, error_html(400))); + } + + Ok(Redirect::found(redirect_uri)) +} + +/// Builds the `bitwarden://` deep link from the cookies found on the request. +/// +/// An unsuffixed cookie wins over any shards, matching the Bitwarden server. When only shards are +/// present, every shard found is forwarded in ascending suffix order so the app can reassemble the +/// token. The counting loop, not the map's iteration order, is what makes that order deterministic. +/// +/// # Errors +/// +/// Returns 404 and an HTML error page when neither the unsuffixed cookie nor any shard is present. +fn build_redirect_uri(cookie_name: &str, cookies: &HashMap) -> Result)> { + 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")); + } + + let mut shards: Vec<(String, String)> = Vec::new(); + for i in 0..MAX_SHARD_COUNT { + let shard_name = format!("{cookie_name}-{i}"); + if let Some(value) = cookies.get(&shard_name) { + shards.push((shard_name, url_encode(value))); + } + } + + if shards.is_empty() { + return Err((Status::NotFound, error_html(404))); + } + + let params: Vec = shards.into_iter().map(|(name, value)| format!("{name}={value}")).collect(); + Ok(format!("bitwarden://sso-cookie-vendor?{}&d=1", params.join("&"))) +} + +/// 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] + fn test_url_encode_simple() { + assert_eq!(url_encode("abc123"), "abc123"); + } + + #[test] + fn test_url_encode_special_chars() { + let encoded = url_encode("eyJhbGci.test=value&other"); + assert!(encoded.contains("eyJhbGci.test")); + assert!(encoded.contains("%3D")); + assert!(encoded.contains("%26")); + } + + #[test] + fn test_error_html_format() { + let html = error_html(404); + let content = html.0; + assert!(content.contains("")); + assert!(content.contains("Error code 404")); + assert!(content.contains("Please return to the Bitwarden app and try again.")); + } + + #[test] + fn test_error_html_500() { + let html = error_html(500); + assert!(html.0.contains("Error code 500")); + } + + #[test] + fn test_error_html_400() { + let html = error_html(400); + assert!(html.0.contains("Error code 400")); + } + + #[test] + fn test_single_cookie_found() { + let mut cookies = HashMap::new(); + cookies.insert("CF_Authorization".to_string(), "jwt_token_value".to_string()); + + let result = build_redirect_uri("CF_Authorization", &cookies); + assert!(result.is_ok()); + let uri = result.unwrap(); + assert_eq!(uri, "bitwarden://sso-cookie-vendor?CF_Authorization=jwt_token_value&d=1"); + } + + #[test] + fn test_sharded_cookies_found() { + let mut cookies = HashMap::new(); + cookies.insert("CF_Authorization-0".to_string(), "part0".to_string()); + cookies.insert("CF_Authorization-1".to_string(), "part1".to_string()); + cookies.insert("CF_Authorization-2".to_string(), "part2".to_string()); + + let result = build_redirect_uri("CF_Authorization", &cookies); + assert!(result.is_ok()); + let uri = result.unwrap(); + assert!(uri.starts_with("bitwarden://sso-cookie-vendor?")); + assert!(uri.contains("CF_Authorization-0=part0")); + assert!(uri.contains("CF_Authorization-1=part1")); + assert!(uri.contains("CF_Authorization-2=part2")); + assert!(uri.ends_with("&d=1")); + } + + #[test] + fn test_single_cookie_preferred_over_shards() { + let mut cookies = HashMap::new(); + 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()); + + let result = build_redirect_uri("CF_Authorization", &cookies); + assert!(result.is_ok()); + let uri = result.unwrap(); + // 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")); + } + + #[test] + fn test_cookie_not_found_returns_404() { + let cookies = HashMap::new(); + + let result = build_redirect_uri("CF_Authorization", &cookies); + assert!(result.is_err()); + let (status, html) = result.unwrap_err(); + assert_eq!(status, Status::NotFound); + assert!(html.0.contains("Error code 404")); + } + + #[test] + fn test_oversize_cookie_exceeds_uri_limit() { + let mut cookies = HashMap::new(); + // 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(); + // Building succeeds. The 400 comes from `sso_cookie_vendor`, which applies the cap. + assert!(uri.len() > MAX_REDIRECT_URI_LENGTH); + } + + #[test] + fn test_cookie_value_url_encoded() { + let mut cookies = HashMap::new(); + cookies.insert("CF_Authorization".to_string(), "value with spaces&special=chars".to_string()); + + let result = build_redirect_uri("CF_Authorization", &cookies); + assert!(result.is_ok()); + let uri = result.unwrap(); + assert!(!uri.contains(" ")); + assert!(uri.contains("value+with+spaces%26special%3Dchars")); + } + + #[test] + fn test_sharded_cookies_ordered() { + let mut cookies = HashMap::new(); + // 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()); + + let result = build_redirect_uri("CF_Authorization", &cookies); + assert!(result.is_ok()); + let uri = result.unwrap(); + // 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(); + assert!(q < r); + assert!(r < s); + } + + #[test] + fn test_d_sentinel_always_present() { + let mut cookies = HashMap::new(); + cookies.insert("MyAuth".to_string(), "val".to_string()); + + let result = build_redirect_uri("MyAuth", &cookies); + let uri = result.unwrap(); + assert!(uri.ends_with("&d=1")); + } +} diff --git a/src/config.rs b/src/config.rs index 87bea195..2907dc07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -849,6 +849,22 @@ make_config! { sso_debug_tokens: bool, true, def, false; }, + /// SSO Cookie Vendor settings + sso_cookie_vendor { + /// 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 |> 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 |> 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 |> Domain scope of the proxy auth cookie, for example, `vault.example.com` + sso_cookie_vendor_cookie_domain: String, true, def, String::new(); + }, + /// Yubikey settings yubico: _enable_yubico { /// Enabled @@ -1116,6 +1132,18 @@ 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_ENABLED` is true" + ) + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")