committed by
GitHub
5 changed files with 578 additions and 4 deletions
@ -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 |
||||
@ -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<Route> { |
||||
|
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<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>" |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
/// 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?<cookie-name>=<value>&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<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))); |
||||
|
} |
||||
|
|
||||
|
// 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<String, String>) -> Result<String, (Status, Html<String>)> { |
||||
|
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<String> = 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("<!DOCTYPE html>")); |
||||
|
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")); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue