diff --git a/.env.template b/.env.template index 2d5c5d8e..c3f70cf5 100644 --- a/.env.template +++ b/.env.template @@ -575,6 +575,25 @@ ## Log all the tokens, LOG_LEVEL=debug is required # SSO_DEBUG_TOKENS=false +################################## +### SSO Cookie Vendor settings ### +################################## + +## Let the Bitwarden mobile and desktop apps log in when Vaultwarden is behind a +## reverse proxy that authenticates every request, such as Cloudflare Access. +## The three settings below are required when this is enabled. +## These can only be set as environment variables, and changes require a restart. +# SSO_COOKIE_VENDOR_ENABLED=false + +## URL the apps open in a browser to authenticate with the proxy. +# SSO_COOKIE_VENDOR_IDP_LOGIN_URL=https://example.cloudflareaccess.com/cdn-cgi/access/login/vault.example.com + +## Name of the auth cookie set by the proxy. +# SSO_COOKIE_VENDOR_COOKIE_NAME=CF_Authorization + +## Domain of the auth cookie set by the proxy. +# SSO_COOKIE_VENDOR_COOKIE_DOMAIN=vault.example.com + ######################## ### MFA/2FA settings ### ######################## diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index b7923982..b4d1db57 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,11 @@ pub fn routes() -> Vec { routes.append(&mut hibp_routes); routes.append(&mut meta_routes); + // Only evaluated at startup, which is why the SSO_COOKIE_VENDOR_* settings are not editable in the admin panel + if CONFIG.sso_cookie_vendor_enabled() { + routes.append(&mut sso_cookie_vendor::routes()); + } + routes } @@ -221,6 +227,21 @@ fn config() -> Json { ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Used by clients to learn if the server requires extra work to establish a connection. + // 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 @@ -249,16 +270,13 @@ fn config() -> Json { "cloudRegion": null, "fillAssistRules": CONFIG.fill_assist_rules_url(), }, + "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..3c4cb6ce --- /dev/null +++ b/src/api/core/sso_cookie_vendor.rs @@ -0,0 +1,182 @@ +//! SSO cookie vending, which lets the Bitwarden apps log in through an authenticating reverse proxy. +//! +//! The app opens a browser at the proxy's login page, and once the proxy has set its auth cookie the +//! browser lands on this route. The route hands that cookie back to the app via a `bitwarden://` deep +//! link, which the app then sends with every request. +//! +//! Port of the Bitwarden server implementation: https://github.com/bitwarden/server/pull/6903 + +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; +use rocket::{ + Route, + http::{CookieJar, Status}, + response::{Redirect, content::RawHtml as Html}, +}; + +use crate::CONFIG; + +pub fn routes() -> Vec { + routes![sso_cookie_vendor] +} + +/// Maximum length of the deep link, same as upstream. +const MAX_REDIRECT_URI_LENGTH: usize = 8192; + +/// Large tokens get split into `-0` through `-19` (Cloudflare Access does this). +const MAX_SHARD_COUNT: usize = 20; + +/// Everything except the RFC 3986 unreserved characters, matching .NET's `Uri.EscapeDataString`. +const QUERY_VALUE: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~'); + +fn error_html(status_code: u16) -> Html { + Html(format!( + "Error\ +

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

" + )) +} + +#[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))); + } + + let Some(redirect_uri) = build_redirect_uri(&cookie_name, |name| cookies.get(name).map(|c| c.value().to_owned())) + else { + return Err((Status::NotFound, error_html(404))); + }; + + if redirect_uri.len() > MAX_REDIRECT_URI_LENGTH { + return Err((Status::BadRequest, error_html(400))); + } + + Ok(Redirect::found(redirect_uri)) +} + +/// Builds the deep link from the unsuffixed cookie, or else from its shards. +/// Empty values are treated as missing, and shards are read in order up to the first missing one. +/// Returns `None` when no usable cookie was found. +fn build_redirect_uri(cookie_name: &str, get_cookie: impl Fn(&str) -> Option) -> Option { + let non_empty = |name: &str| get_cookie(name).filter(|v| !v.is_empty()); + + let mut params = Vec::new(); + if let Some(value) = non_empty(cookie_name) { + params.push(format!("{cookie_name}={}", encode(&value))); + } else { + for i in 0..MAX_SHARD_COUNT { + let shard_name = format!("{cookie_name}-{i}"); + let Some(value) = non_empty(&shard_name) else { + break; + }; + params.push(format!("{shard_name}={}", encode(&value))); + } + } + + if params.is_empty() { + return None; + } + + // `d=1` lets the clients detect a truncated link + params.push("d=1".to_owned()); + Some(format!("bitwarden://sso-cookie-vendor?{}", params.join("&"))) +} + +fn encode(value: &str) -> String { + utf8_percent_encode(value, QUERY_VALUE).to_string() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn build(cookies: &[(&str, &str)]) -> Option { + let cookies: HashMap = + cookies.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); + build_redirect_uri("CF_Authorization", |name| cookies.get(name).cloned()) + } + + #[test] + fn test_single_cookie() { + assert_eq!( + build(&[("CF_Authorization", "jwt_token_value")]).unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization=jwt_token_value&d=1" + ); + } + + #[test] + fn test_single_cookie_preferred_over_shards() { + assert_eq!( + build(&[("CF_Authorization", "single"), ("CF_Authorization-0", "shard0")]).unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization=single&d=1" + ); + } + + #[test] + fn test_empty_single_cookie_falls_back_to_shards() { + assert_eq!( + build(&[("CF_Authorization", ""), ("CF_Authorization-0", "shard0")]).unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization-0=shard0&d=1" + ); + } + + #[test] + fn test_sharded_cookies_in_order() { + assert_eq!( + build(&[("CF_Authorization-2", "part2"), ("CF_Authorization-0", "part0"), ("CF_Authorization-1", "part1")]) + .unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization-0=part0&CF_Authorization-1=part1&CF_Authorization-2=part2&d=1" + ); + } + + #[test] + fn test_shards_stop_at_first_gap() { + assert_eq!( + build(&[("CF_Authorization-0", "part0"), ("CF_Authorization-2", "part2")]).unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization-0=part0&d=1" + ); + } + + #[test] + fn test_shards_stop_at_first_empty() { + assert_eq!( + build(&[("CF_Authorization-0", "part0"), ("CF_Authorization-1", ""), ("CF_Authorization-2", "part2")]) + .unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization-0=part0&d=1" + ); + } + + #[test] + fn test_shards_must_start_at_zero() { + assert_eq!(build(&[("CF_Authorization-1", "part1")]), None); + } + + #[test] + fn test_no_cookie() { + assert_eq!(build(&[]), None); + assert_eq!(build(&[("CF_Authorization", "")]), None); + } + + #[test] + fn test_value_encoding_matches_escape_data_string() { + assert_eq!( + build(&[("CF_Authorization", "a b&c=d+e/f~g_h-i.j")]).unwrap(), + "bitwarden://sso-cookie-vendor?CF_Authorization=a%20b%26c%3Dd%2Be%2Ff~g_h-i.j&d=1" + ); + } + + #[test] + fn test_redirect_uri_is_valid_for_rocket() { + // `Redirect` turns an unparsable URI into a 500, so make sure every character we emit is accepted + let all_ascii: String = (0x20u8..0x7f).map(char::from).collect(); + let uri = build(&[("CF_Authorization-0", &all_ascii), ("CF_Authorization-1", "ünïcødé")]).unwrap(); + assert!(rocket::http::uri::Reference::parse_owned(uri).is_ok()); + } + + #[test] + fn test_error_html() { + assert!(error_html(404).0.contains("Error code 404. Please return to the Bitwarden app and try again.")); + } +} diff --git a/src/config.rs b/src/config.rs index a565a93e..16b2351b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -879,6 +879,17 @@ make_config! { sso_debug_tokens: bool, true, def, false; }, + sso_cookie_vendor { + /// SSO cookie vendor enabled |> Allow the Bitwarden apps to log in through an authenticating reverse proxy + sso_cookie_vendor_enabled: bool, false, def, false; + /// SSO cookie vendor IdP login URL |> URL the apps open in a browser to authenticate with the proxy + sso_cookie_vendor_idp_login_url: String, false, def, String::new(); + /// SSO cookie vendor cookie name |> Name of the auth cookie set by the proxy, for example `CF_Authorization` + sso_cookie_vendor_cookie_name: String, false, def, String::new(); + /// SSO cookie vendor cookie domain |> Domain of the auth cookie set by the proxy + sso_cookie_vendor_cookie_domain: String, false, def, String::new(); + }, + /// Yubikey settings yubico: _enable_yubico { /// Enabled @@ -1165,6 +1176,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")