From e96145d557e53d0e156eefa37cf908d7fe96bf1d 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. --- .../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 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 2b1ddfb1..5f6f315b 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -541,7 +541,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": { @@ -599,6 +601,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 87bea195..259c8edc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -815,6 +815,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 @@ -1116,6 +1122,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 bdb69864..745f26be 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, @@ -495,7 +495,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, @@ -516,8 +516,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 93d750d5..9a06cdb3 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 @@ -303,7 +308,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 6de2d803..1efbe5e8 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 f432a5802a706cebbfc33393e6bd013c773b0808 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 5f6f315b..0f936d6c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -542,7 +542,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 259c8edc..657539b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1134,6 +1134,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 66c7fad7148dce03b480a727b58b60e0f228372f 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 0f936d6c..229bbb86 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -604,7 +604,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 3779f206850fe95e20c393a74b0e50114516390f 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 e28d22d3de71f41ecea39e74fedacc413a937ce7 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 657539b0..9dfd8415 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1134,8 +1134,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 1efbe5e8..13e2b9a3 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 eeaa898c68a422983f50bf8a53884e5dfdceaff4 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 229bbb86..77c648dd 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 762088e5..fcc5d5e5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,11 +10,13 @@ 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 serde::{de::DeserializeOwned, ser::Serialize}; +use serde_json::json; use rocket::{ outcome::try_outcome, @@ -65,6 +67,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; @@ -91,6 +94,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() { @@ -99,9 +120,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 46283cf651c81861bee5b68568cac2b670533de2 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 cab3edeeb990667933f671b63ca88ccc39a213f1 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 77c648dd..6a8cb8cb 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -30,9 +30,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, @@ -624,8 +624,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 9dfd8415..d304ff60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -821,6 +821,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