From e95510e9cdde8e1abe9e6fb89863a557028a7cc0 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Sat, 11 Jul 2026 15:49:36 +0200 Subject: [PATCH 1/8] Add Key Connector support for SSO users Adds the server-side endpoints the clients expect for Key Connector (set-key-connector-key, convert-to-key-connector, confirmation-details), a uses_key_connector flag on users plus migrations, and new KEY_CONNECTOR_* config options gated behind SSO_ENABLED. SSO logins now carry amr=external and the login response advertises the connector URL so the clients pick it up. The protocol was worked out from the GPL client code only, without looking at Bitwarden's key-connector repo. --- Cargo.toml | 2 +- .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 1 + .../up.sql | 1 + src/api/core/key_connector.rs | 94 +++++++++++++++++++ src/api/core/mod.rs | 2 + src/api/identity.rs | 9 +- src/config.rs | 20 ++++ src/db/models/organization.rs | 8 +- src/db/models/user.rs | 7 +- src/db/schema.rs | 1 + src/sso.rs | 8 +- src/util.rs | 8 ++ 16 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql create mode 100644 migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql create mode 100644 migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql create mode 100644 migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql create mode 100644 migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql create mode 100644 migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql create mode 100644 src/api/core/key_connector.rs diff --git a/Cargo.toml b/Cargo.toml index 764fbd53..a2c0e51d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } +diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] } diesel_migrations = "2.3.2" derive_more = { version = "2.1.1", features = [ diff --git a/migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql new file mode 100644 index 00000000..2457cea0 --- /dev/null +++ b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN uses_key_connector; diff --git a/migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql new file mode 100644 index 00000000..4a25c4c0 --- /dev/null +++ b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql new file mode 100644 index 00000000..2457cea0 --- /dev/null +++ b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN uses_key_connector; diff --git a/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql new file mode 100644 index 00000000..4a25c4c0 --- /dev/null +++ b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql new file mode 100644 index 00000000..2457cea0 --- /dev/null +++ b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN uses_key_connector; diff --git a/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql new file mode 100644 index 00000000..c63ee5fa --- /dev/null +++ b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT 0; diff --git a/src/api/core/key_connector.rs b/src/api/core/key_connector.rs new file mode 100644 index 00000000..f7c60371 --- /dev/null +++ b/src/api/core/key_connector.rs @@ -0,0 +1,94 @@ +use rocket::Route; +use rocket::serde::json::Json; +use serde_json::Value; + +use crate::{ + CONFIG, + api::{EmptyResult, JsonResult}, + auth::Headers, + db::DbConn, +}; + +pub fn routes() -> Vec { + routes![post_set_key_connector_key, post_convert_to_key_connector, get_confirmation_details,] +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyPairData { + encrypted_private_key: String, + public_key: String, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetKeyConnectorKeyData { + key: String, + keys: KeyPairData, + kdf: i32, + kdf_iterations: i32, + kdf_memory: Option, + kdf_parallelism: Option, + #[allow(dead_code)] + org_identifier: String, +} + +// Called by the client to finish provisioning a new SSO user whose master key +// was just stored on the key connector. +#[post("/accounts/set-key-connector-key", data = "")] +async fn post_set_key_connector_key(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + if !CONFIG.key_connector_enabled() { + err!("Key Connector is not enabled on this server"); + } + + let data = data.into_inner(); + let mut user = headers.user; + + user.client_kdf_type = data.kdf; + user.client_kdf_iter = data.kdf_iterations; + user.client_kdf_memory = data.kdf_memory; + user.client_kdf_parallelism = data.kdf_parallelism; + + user.akey = data.key; + user.private_key = Some(data.keys.encrypted_private_key); + user.public_key = Some(data.keys.public_key); + + // Key connector users don't have a master password + user.password_hash = Vec::new(); + user.uses_key_connector = true; + + user.save(&conn).await +} + +// Migrates an existing password user to the key connector. The client has already +// uploaded the current master key to the connector at this point. +#[post("/accounts/convert-to-key-connector")] +async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyResult { + if !CONFIG.key_connector_enabled() { + err!("Key Connector is not enabled on this server"); + } + + let mut user = headers.user; + user.password_hash = Vec::new(); + user.password_hint = None; + user.uses_key_connector = true; + + user.save(&conn).await +} + +#[get("/accounts/key-connector/confirmation-details/<_org_identifier>")] +fn get_confirmation_details(_org_identifier: &str, _headers: Headers) -> JsonResult { + if !CONFIG.key_connector_enabled() { + err!("Key Connector is not enabled on this server"); + } + + // SSO (and therefore the key connector) is global, so there is no real org to look up + Ok(Json(serde_json::json!({ + "OrganizationName": CONFIG.key_connector_org_name(), + "Object": "keyConnectorUserDecryptionOptionConfirmationDetails" + }))) +} + +pub fn key_connector_user_decryption_option() -> Value { + serde_json::json!({ "KeyConnectorUrl": CONFIG.key_connector_url() }) +} diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..a959dd6d 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod key_connector; pub mod two_factor; mod ciphers; @@ -39,6 +40,7 @@ pub fn routes() -> Vec { let mut routes = Vec::new(); routes.append(&mut accounts::routes()); + routes.append(&mut key_connector::routes()); routes.append(&mut ciphers::routes()); routes.append(&mut emergency_access::routes()); routes.append(&mut events::routes()); diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..d38a5851 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -532,7 +532,9 @@ async fn authenticated_response( let master_password_policy = identity_master_password_policy(user, conn).await; - let has_master_password = !user.password_hash.is_empty(); + // Key connector users have no master password, the master key is stored on the connector + let uses_key_connector = CONFIG.key_connector_enabled() && user.uses_key_connector; + let has_master_password = !user.password_hash.is_empty() && !uses_key_connector; let master_password_unlock = if has_master_password { json!({ "Kdf": { @@ -589,6 +591,11 @@ async fn authenticated_response( result["Key"] = Value::String(user.akey.clone()); } + if uses_key_connector { + result["UserDecryptionOptions"]["KeyConnectorOption"] = + crate::api::core::key_connector::key_connector_user_decryption_option(); + } + if let Some(token) = twofactor_token { result["TwoFactorToken"] = Value::String(token); } diff --git a/src/config.rs b/src/config.rs index a56748f4..7bc6ad14 100644 --- a/src/config.rs +++ b/src/config.rs @@ -816,6 +816,12 @@ make_config! { sso { /// Enabled sso_enabled: bool, true, def, false; + /// Key Connector enabled |> Store master keys on an external Key Connector (requires SSO) + key_connector_enabled: bool, true, def, false; + /// Key Connector URL |> Base URL of the Key Connector service, e.g. https://keyconnector.example.com + key_connector_url: String, true, def, String::new(); + /// Key Connector org name |> Name shown in the client's domain-confirmation dialog + key_connector_org_name: String, true, def, String::from("Key Connector"); /// Only SSO login |> Disable Email+Master Password login sso_only: bool, true, def, false; /// Allow SSO flow to create account |> You probably want to disable it when using a public provider @@ -1113,6 +1119,20 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } + if cfg.key_connector_enabled { + if !cfg.sso_enabled { + err!("`KEY_CONNECTOR_ENABLED=true` requires `SSO_ENABLED=true`") + } + if cfg.sso_auth_only_not_session { + err!( + "Key Connector is incompatible with `SSO_AUTH_ONLY_NOT_SESSION=true` (the connector must validate Vaultwarden-issued access tokens)" + ) + } + if cfg.key_connector_url.is_empty() { + err!("`KEY_CONNECTOR_URL` must be set when Key Connector 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") diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72684782..7130be8f 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -209,7 +209,7 @@ impl Organization { "usePolicies": true, "useScim": false, // Not supported (Not AGPLv3 Licensed) "useSso": false, // Not supported - "useKeyConnector": false, // Not supported + "useKeyConnector": CONFIG.key_connector_enabled(), "usePasswordManager": true, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "selfHost": true, @@ -483,7 +483,7 @@ impl Membership { "useResetPassword": CONFIG.mail_enabled(), "ssoBound": false, // Not supported "useSso": false, // Not supported - "useKeyConnector": false, + "useKeyConnector": CONFIG.key_connector_enabled(), "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "usePasswordManager": true, "useCustomPermissions": true, @@ -504,8 +504,8 @@ impl Membership { "familySponsorshipFriendlyName": null, "familySponsorshipAvailable": false, "productTierType": 3, // Enterprise tier - "keyConnectorEnabled": false, - "keyConnectorUrl": null, + "keyConnectorEnabled": CONFIG.key_connector_enabled(), + "keyConnectorUrl": if CONFIG.key_connector_enabled() { Value::String(CONFIG.key_connector_url()) } else { Value::Null }, "familySponsorshipLastSyncDate": null, "familySponsorshipValidUntil": null, "familySponsorshipToDelete": null, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 2f3afc02..ef6986da 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -72,6 +72,7 @@ pub struct User { pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. pub key_id: Option, + pub uses_key_connector: bool, } #[derive(Identifiable, Queryable, Insertable)] @@ -159,6 +160,7 @@ impl User { external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. key_id: None, + uses_key_connector: false, } } @@ -299,7 +301,8 @@ impl User { let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty(); // TODO: Might want to save the status field in the DB - let status = if self.password_hash.is_empty() { + // Key connector users have an empty password hash but are not invited + let status = if self.password_hash.is_empty() && !self.uses_key_connector { UserStatus::Invited } else { UserStatus::Enabled @@ -327,7 +330,7 @@ impl User { "providerOrganizations": [], "forcePasswordReset": false, "avatarColor": self.avatar_color, - "usesKeyConnector": false, + "usesKeyConnector": self.uses_key_connector, "creationDate": format_date(&self.created_at), "object": "profile", }) diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..fce7a1c2 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -219,6 +219,7 @@ table! { avatar_color -> Nullable, external_id -> Nullable, key_id -> Nullable, + uses_key_connector -> Bool, } } diff --git a/src/sso.rs b/src/sso.rs index 6b575a3e..2119e02c 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -387,9 +387,15 @@ pub fn create_auth_tokens( fn create_auth_tokens_impl( device: &Device, refresh_token: Option, - access_claims: auth::LoginJwtClaims, + mut access_claims: auth::LoginJwtClaims, access_token: String, ) -> ApiResult { + // Mark the access token as externally authenticated (SSO). Bitwarden clients gate the + // Key Connector flow on `amr` containing "external" (TokenService.getIsExternal). + if !access_claims.amr.iter().any(|m| m == "external") { + access_claims.amr.push("external".to_owned()); + } + let (nbf, exp, token) = if let Some(rt) = refresh_token { match decode_token_claims("refresh_token", &rt) { Err(_) => { diff --git a/src/util.rs b/src/util.rs index 9d319542..a1776127 100644 --- a/src/util.rs +++ b/src/util.rs @@ -128,11 +128,19 @@ impl Fairing for AppHeaders { https://app.addy.io/api/ \ https://api.fastmail.com/ \ https://api.forwardemail.net \ + {key_connector_src} \ {allowed_connect_src};\ ", icon_service_csp = CONFIG._icon_service_csp(), allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(), allowed_connect_src = CONFIG.allowed_connect_src(), + // The web vault fetches the master key from the key connector, so it + // has to be allowed to connect to it. + key_connector_src = if CONFIG.key_connector_enabled() { + CONFIG.key_connector_url() + } else { + String::new() + }, ) }; From 54ad9f0087e96c872548a1ab0de850fc691569fa Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 10:40:46 +0200 Subject: [PATCH 2/8] Harden the Key Connector account endpoints set-key-connector-key now refuses to touch an already initialized account (same check as set-password) and convert-to-key-connector refuses accounts that have no keys yet. Logins of key connector users fail with a clear error when KEY_CONNECTOR_ENABLED was turned off again, instead of returning a response without any usable decryption option. KEY_CONNECTOR_URL is also validated as a URL on startup. --- src/api/core/key_connector.rs | 9 +++++++++ src/api/identity.rs | 5 ++++- src/config.rs | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/api/core/key_connector.rs b/src/api/core/key_connector.rs index f7c60371..a7fe27d1 100644 --- a/src/api/core/key_connector.rs +++ b/src/api/core/key_connector.rs @@ -44,6 +44,10 @@ async fn post_set_key_connector_key(data: Json, headers: let data = data.into_inner(); let mut user = headers.user; + if user.private_key.is_some() { + err!("Account already initialized, cannot set Key Connector key"); + } + user.client_kdf_type = data.kdf; user.client_kdf_iter = data.kdf_iterations; user.client_kdf_memory = data.kdf_memory; @@ -69,6 +73,11 @@ async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyR } let mut user = headers.user; + + if user.private_key.is_none() { + err!("Account is not initialized, cannot convert to Key Connector"); + } + user.password_hash = Vec::new(); user.password_hint = None; user.uses_key_connector = true; diff --git a/src/api/identity.rs b/src/api/identity.rs index d38a5851..0bb7078c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -533,7 +533,10 @@ async fn authenticated_response( let master_password_policy = identity_master_password_policy(user, conn).await; // Key connector users have no master password, the master key is stored on the connector - let uses_key_connector = CONFIG.key_connector_enabled() && user.uses_key_connector; + let uses_key_connector = user.uses_key_connector; + if uses_key_connector && !CONFIG.key_connector_enabled() { + err!("This user's master key is stored on a key connector, but Key Connector support is disabled") + } let has_master_password = !user.password_hash.is_empty() && !uses_key_connector; let master_password_unlock = if has_master_password { json!({ diff --git a/src/config.rs b/src/config.rs index 7bc6ad14..14ec3930 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1131,6 +1131,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { if cfg.key_connector_url.is_empty() { err!("`KEY_CONNECTOR_URL` must be set when Key Connector is enabled") } + if Url::parse(&cfg.key_connector_url).is_err() { + err!("Invalid URL format for `KEY_CONNECTOR_URL`."); + } } if cfg._enable_yubico { From fd8f748c0bbad15af0c53ef427201a85f9be3a5b Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 11:05:08 +0200 Subject: [PATCH 3/8] Advertise the Key Connector to new SSO users The clients only start the enrollment flow when the login response contains KeyConnectorOption, which so far was only sent for users that were already enrolled. Send it to every user without a master password while the connector is enabled, so a freshly provisioned SSO user gets enrolled instead of being asked to create a master password. --- src/api/identity.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 0bb7078c..566b4ddd 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -594,7 +594,9 @@ async fn authenticated_response( result["Key"] = Value::String(user.akey.clone()); } - if uses_key_connector { + // Also advertised to users without a master password, that is how the client + // knows to enroll a new SSO user with the connector + if CONFIG.key_connector_enabled() && !has_master_password { result["UserDecryptionOptions"]["KeyConnectorOption"] = crate::api::core::key_connector::key_connector_user_decryption_option(); } From 87519caa1ada2df75bb57580a1bfde83a7639637 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 13:28:00 +0200 Subject: [PATCH 4/8] Add Playwright tests for the Key Connector flow Drives the real web vault through the enrollment of a new SSO user (domain confirmation instead of master password creation) and a subsequent login that unlocks straight from the connector. The connector is a small in-memory mock started by the spec, it stores keys per token subject and leaves token validation to the service being mocked. --- playwright/docker-compose.yml | 2 + playwright/test.env | 4 ++ playwright/tests/setups/keyconnector.ts | 72 +++++++++++++++++++++++ playwright/tests/sso_keyconnector.spec.ts | 61 +++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 playwright/tests/setups/keyconnector.ts create mode 100644 playwright/tests/sso_keyconnector.spec.ts diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index 5bfc47a5..5d8f73ad 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -27,6 +27,8 @@ services: - CLIENT_SUPPRESS_ONBOARDING - EMAIL_2FA_AUTO_FALLBACK - I_REALLY_WANT_VOLATILE_STORAGE + - KEY_CONNECTOR_ENABLED + - KEY_CONNECTOR_URL - LOG_LEVEL - LOGIN_RATELIMIT_MAX_BURST - SMTP_HOST diff --git a/playwright/test.env b/playwright/test.env index 2260f860..61feecc0 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -69,6 +69,10 @@ SSO_CLIENT_SECRET=warden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_DEBUG_TOKENS=true +# Mock service started by sso_keyconnector.spec.ts +KEY_CONNECTOR_PORT=8004 +KEY_CONNECTOR_URL=http://127.0.0.1:${KEY_CONNECTOR_PORT} + # Custom web-vault build # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 diff --git a/playwright/tests/setups/keyconnector.ts b/playwright/tests/setups/keyconnector.ts new file mode 100644 index 00000000..341d5779 --- /dev/null +++ b/playwright/tests/setups/keyconnector.ts @@ -0,0 +1,72 @@ +import { createServer, type Server } from 'node:http'; + +/** + * Barebone in-memory key connector, just enough for the clients to enroll and unlock. + * Keys are stored per token subject; token validation is deliberately out of scope, + * this only exists to test the Vaultwarden and web client side of the flow. + */ +export function startMockKeyConnector(port: number) { + const keys = new Map(); + + const server = createServer((req, res) => { + // The web client calls us cross-origin, with credentials + res.setHeader('Access-Control-Allow-Origin', req.headers['origin'] ?? '*'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? '*'); + + if (req.method === 'OPTIONS') { + res.writeHead(204).end(); + return; + } + + if (req.url === '/alive') { + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({})); + return; + } + + const sub = tokenSubject(req.headers['authorization']); + if (!sub) { + res.writeHead(401).end(); + return; + } + + if (req.url !== '/user-keys') { + res.writeHead(404).end(); + return; + } + + if (req.method === 'GET') { + if (!keys.has(sub)) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ key: keys.get(sub) })); + } else if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => body += chunk); + req.on('end', () => { + keys.set(sub, JSON.parse(body).key); + res.writeHead(200).end(); + }); + } else { + res.writeHead(405).end(); + } + }); + + server.listen(port); + console.log(`Mock key connector running on port ${port}`); + + return { + keys, + stop: () => server.close(), + }; +} + +function tokenSubject(header?: string): string | null { + try { + return JSON.parse(Buffer.from(header.replace(/^Bearer /, '').split('.')[1], 'base64url').toString()).sub; + } catch { + return null; + } +} diff --git a/playwright/tests/sso_keyconnector.spec.ts b/playwright/tests/sso_keyconnector.spec.ts new file mode 100644 index 00000000..8eba8d60 --- /dev/null +++ b/playwright/tests/sso_keyconnector.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; + +import { startMockKeyConnector } from './setups/keyconnector'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); +let keyConnector; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + keyConnector = startMockKeyConnector(Number(process.env.KEY_CONNECTOR_PORT)); + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false, + KEY_CONNECTOR_ENABLED: true, + KEY_CONNECTOR_URL: process.env.KEY_CONNECTOR_URL, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + keyConnector.stop(); +}); + +async function ssoLogin(page: Page, user: { email: string, name: string, password: string }) { + await page.context().clearCookies(); + await utils.cleanLanding(page); + + await page.locator("input[type=email].vw-email-sso").fill(user.email); + await page.getByRole('button', { name: /Use single sign-on/ }).click(); + + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); +} + +test('Account creation using SSO enrolls with the key connector', async ({ page }) => { + await ssoLogin(page, users.user1); + + // Instead of the master password creation we land on the domain confirmation + await expect(page.getByText(process.env.KEY_CONNECTOR_URL)).toBeVisible(); + // Button label differs between web-vault versions + await page.getByRole('button', { name: /^(Confirm|Continue with log in)$/ }).click(); + + await utils.ignoreExtension(page); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); + + expect(keyConnector.keys.size).toBe(1); +}); + +test('SSO login unlocks with the key from the connector', async ({ page }) => { + await ssoLogin(page, users.user1); + + // No master password prompt, the vault opens directly + await utils.ignoreExtension(page); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); +}); From 197ef1a006001433badb3b0ff5d81a436dbb3eb2 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 17:54:06 +0200 Subject: [PATCH 5/8] Tighten the KEY_CONNECTOR_URL validation Url::parse alone also accepts schemes like javascript: that make no sense here, so require http(s) like the other URL settings do. The CSP entry now only contains the origin of the configured URL, since a query or fragment would produce an invalid source expression. Also removes an unused import from the playwright mock. --- playwright/tests/setups/keyconnector.ts | 2 +- src/config.rs | 5 +++-- src/util.rs | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/playwright/tests/setups/keyconnector.ts b/playwright/tests/setups/keyconnector.ts index 341d5779..3de31a8d 100644 --- a/playwright/tests/setups/keyconnector.ts +++ b/playwright/tests/setups/keyconnector.ts @@ -1,4 +1,4 @@ -import { createServer, type Server } from 'node:http'; +import { createServer } from 'node:http'; /** * Barebone in-memory key connector, just enough for the clients to enroll and unlock. diff --git a/src/config.rs b/src/config.rs index 14ec3930..1f3bc531 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1131,8 +1131,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { if cfg.key_connector_url.is_empty() { err!("`KEY_CONNECTOR_URL` must be set when Key Connector is enabled") } - if Url::parse(&cfg.key_connector_url).is_err() { - err!("Invalid URL format for `KEY_CONNECTOR_URL`."); + let kc_url = cfg.key_connector_url.to_lowercase(); + if !kc_url.starts_with("http://") && !kc_url.starts_with("https://") || Url::parse(&kc_url).is_err() { + err!("`KEY_CONNECTOR_URL` must be a valid URL containing the protocol (http, https)") } } diff --git a/src/util.rs b/src/util.rs index a1776127..4e6c03a2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -135,9 +135,11 @@ impl Fairing for AppHeaders { allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(), allowed_connect_src = CONFIG.allowed_connect_src(), // The web vault fetches the master key from the key connector, so it - // has to be allowed to connect to it. + // has to be allowed to connect to it. Only use the origin here, a + // query or fragment in the URL would break the source expression. key_connector_src = if CONFIG.key_connector_enabled() { - CONFIG.key_connector_url() + url::Url::parse(&CONFIG.key_connector_url()) + .map_or_else(|_| String::new(), |u| u.origin().ascii_serialization()) } else { String::new() }, From 08bd35127cf996921c671ce4abd52e01b60c4b53 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Sun, 12 Jul 2026 17:00:44 +0200 Subject: [PATCH 6/8] Serve the login JWKS via OIDC discovery Services that verify our access tokens, like a key connector, had to get a copy of the RSA public key by hand and keep it in sync. Publish issuer and signing key at /identity/.well-known/openid-configuration instead, the same way the Bitwarden identity service shares them with its consumers. --- src/api/identity.rs | 19 ++++++++++++++++++- src/auth.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 566b4ddd..58522dd2 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -50,10 +50,27 @@ pub fn routes() -> Vec { prevalidate, authorize, oidcsignin, - oidcsignin_error + oidcsignin_error, + openid_configuration, + jwks ] } +// Issuer and signing key of our own login tokens, for services that verify +// them (e.g. a key connector). The issuer is the `iss` claim, not a URL. +#[get("/.well-known/openid-configuration")] +fn openid_configuration() -> Json { + Json(json!({ + "issuer": *auth::JWT_LOGIN_ISSUER, + "jwks_uri": format!("{}/identity/.well-known/jwks", CONFIG.domain()), + })) +} + +#[get("/.well-known/jwks")] +fn jwks() -> Json { + Json(auth::login_jwks().clone()) +} + #[post("/connect/token", data = "")] async fn login(data: Form, client_header: ClientHeaders, conn: DbConn) -> JsonResult { let data: ConnectData = data.into_inner(); diff --git a/src/auth.rs b/src/auth.rs index 57720382..c382c286 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -13,12 +13,14 @@ use std::{ }; use chrono::{DateTime, TimeDelta, Utc}; +use data_encoding::BASE64URL_NOPAD; use ipnet::IpNet; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; use ring::hmac; use serde::{de::DeserializeOwned, ser::Serialize}; +use serde_json::json; use rocket::{ outcome::try_outcome, @@ -70,6 +72,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}| static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); static PRELOGIN_KDF_KEY: OnceLock = OnceLock::new(); +static LOGIN_JWKS: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { use std::io::Error as IoError; @@ -96,6 +99,24 @@ pub async fn initialize_keys() -> Result<(), Error> { }; let pub_key_buffer = priv_key.public_key_to_pem()?; + // Expose the public half as a JWKS on /identity/.well-known/ so token + // consumers (e.g. a key connector) don't need a copy of the PEM. + let n = BASE64URL_NOPAD.encode(&priv_key.n().to_vec()); + let e = BASE64URL_NOPAD.encode(&priv_key.e().to_vec()); + // the kid is the RFC 7638 thumbprint of the key + let thumbprint = json!({"e": e, "kty": "RSA", "n": n}).to_string(); + let kid = BASE64URL_NOPAD.encode(&openssl::sha::sha256(thumbprint.as_bytes())); + let jwks = json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": n, + "e": e, + }] + }); + let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?; let dec: DecodingKey = DecodingKey::from_rsa_pem(&pub_key_buffer)?; if PRIVATE_RSA_KEY.set(enc).is_err() { @@ -104,6 +125,9 @@ pub async fn initialize_keys() -> Result<(), Error> { if PUBLIC_RSA_KEY.set(dec).is_err() { err!("PUBLIC_RSA_KEY must only be initialized once") } + if LOGIN_JWKS.set(jwks).is_err() { + err!("LOGIN_JWKS must only be initialized once") + } // Derived from the RSA key, so it stays the same across restarts without storing another secret let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf"); @@ -120,6 +144,10 @@ pub fn prelogin_kdf_index(email: &str, len: usize) -> usize { usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len } +pub fn login_jwks() -> &'static serde_json::Value { + LOGIN_JWKS.wait() +} + pub fn encode_jwt(claims: &T) -> String { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { Ok(token) => token, From 43de71cec722c999633a8e139a0738cc645b3042 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Wed, 22 Jul 2026 10:41:49 +0200 Subject: [PATCH 7/8] Block owners and admins from enrolling in the Key Connector They administer the key connector itself and are expected to keep a master password, so refuse set-key-connector-key and convert-to-key-connector for anyone with an owner or admin membership, including pending invites. --- src/api/core/key_connector.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/api/core/key_connector.rs b/src/api/core/key_connector.rs index a7fe27d1..f0c39b5a 100644 --- a/src/api/core/key_connector.rs +++ b/src/api/core/key_connector.rs @@ -6,7 +6,10 @@ use crate::{ CONFIG, api::{EmptyResult, JsonResult}, auth::Headers, - db::DbConn, + db::{ + DbConn, + models::{Membership, MembershipType, UserId}, + }, }; pub fn routes() -> Vec { @@ -33,6 +36,13 @@ pub struct SetKeyConnectorKeyData { org_identifier: String, } +async fn can_use_key_connector(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + if Membership::find_by_user(user_uuid, conn).await.iter().any(|m| m.atype >= MembershipType::Admin) { + err!("Owners and admins cannot use Key Connector and must keep a master password"); + } + Ok(()) +} + // Called by the client to finish provisioning a new SSO user whose master key // was just stored on the key connector. #[post("/accounts/set-key-connector-key", data = "")] @@ -48,6 +58,8 @@ async fn post_set_key_connector_key(data: Json, headers: err!("Account already initialized, cannot set Key Connector key"); } + can_use_key_connector(&user.uuid, &conn).await?; + user.client_kdf_type = data.kdf; user.client_kdf_iter = data.kdf_iterations; user.client_kdf_memory = data.kdf_memory; @@ -78,6 +90,8 @@ async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyR err!("Account is not initialized, cannot convert to Key Connector"); } + can_use_key_connector(&user.uuid, &conn).await?; + user.password_hash = Vec::new(); user.password_hint = None; user.uses_key_connector = true; From 59f24fbadb250d66019538b6f9e4625ac84c66cd Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Thu, 3 Sep 2026 14:04:41 +0200 Subject: [PATCH 8/8] Make the Key Connector admin exemption configurable Owners and admins no longer get the connector advertised by default (KEY_CONNECTOR_EXEMPT_ADMINS=true), so clients ask them for a master password instead of enrolling them. Setting it to false advertises it to them as well. Users already enrolled keep unlocking through the connector either way. --- src/api/identity.rs | 15 ++++++++++----- src/config.rs | 2 ++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 58522dd2..f7e72d22 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -29,9 +29,9 @@ use crate::{ db::{ DbConn, models::{ - AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, - OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, - TwoFactorType, User, UserId, + AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, Membership, MembershipType, + OIDCCodeResponseError, OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, + TwoFactorIncomplete, TwoFactorType, User, UserId, }, }, error::MapResult, @@ -614,8 +614,13 @@ async fn authenticated_response( // Also advertised to users without a master password, that is how the client // knows to enroll a new SSO user with the connector if CONFIG.key_connector_enabled() && !has_master_password { - result["UserDecryptionOptions"]["KeyConnectorOption"] = - crate::api::core::key_connector::key_connector_user_decryption_option(); + let advertise = uses_key_connector + || !CONFIG.key_connector_exempt_admins() + || !Membership::find_by_user(&user.uuid, conn).await.iter().any(|m| m.atype >= MembershipType::Admin); + if advertise { + result["UserDecryptionOptions"]["KeyConnectorOption"] = + crate::api::core::key_connector::key_connector_user_decryption_option(); + } } if let Some(token) = twofactor_token { diff --git a/src/config.rs b/src/config.rs index 1f3bc531..6b6bb1fb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -822,6 +822,8 @@ make_config! { key_connector_url: String, true, def, String::new(); /// Key Connector org name |> Name shown in the client's domain-confirmation dialog key_connector_org_name: String, true, def, String::from("Key Connector"); + /// Key Connector exempt admins |> Do not advertise the Key Connector to owners and admins so they set a master password instead. Users already enrolled keep using key connector + key_connector_exempt_admins: bool, true, def, true; /// Only SSO login |> Disable Email+Master Password login sso_only: bool, true, def, false; /// Allow SSO flow to create account |> You probably want to disable it when using a public provider