Browse Source
Implements the server-side pieces of Bitwarden's SSO cookie vending flow (upstream PRs bitwarden/server#6880, #6892, #6903) so the native Bitwarden mobile and desktop apps work when Vaultwarden sits behind an authenticating reverse proxy such as Cloudflare Access, Authentik, Authelia, or oauth2-proxy. Without this, users behind such a proxy can authenticate the web vault in a browser but the native apps 404 on /api/sso-cookie-vendor after the browser-assisted IdP step, leaving the app unable to acquire the proxy's auth cookie. What's added: * New config section `sso_cookie_vendor` (4 fields, default-off) driving both env-var and admin-UI configuration via the existing make_config! macro, with startup validation. * GET /api/sso-cookie-vendor — reads the proxy auth cookie from the request (including sharded variants CF_Authorization-0..19) and 302-redirects to bitwarden://sso-cookie-vendor?<cookie>=<value>&d=1 so the native app can capture and reuse the cookie. Upstream-compatible 404/400/500 HTML error pages; 8192-byte URI cap. * communication.bootstrap block in /api/config matching the shape from bitwarden/server#6892 so clients discover the flow without modification. * Unit tests covering single/sharded cookies, precedence, missing-cookie 404, URL-encoding, oversize URI, and error HTML format. See docs/sso-cookie-vendor.md for the full writeup (background, security considerations, end-to-end flow, and per-proxy configuration notes).pull/7127/head
5 changed files with 493 additions and 0 deletions
@ -0,0 +1,180 @@ |
|||
# SSO Cookie Vendor — Native App Support Behind Authenticating Reverse Proxies |
|||
|
|||
## Background |
|||
|
|||
Users of Vaultwarden frequently put it behind an authenticating reverse proxy — |
|||
most commonly **Cloudflare Access** or similar Zero Trust gateways — so that |
|||
only authenticated users can reach the vault at all. This is a strong defensive |
|||
layer: bots can't crawl the endpoint, credential-stuffing never reaches the |
|||
login form, and the attack surface drops to "whoever passes my IdP." |
|||
|
|||
The problem is that when the proxy sits in front of the API, the **native |
|||
Bitwarden clients (mobile, desktop)** can no longer complete their login flow. |
|||
The proxy expects a browser with a cookie jar and OAuth redirect support; the |
|||
native apps' HTTP clients have neither. After the browser-assisted IdP step, |
|||
the client is stuck — requests to the API come back as HTML login pages from |
|||
the proxy instead of JSON from Vaultwarden. |
|||
|
|||
Bitwarden's upstream server solved this in February 2026 with a flow they call |
|||
**SSO cookie vending**: the server advertises, via `/api/config`, that it lives |
|||
behind an authenticating proxy, and exposes an endpoint (`/api/sso-cookie-vendor`) |
|||
that reads the proxy's auth cookie after the user authenticates in a browser |
|||
and hands it back to the native app via a `bitwarden://` deep link. The app |
|||
then attaches that cookie to every subsequent API request, and the proxy lets |
|||
those requests through. |
|||
|
|||
See the upstream PRs: [bitwarden/server#6880][pr-6880], |
|||
[bitwarden/server#6892][pr-6892], [bitwarden/server#6903][pr-6903], |
|||
[bitwarden/clients#18476][pr-18476], [bitwarden/clients#19392][pr-19392]. |
|||
|
|||
Vaultwarden shipped the web-vault connector page (from |
|||
`bitwarden/clients#18476`) as part of v2026.2.0, but the server-side pieces |
|||
(`/api/sso-cookie-vendor` and the `communication.bootstrap` advertisement in |
|||
`/api/config`) were missing. Native apps would detect the web-vault connector, |
|||
open a browser, complete the Access auth, and then 404 when they tried to |
|||
hand the cookie off. This change adds the two missing server pieces. |
|||
|
|||
## What this change does |
|||
|
|||
Four things: |
|||
|
|||
1. **Adds a new config section** `sso_cookie_vendor` with four fields: |
|||
- `SSO_COOKIE_VENDOR_ENABLED` — master switch (default `false`) |
|||
- `SSO_COOKIE_VENDOR_IDP_LOGIN_URL` — the URL the app should navigate to |
|||
in a browser for IdP authentication (e.g. the Cloudflare Access login |
|||
URL for your Vaultwarden application) |
|||
- `SSO_COOKIE_VENDOR_COOKIE_NAME` — the name of the cookie the proxy sets |
|||
on authenticated requests (e.g. `CF_Authorization` for Cloudflare Access) |
|||
- `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` — the cookie's domain scope |
|||
2. **Advertises the configuration** in the `/api/config` response as a |
|||
`communication.bootstrap` object, matching the shape Bitwarden's clients |
|||
already expect from `bitwarden/server#6892`. |
|||
3. **Adds the `/api/sso-cookie-vendor` endpoint** that reads the proxy cookie |
|||
from the incoming request and 302-redirects to |
|||
`bitwarden://sso-cookie-vendor?<cookie-name>=<url-encoded-value>&d=1`. |
|||
4. **Validates config at startup**: if `SSO_COOKIE_VENDOR_ENABLED=true` but |
|||
any of the three string fields is empty, Vaultwarden refuses to start with |
|||
a clear error message. |
|||
|
|||
The endpoint is only registered when the feature is enabled, so disabled |
|||
installs behave exactly as before — no new attack surface. |
|||
|
|||
### Sharded cookie support |
|||
|
|||
Cloudflare Access can split its auth JWT across multiple cookies when the JWT |
|||
grows past browser size limits (`CF_Authorization-0`, `CF_Authorization-1`, |
|||
…). The endpoint checks for up to 20 shards (`{name}-0` through `{name}-19`) |
|||
and forwards all present shards in a single deep link. A non-sharded cookie, |
|||
if present, takes precedence (matching upstream Bitwarden's semantics). |
|||
|
|||
### Why this belongs in the server and not in a reverse-proxy shim |
|||
|
|||
The original workaround for Cloudflare Access users was a small Cloudflare |
|||
Worker that intercepted `/api/config` and `/api/sso-cookie-vendor` and |
|||
injected the same behavior. That works, but: |
|||
|
|||
- Every user behind Cloudflare Access has to deploy and maintain a Worker. |
|||
- A Worker only helps Cloudflare Access users — Authentik, Authelia, |
|||
oauth2-proxy, and any other authenticating proxy that drops a cookie can |
|||
use the exact same flow, but each would need its own shim. |
|||
- The `communication.bootstrap` block is a first-class feature of Bitwarden's |
|||
`/api/config` contract — it should come from the server, not a proxy layer. |
|||
|
|||
Putting the logic in Vaultwarden makes any authenticating proxy work with |
|||
native clients just by flipping four env vars. |
|||
|
|||
## How to enable it |
|||
|
|||
In your `.env` (or `config.json`, or the admin UI): |
|||
|
|||
```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 specifics |
|||
|
|||
`SSO_COOKIE_VENDOR_IDP_LOGIN_URL` is the "Access Login URL" shown on the |
|||
application's details page (format: |
|||
`https://<team>.cloudflareaccess.com/cdn-cgi/access/login/<your-domain>`). |
|||
`SSO_COOKIE_VENDOR_COOKIE_NAME` is always `CF_Authorization` for Cloudflare |
|||
Access. `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` is the domain your Access |
|||
application protects. |
|||
|
|||
### Other proxies (Authentik, Authelia, oauth2-proxy, …) |
|||
|
|||
Any reverse proxy that (a) redirects unauthenticated requests to a |
|||
browser-based IdP flow, and (b) sets a cookie on the authenticated response, |
|||
will work. Set `SSO_COOKIE_VENDOR_IDP_LOGIN_URL` to the proxy's login URL |
|||
and `SSO_COOKIE_VENDOR_COOKIE_NAME` / `SSO_COOKIE_VENDOR_COOKIE_DOMAIN` to |
|||
the cookie your proxy sets on authenticated sessions. |
|||
|
|||
## End-to-end flow (what the user sees) |
|||
|
|||
1. User opens the Bitwarden app and points it at their Vaultwarden server. |
|||
2. App fetches `/api/config`, sees `communication.bootstrap.type == "ssoCookieVendor"`, |
|||
and knows to use the cookie-vending flow. |
|||
3. App shows a "sync your browser" prompt and opens the system browser at |
|||
`idpLoginUrl`. |
|||
4. Browser is redirected through the IdP (Google, GitHub, Okta, …). User |
|||
authenticates. |
|||
5. Proxy sets its auth cookie on the response and redirects the browser to |
|||
`/api/sso-cookie-vendor`. |
|||
6. Vaultwarden receives the request, pulls the cookie out of the jar, and |
|||
302-redirects the browser to |
|||
`bitwarden://sso-cookie-vendor?CF_Authorization=<value>&d=1`. |
|||
7. The OS hands the deep link back to the Bitwarden app. |
|||
8. App stores the cookie value and attaches it to every subsequent API |
|||
request. The proxy sees the cookie, lets the request through, and the app |
|||
continues with the normal Bitwarden master-password unlock. |
|||
|
|||
No app-side modifications are required — this uses the cookie-vending support |
|||
Bitwarden's clients already ship. |
|||
|
|||
## Security considerations |
|||
|
|||
- The endpoint is only registered when `SSO_COOKIE_VENDOR_ENABLED=true`. |
|||
Default-off installs are byte-identical to current behavior. |
|||
- The endpoint **reads the cookie from an already-authenticated request** — |
|||
the proxy has already validated the IdP session before the request ever |
|||
reaches Vaultwarden. No new authentication boundary is introduced. |
|||
- The deep-link response never crosses a trust boundary the browser wasn't |
|||
already on: the browser holds the same cookie, the app holds the same |
|||
cookie, the proxy validates the same cookie. |
|||
- Vaultwarden's own authentication (master password) is still required after |
|||
the proxy gate — this feature does not weaken the vault. |
|||
- Deep-link length is capped at 8192 bytes to match the upstream Bitwarden |
|||
limit; oversize requests return HTTP 400 with the standard error page. |
|||
- Missing/empty cookie returns HTTP 404 with the upstream-compatible error |
|||
page telling the user to return to the app. |
|||
|
|||
## Testing |
|||
|
|||
Unit tests live inline in `src/api/core/sso_cookie_vendor.rs` under the usual |
|||
`#[cfg(test)] mod tests` pattern. They cover: |
|||
|
|||
- Single-cookie happy path |
|||
- Sharded cookies (ordered 0..19) |
|||
- Single cookie takes precedence over shards when both are present |
|||
- Missing cookie → 404 |
|||
- URL-encoding of cookie values with spaces and special characters |
|||
- Oversize URI handling |
|||
- Error-page HTML matches the upstream Bitwarden format |
|||
|
|||
Run with `cargo test --features sqlite -- sso_cookie_vendor`. |
|||
|
|||
## References |
|||
|
|||
- [bitwarden/server#6880][pr-6880] — Config infrastructure |
|||
- [bitwarden/server#6892][pr-6892] — Expose config in `/api/config` |
|||
- [bitwarden/server#6903][pr-6903] — Endpoint implementation |
|||
- [bitwarden/clients#18476][pr-18476] — Web-vault connector page (already in Vaultwarden v2026.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,251 @@ |
|||
use std::collections::HashMap; |
|||
|
|||
use rocket::{ |
|||
http::{CookieJar, Status}, |
|||
response::{content::RawHtml as Html, Redirect}, |
|||
Route, |
|||
}; |
|||
|
|||
use crate::CONFIG; |
|||
|
|||
/// Maximum allowed length for the redirect URI.
|
|||
/// Matches the official Bitwarden server limit.
|
|||
const MAX_REDIRECT_URI_LENGTH: usize = 8192; |
|||
|
|||
/// Maximum number of sharded cookie suffixes to check (0 through 19).
|
|||
const MAX_SHARD_COUNT: usize = 20; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![sso_cookie_vendor] |
|||
} |
|||
|
|||
/// Error HTML response matching the official Bitwarden server format.
|
|||
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
|
|||
///
|
|||
/// This endpoint is called after the user authenticates through the reverse proxy.
|
|||
/// It reads the proxy auth cookie from the request and redirects the native client
|
|||
/// to a bitwarden:// deep link containing the cookie value.
|
|||
///
|
|||
/// No Bitwarden authentication is required — the proxy handles auth.
|
|||
#[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))); |
|||
} |
|||
|
|||
// Extract cookies from the jar into a HashMap for processing
|
|||
let mut cookie_map = HashMap::new(); |
|||
// Check the main cookie
|
|||
if let Some(cookie) = cookies.get(&cookie_name) { |
|||
cookie_map.insert(cookie_name.clone(), cookie.value().to_string()); |
|||
} |
|||
// Check sharded cookies
|
|||
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_string()); |
|||
} |
|||
} |
|||
|
|||
let redirect_uri = build_redirect_uri(&cookie_name, &cookie_map)?; |
|||
|
|||
if redirect_uri.len() > MAX_REDIRECT_URI_LENGTH { |
|||
return Err((Status::BadRequest, error_html(400))); |
|||
} |
|||
|
|||
Ok(Redirect::found(redirect_uri)) |
|||
} |
|||
|
|||
/// Build the bitwarden:// redirect URI from a map of cookie names to values.
|
|||
///
|
|||
/// Checks for a single (non-sharded) cookie first. If found, it takes precedence.
|
|||
/// Otherwise, checks for sharded cookies ({name}-0 through {name}-19).
|
|||
fn build_redirect_uri( |
|||
cookie_name: &str, |
|||
cookies: &HashMap<String, String>, |
|||
) -> Result<String, (Status, Html<String>)> { |
|||
// Check for the single (non-sharded) cookie — takes precedence over shards
|
|||
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")); |
|||
} |
|||
|
|||
// Check for sharded cookies: {name}-0, {name}-1, ..., {name}-19
|
|||
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("&"))) |
|||
} |
|||
|
|||
/// URL-encode a cookie value using percent-encoding for the query string.
|
|||
fn url_encode(value: &str) -> String { |
|||
url::form_urlencoded::byte_serialize(value.as_bytes()).collect() |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
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(); |
|||
// Add both single and sharded cookies
|
|||
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(); |
|||
// Single cookie should take precedence — no shards in the URI
|
|||
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_uri_too_long_returns_400() { |
|||
let mut cookies = HashMap::new(); |
|||
// Create a very long cookie value that will exceed MAX_REDIRECT_URI_LENGTH
|
|||
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(); |
|||
// The URI exceeds the limit — the caller (sso_cookie_vendor handler) checks this
|
|||
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(); |
|||
// Insert in non-sequential order to verify ordering
|
|||
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 should appear in order 0, 1, 2 regardless of insertion order
|
|||
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