Browse Source

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.
Luca Biemelt 2 weeks ago
parent
commit
149e84c02f
  1. 1
      migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql
  2. 1
      migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql
  3. 1
      migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql
  4. 1
      migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql
  5. 1
      migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql
  6. 1
      migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql
  7. 94
      src/api/core/key_connector.rs
  8. 2
      src/api/core/mod.rs
  9. 9
      src/api/identity.rs
  10. 20
      src/config.rs
  11. 8
      src/db/models/organization.rs
  12. 9
      src/db/models/user.rs
  13. 1
      src/db/schema.rs
  14. 8
      src/sso.rs

1
migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN uses_key_connector;

1
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;

1
migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN uses_key_connector;

1
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;

1
migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN uses_key_connector;

1
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;

94
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<Route> {
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<i32>,
kdf_parallelism: Option<i32>,
#[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 = "<data>")]
async fn post_set_key_connector_key(data: Json<SetKeyConnectorKeyData>, 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() })
}

2
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<Route> {
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());

9
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);
}

20
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")

8
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,

9
src/db/models/user.rs

@ -69,6 +69,8 @@ pub struct User {
pub avatar_color: Option<String>,
pub external_id: Option<String>, // 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",
})

1
src/db/schema.rs

@ -217,6 +217,7 @@ table! {
api_key -> Nullable<Text>,
avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>,
uses_key_connector -> Bool,
}
}

8
src/sso.rs

@ -385,9 +385,15 @@ pub fn create_auth_tokens(
fn create_auth_tokens_impl(
device: &Device,
refresh_token: Option<String>,
access_claims: auth::LoginJwtClaims,
mut access_claims: auth::LoginJwtClaims,
access_token: String,
) -> ApiResult<AuthTokens> {
// 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(_) => {

Loading…
Cancel
Save