Browse Source

Merge c33aa46f7e into 660faee68e

pull/7419/merge
acul021 1 day ago
committed by GitHub
parent
commit
650482442e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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. 2
      playwright/docker-compose.yml
  8. 4
      playwright/test.env
  9. 72
      playwright/tests/setups/keyconnector.ts
  10. 61
      playwright/tests/sso_keyconnector.spec.ts
  11. 117
      src/api/core/key_connector.rs
  12. 2
      src/api/core/mod.rs
  13. 33
      src/api/identity.rs
  14. 28
      src/auth.rs
  15. 24
      src/config.rs
  16. 8
      src/db/models/organization.rs
  17. 9
      src/db/models/user.rs
  18. 1
      src/db/schema.rs
  19. 8
      src/sso.rs
  20. 10
      src/util.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;

2
playwright/docker-compose.yml

@ -25,6 +25,8 @@ services:
- ADMIN_TOKEN - ADMIN_TOKEN
- DATABASE_URL - DATABASE_URL
- I_REALLY_WANT_VOLATILE_STORAGE - I_REALLY_WANT_VOLATILE_STORAGE
- KEY_CONNECTOR_ENABLED
- KEY_CONNECTOR_URL
- LOG_LEVEL - LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST - LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST - SMTP_HOST

4
playwright/test.env

@ -67,6 +67,10 @@ SSO_CLIENT_SECRET=warden
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SSO_DEBUG_TOKENS=true 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 # Custom web-vault build
# PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git
# PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3

72
playwright/tests/setups/keyconnector.ts

@ -0,0 +1,72 @@
import { createServer } 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<string, string>();
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;
}
}

61
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();
});

117
src/api/core/key_connector.rs

@ -0,0 +1,117 @@
use rocket::Route;
use rocket::serde::json::Json;
use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult},
auth::Headers,
db::{
DbConn,
models::{Membership, MembershipType, UserId},
},
};
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,
}
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 = "<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;
if user.private_key.is_some() {
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;
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;
if user.private_key.is_none() {
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;
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 accounts;
pub mod key_connector;
pub mod two_factor; pub mod two_factor;
mod ciphers; mod ciphers;
@ -39,6 +40,7 @@ pub fn routes() -> Vec<Route> {
let mut routes = Vec::new(); let mut routes = Vec::new();
routes.append(&mut accounts::routes()); routes.append(&mut accounts::routes());
routes.append(&mut key_connector::routes());
routes.append(&mut ciphers::routes()); routes.append(&mut ciphers::routes());
routes.append(&mut emergency_access::routes()); routes.append(&mut emergency_access::routes());
routes.append(&mut events::routes()); routes.append(&mut events::routes());

33
src/api/identity.rs

@ -52,10 +52,27 @@ pub fn routes() -> Vec<Route> {
prevalidate, prevalidate,
authorize, authorize,
oidcsignin, 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<Value> {
Json(json!({
"issuer": *auth::JWT_LOGIN_ISSUER,
"jwks_uri": format!("{}/identity/.well-known/jwks", CONFIG.domain()),
}))
}
#[get("/.well-known/jwks")]
fn jwks() -> Json<Value> {
Json(auth::login_jwks().clone())
}
#[post("/connect/token", data = "<data>")] #[post("/connect/token", data = "<data>")]
async fn login( async fn login(
data: Form<ConnectData>, data: Form<ConnectData>,
@ -514,7 +531,12 @@ async fn authenticated_response(
let master_password_policy = master_password_policy(user, conn).await; 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 = 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 { let master_password_unlock = if has_master_password {
json!({ json!({
"Kdf": { "Kdf": {
@ -572,6 +594,13 @@ async fn authenticated_response(
result["Key"] = Value::String(user.akey.clone()); result["Key"] = Value::String(user.akey.clone());
} }
// 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();
}
if let Some(token) = twofactor_token { if let Some(token) = twofactor_token {
result["TwoFactorToken"] = Value::String(token); result["TwoFactorToken"] = Value::String(token);
} }

28
src/auth.rs

@ -10,10 +10,12 @@ use std::{
}; };
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use data_encoding::BASE64URL_NOPAD;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use openssl::rsa::Rsa; use openssl::rsa::Rsa;
use serde::{de::DeserializeOwned, ser::Serialize}; use serde::{de::DeserializeOwned, ser::Serialize};
use serde_json::json;
use rocket::{ use rocket::{
outcome::try_outcome, outcome::try_outcome,
@ -64,6 +66,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new(); static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
static LOGIN_JWKS: OnceLock<serde_json::Value> = OnceLock::new();
pub async fn initialize_keys() -> Result<(), Error> { pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error as IoError; 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()?; 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 enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?;
let dec: DecodingKey = DecodingKey::from_rsa_pem(&pub_key_buffer)?; let dec: DecodingKey = DecodingKey::from_rsa_pem(&pub_key_buffer)?;
if PRIVATE_RSA_KEY.set(enc).is_err() { 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() { if PUBLIC_RSA_KEY.set(dec).is_err() {
err!("PUBLIC_RSA_KEY must only be initialized once") 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(()) Ok(())
} }
pub fn login_jwks() -> &'static serde_json::Value {
LOGIN_JWKS.wait()
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String { pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) {
Ok(token) => token, Ok(token) => token,

24
src/config.rs

@ -799,6 +799,12 @@ make_config! {
sso { sso {
/// Enabled /// Enabled
sso_enabled: bool, true, def, false; 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 /// Only SSO login |> Disable Email+Master Password login
sso_only: bool, true, def, false; sso_only: bool, true, def, false;
/// Allow email association |> Associate existing non-SSO user based on email /// Allow email association |> Associate existing non-SSO user based on email
@ -1086,6 +1092,24 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; 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")
}
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)")
}
}
if cfg._enable_yubico { if cfg._enable_yubico {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { 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") 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, "usePolicies": true,
"useScim": false, // Not supported (Not AGPLv3 Licensed) "useScim": false, // Not supported (Not AGPLv3 Licensed)
"useSso": false, // Not supported "useSso": false, // Not supported
"useKeyConnector": false, // Not supported "useKeyConnector": CONFIG.key_connector_enabled(),
"usePasswordManager": true, "usePasswordManager": true,
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true, "selfHost": true,
@ -488,7 +488,7 @@ impl Membership {
"useResetPassword": CONFIG.mail_enabled(), "useResetPassword": CONFIG.mail_enabled(),
"ssoBound": false, // Not supported "ssoBound": false, // Not supported
"useSso": false, // Not supported "useSso": false, // Not supported
"useKeyConnector": false, "useKeyConnector": CONFIG.key_connector_enabled(),
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"usePasswordManager": true, "usePasswordManager": true,
"useCustomPermissions": true, "useCustomPermissions": true,
@ -503,8 +503,8 @@ impl Membership {
"familySponsorshipFriendlyName": null, "familySponsorshipFriendlyName": null,
"familySponsorshipAvailable": false, "familySponsorshipAvailable": false,
"productTierType": 3, // Enterprise tier "productTierType": 3, // Enterprise tier
"keyConnectorEnabled": false, "keyConnectorEnabled": CONFIG.key_connector_enabled(),
"keyConnectorUrl": null, "keyConnectorUrl": if CONFIG.key_connector_enabled() { Value::String(CONFIG.key_connector_url()) } else { Value::Null },
"familySponsorshipLastSyncDate": null, "familySponsorshipLastSyncDate": null,
"familySponsorshipValidUntil": null, "familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null, "familySponsorshipToDelete": null,

9
src/db/models/user.rs

@ -69,6 +69,8 @@ pub struct User {
pub avatar_color: Option<String>, pub avatar_color: Option<String>,
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore. 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)] #[derive(Identifiable, Queryable, Insertable)]
@ -154,6 +156,8 @@ impl User {
avatar_color: None, avatar_color: None,
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. 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(); let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
// TODO: Might want to save the status field in the DB // 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 UserStatus::Invited
} else { } else {
UserStatus::Enabled UserStatus::Enabled
@ -286,7 +291,7 @@ impl User {
"providerOrganizations": [], "providerOrganizations": [],
"forcePasswordReset": false, "forcePasswordReset": false,
"avatarColor": self.avatar_color, "avatarColor": self.avatar_color,
"usesKeyConnector": false, "usesKeyConnector": self.uses_key_connector,
"creationDate": format_date(&self.created_at), "creationDate": format_date(&self.created_at),
"object": "profile", "object": "profile",
}) })

1
src/db/schema.rs

@ -217,6 +217,7 @@ table! {
api_key -> Nullable<Text>, api_key -> Nullable<Text>,
avatar_color -> Nullable<Text>, avatar_color -> Nullable<Text>,
external_id -> 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( fn create_auth_tokens_impl(
device: &Device, device: &Device,
refresh_token: Option<String>, refresh_token: Option<String>,
access_claims: auth::LoginJwtClaims, mut access_claims: auth::LoginJwtClaims,
access_token: String, access_token: String,
) -> ApiResult<AuthTokens> { ) -> 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 { let (nbf, exp, token) = if let Some(rt) = refresh_token {
match decode_token_claims("refresh_token", &rt) { match decode_token_claims("refresh_token", &rt) {
Err(_) => { Err(_) => {

10
src/util.rs

@ -128,11 +128,21 @@ impl Fairing for AppHeaders {
https://app.addy.io/api/ \ https://app.addy.io/api/ \
https://api.fastmail.com/ \ https://api.fastmail.com/ \
https://api.forwardemail.net \ https://api.forwardemail.net \
{key_connector_src} \
{allowed_connect_src};\ {allowed_connect_src};\
", ",
icon_service_csp = CONFIG._icon_service_csp(), icon_service_csp = CONFIG._icon_service_csp(),
allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(), allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(),
allowed_connect_src = CONFIG.allowed_connect_src(), 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. 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() {
url::Url::parse(&CONFIG.key_connector_url())
.map_or_else(|_| String::new(), |u| u.origin().ascii_serialization())
} else {
String::new()
},
) )
}; };

Loading…
Cancel
Save