Browse Source

Fix correctness, test reliability, and observability issues

Server hardening:

- accounts.rs: reject duplicate ids in `passkey_unlock_data` before they
  collapse through the HashSet superset check and silently double-apply
  with the second write winning.
- accounts.rs: re-check the PRF credential set immediately before
  `set_password` commits the new account key. A concurrent enrol between
  the validation snapshot and the rewrap loop would otherwise leave the
  new credential's `encrypted_user_key` wrapped under the OLD akey,
  permanently broken once the rotation commits.
- mod.rs: drop the legacy bare-`PasskeyRegistration` fallback in
  `passkey_registration_challenge_state` — no writer produces that
  shape, and the fallback permitted token-less finishes against a
  hypothetical un-tokened row.
- mod.rs: switch token compares in both challenge-state extractors to
  `crypto::ct_eq` (matches the `cred_id` pattern already used at
  mod.rs:1424).
- mod.rs: normalise `passkey_assertion_challenge_state`'s deserialise
  error to the generic "Invalid assertion challenge" rather than leaking
  the underlying serde shape.
- mod.rs: document why `post_api_webauthn_delete` is intentionally
  exempt from the SSO_ONLY gate (delete narrows capability, never
  grants it; session is still SSO-authed).
- mod.rs: document that the post-`start_passkey_registration` mutation
  of `authenticator_selection.require_resident_key` is a client-side
  hint only (webauthn-rs destructures the field as unused), so readers
  don't mistake it for a server-enforced policy.

Observability:

- web_authn_credential.rs / two_factor.rs: log Diesel errors from the
  SELECT+DELETE transactions in `WebAuthnLoginChallenge::take` and
  `TwoFactor::take_by_user_and_type` before collapsing to `None`. The
  prior `.unwrap_or(None)` made degraded DB behaviour (deadlock, lock
  timeout, conn drop) indistinguishable from a normal stale-token
  rejection.
- identity.rs: log the failure path of `passkey_transports`'s
  `serde_json::to_value(passkey)` instead of silently emitting
  `Transports: []`. Clients can't otherwise distinguish "authenticator
  reported no transports" from "we failed to serialise the passkey".

UX:

- vaultwarden.scss.hbs: re-add the `.vw-passkey-login` hide under
  `sso_enabled && sso_only`. Without it the login page renders the
  "Log in with passkey" button under SSO_ONLY, but the server gate at
  `identity.rs:1250` rejects the click — UX dead end.

Test reliability:

- passkey.spec.ts: the "Non-PRF passkey" test now logs out + logs back
  in via passkey between enrol and the lock-screen check. Without
  this refresh the lock-screen reads the pre-enrol cached `/api/sync`
  and the assertion passes for the wrong reason (cached
  webAuthnPrfOptions was empty before enrol regardless of what the
  server stored).
- passkey.spec.ts: guard the three describe blocks that restart the
  vault container (`Passkey grant is rejected when SSO_ONLY is on`,
  `Passkey enrolment is rejected when SSO_ONLY is on`, `Passkey login
  rejects forged unverified-email handles`) on `useExternalVault` so
  `PW_USE_EXTERNAL_VAULT=1` (host-cargo iteration) cleanly skips them
  instead of colliding with the host process on port 8003.
- passkey.spec.ts: the SSO_ONLY-enrolment `afterAll` now also blanks
  `sso_authority`, `sso_client_id`, `sso_client_secret` — the previous
  reset left those placeholders in `config.json` and contaminated any
  later test that toggled `sso_enabled=true`.
- passkey.spec.ts: the bearer-sniffer in the SSO_ONLY-enrolment
  beforeAll now filters on `url.includes('/api/')` instead of capturing
  whatever token happened to fly last during createAccount; mirrors
  the disciplined sniffer in `account_lifecycle.spec.ts:178` and
  insulates the test from web-vault request-order churn.
- 2fa.ts: export `resetTotpTimeStep()`; call it from the
  `test.beforeEach` in `account_lifecycle.spec.ts` and `passkey.spec.ts`
  so the module-scoped `lastSubmittedTotpTimeStep` doesn't leak across
  tests / projects under `workers: 1` (would otherwise force a 30s
  wait before the first TOTP of project N+1 based on project N's
  history).
- global-utils.ts: document the `resetDB=false` contract honestly —
  docker recreates the Vaultwarden container on any env-var change in
  the compose `environment:` block, dropping the tmpfs sqlite DB along
  with the in-process RSA key.
- login.smtp.spec.ts: drop the redundant "Dismiss extension prompts"
  step from the 2fa test. `logUser` already calls
  `utils.ignoreExtension` and lands on /vault, so the explicit "Add
  it later" click timed out on a button the prior step had already
  dismissed.

Test coverage additions:

- accounts.rs rotation validator: add a regression test for the
  duplicate-id rejection in `passkey_unlock_data`. With a HashSet-only
  superset check the rewrap loop in `post_rotatekey` could silently
  apply two updates to the same credential id (second blob wins); the
  test pins the new len-equality guard.
- mod.rs registration-challenge state: replace
  `legacy_registration_challenge_rejects_finish_token` (which asserted
  a "legacy bare-`PasskeyRegistration` accepted without token" fallback
  no longer present) with
  `registration_challenge_rejects_unwrapped_legacy_state`, matching
  the assertion-side test's stricter contract: any blob that's not the
  `{token, state}` wrapper is rejected regardless of whether a token
  is sent. Pins the token-binding bypass closure.
pull/7297/head
Zaid Marji 2 weeks ago
parent
commit
247a8905d8
  1. 15
      playwright/global-utils.ts
  2. 11
      playwright/tests/account_lifecycle.spec.ts
  3. 47
      playwright/tests/passkey.spec.ts
  4. 12
      playwright/tests/setups/2fa.ts
  5. 38
      src/api/core/accounts.rs
  6. 71
      src/api/core/mod.rs
  7. 24
      src/api/identity.rs
  8. 16
      src/db/models/two_factor.rs
  9. 15
      src/db/models/web_authn_credential.rs
  10. 11
      src/static/templates/scss/vaultwarden.scss.hbs

15
playwright/global-utils.ts

@ -175,7 +175,20 @@ function dbConfig(testInfo: TestInfo){
}
/**
* All parameters passed in `env` need to be added to the docker-compose.yml
* All parameters passed in `env` need to be added to the docker-compose.yml.
*
* `resetDB=false` skips the explicit DB wipe, but it does NOT guarantee the
* DB survives across calls: `docker compose up -d Vaultwarden` recreates
* the container whenever any env var listed in
* `playwright/docker-compose.yml`'s `environment:` block
* differs from the previous run, and recreation drops the tmpfs-backed
* sqlite DB along with the in-process RSA signing key. So
* `resetDB=false` reliably preserves state only when consecutive
* `startVault` calls pass the SAME env. If you need to preserve user
* state across an env change, toggle the relevant settings via
* `POST /admin/config` instead of restarting (see the
* `Passkey enrolment is rejected when SSO_ONLY is on` describe in
* `playwright/tests/passkey.spec.ts` for the pattern).
**/
export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) {
if( resetDB ){

11
playwright/tests/account_lifecycle.spec.ts

@ -3,7 +3,7 @@ import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as utils from '../global-utils';
import { createAccount, logUser as logUserMP } from './setups/user';
import { logNewUser as ssoLogNewUser, logUser as logUserSSO } from './setups/sso';
import { activateTOTP, disableTOTP, type TwoFactor } from './setups/2fa';
import { activateTOTP, disableTOTP, resetTotpTimeStep, type TwoFactor } from './setups/2fa';
import {
addVirtualAuthenticator,
changeKdfIterations,
@ -87,7 +87,14 @@ test.afterAll('Teardown', async () => {
// CDP sessions are bound to a specific Page; Playwright recycles the page
// between tests, so drop the cached session/authenticator IDs each time
// (the next `addVirtualAuthenticator` lazily re-establishes them).
test.beforeEach(() => resetVirtualAuthenticators());
test.beforeEach(() => {
resetVirtualAuthenticators();
// `submitTwoFactor`'s TOTP `last_used` cache is module-scoped and would
// otherwise leak across tests (and across projects under `workers: 1`)
// — drop it so each test's first TOTP submission isn't paying a 30s
// boundary-wait based on a different test's history.
resetTotpTimeStep();
});
const MP = 'Master Password';

47
playwright/tests/passkey.spec.ts

@ -2,6 +2,7 @@ import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from '../global-utils';
import { createAccount } from './setups/user';
import { resetTotpTimeStep } from './setups/2fa';
import {
addVirtualAuthenticator,
clickLoginWithPasskey,
@ -32,7 +33,12 @@ test.afterAll('Teardown', async () => {
// CDP sessions are bound to a specific Page; Playwright recycles the page
// between tests, so drop the cached session/authenticator IDs each time
// (no-op for the request-only suites below; only the UI flows touch CDP).
test.beforeEach(() => resetVirtualAuthenticators());
// Also drop `submitTwoFactor`'s TOTP `last_used` cache to avoid cross-test
// boundary-wait penalties — see `resetTotpTimeStep` doc.
test.beforeEach(() => {
resetVirtualAuthenticators();
resetTotpTimeStep();
});
// ---------------------------------------------------------------------------
// Unauthenticated API surface — `GET /identity/accounts/webauthn/assertion-options`
@ -410,6 +416,7 @@ test.describe('Passkey grant is rejected when SSO_ONLY is on', () => {
// vault with SSO_ENABLED + SSO_ONLY for this describe's tests, then
// restart with default config in afterAll.
test.beforeAll('Start vault with SSO_ONLY', async ({ browser }, testInfo) => {
test.skip(useExternalVault, 'cannot reconfigure an externally-running vault from the test process');
utils.stopVault(true);
await utils.startVault(browser, testInfo, {
SSO_ENABLED: 'true',
@ -421,6 +428,7 @@ test.describe('Passkey grant is rejected when SSO_ONLY is on', () => {
});
test.afterAll('Restore default vault', async ({ browser }, testInfo) => {
if (useExternalVault) return;
utils.stopVault(true);
await utils.startVault(browser, testInfo, {}, false);
});
@ -452,7 +460,7 @@ test.describe('Passkey grant is rejected when SSO_ONLY is on', () => {
test.describe('Passkey enrolment is rejected when SSO_ONLY is on', () => {
// Defends the deny-by-default gate on the management-side endpoints
// (`src/api/core/mod.rs` lines 308, 390, 459, 516 — guarded by
// `sso_enabled() && sso_only() && !sso_only_allow_passkey_unlock()`).
// `sso_enabled() && sso_only()`).
//
// The enrol endpoints are authenticated, so we need a Bearer token
// to reach the gate; under `SSO_ONLY=true` fresh logins must go
@ -474,10 +482,17 @@ test.describe('Passkey enrolment is rejected when SSO_ONLY is on', () => {
};
test.beforeAll('Provision user, sniff bearer, flip SSO_ONLY via /admin/config', async ({ browser, request }) => {
test.skip(useExternalVault, 'toggling SSO config via /admin would mutate the externally-running vault');
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await ctx.newPage();
const tokens: string[] = [];
page.on('request', req => {
// Filter on /api/ to avoid latching onto a non-vault token
// (e.g. analytics, manifest fetches that some Bitwarden builds
// attach a different bearer to). The /api/ endpoints are the
// ones we'll exercise post-toggle, so capturing their bearer
// is what we need.
if (!req.url().includes('/api/')) return;
const auth = req.headers()['authorization'];
if (auth?.startsWith('Bearer ')) tokens.push(auth.slice('Bearer '.length));
});
@ -500,9 +515,14 @@ test.describe('Passkey enrolment is rejected when SSO_ONLY is on', () => {
});
test.afterAll('Toggle SSO back off', async ({ request }) => {
if (useExternalVault) return;
await adminLogin(request);
// Blank the SSO authority/client too — without this the throwaway
// http://127.0.0.1:65535/realms/test placeholder lingers in
// config.json and contaminates any later test that toggles
// `sso_enabled=true` without rewriting the URL.
await request.post('/admin/config', {
data: { sso_enabled: false, sso_only: false },
data: { sso_enabled: false, sso_only: false, sso_authority: '', sso_client_id: '', sso_client_secret: '' },
failOnStatusCode: false,
});
});
@ -526,6 +546,7 @@ test.describe('Passkey login rejects forged unverified-email handles with the ge
// CONFIG.mail_enabled() returns true. Restart vault with that config; the
// new signup lands in DB with verified_at = NULL.
test.beforeAll('Start vault with SIGNUPS_VERIFY', async ({ browser }, testInfo) => {
test.skip(useExternalVault, 'cannot reconfigure an externally-running vault from the test process');
utils.stopVault(true);
await utils.startVault(browser, testInfo, {
SIGNUPS_VERIFY: 'true',
@ -538,6 +559,7 @@ test.describe('Passkey login rejects forged unverified-email handles with the ge
});
test.afterAll('Restore default vault', async ({ browser }, testInfo) => {
if (useExternalVault) return;
utils.stopVault(true);
await utils.startVault(browser, testInfo, {}, true);
});
@ -735,13 +757,22 @@ test.describe('Passkey UI flows', () => {
await createAccount(test, page, user);
// `useForEncryption: false` enrols the credential without the
// PRF-wrapped user-key blobs; /api/sync's `webAuthnPrfOptions` stays
// empty (already pinned by `account_lifecycle.spec.ts`'s wire-shape
// probe), so the lock-screen "Unlock with passkey" button must stay
// hidden even though the credential is registered.
// PRF-wrapped user-key blobs. The credential can still authenticate
// the user via the discoverable-login grant (login affordance), but
// /api/sync's `webAuthnPrfOptions` stays empty so the lock-screen
// "Unlock with passkey" button must stay hidden (unlock affordance).
await enrollLoginPasskey(page, user.password, 'noprf-key', { useForEncryption: false });
await lockVault(page, user.name);
// Log out + log back in via passkey to exercise the "login
// affordance present" half of the test's title. Without PRF-wrapped
// key blobs, authentication succeeds but vault decryption cannot, so
// the expected destination is the MP lock screen.
await utils.logout(test, page, user);
await clickLoginWithPasskey(page);
await expect(page).toHaveURL(/\/lock/, { timeout: 30_000 });
// Non-PRF credential is in the DB, but webAuthnPrfOptions is empty,
// so the lock screen must not surface the passkey-unlock button.
await expectLockScreenButtons(page, false);
});

12
playwright/tests/setups/2fa.ts

@ -79,6 +79,18 @@ async function ensure2FAProvider(page: Page, kind: TwoFactor['kind']) {
*/
let lastSubmittedTotpTimeStep: number | null = null;
/**
* Drop the module-scoped `last_used` cache. With `workers: 1` the variable
* persists across every spec in a single Playwright invocation, so when
* project A's lifecycle test consumes a time-step and project B then runs
* its first TOTP (different DB, different secret, different time-step) the
* stale value would force a 30s sleep before the otherwise-fresh code. Call
* this from a `test.beforeEach` in any spec that submits TOTP.
*/
export function resetTotpTimeStep() {
lastSubmittedTotpTimeStep = null;
}
export async function submitTwoFactor(test: Test, page: Page, twoFactor: TwoFactor): Promise<void> {
await test.step(`Submit 2FA (${twoFactor.kind})`, async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();

38
src/api/core/accounts.rs

@ -825,6 +825,13 @@ fn validate_passkey_rotation_data(
.map(|c| &c.uuid)
.collect::<HashSet<&WebAuthnCredentialId>>();
let provided_passkey_ids = passkey_unlock_data.iter().map(|p| &p.id).collect::<HashSet<&WebAuthnCredentialId>>();
// Reject duplicate ids before they reach the update loop: the HashSet
// de-dups silently, so a payload like `[{id: A, key: K1}, {id: A, key: K2}]`
// would pass the superset check above and the loop would apply both
// writes in order, with the second silently overwriting the first.
if provided_passkey_ids.len() != passkey_unlock_data.len() {
err!("Duplicate passkey ids in rotation payload")
}
if !provided_passkey_ids.is_superset(&existing_prf_credential_ids) {
err!("All passkeys with encryption enabled must be included in the rotation")
}
@ -980,6 +987,24 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
}
}
// Re-check the PRF credential set immediately before committing the new
// account key. A concurrent enrol (another session/tab) can register a
// PRF passkey between the validation re-check above and now — that
// credential is missing from `passkey_unlock_data`, so its
// `encrypted_user_key` is still wrapped under the OLD account key.
// Erroring out here forces the caller to retry the rotation; without
// this guard, the new akey commits and the orphan credential can never
// unlock the vault again.
let post_loop_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn)
.await
.into_iter()
.filter(WebAuthnCredential::has_prf_keyset)
.map(|c| c.uuid)
.collect::<HashSet<WebAuthnCredentialId>>();
if post_loop_prf_ids != snapshot_prf_ids {
err!("Passkey credentials changed during key rotation; please retry")
}
// Update user data
let mut user = headers.user;
@ -1853,4 +1878,17 @@ mod tests {
assert!(validate_passkey_rotation_data(&data, &credentials).is_err());
}
/// A duplicate id in `passkey_unlock_data` collapses into a single
/// `HashSet` entry for the superset check, but the rewrap loop in
/// `post_rotatekey` iterates the original `Vec` and would apply BOTH
/// updates, silently letting the second blob overwrite the first.
/// The validator must reject duplicates upfront.
#[test]
fn passkey_rotation_rejects_duplicate_ids() {
let credentials = vec![webauthn_credential("prf", true, true)];
let data = vec![passkey_unlock_data("prf"), passkey_unlock_data("prf")];
assert!(validate_passkey_rotation_data(&data, &credentials).is_err());
}
}

71
src/api/core/mod.rs

@ -257,22 +257,28 @@ struct WebAuthnPasskeyAssertionChallenge {
}
fn passkey_registration_challenge_state(data: &str, token: Option<&str>) -> ApiResult<PasskeyRegistration> {
if let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyRegistrationChallenge>(data) {
if token != Some(saved.token.as_str()) {
err!("Invalid registration challenge. Please try again.")
}
Ok(saved.state)
} else {
if token.is_some() {
err!("Invalid registration challenge. Please try again.")
}
Ok(serde_json::from_str::<PasskeyRegistration>(data)?)
// Persisted challenge rows are always the `{token, state}` wrapper —
// nothing in the current code path writes the bare `PasskeyRegistration`
// shape. Reject a row that doesn't deserialise (corrupted, stale schema)
// with the same generic message we use for token mismatch, rather than
// falling through to an un-tokened legacy path.
let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyRegistrationChallenge>(data) else {
err!("Invalid registration challenge. Please try again.")
};
if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) {
err!("Invalid registration challenge. Please try again.")
}
Ok(saved.state)
}
fn passkey_assertion_challenge_state(data: &str, token: &str) -> ApiResult<PasskeyAuthentication> {
let saved: WebAuthnPasskeyAssertionChallenge = serde_json::from_str(data)?;
if token != saved.token.as_str() {
// Same shape contract as `passkey_registration_challenge_state` above —
// reject undecodable rows with the generic message rather than leaking
// the underlying serde error.
let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyAssertionChallenge>(data) else {
err!("Invalid assertion challenge. Please try again.")
};
if !crypto::ct_eq(token, &saved.token) {
err!("Invalid assertion challenge. Please try again.")
}
Ok(saved.state)
@ -321,19 +327,25 @@ async fn post_api_webauthn_attestation_options(
let (mut challenge, state) =
WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?;
// For passkey login, we need discoverable credentials (resident keys)
// and require user verification.
// start_passkey_registration() defaults to require_resident_key=false, but passkey login
// requires the credential to be discoverable (resident) so the authenticator can find it
// without the server providing allowCredentials.
// Tell the client we want a discoverable (resident) credential with UV.
// `start_passkey_registration` already pins UV=Required in the stored
// `state`; resident-key is NOT enforced server-side by webauthn-rs's
// `register_credential` (the corresponding field on the state is
// destructured as unused), so this mutation is a client-side hint only:
// it controls the challenge JSON shipped to the browser, not what the
// server validates against. A non-resident credential that completes
// the ceremony would still be accepted but later fail discoverable-
// login, so honest clients comply and tampering clients shoot themselves.
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() {
asc.user_verification = UserVerificationPolicy::Required;
asc.require_resident_key = true;
asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
}
// Drop any abandoned challenge from a previous, unfinished registration attempt
// so these rows cannot accumulate in the database.
// Drop any abandoned challenge from a previous, unfinished registration
// attempt so these rows cannot accumulate. `TwoFactor::save` below uses
// `replace_into` keyed on `uuid` (not on `(user_uuid, atype)`), so without
// this delete, sqlite/mysql would insert a sibling row each retry.
if let Some(tf) =
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge as i32, &conn)
.await
@ -572,6 +584,13 @@ async fn put_api_webauthn(
Ok(Status::Ok)
}
// NOTE: unlike the four enrol/update entry points above, this delete handler
// is intentionally NOT gated on `sso_enabled() && sso_only()`. SSO_ONLY restricts
// what a user can ADD to their own account — deletion only narrows capability
// (revoke a credential, never grants one), so leaving it accessible under
// SSO_ONLY lets an SSO-authenticated user clean up credentials that were
// enrolled when the policy was different. The session is still
// SSO-authenticated by the time this handler runs, so there's no auth bypass.
#[post("/webauthn/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete(
data: Json<PasswordOrOtpData>,
@ -659,12 +678,20 @@ mod tests {
assert!(passkey_registration_challenge_state(&data, None).is_err());
}
/// `passkey_registration_challenge_state` has no legacy unwrapped fallback —
/// the only writer is the attestation-options endpoint, and it always
/// persists the `{token, state}` wrapper. A bare `PasskeyRegistration`
/// blob in `twofactor.data` (corrupted row, hand-crafted attack) must
/// be rejected regardless of whether a token is sent — accepting it
/// without a token would let an attacker bypass the token-binding
/// check by writing the wrong shape.
#[test]
fn legacy_registration_challenge_rejects_finish_token() {
fn registration_challenge_rejects_unwrapped_legacy_state() {
let data = serde_json::to_string(&registration_state()).unwrap();
assert!(passkey_registration_challenge_state(&data, None).is_ok());
assert!(passkey_registration_challenge_state(&data, Some("token")).is_err());
assert!(passkey_registration_challenge_state(&data, None).is_err());
assert!(passkey_registration_challenge_state(&data, Some("any-token")).is_err());
assert!(passkey_registration_challenge_state(&data, Some("")).is_err());
}
#[test]

24
src/api/identity.rs

@ -1166,14 +1166,22 @@ fn passkey_credential_id(passkey: &Passkey) -> ApiResult<String> {
}
fn passkey_transports(passkey: &Passkey) -> Vec<String> {
serde_json::to_value(passkey)
.ok()
.and_then(|value| {
value
.pointer("/cred/transports")
.and_then(Value::as_array)
.map(|transports| transports.iter().filter_map(Value::as_str).map(str::to_owned).collect::<Vec<_>>())
})
// Serializing a `webauthn_rs::Passkey` should never fail in practice
// (it's a derive(Serialize) on a well-typed struct); if it does, log
// and fall through to an empty list rather than silently masking it
// — clients can't distinguish "authenticator reported no transports"
// from "we failed to encode the passkey" otherwise.
let value = match serde_json::to_value(passkey) {
Ok(v) => v,
Err(e) => {
error!("Failed to serialise passkey for transport extraction: {e:#?}");
return Vec::new();
}
};
value
.pointer("/cred/transports")
.and_then(Value::as_array)
.map(|transports| transports.iter().filter_map(Value::as_str).map(str::to_owned).collect::<Vec<_>>())
.unwrap_or_default()
}

16
src/db/models/two_factor.rs

@ -159,7 +159,7 @@ impl TwoFactor {
pub async fn take_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option<Self> {
let user_uuid = user_uuid.clone();
conn.run(move |conn| {
conn.transaction::<Option<Self>, diesel::result::Error, _>(|conn| {
let result = conn.transaction::<Option<Self>, diesel::result::Error, _>(|conn| {
let tf = twofactor::table
.filter(twofactor::user_uuid.eq(&user_uuid))
.filter(twofactor::atype.eq(atype))
@ -171,8 +171,18 @@ impl TwoFactor {
let deleted =
diesel::delete(twofactor::table.filter(twofactor::uuid.eq(&existing.uuid))).execute(conn)?;
Ok(tf.filter(|_| deleted == 1))
})
.unwrap_or(None)
});
match result {
Ok(opt) => opt,
Err(e) => {
// Surface the underlying error so DB degradation
// (deadlock, conn drop, lock timeout) is operator-
// visible rather than indistinguishable from a normal
// "row consumed by a concurrent caller" result.
error!("TwoFactor::take_by_user_and_type failed for user {user_uuid} atype {atype}: {e:#?}");
None
}
}
})
.await
}

15
src/db/models/web_authn_credential.rs

@ -235,7 +235,7 @@ impl WebAuthnLoginChallenge {
// ensures the SELECT+DELETE pair rolls back atomically on a DB
// error, leaving the challenge intact rather than silently
// consuming it.
let taken = conn
let taken = match conn
.transaction::<Option<WebAuthnLoginChallenge>, diesel::result::Error, _>(|conn| {
let challenge = web_authn_login_challenges::table
.filter(web_authn_login_challenges::id.eq(id))
@ -246,8 +246,17 @@ impl WebAuthnLoginChallenge {
)
.execute(conn)?;
Ok(challenge.filter(|_| deleted == 1))
})
.unwrap_or(None);
}) {
Ok(opt) => opt,
Err(e) => {
// Surface the underlying error so a degrading DB
// (deadlock, conn drop, lock timeout) is operator-
// visible instead of masquerading as a stale-token
// rejection at the caller.
error!("WebAuthnLoginChallenge::take failed for id {id}: {e:#?}");
None
}
};
let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS);
taken.filter(|c| c.created_at >= cutoff)

11
src/static/templates/scss/vaultwarden.scss.hbs

@ -54,6 +54,17 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
}
{{/if}}
/* Hide the `Log in with passkey` button on the login page under SSO_ONLY.
The server gate at `src/api/identity.rs` rejects the unauthenticated
`/accounts/webauthn/assertion-options` request the button triggers,
so clicking it would dead-end with "SSO sign-in is required". Keep the
button visible whenever passkey login is reachable (default + SSO modes). */
{{#if (and sso_enabled sso_only)}}
.vw-passkey-login {
@extend %vw-hide;
}
{{/if}}
/* Hide the or text followed by the two buttons hidden above */
{{#if (webver ">=2025.5.1")}}
{{#if (or (not sso_enabled) sso_only)}}

Loading…
Cancel
Save