Browse Source

Merge 81c2a2436f into 17b159beff

pull/7127/merge
Nathan Moreton 6 days ago
committed by GitHub
parent
commit
9e43fb37f7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 19
      .env.template
  2. 26
      src/api/core/mod.rs
  3. 182
      src/api/core/sso_cookie_vendor.rs
  4. 23
      src/config.rs

19
.env.template

@ -575,6 +575,25 @@
## Log all the tokens, LOG_LEVEL=debug is required ## Log all the tokens, LOG_LEVEL=debug is required
# SSO_DEBUG_TOKENS=false # 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 ### ### MFA/2FA settings ###
######################## ########################

26
src/api/core/mod.rs

@ -8,6 +8,7 @@ mod folders;
mod organizations; mod organizations;
mod public; mod public;
mod sends; mod sends;
mod sso_cookie_vendor;
pub use accounts::purge_auth_requests; pub use accounts::purge_auth_requests;
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers}; pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
@ -51,6 +52,11 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut hibp_routes); routes.append(&mut hibp_routes);
routes.append(&mut meta_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 routes
} }
@ -221,6 +227,21 @@ fn config() -> Json<Value> {
); );
feature_states.insert("pm-19148-innovation-archive".to_owned(), true); 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!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns
// This means they expect a version that closely matches the Bitwarden server version // This means they expect a version that closely matches the Bitwarden server version
@ -249,16 +270,13 @@ fn config() -> Json<Value> {
"cloudRegion": null, "cloudRegion": null,
"fillAssistRules": CONFIG.fill_assist_rules_url(), "fillAssistRules": CONFIG.fill_assist_rules_url(),
}, },
"communication": communication,
// Bitwarden uses this for the self-hosted servers to indicate the default push technology // Bitwarden uses this for the self-hosted servers to indicate the default push technology
"push": { "push": {
"pushTechnology": 0, "pushTechnology": 0,
"vapidPublicKey": null "vapidPublicKey": null
}, },
"featureStates": feature_states, "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", "object": "config",
})) }))
} }

182
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<Route> {
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 `<name>-0` through `<name>-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<String> {
Html(format!(
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Error</title></head>\
<body><p>Error code {status_code}. Please return to the Bitwarden app and try again.</p></body></html>"
))
}
#[get("/sso-cookie-vendor")]
fn sso_cookie_vendor(cookies: &CookieJar<'_>) -> Result<Redirect, (Status, Html<String>)> {
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<String>) -> Option<String> {
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<String> {
let cookies: HashMap<String, String> =
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."));
}
}

23
src/config.rs

@ -879,6 +879,17 @@ make_config! {
sso_debug_tokens: bool, true, def, false; 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 /// Yubikey settings
yubico: _enable_yubico { yubico: _enable_yubico {
/// Enabled /// 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())?; 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._enable_yubico {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { 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") err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")

Loading…
Cancel
Save