Browse Source

Improvements and tests

pull/7769/head
Timshel 2 days ago
parent
commit
db3726b623
  1. 5
      .env.template
  2. 17
      migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql
  3. 6
      migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql
  4. 47
      playwright/tests/setups/sso.ts
  5. 114
      playwright/tests/sso_trusted.spec.ts
  6. 26
      playwright/tests/sso_trusted_device.spec.ts
  7. 73
      src/api/core/accounts.rs
  8. 4
      src/api/identity.rs
  9. 2
      src/api/mod.rs
  10. 181
      src/api/user_decryption.rs
  11. 4
      src/config.rs
  12. 51
      src/db/models/device.rs
  13. 12
      src/db/models/organization.rs
  14. 10
      src/main.rs

5
.env.template

@ -558,13 +558,14 @@
## Use sso only for authentication not the session lifecycle
# SSO_AUTH_ONLY_NOT_SESSION=false
## Trusted Device Encryption (TDE) for SSO — adds TrustedDeviceOption to SSO login responses.
# SSO_TRUSTED_DEVICE_ENCRYPTION=false
## Client cache for discovery endpoint. Duration in seconds (0 to disable).
# SSO_CLIENT_CACHE_EXPIRATION=0
## Log all the tokens, LOG_LEVEL=debug is required
# SSO_DEBUG_TOKENS=false
## Trusted Device Encryption (TDE) for SSO — adds TrustedDeviceOption to SSO login responses (Bitwarden-compatible).
# SSO_TRUSTED_DEVICE_ENCRYPTION=false
########################
### MFA/2FA settings ###

17
migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql

@ -1,4 +1,13 @@
ALTER TABLE devices
ADD COLUMN encrypted_private_key TEXT NULL,
ADD COLUMN encrypted_public_key TEXT NULL,
ADD COLUMN encrypted_user_key TEXT NULL;
SELECT if (
NOT EXISTS(
SELECT DISTINCT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'devices'
AND column_name = 'encrypted_private_key'
)
,'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT NULL, ADD COLUMN encrypted_public_key TEXT NULL, ADD COLUMN encrypted_user_key TEXT NULL'
,'SELECT "info: column exist."'
) INTO @add_col_stmt;
PREPARE add_col_stmt FROM @add_col_stmt;
EXECUTE add_col_stmt;
DEALLOCATE PREPARE add_col_stmt;

6
migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql

@ -1,4 +1,4 @@
ALTER TABLE devices
ADD COLUMN encrypted_private_key TEXT NULL,
ADD COLUMN encrypted_public_key TEXT NULL,
ADD COLUMN encrypted_user_key TEXT NULL;
ADD COLUMN IF NOT EXISTS encrypted_private_key TEXT NULL,
ADD COLUMN IF NOT EXISTS encrypted_public_key TEXT NULL,
ADD COLUMN IF NOT EXISTS encrypted_user_key TEXT NULL;

47
playwright/tests/setups/sso.ts

@ -5,28 +5,48 @@ import * as OTPAuth from "otpauth";
import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa';
/**
* If a MailBuffer is passed it will be used and consume the expected emails
*/
export async function logNewUser(
export async function landing(
test: Test,
page: Page,
user: { email: string, name: string, password: string },
options: { mailBuffer?: MailBuffer } = {}
) {
await test.step(`Create user ${user.name}`, async () => {
options: { noReset?: bool } = {}
){
await test.step('Landing page', async () => {
if( !options.noReset ) {
await utils.cleanLanding(page);
}
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
}
export async function keycloak(
test: Test,
page: Page,
user: { name: string, password: string }
){
await test.step('Keycloak login', async () => {
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();
});
}
/**
* If a MailBuffer is passed it will be used and consume the expected emails
*/
export async function logNewUser(
test: Test,
page: Page,
user: { email: string, name: string, password: string },
options: { mailBuffer?: MailBuffer } = {}
) {
await test.step(`Create user ${user.name}`, async () => {
await landing(test, page, user);
await keycloak(test, page, user);
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
@ -70,18 +90,9 @@ export async function logUser(
let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => {
await test.step('Landing page', async () => {
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 landing(test, page, user);
await test.step('Keycloak login', async () => {
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();
});
await keycloak(test, page, user);
if( options.totp || options.mail2fa ){
let code;

114
playwright/tests/sso_trusted.spec.ts

@ -0,0 +1,114 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { keycloak, landing, logNewUser, logUser } from './setups/sso';
import { activateTOTP, disableTOTP } from './setups/2fa';
import * as utils from "../global-utils";
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_TRUSTED_DEVICE_ENCRYPTION: true,
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
export async function startTrusted(test: Test, page: Page) {
await landing(test, page, users.user1);
await keycloak(test, page, users.user1);
await test.step('Approval required', async () => {
await expect(page.getByRole('heading', { name: 'Device approval required' })).toBeVisible();
})
}
export async function trustedUnlock(test: Test, page: Page) {
await test.step('Unlock', async () => {
await page.getByRole('button', { name: users.user1.name, exact: true }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await landing(test, page, users.user1, { noReset: true });
await expect(page).toHaveTitle(/Vaults/);
});
}
test('Trusted', async ({ browser, page }) => {
// No change to onboarding
await logNewUser(test, page, users.user1);
await test.step('Password', async () => {
await startTrusted(test, page);
await test.step('Only password', async () => {
await expect(page.getByRole('button', { name: 'Approve from your other device' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Request admin approval' })).toHaveCount(0);
});
await test.step('Activate', async () => {
await page.getByRole('button', { name: 'Use master password' }).click();
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Unlock' }).click();
});
await test.step('Activated', async () => {
await expect(page).toHaveTitle(/Vaults/);
await utils.checkNotification(page, 'Device Trusted');
});
await trustedUnlock(test, page);
});
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await test.step('Approval', async () => {
await startTrusted(test, page2);
await test.step('Request', async () => {
await page2.getByRole('button', { name: 'Approve from your other device' }).click();
await expect(page2.getByRole('heading', { name: 'Request sent' })).toBeVisible();
});
await test.step('Validate', async () => {
await page.getByText('You have a pending login').click();
await page.getByRole('link', { name: 'Review login request' }).click();
await expect(page.getByRole('heading', { name: 'Devices' })).toBeVisible();
await page.getByRole('row').filter({hasText: "Request pending"}).getByRole('link').click();
await page.getByRole('button', { name: 'Confirm access' }).click();
await utils.checkNotification(page, 'Login request approved');
});
await test.step('Validated', async () => {
await expect(page2).toHaveTitle(/Vaults/);
await utils.checkNotification(page2, 'Login Approved');
await utils.checkNotification(page2, 'Device Trusted');
});
await trustedUnlock(test, page2);
});
await test.step('Invalidate', async () => {
await page.getByRole('link', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Deauthorise sessions' }).click();;
await expect(page.getByRole('heading', { name: 'Deauthorise sessions' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Deauthorise sessions' }).click();
});
await test.step('Invalidated', async () => {
await landing(test, page, users.user1, { noReset: true });
await page.getByRole('heading', { name: 'Device approval required' }).click();
await landing(test, page2, users.user1, { noReset: true });
await page2.getByRole('heading', { name: 'Device approval required' }).click();
});
await context2.close();
});

26
playwright/tests/sso_trusted_device.spec.ts

@ -1,26 +0,0 @@
import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from '../global-utils';
/**
* Web-first checks for SSO + trusted-device (TDE) support:
* - `sso-connector.html` must be served for browser OIDC redirect.
*/
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: 'true',
SSO_ONLY: 'false',
SSO_TRUSTED_DEVICE_ENCRYPTION: 'true',
});
});
test.afterAll('Teardown', async () => {
utils.stopVault();
});
test('Web vault serves sso-connector.html for browser SSO', async ({ request }) => {
const res = await request.get('/sso-connector.html');
expect(res.ok(), await res.text()).toBeTruthy();
const ct = res.headers()['content-type'] || '';
expect(ct).toMatch(/text\/html/i);
});

73
src/api/core/accounts.rs

@ -66,14 +66,12 @@ pub fn routes() -> Vec<rocket::Route> {
get_known_device,
get_all_devices,
get_device,
post_device_lost_trust,
post_device_token,
put_device_token,
put_clear_device_token,
post_clear_device_token,
put_device_keys,
post_device_keys,
put_device_keys_by_uuid,
post_device_keys_by_uuid,
get_tasks,
post_auth_request,
get_auth_request,
@ -1518,6 +1516,18 @@ async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
})))
}
#[post("/devices/lost-trust")]
async fn post_device_lost_trust(headers: Headers, conn: DbConn) -> JsonResult {
let mut device = headers.device;
device.encrypted_user_key = None;
device.encrypted_public_key = None;
device.encrypted_private_key = None;
device.save(true, &conn).await?;
Ok(Json(json!({})))
}
#[get("/devices/identifier/<device_id>")]
async fn get_device(device_id: DeviceId, headers: Headers, conn: DbConn) -> JsonResult {
let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else {
@ -1593,16 +1603,19 @@ async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn
put_clear_device_token(device_id, ip, conn).await
}
// https://github.com/bitwarden/server/blob/v2026.3.1/src/Api/Controllers/DevicesController.cs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeviceKeysData {
#[serde(alias = "EncryptedUserKey")]
encrypted_user_key: String,
#[serde(alias = "EncryptedPublicKey")]
encrypted_public_key: String,
#[serde(alias = "EncryptedPrivateKey")]
encrypted_private_key: String,
}
#[put("/devices/identifier/<device_id>/keys", data = "<data>")]
// https://github.com/bitwarden/server/blob/v2026.3.1/src/Api/Controllers/DevicesController.cs
#[put("/devices/<device_id>/keys", data = "<data>")]
async fn put_device_keys(
device_id: DeviceId,
data: Json<DeviceKeysData>,
@ -1612,52 +1625,16 @@ async fn put_device_keys(
if headers.device.uuid != device_id {
err!("No device found");
}
let Some(mut device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else {
err!("No device found");
};
let data = data.into_inner();
if data.encrypted_user_key.is_empty()
|| data.encrypted_public_key.is_empty()
|| data.encrypted_private_key.is_empty()
{
err!("Invalid device keys");
}
device.encrypted_user_key = Some(data.encrypted_user_key);
device.encrypted_public_key = Some(data.encrypted_public_key);
device.encrypted_private_key = Some(data.encrypted_private_key);
device.save(true, &conn).await?;
Ok(Json(device.to_json()))
}
#[post("/devices/identifier/<device_id>/keys", data = "<data>")]
async fn post_device_keys(
device_id: DeviceId,
data: Json<DeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
put_device_keys(device_id, data, headers, conn).await
}
let mut device = headers.device;
let data = data.into_inner();
// Bitwarden server: `PUT|POST devices/{identifier}/keys` (not `devices/identifier/.../keys`).
#[put("/devices/<device_id>/keys", data = "<data>")]
async fn put_device_keys_by_uuid(
device_id: DeviceId,
data: Json<DeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
put_device_keys(device_id, data, headers, conn).await
}
device.encrypted_user_key = Some(data.encrypted_user_key).filter(|k| !k.is_empty());
device.encrypted_public_key = Some(data.encrypted_public_key).filter(|k| !k.is_empty());
device.encrypted_private_key = Some(data.encrypted_private_key).filter(|k| !k.is_empty());
device.save(true, &conn).await?;
#[post("/devices/<device_id>/keys", data = "<data>")]
async fn post_device_keys_by_uuid(
device_id: DeviceId,
data: Json<DeviceKeysData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
put_device_keys(device_id, data, headers, conn).await
Ok(Json(device.to_json()))
}
#[get("/tasks")]

4
src/api/identity.rs

@ -543,7 +543,7 @@ async fn authenticated_response(
let master_password_policy = master_password_policy(user, conn).await;
let user_decryption_options =
super::user_decryption::build_token_user_decryption_options(user, device, conn, sso_login).await;
super::user_decryption::build_token_user_decryption_options(user, device, sso_login, conn).await;
let account_keys = if user.private_key.is_some() {
json!({
@ -674,7 +674,7 @@ async fn user_api_key_login(
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
let user_decryption_options =
super::user_decryption::build_token_user_decryption_options(&user, &device, conn, false).await;
super::user_decryption::build_token_user_decryption_options(&user, &device, false, conn).await;
let account_keys = if user.private_key.is_some() {
json!({

2
src/api/mod.rs

@ -4,7 +4,7 @@ mod icons;
mod identity;
mod notifications;
mod push;
pub(crate) mod user_decryption;
mod user_decryption;
mod web;
use rocket::serde::json::Json;

181
src/api/user_decryption.rs

@ -7,144 +7,22 @@ use serde_json::{Value, json};
use crate::CONFIG;
use crate::db::DbConn;
use crate::db::models::{Device, Membership, SsoUser, User, UserId};
use crate::db::models::{Device, Membership, SsoUser, User};
/// Device types that may approve “login with device” / trusted-device flows (see Bitwarden `LoginApprovingClientTypes`).
pub fn device_type_can_approve_trusted_login(atype: i32) -> bool {
!matches!(atype, 21..=25) // SDK, Server, CLIs
}
async fn has_login_approving_device(user_uuid: &UserId, current: &Device, conn: &DbConn) -> bool {
let devices = Device::find_by_user(user_uuid, conn).await;
devices.iter().any(|d| d.uuid != current.uuid && device_type_can_approve_trusted_login(d.atype))
}
fn has_valid_reset_password_key(m: &Membership) -> bool {
m.reset_password_key.as_ref().is_some_and(|s| !s.trim().is_empty())
}
/// Owner or Admin (Vaultwarden does not persist custom-role JSON for `manageResetPassword` on members).
fn membership_has_manage_reset_password(m: &Membership) -> bool {
matches!(m.atype, 0 | 1)
}
async fn aggregate_trusted_device_flags(user: &User, device: &Device, conn: &DbConn) -> (bool, bool, bool) {
let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let has_admin_approval = members.iter().any(has_valid_reset_password_key);
let has_manage_reset = members.iter().any(membership_has_manage_reset_password);
let has_login_approving = has_login_approving_device(&user.uuid, device, conn).await;
(has_admin_approval, has_manage_reset, has_login_approving)
}
/// Sync may be called long after SSO login; include TDE hints for users linked to SSO.
async fn user_in_sso_context(user_uuid: &UserId, conn: &DbConn) -> bool {
if !CONFIG.sso_enabled() {
return false;
}
SsoUser::find_by_user(user_uuid, conn).await.is_some()
}
fn trusted_device_option_token(
has_admin_approval: bool,
has_login_approving_device: bool,
has_manage_reset_password_permission: bool,
is_tde_offboarding: bool,
device: &Device,
) -> Value {
let (enc_priv, enc_user) = if device.is_trusted() {
(
device.encrypted_private_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null),
device.encrypted_user_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null),
)
} else {
(Value::Null, Value::Null)
};
json!({
"HasAdminApproval": has_admin_approval,
"HasLoginApprovingDevice": has_login_approving_device,
"HasManageResetPasswordPermission": has_manage_reset_password_permission,
"IsTdeOffboarding": is_tde_offboarding,
"EncryptedPrivateKey": enc_priv,
"EncryptedUserKey": enc_user,
})
}
fn trusted_device_option_sync(
has_admin_approval: bool,
has_login_approving_device: bool,
has_manage_reset_password_permission: bool,
is_tde_offboarding: bool,
device: &Device,
) -> Value {
let (enc_priv, enc_user) = if device.is_trusted() {
(
device.encrypted_private_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null),
device.encrypted_user_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null),
)
} else {
(Value::Null, Value::Null)
};
json!({
"hasAdminApproval": has_admin_approval,
"hasLoginApprovingDevice": has_login_approving_device,
"hasManageResetPasswordPermission": has_manage_reset_password_permission,
"isTdeOffboarding": is_tde_offboarding,
"encryptedPrivateKey": enc_priv,
"encryptedUserKey": enc_user,
})
pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbConn) -> Value {
let with_trusted =
CONFIG.sso_enabled() && (CONFIG.sso_only() || SsoUser::find_by_user(&user.uuid, conn).await.is_some());
build_token_user_decryption_options(user, device, with_trusted, conn).await
}
/// `UserDecryptionOptions` for `POST /identity/connect/token` (PascalCase, Bitwarden Identity).
// Bitwarden only builds trusted-device options when SSO Identity context exists (authorization_code grant).
// Do not return the Trusted information if there is no master password (otherwise onboarding does not allow setting one)
pub async fn build_token_user_decryption_options(
user: &User,
device: &Device,
with_trusted: bool,
conn: &DbConn,
sso_login: bool,
) -> Value {
let has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
json!({
"Kdf": {
"KdfType": user.client_kdf_type,
"Iterations": user.client_kdf_iter,
"Memory": user.client_kdf_memory,
"Parallelism": user.client_kdf_parallelism
},
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
})
} else {
Value::Null
};
let mut out = json!({
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"Object": "userDecryptionOptions"
});
// Bitwarden only builds trusted-device options when SSO Identity context exists (authorization_code grant).
if !sso_login {
return out;
}
let is_tde_active = CONFIG.sso_trusted_device_encryption();
let is_tde_offboarding = !has_master_password && device.is_trusted() && !is_tde_active;
if !is_tde_active && !is_tde_offboarding {
return out;
}
let (ha, hm, hl) = aggregate_trusted_device_flags(user, device, conn).await;
out["TrustedDeviceOption"] = trusted_device_option_token(ha, hl, hm, is_tde_offboarding, device);
out
}
/// `userDecryption` object on full sync (camelCase nested keys; see `GET /sync`).
pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbConn) -> Value {
let has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
json!({
@ -163,34 +41,43 @@ pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbC
};
let mut out = json!({
"hasMasterPassword": has_master_password,
"masterPasswordUnlock": master_password_unlock,
"userKeyId": user.key_id,
"object": "userDecryptionOptions"
});
if with_trusted && CONFIG.sso_trusted_device_encryption() && has_master_password {
let mut trusted = json!({
"hasAdminApproval": false,
"hasLoginApprovingDevice": has_login_approving_device(user, device, conn).await,
"hasManageResetPasswordPermission": is_owner_admin(user, conn).await,
"isTdeOffboarding": false,
});
if !user_in_sso_context(&user.uuid, conn).await {
return out;
if let Some(key) = device.encrypted_user_key.as_ref() {
trusted["encryptedUserKey"] = json!(key);
trusted["EncryptedUserKey"] = json!(key);
}
let is_tde_active = CONFIG.sso_trusted_device_encryption();
let is_tde_offboarding = !has_master_password && device.is_trusted() && !is_tde_active;
if let Some(key) = device.encrypted_private_key.as_ref() {
trusted["encryptedPrivateKey"] = json!(key);
trusted["EncryptedPrivateKey"] = json!(key);
}
if !is_tde_active && !is_tde_offboarding {
return out;
out["trustedDeviceOption"] = trusted.clone();
out["TrustedDeviceOption"] = trusted;
}
let (ha, hm, hl) = aggregate_trusted_device_flags(user, device, conn).await;
out["trustedDeviceOption"] = trusted_device_option_sync(ha, hl, hm, is_tde_offboarding, device);
out
}
#[cfg(test)]
mod tests {
use super::*;
// Details on trusted settings:
// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/auth/src/common/models/domain/user-decryption-options.ts#L114
async fn is_owner_admin(user: &User, conn: &DbConn) -> bool {
Membership::find_confirmed_by_user(&user.uuid, conn).await.iter().any(|m| m.is_owner() || m.is_admin())
}
#[test]
fn device_type_approver_excludes_cli_and_server() {
assert!(device_type_can_approve_trusted_login(14));
assert!(!device_type_can_approve_trusted_login(22));
assert!(!device_type_can_approve_trusted_login(23));
}
async fn has_login_approving_device(user: &User, device: &Device, conn: &DbConn) -> bool {
Device::find_by_user(&user.uuid, conn).await.iter().any(|d| d.uuid != device.uuid && d.can_approve_trusted_login())
}

4
src/config.rs

@ -843,12 +843,12 @@ make_config! {
sso_master_password_policy: String, true, option;
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
sso_auth_only_not_session: bool, true, def, false;
/// Trusted Device Encryption (TDE) for SSO |> When enabled, SSO token responses include `TrustedDeviceOption` per Bitwarden Identity (`UserDecryptionOptions`). Requires clients that support TDE. See: https://bitwarden.com/help/sso-decryption-options/
sso_trusted_device_encryption: bool, true, def, false;
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false;
/// Trusted Device Encryption (TDE) for SSO |> When enabled, SSO token responses include `TrustedDeviceOption` per Bitwarden Identity (`UserDecryptionOptions`). Requires clients that support TDE. See: https://bitwarden.com/help/sso-decryption-options/
sso_trusted_device_encryption: bool, true, def, false;
},
/// Yubikey settings

51
src/db/models/device.rs

@ -74,9 +74,7 @@ impl Device {
/// Matches upstream `DeviceExtensions.IsTrusted` / device list responses.
pub fn is_trusted(&self) -> bool {
self.encrypted_user_key.as_ref().is_some_and(|s| !s.is_empty())
&& self.encrypted_public_key.as_ref().is_some_and(|s| !s.is_empty())
&& self.encrypted_private_key.as_ref().is_some_and(|s| !s.is_empty())
self.encrypted_user_key.is_some() && self.encrypted_public_key.is_some() && self.encrypted_private_key.is_some()
}
pub fn to_json(&self) -> Value {
@ -87,13 +85,13 @@ impl Device {
"identifier": self.uuid,
"creationDate": format_date(&self.created_at),
"isTrusted": self.is_trusted(),
"encryptedUserKey": Self::enc_string_json(&self.encrypted_user_key),
"encryptedPublicKey": Self::enc_string_json(&self.encrypted_public_key),
"encryptedUserKey": Self::enc_string_json(self.encrypted_user_key.as_ref()),
"encryptedPublicKey": Self::enc_string_json(self.encrypted_public_key.as_ref()),
"object":"device"
})
}
fn enc_string_json(v: &Option<String>) -> Value {
fn enc_string_json(v: Option<&String>) -> Value {
match v {
Some(s) if !s.is_empty() => Value::String(s.clone()),
_ => Value::Null,
@ -128,7 +126,42 @@ impl Device {
}
pub fn is_mobile(&self) -> bool {
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios)
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios | DeviceType::AndroidAmazon)
}
pub fn is_browser(&self) -> bool {
matches!(
DeviceType::from_i32(self.atype),
DeviceType::ChromeBrowser
| DeviceType::FirefoxBrowser
| DeviceType::OperaBrowser
| DeviceType::EdgeBrowser
| DeviceType::IEBrowser
| DeviceType::UnknownBrowser
| DeviceType::DuckDuckGoBrowser
)
}
pub fn is_desktop(&self) -> bool {
matches!(
DeviceType::from_i32(self.atype),
DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop
)
}
pub fn is_extension(&self) -> bool {
matches!(
DeviceType::from_i32(self.atype),
DeviceType::ChromeExtension
| DeviceType::FirefoxExtension
| DeviceType::OperaExtension
| DeviceType::EdgeExtension
)
}
// https://github.com/bitwarden/server/blob/v2026.4.2/src/Identity/Utilities/LoginApprovingClientTypes.cs
pub fn can_approve_trusted_login(&self) -> bool {
self.is_browser() || self.is_extension() || self.is_desktop() || self.is_mobile()
}
}
@ -151,8 +184,8 @@ impl DeviceWithAuthRequest {
"creationDate": format_date(&self.device.created_at),
"devicePendingAuthRequest": auth_request,
"isTrusted": self.device.is_trusted(),
"encryptedPublicKey": Device::enc_string_json(&self.device.encrypted_public_key),
"encryptedUserKey": Device::enc_string_json(&self.device.encrypted_user_key),
"encryptedPublicKey": Device::enc_string_json(self.device.encrypted_public_key.as_ref()),
"encryptedUserKey": Device::enc_string_json(self.device.encrypted_user_key.as_ref()),
"object": "device",
})
}

12
src/db/models/organization.rs

@ -56,6 +56,18 @@ pub struct Membership {
pub external_id: Option<String>,
}
impl Membership {
#[inline(always)]
pub fn is_admin(&self) -> bool {
self.atype == MembershipType::Admin as i32
}
#[inline(always)]
pub fn is_owner(&self) -> bool {
self.atype == MembershipType::Owner as i32
}
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = organization_api_key)]
#[diesel(primary_key(uuid, org_uuid))]

10
src/main.rs

@ -548,16 +548,6 @@ fn check_web_vault() {
error!("You can also set the environment variable 'WEB_VAULT_ENABLED=false' to disable it");
exit(1);
}
if CONFIG.sso_enabled() {
let sso_connector = Path::new(&CONFIG.web_vault_folder()).join("sso-connector.html");
if !sso_connector.is_file() {
warn!(
"Web vault is missing 'sso-connector.html' at '{}'. Browser OIDC SSO redirects to this file; install a current web vault or disable SSO.",
sso_connector.display()
);
}
}
}
async fn create_db_pool() -> db::DbPool {

Loading…
Cancel
Save