Browse Source

Feat add webauthn login

pull/6820/merge^2
Sammy ETUR 4 weeks ago
parent
commit
a3d16b7dce
Failed to extract signature
  1. 1
      migrations/mysql/2026-02-12-000000_add_web_authn_credentials/down.sql
  2. 10
      migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql
  3. 1
      migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/down.sql
  4. 10
      migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql
  5. 1
      migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/down.sql
  6. 10
      migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql
  7. 163
      src/api/core/mod.rs
  8. 4
      src/api/core/two_factor/webauthn.rs
  9. 320
      src/api/identity.rs
  10. 3
      src/auth.rs
  11. 2
      src/db/models/mod.rs
  12. 1
      src/db/models/two_factor.rs
  13. 2
      src/db/models/user.rs
  14. 127
      src/db/models/web_authn_credential.rs
  15. 15
      src/db/schema.rs
  16. 15
      src/static/templates/scss/vaultwarden.scss.hbs

1
migrations/mysql/2026-02-12-000000_add_web_authn_credentials/down.sql

@ -0,0 +1 @@
DROP TABLE web_authn_credentials;

10
migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql

@ -0,0 +1,10 @@
CREATE TABLE web_authn_credentials (
uuid VARCHAR(40) NOT NULL PRIMARY KEY,
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid),
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL DEFAULT 0,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT
);

1
migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/down.sql

@ -0,0 +1 @@
DROP TABLE web_authn_credentials;

10
migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql

@ -0,0 +1,10 @@
CREATE TABLE web_authn_credentials (
uuid VARCHAR(40) NOT NULL PRIMARY KEY,
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid),
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL DEFAULT FALSE,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT
);

1
migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/down.sql

@ -0,0 +1 @@
DROP TABLE web_authn_credentials;

10
migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql

@ -0,0 +1,10 @@
CREATE TABLE web_authn_credentials (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users(uuid),
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL DEFAULT 0,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT
);

163
src/api/core/mod.rs

@ -18,7 +18,16 @@ pub use sends::purge_sends;
pub fn routes() -> Vec<Route> {
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
let mut hibp_routes = routes![hibp_breach];
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
let mut meta_routes = routes![
alive,
now,
version,
config,
get_api_webauthn,
post_api_webauthn,
post_api_webauthn_attestation_options,
post_api_webauthn_delete
];
let mut routes = Vec::new();
routes.append(&mut accounts::routes());
@ -47,13 +56,21 @@ pub fn events_routes() -> Vec<Route> {
//
// Move this somewhere else
//
use rocket::http::Status;
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
use webauthn_rs::prelude::{Passkey, PasskeyRegistration};
use webauthn_rs_proto::UserVerificationPolicy;
use crate::api::core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN};
use crate::{
api::{EmptyResult, JsonResult, Notify, UpdateType},
api::{ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
auth::Headers,
db::{
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
models::{
Membership, MembershipStatus, OrgPolicy, Organization, TwoFactor, TwoFactorType, User, WebAuthnCredential,
WebAuthnCredentialId,
},
DbConn,
},
error::Error,
@ -184,17 +201,147 @@ fn version() -> Json<&'static str> {
}
#[get("/webauthn")]
fn get_api_webauthn(_headers: Headers) -> Json<Value> {
// Prevent a 404 error, which also causes key-rotation issues
// It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support
// An empty list/data also works fine
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json<Value> {
let user = headers.user;
let data: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, &conn).await;
let data = data
.into_iter()
.map(|wac| {
json!({
"id": wac.uuid,
"name": wac.name,
// TODO: Generate prfStatus like GetPrfStatus() does in the C# implementation
"prfStatus": if wac.supports_prf { 1 } else { 0 },
"encryptedUserKey": wac.encrypted_user_key,
"encryptedPublicKey": wac.encrypted_public_key,
"object": "webauthnCredential",
})
})
.collect::<Value>();
Json(json!({
"object": "list",
"data": [],
"data": data,
"continuationToken": null
}))
}
#[post("/webauthn/attestation-options", data = "<data>")]
async fn post_api_webauthn_attestation_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, false, &conn).await?;
let all_creds: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, &conn).await;
let existing_cred_ids: Vec<_> = all_creds
.into_iter()
.filter_map(|wac| {
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
Some(passkey.cred_id().to_owned())
})
.collect();
let user_uuid = uuid::Uuid::parse_str(&user.uuid).expect("Failed to parse user UUID");
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.
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);
}
// Persist the registration state in the database (same pattern as 2FA webauthn)
TwoFactor::new(user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge, serde_json::to_string(&state)?)
.save(&conn)
.await?;
let mut options = serde_json::to_value(challenge.public_key)?;
options["status"] = "ok".into();
options["errorMessage"] = "".into();
Ok(Json(json!({
"options": options,
"object": "webauthnCredentialCreateOptions"
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebAuthnLoginCredentialCreateRequest {
device_response: RegisterPublicKeyCredentialCopy,
name: String,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[post("/webauthn", data = "<data>")]
async fn post_api_webauthn(
data: Json<WebAuthnLoginCredentialCreateRequest>,
headers: Headers,
conn: DbConn,
) -> ApiResult<Status> {
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner();
let user = headers.user;
// Retrieve and delete the saved challenge state from the database
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32;
let credential = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
Some(tf) => {
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
tf.delete(&conn).await?;
WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?
}
None => err!("No registration challenge found. Please try again."),
};
WebAuthnCredential::new(
user.uuid,
data.name,
serde_json::to_string(&credential)?,
data.supports_prf,
data.encrypted_user_key,
data.encrypted_public_key,
data.encrypted_private_key,
)
.save(&conn)
.await?;
Ok(Status::Ok)
}
#[post("/webauthn/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete(
data: Json<PasswordOrOtpData>,
uuid: &str,
headers: Headers,
conn: DbConn,
) -> ApiResult<Status> {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, false, &conn).await?;
WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId::from(uuid.to_string()), &user.uuid, &conn)
.await?;
Ok(Status::Ok)
}
#[get("/config")]
fn config() -> Json<Value> {
let domain = crate::CONFIG.domain();

4
src/api/core/two_factor/webauthn.rs

@ -29,7 +29,7 @@ use webauthn_rs_proto::{
RequestAuthenticationExtensions, UserVerificationPolicy,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
pub static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
@ -180,7 +180,7 @@ struct EnableWebauthnData {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterPublicKeyCredentialCopy {
pub struct RegisterPublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAttestationResponseRawCopy,

320
src/api/identity.rs

@ -1,3 +1,6 @@
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use chrono::Utc;
use num_traits::FromPrimitive;
use rocket::{
@ -8,7 +11,12 @@ use rocket::{
Route,
};
use serde_json::Value;
use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication};
use webauthn_rs_proto::{
AuthenticatorAssertionResponseRaw, PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy,
};
use crate::api::core::two_factor::webauthn::WEBAUTHN;
use crate::{
api::{
core::{
@ -26,6 +34,7 @@ use crate::{
models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
WebAuthnCredential,
},
DbConn,
},
@ -45,7 +54,8 @@ pub fn routes() -> Vec<Route> {
prevalidate,
authorize,
oidcsignin,
oidcsignin_error
oidcsignin_error,
get_web_authn_assertion_options
]
}
@ -101,6 +111,19 @@ async fn login(
_sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"webauthn" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.scope, "scope cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?;
_check_is_some(&data.device_response, "device_response cannot be blank")?;
_check_is_some(&data.token, "token cannot be blank")?;
_webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
}
t => err!("Invalid type", t),
};
@ -981,7 +1004,7 @@ async fn register_verification_email(
let mut rng = SmallRng::from_os_rng();
let delta: i32 = 100;
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
} else {
mail::send_register_verify_email(&data.email, &token).await?;
}
@ -999,13 +1022,297 @@ async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
_register(data, true, conn).await
}
// Cache for webauthn authentication states, keyed by a random token.
// Entries expire after 5 minutes (matching the WebAuthn ceremony timeout of 60s with margin).
// This is used for the discoverable credential (passkey login) flow where we don't know
// the user until the authenticator response arrives.
// Wrapped in Arc because PasskeyAuthentication does not implement Clone.
static WEBAUTHN_AUTHENTICATION_STATES: LazyLock<mini_moka::sync::Cache<String, Arc<PasskeyAuthentication>>> =
LazyLock::new(|| {
mini_moka::sync::Cache::builder().max_capacity(10_000).time_to_live(Duration::from_secs(300)).build()
});
// Copied from webauthn-rs to rename clientDataJSON -> clientDataJson for Bitwarden compatibility
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssertionResponseCopy {
pub authenticator_data: Base64UrlSafeData,
#[serde(rename = "clientDataJson", alias = "clientDataJSON")]
pub client_data_json: Base64UrlSafeData,
pub signature: Base64UrlSafeData,
pub user_handle: Option<Base64UrlSafeData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AssertionResponseCopy,
pub r#type: String,
#[allow(dead_code)]
pub extensions: Option<Value>,
}
impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
fn from(p: PublicKeyCredentialCopy) -> Self {
Self {
id: p.id,
raw_id: p.raw_id,
response: AuthenticatorAssertionResponseRaw {
authenticator_data: p.response.authenticator_data,
client_data_json: p.response.client_data_json,
signature: p.response.signature,
user_handle: p.response.user_handle,
},
extensions: Default::default(),
type_: p.r#type,
}
}
}
#[get("/accounts/webauthn/assertion-options")]
fn get_web_authn_assertion_options() -> JsonResult {
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?;
// Allow any credential (discoverable) and require user verification
response.public_key.allow_credentials = vec![];
response.public_key.user_verification = UserVerificationPolicy::Required;
response.public_key.extensions = Some(RequestAuthenticationExtensions {
appid: None,
uvm: None,
hmac_get_secret: None,
});
let token = util::get_uuid();
WEBAUTHN_AUTHENTICATION_STATES.insert(token.clone(), Arc::new(state));
let options = serde_json::to_value(response.public_key)?;
Ok(Json(json!({
"options": options,
"token": token,
"object": "webAuthnLoginAssertionOptions"
})))
}
async fn _webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Validate scope
AuthMethod::WebAuthn.check_scope(data.scope.as_ref())?;
// Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?;
// Parse the device response to get the user handle (user UUID)
let device_response: PublicKeyCredentialCopy = serde_json::from_str(data.device_response.as_ref().unwrap())?;
let user = if let Some(ref uuid_bytes) = device_response.response.user_handle {
// The user_handle contains the raw UUID bytes (16 bytes) set during passkey registration.
// We need to reconstruct the UUID string from these bytes.
let bytes: &[u8] = uuid_bytes.as_ref();
let uuid_str = uuid::Uuid::from_slice(bytes)
.map(|u| u.to_string())
.or_else(|_| {
// Fallback: try interpreting as UTF-8 string (for compatibility)
String::from_utf8(bytes.to_vec())
})
.map_err(|_| crate::error::Error::new("Invalid user handle encoding", ""))?;
let uuid = UserId::from(uuid_str);
User::find_by_uuid(&uuid, conn).await
} else {
None
};
let Some(user) = user else {
err!(
"Passkey authentication failed.",
format!("IP: {}. Could not find user from device response.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
};
let username = user.display_name().to_string();
// Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Retrieve all webauthn login credentials for this user
let web_authn_credentials: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await;
let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = web_authn_credentials
.into_iter()
.filter_map(|wac| {
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
Some((wac, passkey))
})
.collect();
if parsed_credentials.is_empty() {
err!(
"No passkey credentials registered for this user.",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Retrieve and consume the saved authentication state (one-time use)
let token = data.token.as_ref().unwrap();
let state = WEBAUTHN_AUTHENTICATION_STATES.get(token);
// Invalidate immediately to prevent replay
WEBAUTHN_AUTHENTICATION_STATES.invalidate(token);
debug!(
"WebAuthn login: found {} credentials for user, state present: {}",
parsed_credentials.len(),
state.is_some()
);
let Some(state_arc) = state else {
err!(
"Passkey authentication failed. Please try again.",
format!("IP: {}. Username: {username}. Missing authentication state.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
};
// Inject the user's credentials into the state so the library can verify against them.
// We serialize the state to JSON, inject the user's credentials, then deserialize back.
// This is necessary because for discoverable credentials (passkey login), the initial
// assertion was created without knowing which user will authenticate, so the state has
// no credentials to verify against. This is the same pattern used by
// check_and_update_backup_eligible() in two_factor/webauthn.rs.
let passkeys: Vec<Passkey> =
parsed_credentials.iter().map(|(_, p): &(WebAuthnCredential, Passkey)| p.clone()).collect();
let mut raw_state = serde_json::to_value(&*state_arc)?;
if let Some(credentials) =
raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut())
{
credentials.clear();
for passkey in &passkeys {
let passkey_owned: Passkey = passkey.clone();
let cred = <webauthn_rs::prelude::Credential>::from(passkey_owned);
credentials.push(serde_json::to_value(&cred)?);
}
}
let state: PasskeyAuthentication = serde_json::from_value(raw_state).map_err(|e| {
error!("Failed to deserialize PasskeyAuthentication state after credential injection: {e:?}");
e
})?;
let rsp: PublicKeyCredential = device_response.into();
let authentication_result = match WEBAUTHN.finish_passkey_authentication(&rsp, &state) {
Ok(result) => result,
Err(e) => {
err!(
"Passkey authentication failed.",
format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
};
// Find the matching credential and update its counter
let matched_wac = parsed_credentials.iter().find(|(_, p): &&(WebAuthnCredential, Passkey)| {
crate::crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice())
});
let matched_wac = match matched_wac {
Some((wac, _)) => wac,
None => {
err!(
"Passkey authentication failed.",
format!("IP: {}. Username: {username}. Credential not found.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
};
// Update the credential counter
let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?;
if passkey.update_credential(&authentication_result) == Some(true) {
WebAuthnCredential::update_credential_by_uuid(&matched_wac.uuid, serde_json::to_string(&passkey)?, conn)
.await?;
}
// Email verification check
let now = Utc::now().naive_utc();
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
if user.last_verifying_at.is_none()
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
> CONFIG.signups_verify_resend_time() as i64
{
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
if resend_limit == 0 || user.login_verify_count < resend_limit {
let mut user = user;
user.last_verifying_at = Some(now);
user.login_verify_count += 1;
if let Err(e) = user.save(conn).await {
error!("Error updating user: {e:#?}");
}
if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await {
error!("Error auto-sending email verification email: {e:#?}");
}
}
}
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let mut device = get_device(&data, conn, &user).await?;
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id);
// Build response using the common authenticated_response helper
let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?;
// Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption)
if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() {
let Json(ref mut val) = result;
val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({
"EncryptedPrivateKey": matched_wac.encrypted_private_key,
"EncryptedUserKey": matched_wac.encrypted_user_key,
});
}
Ok(result)
}
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
// https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs
#[derive(Debug, Clone, Default, FromForm)]
struct ConnectData {
#[field(name = uncased("grant_type"))]
#[field(name = uncased("granttype"))]
grant_type: String, // refresh_token, password, client_credentials (API key)
grant_type: String, // refresh_token, password, client_credentials (API key), webauthn
// Needed for grant_type="refresh_token"
#[field(name = uncased("refresh_token"))]
@ -1058,6 +1365,13 @@ struct ConnectData {
code: Option<OIDCState>,
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,
// Needed for grant_type = "webauthn"
#[field(name = uncased("deviceresponse"))]
device_response: Option<String>,
// Token identifying the webauthn authentication state
#[field(name = uncased("token"))]
token: Option<String>,
}
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
if value.is_none() {

3
src/auth.rs

@ -1103,6 +1103,7 @@ pub enum AuthMethod {
Password,
Sso,
UserApiKey,
WebAuthn,
}
impl AuthMethod {
@ -1112,6 +1113,7 @@ impl AuthMethod {
AuthMethod::Password => "api offline_access".to_string(),
AuthMethod::Sso => "api offline_access".to_string(),
AuthMethod::UserApiKey => "api".to_string(),
AuthMethod::WebAuthn => "api offline_access".to_string(),
}
}
@ -1252,6 +1254,7 @@ pub async fn refresh_tokens(
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
AuthMethod::WebAuthn => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
_ => err!("Invalid auth method, cannot refresh token"),
};

2
src/db/models/mod.rs

@ -16,6 +16,7 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod web_authn_credential;
pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId};
@ -41,3 +42,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::web_authn_credential::{WebAuthnCredential, WebAuthnCredentialId};

1
src/db/models/two_factor.rs

@ -39,6 +39,7 @@ pub enum TwoFactorType {
EmailVerificationChallenge = 1002,
WebauthnRegisterChallenge = 1003,
WebauthnLoginChallenge = 1004,
WebauthnPasskeyRegisterChallenge = 1005,
// Special type for Protected Actions verification via email
ProtectedActions = 2000,

2
src/db/models/user.rs

@ -6,6 +6,7 @@ use serde_json::Value;
use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
WebAuthnCredential,
};
use crate::{
api::EmptyResult,
@ -331,6 +332,7 @@ impl User {
Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
WebAuthnCredential::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
db_run! { conn: {

127
src/db/models/web_authn_credential.rs

@ -0,0 +1,127 @@
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use macros::UuidFromParam;
use crate::api::EmptyResult;
use crate::db::schema::web_authn_credentials;
use crate::db::DbConn;
use crate::error::MapResult;
use super::UserId;
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = web_authn_credentials)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct WebAuthnCredential {
pub uuid: WebAuthnCredentialId,
pub user_uuid: UserId,
pub name: String,
pub credential: String,
pub supports_prf: bool,
pub encrypted_user_key: Option<String>,
pub encrypted_public_key: Option<String>,
pub encrypted_private_key: Option<String>,
}
impl WebAuthnCredential {
pub fn new(
user_uuid: UserId,
name: String,
credential: String,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
) -> Self {
Self {
uuid: WebAuthnCredentialId(crate::util::get_uuid()),
user_uuid,
name,
credential,
supports_prf,
encrypted_user_key,
encrypted_public_key,
encrypted_private_key,
}
}
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::insert_into(web_authn_credentials::table)
.values(self)
.execute(conn)
.map_res("Error saving web_authn_credential")
}}
}
pub async fn find_all_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
.load::<Self>(conn)
.unwrap_or_default()
}}
}
pub async fn delete_by_uuid_and_user(
uuid: &WebAuthnCredentialId,
user_uuid: &UserId,
conn: &DbConn,
) -> EmptyResult {
db_run! { conn: {
diesel::delete(
web_authn_credentials::table
.filter(web_authn_credentials::uuid.eq(uuid))
.filter(web_authn_credentials::user_uuid.eq(user_uuid)),
)
.execute(conn)
.map_res("Error removing web_authn_credential")
}}
}
pub async fn update_credential_by_uuid(
uuid: &WebAuthnCredentialId,
credential: String,
conn: &DbConn,
) -> EmptyResult {
db_run! { conn: {
diesel::update(
web_authn_credentials::table
.filter(web_authn_credentials::uuid.eq(uuid)),
)
.set(web_authn_credentials::credential.eq(credential))
.execute(conn)
.map_res("Error updating credential for web_authn_credential")
}}
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(
web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(user_uuid)),
)
.execute(conn)
.map_res("Error deleting all web_authn_credentials for user")
}}
}
}
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct WebAuthnCredentialId(String);

15
src/db/schema.rs

@ -341,6 +341,19 @@ table! {
}
}
table! {
web_authn_credentials (uuid) {
uuid -> Text,
user_uuid -> Text,
name -> Text,
credential -> Text,
supports_prf -> Bool,
encrypted_user_key -> Nullable<Text>,
encrypted_public_key -> Nullable<Text>,
encrypted_private_key -> Nullable<Text>,
}
}
joinable!(attachments -> ciphers (cipher_uuid));
joinable!(ciphers -> organizations (organization_uuid));
joinable!(ciphers -> users (user_uuid));
@ -370,6 +383,7 @@ joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
joinable!(web_authn_credentials -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -395,4 +409,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
web_authn_credentials,
);

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

@ -54,21 +54,6 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
}
{{/if}}
/* Hide the `Log in with passkey` settings */
app-user-layout app-password-settings app-webauthn-login-settings {
@extend %vw-hide;
}
/* Hide Log in with passkey on the login page */
{{#if (webver ">=2025.5.1")}}
.vw-passkey-login {
@extend %vw-hide;
}
{{else}}
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) {
@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