Add SSO cookie vendor endpoint for native apps behind authenticating proxies

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).
This commit is contained in:
nathanmoreton 2026-04-23 21:19:15 -05:00
parent e7e4b9a86d
commit 2b79525441
5 changed files with 493 additions and 0 deletions

View file

@ -532,6 +532,28 @@
## Log all the tokens, LOG_LEVEL=debug is required
# SSO_DEBUG_TOKENS=false
##########################################
### SSO Cookie Vendor settings ###
##########################################
## Enable the SSO cookie vendor endpoint. This allows native Bitwarden apps
## (mobile/desktop) to work when Vaultwarden is behind an authenticating reverse
## proxy such as Cloudflare Access. The proxy sets an auth cookie on the request,
## and this endpoint reads it and redirects the client with the cookie value
## embedded in a bitwarden:// deep link.
# SSO_COOKIE_VENDOR_ENABLED=false
## The IdP login URL the client should navigate to for authentication
## (e.g. the Cloudflare Access login URL for your Vaultwarden application)
# SSO_COOKIE_VENDOR_IDP_LOGIN_URL=https://example.cloudflareaccess.com/cdn-cgi/access/login/vault.example.com
## The name of the cookie set by the authenticating reverse proxy
## (e.g. CF_Authorization for Cloudflare Access)
# SSO_COOKIE_VENDOR_COOKIE_NAME=CF_Authorization
## The domain scope of the proxy auth cookie (e.g. vault.example.com)
# SSO_COOKIE_VENDOR_COOKIE_DOMAIN=vault.example.com
########################
### MFA/2FA settings ###
########################

180
docs/sso-cookie-vendor.md Normal file
View file

@ -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

View file

@ -6,6 +6,7 @@ mod folders;
mod organizations;
mod public;
mod sends;
mod sso_cookie_vendor;
pub mod two_factor;
pub use accounts::purge_auth_requests;
@ -34,6 +35,10 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut hibp_routes);
routes.append(&mut meta_routes);
if CONFIG.sso_cookie_vendor_enabled() {
routes.append(&mut sso_cookie_vendor::routes());
}
routes
}
@ -210,6 +215,19 @@ fn config() -> Json<Value> {
);
// Add default feature_states here if needed, currently no features are needed by default.
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
@ -234,6 +252,7 @@ fn config() -> Json<Value> {
"sso": "",
"cloudRegion": null,
},
"communication": communication,
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
"push": {
"pushTechnology": 0,

View file

@ -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"));
}
}

View file

@ -834,6 +834,18 @@ make_config! {
sso_debug_tokens: bool, true, def, false;
},
/// SSO Cookie Vendor settings
sso_cookie_vendor {
/// Enabled |> Enable the SSO cookie vendor endpoint for native app support behind authenticating reverse proxies
sso_cookie_vendor_enabled: bool, true, def, false;
/// IdP Login URL |> The URL the client should navigate to for IdP authentication (e.g. Cloudflare Access login URL)
sso_cookie_vendor_idp_login_url: String, true, def, String::new();
/// Cookie Name |> The name of the cookie set by the authenticating reverse proxy (e.g. CF_Authorization)
sso_cookie_vendor_cookie_name: String, true, def, String::new();
/// Cookie Domain |> The domain scope of the proxy auth cookie (e.g. vault.example.com)
sso_cookie_vendor_cookie_domain: String, true, def, String::new();
},
/// Yubikey settings
yubico: _enable_yubico {
/// Enabled
@ -1079,6 +1091,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
validate_sso_master_password_policy(&cfg.sso_master_password_policy)?;
}
if cfg.sso_cookie_vendor_enabled {
if 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 is enabled")
}
}
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")