From f513a01ef9c4a3359c3a45e4ce8af777626adcbb Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Sat, 11 Jul 2026 15:49:36 +0200 Subject: [PATCH 1/7] 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. --- .../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 | 9 +- src/db/schema.rs | 1 + src/sso.rs | 8 +- src/util.rs | 8 ++ 15 files changed, 157 insertions(+), 8 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/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 2ea8ab21..8d71d86b 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 1597698f..c08a2b2a 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -514,7 +514,9 @@ async fn authenticated_response( let master_password_policy = 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": { @@ -572,6 +574,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 49281b6c..8855210c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -799,6 +799,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 email association |> Associate existing non-SSO user based on email @@ -1086,6 +1092,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 72b1df0b..20792151 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -212,7 +212,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, @@ -488,7 +488,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, @@ -503,8 +503,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 24bee751..8ac2414b 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -69,6 +69,8 @@ pub struct User { pub avatar_color: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. + + pub uses_key_connector: bool, } #[derive(Identifiable, Queryable, Insertable)] @@ -154,6 +156,8 @@ impl User { avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. + + uses_key_connector: false, } } @@ -262,7 +266,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 @@ -286,7 +291,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 af342186..c0d9c5fb 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -217,6 +217,7 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, + uses_key_connector -> Bool, } } diff --git a/src/sso.rs b/src/sso.rs index 01fbd906..56cc0e29 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -385,9 +385,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 91f075d1..0ce1a966 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 f48a4b7eba1d14e1caf805da4b17f7c5d48e2ad7 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 10:40:46 +0200 Subject: [PATCH 2/7] 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 c08a2b2a..dc3ea6b9 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -515,7 +515,10 @@ async fn authenticated_response( let master_password_policy = 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 8855210c..8c57e0bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1104,6 +1104,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 317883c8c26878335c9940394f37c0eee166cbcd Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 11:05:08 +0200 Subject: [PATCH 3/7] 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 dc3ea6b9..d2b7a02e 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -577,7 +577,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 c86ebd07fd32b250ae080ab3d7195cc4c514fe22 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 13:28:00 +0200 Subject: [PATCH 4/7] 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 f4402326..0cd6874c 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -25,6 +25,8 @@ services: - ADMIN_TOKEN - DATABASE_URL - 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 df182ebe..c54c5705 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -67,6 +67,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 7adf1b50cfb65f7a503446f45bc4d522abeb2d20 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Tue, 14 Jul 2026 17:54:06 +0200 Subject: [PATCH 5/7] 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 8c57e0bd..a1e6789b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1104,8 +1104,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 0ce1a966..6fd68bfd 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 826aea858e4d49ddc37eeaeb64b1768ced360233 Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Sun, 12 Jul 2026 17:00:44 +0200 Subject: [PATCH 6/7] 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 d2b7a02e..b6ea68ff 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -52,10 +52,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, diff --git a/src/auth.rs b/src/auth.rs index 88a59b4b..7c4cbfbc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,10 +10,12 @@ use std::{ }; use chrono::{DateTime, TimeDelta, Utc}; +use data_encoding::BASE64URL_NOPAD; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; use serde::{de::DeserializeOwned, ser::Serialize}; +use serde_json::json; use rocket::{ outcome::try_outcome, @@ -64,6 +66,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 LOGIN_JWKS: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { use std::io::Error as IoError; @@ -90,6 +93,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() { @@ -98,9 +119,16 @@ 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") + } Ok(()) } +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 c33aa46f7ef2821a3aa2aeecb3b94daa9d23f5dd Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Wed, 22 Jul 2026 10:41:49 +0200 Subject: [PATCH 7/7] 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;