Browse Source
Lets the Bitwarden mobile and desktop apps log in when Vaultwarden sits behind a reverse proxy that authenticates every request, such as Cloudflare Access. This is the server side of Bitwarden's SSO cookie vending flow (bitwarden/server#6892 and #6903). - Advertise the flow in `/api/config` under `communication.bootstrap`. - Add `GET /api/sso-cookie-vendor`, which redirects to a `bitwarden://sso-cookie-vendor` deep link carrying the proxy's auth cookie, including sharded `<name>-0..19` cookies. - Configure through `SSO_COOKIE_VENDOR_*`. These are read-only in the admin panel, because the route is only registered at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>pull/7127/head
4 changed files with 246 additions and 4 deletions
@ -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.")); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue