From 4e31b9870f90253af2b2bd2271469f477f07ccac Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Nov 2025 20:03:35 -0500 Subject: [PATCH 01/54] PM-14922 - Identity Client - Offer get_password_prelogin_data as top level API. --- Cargo.lock | 3 + crates/bitwarden-auth/Cargo.toml | 5 +- crates/bitwarden-auth/src/identity/client.rs | 246 ++++++++++++++++++- crates/bitwarden-auth/src/identity/mod.rs | 2 +- 4 files changed, 251 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d36fda4dc..b18459721 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,11 +449,14 @@ dependencies = [ name = "bitwarden-auth" version = "1.0.0" dependencies = [ + "bitwarden-api-identity", "bitwarden-core", + "bitwarden-crypto", "bitwarden-error", "bitwarden-test", "chrono", "reqwest", + "schemars 1.0.0", "serde", "serde_json", "thiserror 2.0.12", diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 416b18449..6542ddb62 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -19,15 +19,18 @@ wasm = [ "bitwarden-core/wasm", "dep:tsify", "dep:wasm-bindgen", - "dep:wasm-bindgen-futures" + "dep:wasm-bindgen-futures", ] # WASM support # Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline. [dependencies] +bitwarden-api-identity = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } +bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } +schemars = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } diff --git a/crates/bitwarden-auth/src/identity/client.rs b/crates/bitwarden-auth/src/identity/client.rs index b2ae75e95..3962a2a7c 100644 --- a/crates/bitwarden-auth/src/identity/client.rs +++ b/crates/bitwarden-auth/src/identity/client.rs @@ -1,4 +1,10 @@ -use bitwarden_core::Client; +use bitwarden_api_identity::models::{PreloginRequestModel, PreloginResponseModel}; +use bitwarden_core::{ApiError, Client, MissingFieldError, require}; +use bitwarden_crypto::Kdf; +use bitwarden_error::bitwarden_error; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; @@ -6,7 +12,6 @@ use wasm_bindgen::prelude::*; #[derive(Clone)] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct IdentityClient { - #[allow(dead_code)] // TODO: Remove when methods using client are implemented pub(crate) client: Client, } @@ -17,9 +22,95 @@ impl IdentityClient { } } +/// Error type for password prelogin operations +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordPreloginError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), +} + +/// Response containing the data required before password-based authentication +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct PasswordPreloginData { + /// The Key Derivation Function (KDF) configuration for the user + pub kdf: Kdf, +} + #[cfg_attr(feature = "wasm", wasm_bindgen)] impl IdentityClient { - // TODO: Add methods to interact with the Identity API. + /// Retrieves the data required before authenticating with a password. + /// This includes the user's KDF configuration needed to properly derive the master key. + /// + /// # Arguments + /// * `email` - The user's email address + /// + /// # Returns + /// * `PasswordPreloginData` - Contains the KDF configuration for the user + pub async fn get_password_prelogin_data( + &self, + email: String, + ) -> Result { + let request_model = PreloginRequestModel::new(email); + let config = self.client.internal.get_api_configurations().await; + let response = config + .identity_client + .accounts_api() + .post_prelogin(Some(request_model)) + .await + .map_err(ApiError::from)?; + + let kdf = parse_password_prelogin_response(response)?; + Ok(PasswordPreloginData { kdf }) + } +} + +/// Parses the password prelogin API response into a KDF configuration +fn parse_password_prelogin_response( + response: PreloginResponseModel, +) -> Result { + use std::num::NonZeroU32; + + use bitwarden_api_identity::models::KdfType; + use bitwarden_crypto::{ + default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + + let kdf = require!(response.kdf); + + Ok(match kdf { + KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { + iterations: response + .kdf_iterations + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_pbkdf2_iterations), + }, + KdfType::Argon2id => Kdf::Argon2id { + iterations: response + .kdf_iterations + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_iterations), + memory: response + .kdf_memory + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_memory), + parallelism: response + .kdf_parallelism + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_parallelism), + }, + }) } #[cfg(test)] @@ -35,4 +126,153 @@ mod tests { // The client field is present and accessible let _ = identity_client.client; } + + mod get_password_prelogin_data { + use std::num::NonZeroU32; + + use bitwarden_api_identity::models::{KdfType, PreloginResponseModel}; + use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + + use super::*; + + #[test] + fn test_parse_prelogin_pbkdf2_with_iterations() { + let response = PreloginResponseModel { + kdf: Some(KdfType::PBKDF2_SHA256), + kdf_iterations: Some(100000), + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::PBKDF2 { + iterations: NonZeroU32::new(100000).unwrap() + } + ); + } + + #[test] + fn test_parse_prelogin_pbkdf2_default_iterations() { + let response = PreloginResponseModel { + kdf: Some(KdfType::PBKDF2_SHA256), + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + } + + #[test] + fn test_parse_prelogin_argon2id_with_all_params() { + let response = PreloginResponseModel { + kdf: Some(KdfType::Argon2id), + kdf_iterations: Some(4), + kdf_memory: Some(64), + kdf_parallelism: Some(4), + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::Argon2id { + iterations: NonZeroU32::new(4).unwrap(), + memory: NonZeroU32::new(64).unwrap(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + } + + #[test] + fn test_parse_prelogin_argon2id_default_params() { + let response = PreloginResponseModel { + kdf: Some(KdfType::Argon2id), + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: default_argon2_parallelism(), + } + ); + } + + #[test] + fn test_parse_prelogin_missing_kdf_type() { + let response = PreloginResponseModel { + kdf: None, + kdf_iterations: Some(100000), + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_parse_prelogin_zero_iterations_uses_default() { + // When the server returns 0, NonZeroU32::new returns None, so defaults should be used + let response = PreloginResponseModel { + kdf: Some(KdfType::PBKDF2_SHA256), + kdf_iterations: Some(0), + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + } + + #[test] + fn test_parse_prelogin_argon2id_partial_zero_values() { + // Test that zero values fall back to defaults for Argon2id + let response = PreloginResponseModel { + kdf: Some(KdfType::Argon2id), + kdf_iterations: Some(0), + kdf_memory: Some(0), + kdf_parallelism: Some(4), + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + } + } } diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index e83fb83e5..c3d79b834 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -2,4 +2,4 @@ //! The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. mod client; -pub use client::IdentityClient; +pub use client::{IdentityClient, PasswordPreloginData, PasswordPreloginError}; From 4991a67e358181d29d1a45c3252995415eac7d3f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 7 Nov 2025 16:20:36 -0500 Subject: [PATCH 02/54] PM-14922 - (1) create password_login feature folder (2) create password_prelogin file to prevent identity client file from growing substantially as we add items (per feedback from platform) --- crates/bitwarden-auth/src/identity/client.rs | 248 +----------------- crates/bitwarden-auth/src/identity/mod.rs | 5 +- .../src/identity/password_login/mod.rs | 3 + .../password_login/password_prelogin.rs | 248 ++++++++++++++++++ 4 files changed, 256 insertions(+), 248 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/password_login/mod.rs create mode 100644 crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs diff --git a/crates/bitwarden-auth/src/identity/client.rs b/crates/bitwarden-auth/src/identity/client.rs index 3962a2a7c..61de3a4d5 100644 --- a/crates/bitwarden-auth/src/identity/client.rs +++ b/crates/bitwarden-auth/src/identity/client.rs @@ -1,10 +1,4 @@ -use bitwarden_api_identity::models::{PreloginRequestModel, PreloginResponseModel}; -use bitwarden_core::{ApiError, Client, MissingFieldError, require}; -use bitwarden_crypto::Kdf; -use bitwarden_error::bitwarden_error; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use thiserror::Error; +use bitwarden_core::Client; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; @@ -22,97 +16,6 @@ impl IdentityClient { } } -/// Error type for password prelogin operations -#[allow(missing_docs)] -#[bitwarden_error(flat)] -#[derive(Debug, Error)] -pub enum PasswordPreloginError { - #[error(transparent)] - Api(#[from] ApiError), - #[error(transparent)] - MissingField(#[from] MissingFieldError), -} - -/// Response containing the data required before password-based authentication -#[derive(Serialize, Deserialize, Debug, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -#[cfg_attr( - feature = "wasm", - derive(tsify::Tsify), - tsify(into_wasm_abi, from_wasm_abi) -)] -pub struct PasswordPreloginData { - /// The Key Derivation Function (KDF) configuration for the user - pub kdf: Kdf, -} - -#[cfg_attr(feature = "wasm", wasm_bindgen)] -impl IdentityClient { - /// Retrieves the data required before authenticating with a password. - /// This includes the user's KDF configuration needed to properly derive the master key. - /// - /// # Arguments - /// * `email` - The user's email address - /// - /// # Returns - /// * `PasswordPreloginData` - Contains the KDF configuration for the user - pub async fn get_password_prelogin_data( - &self, - email: String, - ) -> Result { - let request_model = PreloginRequestModel::new(email); - let config = self.client.internal.get_api_configurations().await; - let response = config - .identity_client - .accounts_api() - .post_prelogin(Some(request_model)) - .await - .map_err(ApiError::from)?; - - let kdf = parse_password_prelogin_response(response)?; - Ok(PasswordPreloginData { kdf }) - } -} - -/// Parses the password prelogin API response into a KDF configuration -fn parse_password_prelogin_response( - response: PreloginResponseModel, -) -> Result { - use std::num::NonZeroU32; - - use bitwarden_api_identity::models::KdfType; - use bitwarden_crypto::{ - default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, - default_pbkdf2_iterations, - }; - - let kdf = require!(response.kdf); - - Ok(match kdf { - KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { - iterations: response - .kdf_iterations - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_pbkdf2_iterations), - }, - KdfType::Argon2id => Kdf::Argon2id { - iterations: response - .kdf_iterations - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_iterations), - memory: response - .kdf_memory - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_memory), - parallelism: response - .kdf_parallelism - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_parallelism), - }, - }) -} - #[cfg(test)] mod tests { use super::*; @@ -126,153 +29,4 @@ mod tests { // The client field is present and accessible let _ = identity_client.client; } - - mod get_password_prelogin_data { - use std::num::NonZeroU32; - - use bitwarden_api_identity::models::{KdfType, PreloginResponseModel}; - use bitwarden_crypto::{ - Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, - default_pbkdf2_iterations, - }; - - use super::*; - - #[test] - fn test_parse_prelogin_pbkdf2_with_iterations() { - let response = PreloginResponseModel { - kdf: Some(KdfType::PBKDF2_SHA256), - kdf_iterations: Some(100000), - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_password_prelogin_response(response).unwrap(); - - assert_eq!( - result, - Kdf::PBKDF2 { - iterations: NonZeroU32::new(100000).unwrap() - } - ); - } - - #[test] - fn test_parse_prelogin_pbkdf2_default_iterations() { - let response = PreloginResponseModel { - kdf: Some(KdfType::PBKDF2_SHA256), - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_password_prelogin_response(response).unwrap(); - - assert_eq!( - result, - Kdf::PBKDF2 { - iterations: default_pbkdf2_iterations() - } - ); - } - - #[test] - fn test_parse_prelogin_argon2id_with_all_params() { - let response = PreloginResponseModel { - kdf: Some(KdfType::Argon2id), - kdf_iterations: Some(4), - kdf_memory: Some(64), - kdf_parallelism: Some(4), - }; - - let result = parse_password_prelogin_response(response).unwrap(); - - assert_eq!( - result, - Kdf::Argon2id { - iterations: NonZeroU32::new(4).unwrap(), - memory: NonZeroU32::new(64).unwrap(), - parallelism: NonZeroU32::new(4).unwrap(), - } - ); - } - - #[test] - fn test_parse_prelogin_argon2id_default_params() { - let response = PreloginResponseModel { - kdf: Some(KdfType::Argon2id), - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_password_prelogin_response(response).unwrap(); - - assert_eq!( - result, - Kdf::Argon2id { - iterations: default_argon2_iterations(), - memory: default_argon2_memory(), - parallelism: default_argon2_parallelism(), - } - ); - } - - #[test] - fn test_parse_prelogin_missing_kdf_type() { - let response = PreloginResponseModel { - kdf: None, - kdf_iterations: Some(100000), - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_password_prelogin_response(response); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); - } - - #[test] - fn test_parse_prelogin_zero_iterations_uses_default() { - // When the server returns 0, NonZeroU32::new returns None, so defaults should be used - let response = PreloginResponseModel { - kdf: Some(KdfType::PBKDF2_SHA256), - kdf_iterations: Some(0), - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_password_prelogin_response(response).unwrap(); - - assert_eq!( - result, - Kdf::PBKDF2 { - iterations: default_pbkdf2_iterations() - } - ); - } - - #[test] - fn test_parse_prelogin_argon2id_partial_zero_values() { - // Test that zero values fall back to defaults for Argon2id - let response = PreloginResponseModel { - kdf: Some(KdfType::Argon2id), - kdf_iterations: Some(0), - kdf_memory: Some(0), - kdf_parallelism: Some(4), - }; - - let result = parse_password_prelogin_response(response).unwrap(); - - assert_eq!( - result, - Kdf::Argon2id { - iterations: default_argon2_iterations(), - memory: default_argon2_memory(), - parallelism: NonZeroU32::new(4).unwrap(), - } - ); - } - } } diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index c3d79b834..101a61f85 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -1,5 +1,8 @@ //! Identity client module //! The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. mod client; +/// Password-based authentication functionality +mod password_login; -pub use client::{IdentityClient, PasswordPreloginData, PasswordPreloginError}; +pub use client::IdentityClient; +pub use password_login::{PasswordPreloginData, PasswordPreloginError}; diff --git a/crates/bitwarden-auth/src/identity/password_login/mod.rs b/crates/bitwarden-auth/src/identity/password_login/mod.rs new file mode 100644 index 000000000..ff87424c9 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/password_login/mod.rs @@ -0,0 +1,3 @@ +mod password_prelogin; + +pub use password_prelogin::{PasswordPreloginData, PasswordPreloginError}; diff --git a/crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs b/crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs new file mode 100644 index 000000000..970de7cfd --- /dev/null +++ b/crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs @@ -0,0 +1,248 @@ +use bitwarden_api_identity::models::{KdfType, PreloginRequestModel, PreloginResponseModel}; +use bitwarden_core::{ApiError, MissingFieldError, require}; +use bitwarden_crypto::Kdf; +use bitwarden_error::bitwarden_error; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::identity::IdentityClient; + +/// Error type for password prelogin operations +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordPreloginError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), +} + +/// Response containing the data required before password-based authentication +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct PasswordPreloginData { + /// The Key Derivation Function (KDF) configuration for the user + pub kdf: Kdf, +} + +impl IdentityClient { + /// Retrieves the data required before authenticating with a password. + /// This includes the user's KDF configuration needed to properly derive the master key. + /// + /// # Arguments + /// * `email` - The user's email address + /// + /// # Returns + /// * `PasswordPreloginData` - Contains the KDF configuration for the user + pub async fn get_password_prelogin_data( + &self, + email: String, + ) -> Result { + let request_model = PreloginRequestModel::new(email); + let config = self.client.internal.get_api_configurations().await; + let response = config + .identity_client + .accounts_api() + .post_prelogin(Some(request_model)) + .await + .map_err(ApiError::from)?; + + let kdf = parse_password_prelogin_response(response)?; + Ok(PasswordPreloginData { kdf }) + } +} + +/// Parses the password prelogin API response into a KDF configuration +fn parse_password_prelogin_response( + response: PreloginResponseModel, +) -> Result { + use std::num::NonZeroU32; + + use bitwarden_crypto::{ + default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + + let kdf = require!(response.kdf); + + Ok(match kdf { + KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { + iterations: response + .kdf_iterations + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_pbkdf2_iterations), + }, + KdfType::Argon2id => Kdf::Argon2id { + iterations: response + .kdf_iterations + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_iterations), + memory: response + .kdf_memory + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_memory), + parallelism: response + .kdf_parallelism + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_parallelism), + }, + }) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use bitwarden_api_identity::models::{KdfType, PreloginResponseModel}; + use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + + use super::*; + + #[test] + fn test_parse_prelogin_pbkdf2_with_iterations() { + let response = PreloginResponseModel { + kdf: Some(KdfType::PBKDF2_SHA256), + kdf_iterations: Some(100000), + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::PBKDF2 { + iterations: NonZeroU32::new(100000).unwrap() + } + ); + } + + #[test] + fn test_parse_prelogin_pbkdf2_default_iterations() { + let response = PreloginResponseModel { + kdf: Some(KdfType::PBKDF2_SHA256), + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + } + + #[test] + fn test_parse_prelogin_argon2id_with_all_params() { + let response = PreloginResponseModel { + kdf: Some(KdfType::Argon2id), + kdf_iterations: Some(4), + kdf_memory: Some(64), + kdf_parallelism: Some(4), + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::Argon2id { + iterations: NonZeroU32::new(4).unwrap(), + memory: NonZeroU32::new(64).unwrap(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + } + + #[test] + fn test_parse_prelogin_argon2id_default_params() { + let response = PreloginResponseModel { + kdf: Some(KdfType::Argon2id), + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: default_argon2_parallelism(), + } + ); + } + + #[test] + fn test_parse_prelogin_missing_kdf_type() { + let response = PreloginResponseModel { + kdf: None, + kdf_iterations: Some(100000), + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_parse_prelogin_zero_iterations_uses_default() { + // When the server returns 0, NonZeroU32::new returns None, so defaults should be used + let response = PreloginResponseModel { + kdf: Some(KdfType::PBKDF2_SHA256), + kdf_iterations: Some(0), + kdf_memory: None, + kdf_parallelism: None, + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + } + + #[test] + fn test_parse_prelogin_argon2id_partial_zero_values() { + // Test that zero values fall back to defaults for Argon2id + let response = PreloginResponseModel { + kdf: Some(KdfType::Argon2id), + kdf_iterations: Some(0), + kdf_memory: Some(0), + kdf_parallelism: Some(4), + }; + + let result = parse_password_prelogin_response(response).unwrap(); + + assert_eq!( + result, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + } +} From fb0c1d9fcc379828d7d29427a445bf885edda11a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 10 Nov 2025 17:39:04 -0500 Subject: [PATCH 03/54] PM-14922 - Rename password_prelogin to prelogin_password --- .../src/identity/login_via_password/mod.rs | 3 ++ .../prelogin_password.rs} | 32 +++++++++---------- crates/bitwarden-auth/src/identity/mod.rs | 3 -- .../src/identity/password_login/mod.rs | 3 -- 4 files changed, 19 insertions(+), 22 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/mod.rs rename crates/bitwarden-auth/src/identity/{password_login/password_prelogin.rs => login_via_password/prelogin_password.rs} (88%) delete mode 100644 crates/bitwarden-auth/src/identity/password_login/mod.rs diff --git a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs new file mode 100644 index 000000000..3d6a9da8a --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -0,0 +1,3 @@ +mod prelogin_password; + +pub use prelogin_password::{PreloginPasswordData, PreloginPasswordError}; diff --git a/crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs similarity index 88% rename from crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs rename to crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs index 970de7cfd..f079b719a 100644 --- a/crates/bitwarden-auth/src/identity/password_login/password_prelogin.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs @@ -12,7 +12,7 @@ use crate::identity::IdentityClient; #[allow(missing_docs)] #[bitwarden_error(flat)] #[derive(Debug, Error)] -pub enum PasswordPreloginError { +pub enum PreloginPasswordError { #[error(transparent)] Api(#[from] ApiError), #[error(transparent)] @@ -28,7 +28,7 @@ pub enum PasswordPreloginError { derive(tsify::Tsify), tsify(into_wasm_abi, from_wasm_abi) )] -pub struct PasswordPreloginData { +pub struct PreloginPasswordData { /// The Key Derivation Function (KDF) configuration for the user pub kdf: Kdf, } @@ -41,11 +41,11 @@ impl IdentityClient { /// * `email` - The user's email address /// /// # Returns - /// * `PasswordPreloginData` - Contains the KDF configuration for the user - pub async fn get_password_prelogin_data( + /// * `PreloginPasswordData` - Contains the KDF configuration for the user + pub async fn get_prelogin_password_data( &self, email: String, - ) -> Result { + ) -> Result { let request_model = PreloginRequestModel::new(email); let config = self.client.internal.get_api_configurations().await; let response = config @@ -55,13 +55,13 @@ impl IdentityClient { .await .map_err(ApiError::from)?; - let kdf = parse_password_prelogin_response(response)?; - Ok(PasswordPreloginData { kdf }) + let kdf = parse_prelogin_password_response(response)?; + Ok(PreloginPasswordData { kdf }) } } -/// Parses the password prelogin API response into a KDF configuration -fn parse_password_prelogin_response( +/// Parses the prelogin password API response into a KDF configuration +fn parse_prelogin_password_response( response: PreloginResponseModel, ) -> Result { use std::num::NonZeroU32; @@ -118,7 +118,7 @@ mod tests { kdf_parallelism: None, }; - let result = parse_password_prelogin_response(response).unwrap(); + let result = parse_prelogin_password_response(response).unwrap(); assert_eq!( result, @@ -137,7 +137,7 @@ mod tests { kdf_parallelism: None, }; - let result = parse_password_prelogin_response(response).unwrap(); + let result = parse_prelogin_password_response(response).unwrap(); assert_eq!( result, @@ -156,7 +156,7 @@ mod tests { kdf_parallelism: Some(4), }; - let result = parse_password_prelogin_response(response).unwrap(); + let result = parse_prelogin_password_response(response).unwrap(); assert_eq!( result, @@ -177,7 +177,7 @@ mod tests { kdf_parallelism: None, }; - let result = parse_password_prelogin_response(response).unwrap(); + let result = parse_prelogin_password_response(response).unwrap(); assert_eq!( result, @@ -198,7 +198,7 @@ mod tests { kdf_parallelism: None, }; - let result = parse_password_prelogin_response(response); + let result = parse_prelogin_password_response(response); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); @@ -214,7 +214,7 @@ mod tests { kdf_parallelism: None, }; - let result = parse_password_prelogin_response(response).unwrap(); + let result = parse_prelogin_password_response(response).unwrap(); assert_eq!( result, @@ -234,7 +234,7 @@ mod tests { kdf_parallelism: Some(4), }; - let result = parse_password_prelogin_response(response).unwrap(); + let result = parse_prelogin_password_response(response).unwrap(); assert_eq!( result, diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index 101a61f85..e83fb83e5 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -1,8 +1,5 @@ //! Identity client module //! The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. mod client; -/// Password-based authentication functionality -mod password_login; pub use client::IdentityClient; -pub use password_login::{PasswordPreloginData, PasswordPreloginError}; diff --git a/crates/bitwarden-auth/src/identity/password_login/mod.rs b/crates/bitwarden-auth/src/identity/password_login/mod.rs deleted file mode 100644 index ff87424c9..000000000 --- a/crates/bitwarden-auth/src/identity/password_login/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod password_prelogin; - -pub use password_prelogin::{PasswordPreloginData, PasswordPreloginError}; From 0d5daed6a550735c1fe968ff0c4d989bfbeae1ca Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 11 Nov 2025 13:36:28 -0500 Subject: [PATCH 04/54] PM-14922 - Add api folder + request & response module stubs to set pattern --- crates/bitwarden-auth/src/identity/api/request/mod.rs | 4 ++++ crates/bitwarden-auth/src/identity/api/response/mod.rs | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 crates/bitwarden-auth/src/identity/api/request/mod.rs create mode 100644 crates/bitwarden-auth/src/identity/api/response/mod.rs diff --git a/crates/bitwarden-auth/src/identity/api/request/mod.rs b/crates/bitwarden-auth/src/identity/api/request/mod.rs new file mode 100644 index 000000000..03219fd3e --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/request/mod.rs @@ -0,0 +1,4 @@ +//! Request models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoints). +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/identity/api/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs new file mode 100644 index 000000000..ef5b258c4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/mod.rs @@ -0,0 +1,4 @@ +//! Response models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoint). +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. From 1fd065d042baab9aecd63379c4c1dc15b76fb028 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 11 Nov 2025 18:35:06 -0500 Subject: [PATCH 05/54] PM-14922 - commit draft of api req / response folder structure --- crates/bitwarden-auth/src/api/request/mod.rs | 4 ++++ crates/bitwarden-auth/src/api/response/mod.rs | 4 ++++ crates/bitwarden-auth/src/identity/api/request/mod.rs | 2 +- crates/bitwarden-auth/src/identity/api/response/mod.rs | 2 +- 4 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 crates/bitwarden-auth/src/api/request/mod.rs create mode 100644 crates/bitwarden-auth/src/api/response/mod.rs diff --git a/crates/bitwarden-auth/src/api/request/mod.rs b/crates/bitwarden-auth/src/api/request/mod.rs new file mode 100644 index 000000000..a76eb55de --- /dev/null +++ b/crates/bitwarden-auth/src/api/request/mod.rs @@ -0,0 +1,4 @@ +//! Request models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoints) and are shared across multiple clients. +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/api/response/mod.rs b/crates/bitwarden-auth/src/api/response/mod.rs new file mode 100644 index 000000000..f5ed686d6 --- /dev/null +++ b/crates/bitwarden-auth/src/api/response/mod.rs @@ -0,0 +1,4 @@ +//! Response models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoint) and are shared across multiple clients. +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/identity/api/request/mod.rs b/crates/bitwarden-auth/src/identity/api/request/mod.rs index 03219fd3e..d0148e1e1 100644 --- a/crates/bitwarden-auth/src/identity/api/request/mod.rs +++ b/crates/bitwarden-auth/src/identity/api/request/mod.rs @@ -1,4 +1,4 @@ //! Request models for Identity API endpoints that cannot be auto-generated -//! (e.g., connect/token endpoints). +//! (e.g., connect/token endpoints) and are shared across multiple features within the identity client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/identity/api/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs index ef5b258c4..c5bc3bfbc 100644 --- a/crates/bitwarden-auth/src/identity/api/response/mod.rs +++ b/crates/bitwarden-auth/src/identity/api/response/mod.rs @@ -1,4 +1,4 @@ //! Response models for Identity API endpoints that cannot be auto-generated -//! (e.g., connect/token endpoint). +//! (e.g., connect/token endpoints) and are shared across multiple features within the identity client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. From f3c83326984c8f4af3b0434bc7e58b5f8eba1e60 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 11 Nov 2025 18:41:42 -0500 Subject: [PATCH 06/54] PM-14922 - PreloginPassword - add some comments --- .../src/identity/login_via_password/prelogin_password.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs index f079b719a..023c90f88 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs @@ -22,12 +22,12 @@ pub enum PreloginPasswordError { /// Response containing the data required before password-based authentication #[derive(Serialize, Deserialize, Debug, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support #[cfg_attr( feature = "wasm", derive(tsify::Tsify), tsify(into_wasm_abi, from_wasm_abi) -)] +)] // add wasm support pub struct PreloginPasswordData { /// The Key Derivation Function (KDF) configuration for the user pub kdf: Kdf, From c052c1b83dbc2c090c8350488cf992fe1505f069 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 18:48:32 -0500 Subject: [PATCH 07/54] PM-14922 - Add serde_repr --- Cargo.lock | 1 + crates/bitwarden-auth/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b18459721..0f982f7c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,6 +459,7 @@ dependencies = [ "schemars 1.0.0", "serde", "serde_json", + "serde_repr", "thiserror 2.0.12", "tokio", "tsify", diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 6542ddb62..edf4159f6 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -32,6 +32,7 @@ chrono = { workspace = true } reqwest = { workspace = true } schemars = { workspace = true } serde = { workspace = true } +serde_repr = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } From f67b725929e8ba5b0a937b93ee4a0913506ecdac Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 18:52:21 -0500 Subject: [PATCH 08/54] PM-14922 - Rename client to identity client --- .../bitwarden-auth/src/identity/{client.rs => identity_client.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/bitwarden-auth/src/identity/{client.rs => identity_client.rs} (100%) diff --git a/crates/bitwarden-auth/src/identity/client.rs b/crates/bitwarden-auth/src/identity/identity_client.rs similarity index 100% rename from crates/bitwarden-auth/src/identity/client.rs rename to crates/bitwarden-auth/src/identity/identity_client.rs From 41c17aeac293ac1bb7f2ab9c9cb34179dbfd3c07 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 19:02:06 -0500 Subject: [PATCH 09/54] PM-14922 - Copy over 2FA provider enum to api/enums for now --- crates/bitwarden-auth/src/api/enums/mod.rs | 2 ++ .../src/api/enums/two_factor_provider.rs | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 crates/bitwarden-auth/src/api/enums/two_factor_provider.rs diff --git a/crates/bitwarden-auth/src/api/enums/mod.rs b/crates/bitwarden-auth/src/api/enums/mod.rs index 48bc05872..659c7a2b8 100644 --- a/crates/bitwarden-auth/src/api/enums/mod.rs +++ b/crates/bitwarden-auth/src/api/enums/mod.rs @@ -2,6 +2,8 @@ mod grant_type; mod scope; +mod two_factor_provider; pub(crate) use grant_type::GrantType; pub(crate) use scope::Scope; +pub(crate) use two_factor_provider::TwoFactorProvider; diff --git a/crates/bitwarden-auth/src/api/enums/two_factor_provider.rs b/crates/bitwarden-auth/src/api/enums/two_factor_provider.rs new file mode 100644 index 000000000..0ff1349d1 --- /dev/null +++ b/crates/bitwarden-auth/src/api/enums/two_factor_provider.rs @@ -0,0 +1,20 @@ +use schemars::JsonSchema; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +// TODO: this isn't likely to be only limited to API usage... so maybe move to a more general +// location? + +/// Represents the two-factor authentication providers supported by Bitwarden. +#[allow(missing_docs)] +#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, JsonSchema, Clone)] +#[repr(u8)] +pub enum TwoFactorProvider { + Authenticator = 0, + Email = 1, + Duo = 2, + Yubikey = 3, + U2f = 4, + Remember = 5, + OrganizationDuo = 6, + WebAuthn = 7, +} From 6070fb3274a54218967d5fe4eafe80403a038512 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 19:02:26 -0500 Subject: [PATCH 10/54] PM-14922 - Add password grant type --- crates/bitwarden-auth/src/api/enums/grant_type.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bitwarden-auth/src/api/enums/grant_type.rs b/crates/bitwarden-auth/src/api/enums/grant_type.rs index 757a21cdd..8fd984de9 100644 --- a/crates/bitwarden-auth/src/api/enums/grant_type.rs +++ b/crates/bitwarden-auth/src/api/enums/grant_type.rs @@ -12,4 +12,5 @@ pub(crate) enum GrantType { /// Bitwarden user. SendAccess, // TODO: Add other grant types as needed. + Password, } From 0bc2cff2dd20ef59f43b78c3a9945885fa9e98e6 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 19:03:08 -0500 Subject: [PATCH 11/54] PM-14922 - Identity client rename mod cleanup --- crates/bitwarden-auth/src/identity/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index e83fb83e5..98be53ce4 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -1,5 +1,5 @@ //! Identity client module //! The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. -mod client; +mod identity_client; -pub use client::IdentityClient; +pub use identity_client::IdentityClient; From 433bbfc47f808c51e18809144510886dbc4835e1 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 19:03:32 -0500 Subject: [PATCH 12/54] PM-14922 - Rename api to api_models --- crates/bitwarden-auth/src/identity/api/response/mod.rs | 4 ---- crates/bitwarden-auth/src/identity/api_models/mod.rs | 0 .../src/identity/{api => api_models}/request/mod.rs | 1 + 3 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 crates/bitwarden-auth/src/identity/api/response/mod.rs create mode 100644 crates/bitwarden-auth/src/identity/api_models/mod.rs rename crates/bitwarden-auth/src/identity/{api => api_models}/request/mod.rs (87%) diff --git a/crates/bitwarden-auth/src/identity/api/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs deleted file mode 100644 index c5bc3bfbc..000000000 --- a/crates/bitwarden-auth/src/identity/api/response/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Response models for Identity API endpoints that cannot be auto-generated -//! (e.g., connect/token endpoints) and are shared across multiple features within the identity client -//! -//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/identity/api_models/mod.rs b/crates/bitwarden-auth/src/identity/api_models/mod.rs new file mode 100644 index 000000000..e69de29bb diff --git a/crates/bitwarden-auth/src/identity/api/request/mod.rs b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs similarity index 87% rename from crates/bitwarden-auth/src/identity/api/request/mod.rs rename to crates/bitwarden-auth/src/identity/api_models/request/mod.rs index d0148e1e1..83a614558 100644 --- a/crates/bitwarden-auth/src/identity/api/request/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs @@ -2,3 +2,4 @@ //! (e.g., connect/token endpoints) and are shared across multiple features within the identity client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. +pub mod user_token_request_payload; From a2ea7a8743c2e3d1d719b4d4f45083b72ea3554f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 19:03:58 -0500 Subject: [PATCH 13/54] PM-14922 - WIP on rest of stuff --- .../request/user_token_request_payload.rs | 49 +++++++++++++++++++ .../src/identity/api_models/response/mod.rs | 4 ++ .../login_via_password/login_via_password.rs | 20 ++++++++ .../src/identity/login_via_password/mod.rs | 3 ++ .../password_login_request.rs | 21 ++++++++ .../src/identity/models/login_request.rs | 9 ++++ .../bitwarden-auth/src/identity/models/mod.rs | 2 + 7 files changed, 108 insertions(+) create mode 100644 crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs create mode 100644 crates/bitwarden-auth/src/identity/api_models/response/mod.rs create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs create mode 100644 crates/bitwarden-auth/src/identity/models/login_request.rs create mode 100644 crates/bitwarden-auth/src/identity/models/mod.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs new file mode 100644 index 000000000..2e6d0d5db --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs @@ -0,0 +1,49 @@ +use bitwarden_core::DeviceType; +use serde::{Deserialize, Serialize}; + +use crate::api::enums::{GrantType, Scope, TwoFactorProvider}; + +/// The common payload properties to send to the /connect/token endpoint to obtain +/// tokens for a BW user. This is intended to be flattened into other request payloads +/// that represent specific login mechanisms (e.g., password, SSO, etc) +/// in order to avoid duplication of common OAuth fields and custom BW fields. +#[derive(Serialize, Deserialize, Debug)] +struct UserTokenRequestPayload { + // Standard OAuth2 fields + /// The client ID for the SDK consuming client. + /// Note: snake_case is intentional to match the API expectations. + pub(crate) client_id: String, + + /// The grant type for the token request. + /// Note: snake_case is intentional to match the API expectations. + pub(crate) grant_type: GrantType, + + /// The scope for the token request. + pub(crate) scope: Scope, + + // Custom fields BW uses for user token requests + /// The device type making the request. + #[serde(rename = "deviceType")] + device_type: DeviceType, + + /// The identifier of the device. + #[serde(rename = "deviceIdentifier")] + device_identifier: String, + + /// The name of the device. + #[serde(rename = "deviceName")] + device_name: String, + + // Two-factor authentication fields + /// The two-factor authentication token. + #[serde(rename = "twoFactorToken")] + two_factor_token: Option, + + /// The two-factor authentication provider. + #[serde(rename = "twoFactorProvider")] + two_factor_provider: Option, + + /// Whether to remember two-factor authentication on this device. + #[serde(rename = "twoFactorRemember")] + two_factor_remember: Option, +} diff --git a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs new file mode 100644 index 000000000..c5bc3bfbc --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs @@ -0,0 +1,4 @@ +//! Response models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoints) and are shared across multiple features within the identity client +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs new file mode 100644 index 000000000..d4d94561d --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -0,0 +1,20 @@ +use crate::identity::IdentityClient; + +/// +#[derive(Serialize, Debug)] +struct PasswordLoginRequestPayload { + // Common user token request payload + #[serde(flatten)] + user_token_request_payload: UserTokenRequestPayload, + + /// Bitwarden user email address + pub email: String, + /// Bitwarden user master password hash + pub master_password_hash: String, +} + +impl IdentityClient { + pub async fn login_via_password(&self, request: PasswordLoginRequest) { + // Implementation goes here + } +} diff --git a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs index 3d6a9da8a..334fd98dc 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -1,3 +1,6 @@ +mod login_via_password; +mod password_login_request; mod prelogin_password; +pub use password_login_request::PasswordLoginRequest; pub use prelogin_password::{PreloginPasswordData, PreloginPasswordError}; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs new file mode 100644 index 000000000..ad98e6c92 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -0,0 +1,21 @@ +/// SDK request model for logging in via password +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct PasswordLoginRequest { + pub login_request: LoginRequest, + + /// User's email address + pub email: String, + /// User's master password + pub password: String, + + /// Prelogin data required for password authentication + /// (e.g., KDF configuration for deriving the master key) + pub prelogin_data: PreloginPasswordData, +} diff --git a/crates/bitwarden-auth/src/identity/models/login_request.rs b/crates/bitwarden-auth/src/identity/models/login_request.rs new file mode 100644 index 000000000..99cb58cd2 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_request.rs @@ -0,0 +1,9 @@ +/// The common bucket of login fields to be re-used across all login mechanisms +/// (e.g., password, SSO, etc.). This will include handling client_id and 2FA. +pub struct LoginRequest { + /// OAuth client identifier + pub client_id: String, + // TODO: add two factor support + // Two-factor authentication + // pub two_factor: Option, +} diff --git a/crates/bitwarden-auth/src/identity/models/mod.rs b/crates/bitwarden-auth/src/identity/models/mod.rs new file mode 100644 index 000000000..0a8208b45 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -0,0 +1,2 @@ +mod login_request; +pub use login_request::LoginRequest; From fe988a22e8301bcce0104686be287934c3dd2efb Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 12 Nov 2025 19:15:44 -0500 Subject: [PATCH 14/54] PM-14922 - Document the intention behind the models mod --- crates/bitwarden-auth/src/identity/models/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/bitwarden-auth/src/identity/models/mod.rs b/crates/bitwarden-auth/src/identity/models/mod.rs index 0a8208b45..b80140d96 100644 --- a/crates/bitwarden-auth/src/identity/models/mod.rs +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -1,2 +1,4 @@ +//! SDK models shared across multiple identity features + mod login_request; pub use login_request::LoginRequest; From 22fcdd6536e5dba89ad30ee91627c6b984d8af9c Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 13 Nov 2025 17:34:16 -0500 Subject: [PATCH 15/54] PM-14922 - (1) Move to api_ prefixed request models instead of payload suffixed (2) Add required mod declarations for usages to show up and fix imports. --- .../src/identity/api_models/mod.rs | 3 +++ .../src/identity/api_models/request/mod.rs | 6 +++-- ...t_payload.rs => user_token_api_request.rs} | 4 ++-- .../login_via_password/login_via_password.rs | 24 +++++++++++++------ .../password_login_request.rs | 8 ++++++- .../login_via_password/prelogin_password.rs | 2 +- crates/bitwarden-auth/src/identity/mod.rs | 9 +++++++ .../src/identity/models/login_request.rs | 11 +++++++++ 8 files changed, 54 insertions(+), 13 deletions(-) rename crates/bitwarden-auth/src/identity/api_models/request/{user_token_request_payload.rs => user_token_api_request.rs} (96%) diff --git a/crates/bitwarden-auth/src/identity/api_models/mod.rs b/crates/bitwarden-auth/src/identity/api_models/mod.rs index e69de29bb..0810b3951 100644 --- a/crates/bitwarden-auth/src/identity/api_models/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/mod.rs @@ -0,0 +1,3 @@ +//! API models for Identity endpoints +pub(crate) mod request; +pub(crate) mod response; diff --git a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs index 83a614558..247260e44 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs @@ -1,5 +1,7 @@ //! Request models for Identity API endpoints that cannot be auto-generated -//! (e.g., connect/token endpoints) and are shared across multiple features within the identity client +//! (e.g., connect/token endpoints) and are shared across multiple features within the identity +//! client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. -pub mod user_token_request_payload; +mod user_token_api_request; +pub(crate) use user_token_api_request::UserTokenApiRequest; diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs similarity index 96% rename from crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs rename to crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs index 2e6d0d5db..6228c4216 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_token_request_payload.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs @@ -4,11 +4,11 @@ use serde::{Deserialize, Serialize}; use crate::api::enums::{GrantType, Scope, TwoFactorProvider}; /// The common payload properties to send to the /connect/token endpoint to obtain -/// tokens for a BW user. This is intended to be flattened into other request payloads +/// tokens for a BW user. This is intended to be flattened into other api requests /// that represent specific login mechanisms (e.g., password, SSO, etc) /// in order to avoid duplication of common OAuth fields and custom BW fields. #[derive(Serialize, Deserialize, Debug)] -struct UserTokenRequestPayload { +pub(crate) struct UserTokenApiRequest { // Standard OAuth2 fields /// The client ID for the SDK consuming client. /// Note: snake_case is intentional to match the API expectations. diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index d4d94561d..ee223d3c4 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -1,20 +1,30 @@ -use crate::identity::IdentityClient; +use serde::Serialize; -/// +use crate::identity::{ + IdentityClient, api_models::request::UserTokenApiRequest, + login_via_password::PasswordLoginRequest, +}; + +/// API request model for logging in via password. #[derive(Serialize, Debug)] -struct PasswordLoginRequestPayload { +#[allow(dead_code)] +struct PasswordLoginApiRequest { // Common user token request payload #[serde(flatten)] - user_token_request_payload: UserTokenRequestPayload, + user_token_request_payload: UserTokenApiRequest, /// Bitwarden user email address + #[serde(rename = "username")] pub email: String, + /// Bitwarden user master password hash + #[serde(rename = "password")] pub master_password_hash: String, } impl IdentityClient { - pub async fn login_via_password(&self, request: PasswordLoginRequest) { - // Implementation goes here - } + // TODO: add implementation for login via password + // pub async fn login_via_password(&self, request: PasswordLoginRequest) { + // // Implementation goes here + // } } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs index ad98e6c92..bbedbe459 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -1,5 +1,10 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::identity::{login_via_password::PreloginPasswordData, models::LoginRequest}; + /// SDK request model for logging in via password -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support #[cfg_attr( @@ -8,6 +13,7 @@ tsify(into_wasm_abi, from_wasm_abi) )] // add wasm support pub struct PasswordLoginRequest { + /// Common login request fields pub login_request: LoginRequest, /// User's email address diff --git a/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs index 023c90f88..c6eb494d5 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs @@ -20,7 +20,7 @@ pub enum PreloginPasswordError { } /// Response containing the data required before password-based authentication -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support #[cfg_attr( diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index 98be53ce4..6d73548b6 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -3,3 +3,12 @@ mod identity_client; pub use identity_client::IdentityClient; + +/// Models used by the identity module +pub mod models; + +/// Login via password functionality +pub mod login_via_password; + +// API models should be private to the identity module as they are only used internally. +pub(crate) mod api_models; diff --git a/crates/bitwarden-auth/src/identity/models/login_request.rs b/crates/bitwarden-auth/src/identity/models/login_request.rs index 99cb58cd2..e24e8e955 100644 --- a/crates/bitwarden-auth/src/identity/models/login_request.rs +++ b/crates/bitwarden-auth/src/identity/models/login_request.rs @@ -1,5 +1,16 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + /// The common bucket of login fields to be re-used across all login mechanisms /// (e.g., password, SSO, etc.). This will include handling client_id and 2FA. +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support pub struct LoginRequest { /// OAuth client identifier pub client_id: String, From 65b8c8f91495ac0d108e3e3c9b9c0dc33de3dc3c Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 13 Nov 2025 17:34:40 -0500 Subject: [PATCH 16/54] PM-14922 - formatting --- crates/bitwarden-auth/src/identity/api_models/response/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs index c5bc3bfbc..6c5087b02 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs @@ -1,4 +1,5 @@ //! Response models for Identity API endpoints that cannot be auto-generated -//! (e.g., connect/token endpoints) and are shared across multiple features within the identity client +//! (e.g., connect/token endpoints) and are shared across multiple features within the identity +//! client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. From cf72a49da8ebfa5aec07976f868964b564f7e935 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 14 Nov 2025 13:13:28 -0500 Subject: [PATCH 17/54] PM-14922 misc cleanup --- .../identity/api_models/request/user_token_api_request.rs | 6 +++--- .../src/identity/login_via_password/login_via_password.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs index 6228c4216..469639427 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs @@ -12,14 +12,14 @@ pub(crate) struct UserTokenApiRequest { // Standard OAuth2 fields /// The client ID for the SDK consuming client. /// Note: snake_case is intentional to match the API expectations. - pub(crate) client_id: String, + client_id: String, /// The grant type for the token request. /// Note: snake_case is intentional to match the API expectations. - pub(crate) grant_type: GrantType, + grant_type: GrantType, /// The scope for the token request. - pub(crate) scope: Scope, + scope: Scope, // Custom fields BW uses for user token requests /// The device type making the request. diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index ee223d3c4..99367a2a3 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -11,7 +11,7 @@ use crate::identity::{ struct PasswordLoginApiRequest { // Common user token request payload #[serde(flatten)] - user_token_request_payload: UserTokenApiRequest, + user_token_api_request: UserTokenApiRequest, /// Bitwarden user email address #[serde(rename = "username")] From 7ed9bd40dd3af4740dbff8dbbbc7005b63670a10 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 14 Nov 2025 16:46:05 -0500 Subject: [PATCH 18/54] PM-14922 - KM - adjust accessibility to allow MasterPasswordAuthenticationData usage in bitwarden-auth crate --- .../bitwarden-core/src/key_management/master_password.rs | 7 ++----- crates/bitwarden-core/src/key_management/mod.rs | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/bitwarden-core/src/key_management/master_password.rs b/crates/bitwarden-core/src/key_management/master_password.rs index 514fbaf5e..ef9393339 100644 --- a/crates/bitwarden-core/src/key_management/master_password.rs +++ b/crates/bitwarden-core/src/key_management/master_password.rs @@ -126,11 +126,8 @@ pub struct MasterPasswordAuthenticationData { } impl MasterPasswordAuthenticationData { - pub(crate) fn derive( - password: &str, - kdf: &Kdf, - salt: &str, - ) -> Result { + #[allow(missing_docs)] + pub fn derive(password: &str, kdf: &Kdf, salt: &str) -> Result { let master_key = MasterKey::derive(password, salt, kdf) .map_err(|_| MasterPasswordError::InvalidKdfConfiguration)?; let hash = master_key.derive_master_key_hash( diff --git a/crates/bitwarden-core/src/key_management/mod.rs b/crates/bitwarden-core/src/key_management/mod.rs index 2b14cecac..9341735e6 100644 --- a/crates/bitwarden-core/src/key_management/mod.rs +++ b/crates/bitwarden-core/src/key_management/mod.rs @@ -22,9 +22,11 @@ pub use crypto_client::CryptoClient; #[cfg(feature = "internal")] mod master_password; #[cfg(feature = "internal")] +pub use master_password::MasterPasswordAuthenticationData; +#[cfg(feature = "internal")] pub use master_password::MasterPasswordError; #[cfg(feature = "internal")] -pub(crate) use master_password::{MasterPasswordAuthenticationData, MasterPasswordUnlockData}; +pub(crate) use master_password::MasterPasswordUnlockData; #[cfg(feature = "internal")] mod security_state; #[cfg(feature = "internal")] From ddce26e0c4489a86a0528fa530b62fea83ca03ef Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 14 Nov 2025 17:11:20 -0500 Subject: [PATCH 19/54] PM-14922 - (1) UserTokenApiRequest - make props public (2) Scope - Add required scopes for standard BW user and mechanism for converting array of scopes to space separated string (3) UserTokenApiRequest - build constructor with default scopes already included --- crates/bitwarden-auth/src/api/enums/mod.rs | 2 +- crates/bitwarden-auth/src/api/enums/scope.rs | 29 ++++++++++- .../request/user_token_api_request.rs | 49 ++++++++++++++----- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/bitwarden-auth/src/api/enums/mod.rs b/crates/bitwarden-auth/src/api/enums/mod.rs index 659c7a2b8..97a1eb683 100644 --- a/crates/bitwarden-auth/src/api/enums/mod.rs +++ b/crates/bitwarden-auth/src/api/enums/mod.rs @@ -5,5 +5,5 @@ mod scope; mod two_factor_provider; pub(crate) use grant_type::GrantType; -pub(crate) use scope::Scope; +pub(crate) use scope::{Scope, scopes_to_string}; pub(crate) use two_factor_provider::TwoFactorProvider; diff --git a/crates/bitwarden-auth/src/api/enums/scope.rs b/crates/bitwarden-auth/src/api/enums/scope.rs index d016c17f1..70ab70bac 100644 --- a/crates/bitwarden-auth/src/api/enums/scope.rs +++ b/crates/bitwarden-auth/src/api/enums/scope.rs @@ -4,10 +4,35 @@ use serde::{Deserialize, Serialize}; /// Scopes define the specific permissions an access token grants to the client. /// They are requested by the client during token acquisition and enforced by the /// resource server when the token is used. -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Scope { + /// The scope for accessing the Bitwarden API. + #[serde(rename = "api")] + Api, + /// The scope for obtaining refresh tokens that allow offline access. + #[serde(rename = "offline_access")] + OfflineAccess, /// The scope for accessing send resources outside the context of a Bitwarden user. #[serde(rename = "api.send.access")] ApiSendAccess, - // TODO: Add other scopes as needed. +} + +impl Scope { + /// Returns the string representation of the scope as used in OAuth 2.0 requests. + pub(crate) fn as_str(&self) -> &'static str { + match self { + Scope::Api => "api", + Scope::OfflineAccess => "offline_access", + Scope::ApiSendAccess => "api.send.access", + } + } +} + +/// Converts a slice of scopes into a space-separated string suitable for OAuth 2.0 requests. +pub(crate) fn scopes_to_string(scopes: &[Scope]) -> String { + scopes + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(" ") } diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs index 469639427..2a7da3e19 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs @@ -1,7 +1,10 @@ use bitwarden_core::DeviceType; use serde::{Deserialize, Serialize}; -use crate::api::enums::{GrantType, Scope, TwoFactorProvider}; +use crate::api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string}; + +/// Standard scopes for user token requests: "api offline_access" +pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAccess]; /// The common payload properties to send to the /connect/token endpoint to obtain /// tokens for a BW user. This is intended to be flattened into other api requests @@ -12,38 +15,62 @@ pub(crate) struct UserTokenApiRequest { // Standard OAuth2 fields /// The client ID for the SDK consuming client. /// Note: snake_case is intentional to match the API expectations. - client_id: String, + pub client_id: String, /// The grant type for the token request. /// Note: snake_case is intentional to match the API expectations. - grant_type: GrantType, + pub grant_type: GrantType, - /// The scope for the token request. - scope: Scope, + /// The space-separated scopes for the token request (e.g., "api offline_access"). + pub scope: String, // Custom fields BW uses for user token requests /// The device type making the request. #[serde(rename = "deviceType")] - device_type: DeviceType, + pub device_type: DeviceType, /// The identifier of the device. #[serde(rename = "deviceIdentifier")] - device_identifier: String, + pub device_identifier: String, /// The name of the device. #[serde(rename = "deviceName")] - device_name: String, + pub device_name: String, // Two-factor authentication fields /// The two-factor authentication token. #[serde(rename = "twoFactorToken")] - two_factor_token: Option, + pub two_factor_token: Option, /// The two-factor authentication provider. #[serde(rename = "twoFactorProvider")] - two_factor_provider: Option, + pub two_factor_provider: Option, /// Whether to remember two-factor authentication on this device. #[serde(rename = "twoFactorRemember")] - two_factor_remember: Option, + pub two_factor_remember: Option, +} + +impl UserTokenApiRequest { + /// Creates a new UserTokenApiRequest with standard scopes ("api offline_access"). + /// The scope can be overridden after construction if needed for specific auth flows. + pub(crate) fn new( + client_id: String, + grant_type: GrantType, + device_type: DeviceType, + device_identifier: String, + device_name: String, + ) -> Self { + Self { + client_id, + grant_type, + scope: scopes_to_string(STANDARD_USER_SCOPES), + device_type, + device_identifier, + device_name, + two_factor_token: None, + two_factor_provider: None, + two_factor_remember: None, + } + } } From a54709e092822a785afcdb0f9f433538ddf774e5 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 14 Nov 2025 18:16:10 -0500 Subject: [PATCH 20/54] PM-14922 - LoginViaPassword - wire up from to go from password login req + MP authN data to api payload --- .../login_via_password/login_via_password.rs | 62 +++++++++++++++++-- .../identity/models/login_device_request.rs | 25 ++++++++ .../src/identity/models/login_request.rs | 5 ++ .../bitwarden-auth/src/identity/models/mod.rs | 3 + 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/models/login_device_request.rs diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 99367a2a3..66012dc21 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -1,8 +1,12 @@ +use bitwarden_core::key_management::MasterPasswordAuthenticationData; use serde::Serialize; -use crate::identity::{ - IdentityClient, api_models::request::UserTokenApiRequest, - login_via_password::PasswordLoginRequest, +use crate::{ + api::enums::GrantType, + identity::{ + IdentityClient, api_models::request::UserTokenApiRequest, + login_via_password::PasswordLoginRequest, + }, }; /// API request model for logging in via password. @@ -22,9 +26,57 @@ struct PasswordLoginApiRequest { pub master_password_hash: String, } +/// Converts a `PasswordLoginRequest` and `MasterPasswordAuthenticationData` into a +/// `PasswordLoginApiRequest` for making the API call. +impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> for PasswordLoginApiRequest { + fn from( + (request, master_password_authentication): ( + PasswordLoginRequest, + MasterPasswordAuthenticationData, + ), + ) -> Self { + // Create the UserTokenApiRequest with standard scopes configuration + let user_token_api_request = UserTokenApiRequest::new( + request.login_request.client_id, + GrantType::Password, + request.login_request.device.device_type, + request.login_request.device.device_identifier, + request.login_request.device.device_name, + ); + + Self { + user_token_api_request, + email: request.email, + master_password_hash: master_password_authentication + .master_password_authentication_hash + .to_string(), + } + } +} + impl IdentityClient { - // TODO: add implementation for login via password + // #![allow(dead_code)] + // #![allow(unused_imports)] + // #![allow(unused_variables)] + // #![allow(missing_docs)] // pub async fn login_via_password(&self, request: PasswordLoginRequest) { - // // Implementation goes here + // // use request password prelogin data to derive master password authentication data: + // let master_password_authentication: Result< + // MasterPasswordAuthenticationData, + // bitwarden_core::key_management::MasterPasswordError, + // > = MasterPasswordAuthenticationData::derive( &request.password, + // > &request.prelogin_data.kdf, &request.email, + // ); + + // // construct API request + // let api_request: PasswordLoginApiRequest = + // (request, master_password_authentication.unwrap()).into(); + + // // make API call to login endpoint with api_request + // let config = self.client.internal.get_api_configurations().await; + + // // TODO: next week talk through implementing the actual API call and handling the + // response // The existing password flow uses a base send_identity_connect_request + // which is re-used // across multiple login methods. Should we do the same here? // } } diff --git a/crates/bitwarden-auth/src/identity/models/login_device_request.rs b/crates/bitwarden-auth/src/identity/models/login_device_request.rs new file mode 100644 index 000000000..402dc8204 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_device_request.rs @@ -0,0 +1,25 @@ +use bitwarden_core::DeviceType; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Device information for login requests. +/// This is common across all login mechanisms and describes the device +/// making the authentication request. +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct LoginDeviceRequest { + /// The type of device making the login request + pub device_type: DeviceType, + + /// Unique identifier for the device + pub device_identifier: String, + + /// Human-readable name of the device + pub device_name: String, +} diff --git a/crates/bitwarden-auth/src/identity/models/login_request.rs b/crates/bitwarden-auth/src/identity/models/login_request.rs index e24e8e955..5535c98a4 100644 --- a/crates/bitwarden-auth/src/identity/models/login_request.rs +++ b/crates/bitwarden-auth/src/identity/models/login_request.rs @@ -1,6 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use super::LoginDeviceRequest; + /// The common bucket of login fields to be re-used across all login mechanisms /// (e.g., password, SSO, etc.). This will include handling client_id and 2FA. #[derive(Serialize, Deserialize, Debug, JsonSchema)] @@ -14,6 +16,9 @@ use serde::{Deserialize, Serialize}; pub struct LoginRequest { /// OAuth client identifier pub client_id: String, + + /// Device information for this login request + pub device: LoginDeviceRequest, // TODO: add two factor support // Two-factor authentication // pub two_factor: Option, diff --git a/crates/bitwarden-auth/src/identity/models/mod.rs b/crates/bitwarden-auth/src/identity/models/mod.rs index b80140d96..4437bd0d6 100644 --- a/crates/bitwarden-auth/src/identity/models/mod.rs +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -1,4 +1,7 @@ //! SDK models shared across multiple identity features +mod login_device_request; mod login_request; + +pub use login_device_request::LoginDeviceRequest; pub use login_request::LoginRequest; From e78014057286d25809481faba70bd9c794079e44 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 18 Nov 2025 16:40:49 -0500 Subject: [PATCH 21/54] PM-14922 - BW-auth crate - cargo.toml - add serde_json --- crates/bitwarden-auth/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index edf4159f6..ffb743990 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -32,6 +32,7 @@ chrono = { workspace = true } reqwest = { workspace = true } schemars = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } serde_repr = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } @@ -40,7 +41,6 @@ wasm-bindgen-futures = { workspace = true, optional = true } [dev-dependencies] bitwarden-test = { workspace = true } -serde_json = { workspace = true } tokio = { workspace = true, features = ["rt"] } wiremock = "0.6.0" From 0db8985376c0e0a4a8b1d4c64394db6e10f240af Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 18 Nov 2025 16:49:05 -0500 Subject: [PATCH 22/54] PM-14922 - LoginDeviceRequest - Add docs about using device_type --- .../src/identity/models/login_device_request.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/bitwarden-auth/src/identity/models/login_device_request.rs b/crates/bitwarden-auth/src/identity/models/login_device_request.rs index 402dc8204..1456acf56 100644 --- a/crates/bitwarden-auth/src/identity/models/login_device_request.rs +++ b/crates/bitwarden-auth/src/identity/models/login_device_request.rs @@ -15,6 +15,12 @@ use serde::{Deserialize, Serialize}; )] // add wasm support pub struct LoginDeviceRequest { /// The type of device making the login request + /// Note: today, we already have the DeviceType on the ApiConfigurations + /// but we do not have the other device fields so we will accept the device data at login time + /// for now. In the future, we might refactor the unauthN client to instantiate with full + /// device info which would deprecate this struct. However, using the device_type here + /// allows us to avoid any timing issues in scenarios where the device type could change + /// between client instantiation and login (unlikely but possible). pub device_type: DeviceType, /// Unique identifier for the device From 3c82f529f84c225145d67811208f3796a8a4573a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 18 Nov 2025 16:49:43 -0500 Subject: [PATCH 23/54] PM-14922 - WIP on Password login --- .../api_models/login_request_header.rs | 57 +++++++++++ .../src/identity/api_models/mod.rs | 1 + .../src/identity/api_models/request/mod.rs | 4 +- ...i_request.rs => user_login_api_request.rs} | 33 +++++-- .../login_via_password/login_via_password.rs | 97 +++++-------------- .../src/identity/login_via_password/mod.rs | 2 + .../password_login_api_request.rs | 55 +++++++++++ .../password_login_request.rs | 2 +- crates/bitwarden-auth/src/identity/mod.rs | 3 + .../src/identity/send_login_request.rs | 43 ++++++++ 10 files changed, 217 insertions(+), 80 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/api_models/login_request_header.rs rename crates/bitwarden-auth/src/identity/api_models/request/{user_token_api_request.rs => user_login_api_request.rs} (68%) create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs create mode 100644 crates/bitwarden-auth/src/identity/send_login_request.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs b/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs new file mode 100644 index 000000000..7797e3aef --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs @@ -0,0 +1,57 @@ +use bitwarden_core::DeviceType; + +/// Custom headers used in login requests to the connect/token endpoint +/// - distinct from standard HTTP headers available in `reqwest::header`. +#[derive(Debug, Clone)] +pub enum LoginRequestHeader { + /// The "Device-Type" header indicates the type of device making the request. + DeviceType(DeviceType), +} + +impl LoginRequestHeader { + /// Returns the header name as a string. + pub fn header_name(&self) -> &'static str { + match self { + Self::DeviceType(_) => "Device-Type", + } + } + + /// Returns the header value as a string. + pub fn header_value(&self) -> String { + match self { + Self::DeviceType(device_type) => (*device_type as u8).to_string(), + } + } +} + +// TODO: see if we can implement a to header tryInto trait for this instead of defining header_name +// and header_value methods + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_type_header_name() { + let header = LoginRequestHeader::DeviceType(DeviceType::SDK); + assert_eq!(header.header_name(), "Device-Type"); + } + + #[test] + fn test_device_type_header_value() { + let header = LoginRequestHeader::DeviceType(DeviceType::SDK); + assert_eq!(header.header_value(), "21"); + } + + #[test] + fn test_device_type_header_value_android() { + let header = LoginRequestHeader::DeviceType(DeviceType::Android); + assert_eq!(header.header_value(), "0"); + } + + #[test] + fn test_device_type_header_value_mac_os_cli() { + let header = LoginRequestHeader::DeviceType(DeviceType::MacOsCLI); + assert_eq!(header.header_value(), "24"); + } +} diff --git a/crates/bitwarden-auth/src/identity/api_models/mod.rs b/crates/bitwarden-auth/src/identity/api_models/mod.rs index 0810b3951..b38aae319 100644 --- a/crates/bitwarden-auth/src/identity/api_models/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/mod.rs @@ -1,3 +1,4 @@ //! API models for Identity endpoints +pub(crate) mod login_request_header; pub(crate) mod request; pub(crate) mod response; diff --git a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs index 247260e44..cd9a6503d 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs @@ -3,5 +3,5 @@ //! client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. -mod user_token_api_request; -pub(crate) use user_token_api_request::UserTokenApiRequest; +mod user_login_api_request; +pub(crate) use user_login_api_request::UserLoginApiRequest; diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs similarity index 68% rename from crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs rename to crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs index 2a7da3e19..791500b9b 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_token_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs @@ -1,7 +1,12 @@ -use bitwarden_core::DeviceType; -use serde::{Deserialize, Serialize}; +use std::fmt::Debug; -use crate::api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string}; +use bitwarden_core::{DeviceType, auth::login::LoginError, client::ApiConfigurations}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::{ + api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string}, + identity::send_login_request::send_login_request, +}; /// Standard scopes for user token requests: "api offline_access" pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAccess]; @@ -11,7 +16,8 @@ pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAc /// that represent specific login mechanisms (e.g., password, SSO, etc) /// in order to avoid duplication of common OAuth fields and custom BW fields. #[derive(Serialize, Deserialize, Debug)] -pub(crate) struct UserTokenApiRequest { +#[serde(bound = "T: Serialize + DeserializeOwned + Debug")] // Ensure T meets trait bounds +pub(crate) struct UserLoginApiRequest { // Standard OAuth2 fields /// The client ID for the SDK consuming client. /// Note: snake_case is intentional to match the API expectations. @@ -49,10 +55,14 @@ pub(crate) struct UserTokenApiRequest { /// Whether to remember two-factor authentication on this device. #[serde(rename = "twoFactorRemember")] pub two_factor_remember: Option, + + // Specific login mechanism fields would go here (e.g., password, SSO, etc) + #[serde(flatten)] + pub login_mechanism_fields: T, } -impl UserTokenApiRequest { - /// Creates a new UserTokenApiRequest with standard scopes ("api offline_access"). +impl UserLoginApiRequest { + /// Creates a new UserLoginApiRequest with standard scopes ("api offline_access"). /// The scope can be overridden after construction if needed for specific auth flows. pub(crate) fn new( client_id: String, @@ -60,6 +70,7 @@ impl UserTokenApiRequest { device_type: DeviceType, device_identifier: String, device_name: String, + login_mechanism_fields: T, ) -> Self { Self { client_id, @@ -71,6 +82,16 @@ impl UserTokenApiRequest { two_factor_token: None, two_factor_provider: None, two_factor_remember: None, + login_mechanism_fields, } } + + // TODO: move LoginError from bitwarden-core and clean up + // TODO: move and call this directly in login_via_password + pub(crate) async fn send( + &self, + configurations: &ApiConfigurations, + ) -> Result { + send_login_request(configurations, self).await + } } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 66012dc21..0bf80019b 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -1,82 +1,37 @@ use bitwarden_core::key_management::MasterPasswordAuthenticationData; -use serde::Serialize; -use crate::{ - api::enums::GrantType, - identity::{ - IdentityClient, api_models::request::UserTokenApiRequest, - login_via_password::PasswordLoginRequest, - }, +use crate::identity::{ + IdentityClient, + api_models::request::UserLoginApiRequest, + login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, }; -/// API request model for logging in via password. -#[derive(Serialize, Debug)] -#[allow(dead_code)] -struct PasswordLoginApiRequest { - // Common user token request payload - #[serde(flatten)] - user_token_api_request: UserTokenApiRequest, - - /// Bitwarden user email address - #[serde(rename = "username")] - pub email: String, - - /// Bitwarden user master password hash - #[serde(rename = "password")] - pub master_password_hash: String, -} - -/// Converts a `PasswordLoginRequest` and `MasterPasswordAuthenticationData` into a -/// `PasswordLoginApiRequest` for making the API call. -impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> for PasswordLoginApiRequest { - fn from( - (request, master_password_authentication): ( - PasswordLoginRequest, +impl IdentityClient { + /// Logs in a user via their email and master password. + /// + /// This function derives the necessary master password authentication data + /// using the provided prelogin data, constructs the appropriate API request, + /// and sends the request to the Identity connect/token endpoint to log the user in. + pub async fn login_via_password(&self, request: PasswordLoginRequest) { + // use request password prelogin data to derive master password authentication data: + let master_password_authentication: Result< MasterPasswordAuthenticationData, - ), - ) -> Self { - // Create the UserTokenApiRequest with standard scopes configuration - let user_token_api_request = UserTokenApiRequest::new( - request.login_request.client_id, - GrantType::Password, - request.login_request.device.device_type, - request.login_request.device.device_identifier, - request.login_request.device.device_name, + bitwarden_core::key_management::MasterPasswordError, + > = MasterPasswordAuthenticationData::derive( + &request.password, + &request.prelogin_data.kdf, + &request.email, ); - Self { - user_token_api_request, - email: request.email, - master_password_hash: master_password_authentication - .master_password_authentication_hash - .to_string(), - } - } -} + // construct API request + let api_request: UserLoginApiRequest = + (request, master_password_authentication.unwrap()).into(); -impl IdentityClient { - // #![allow(dead_code)] - // #![allow(unused_imports)] - // #![allow(unused_variables)] - // #![allow(missing_docs)] - // pub async fn login_via_password(&self, request: PasswordLoginRequest) { - // // use request password prelogin data to derive master password authentication data: - // let master_password_authentication: Result< - // MasterPasswordAuthenticationData, - // bitwarden_core::key_management::MasterPasswordError, - // > = MasterPasswordAuthenticationData::derive( &request.password, - // > &request.prelogin_data.kdf, &request.email, - // ); + // make API call to login endpoint with api_request + let api_configs = self.client.internal.get_api_configurations().await; - // // construct API request - // let api_request: PasswordLoginApiRequest = - // (request, master_password_authentication.unwrap()).into(); + let response = api_request.send(&api_configs).await; - // // make API call to login endpoint with api_request - // let config = self.client.internal.get_api_configurations().await; - - // // TODO: next week talk through implementing the actual API call and handling the - // response // The existing password flow uses a base send_identity_connect_request - // which is re-used // across multiple login methods. Should we do the same here? - // } + // TODO: figure out how to handle errors. + } } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs index 334fd98dc..0753e7c62 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -1,6 +1,8 @@ mod login_via_password; +mod password_login_api_request; mod password_login_request; mod prelogin_password; +pub(crate) use password_login_api_request::PasswordLoginApiRequest; pub use password_login_request::PasswordLoginRequest; pub use prelogin_password::{PreloginPasswordData, PreloginPasswordError}; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs new file mode 100644 index 000000000..50dc03f08 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -0,0 +1,55 @@ +use bitwarden_core::key_management::MasterPasswordAuthenticationData; +use serde::{Deserialize, Serialize}; + +use crate::{ + api::enums::GrantType, + identity::{ + api_models::request::UserLoginApiRequest, login_via_password::PasswordLoginRequest, + }, +}; + +/// Internal API request model for logging in via password. +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct PasswordLoginApiRequest { + // // Common user token request payload + // #[serde(flatten)] + // user_login_api_request: UserLoginApiRequest, + /// Bitwarden user email address + #[serde(rename = "username")] + pub email: String, + + /// Bitwarden user master password hash + #[serde(rename = "password")] + pub master_password_hash: String, +} + +/// Converts a `PasswordLoginRequest` and `MasterPasswordAuthenticationData` into a +/// `PasswordLoginApiRequest` for making the API call. +impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> + for UserLoginApiRequest +{ + fn from( + (request, master_password_authentication): ( + PasswordLoginRequest, + MasterPasswordAuthenticationData, + ), + ) -> Self { + // Create the PasswordLoginApiRequest with required fields + let password_login_api_request = PasswordLoginApiRequest { + email: request.email, + master_password_hash: master_password_authentication + .master_password_authentication_hash + .to_string(), + }; + + // Create the UserLoginApiRequest with standard scopes configuration and return + UserLoginApiRequest::new( + request.login_request.client_id, + GrantType::Password, + request.login_request.device.device_type, + request.login_request.device.device_identifier, + request.login_request.device.device_name, + password_login_api_request, + ) + } +} diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs index bbedbe459..406c1d5e5 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::identity::{login_via_password::PreloginPasswordData, models::LoginRequest}; -/// SDK request model for logging in via password +/// Public SDK request model for logging in via password #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index 6d73548b6..e5d434371 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -12,3 +12,6 @@ pub mod login_via_password; // API models should be private to the identity module as they are only used internally. pub(crate) mod api_models; + +/// Common send function for login requests +mod send_login_request; diff --git a/crates/bitwarden-auth/src/identity/send_login_request.rs b/crates/bitwarden-auth/src/identity/send_login_request.rs new file mode 100644 index 000000000..042d6122f --- /dev/null +++ b/crates/bitwarden-auth/src/identity/send_login_request.rs @@ -0,0 +1,43 @@ +// Cleanest idea for allowing access to data needed for sending login requests +// Make this function accept the commmon model and flatten the specific + +use bitwarden_core::client::ApiConfigurations; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::identity::api_models::{ + login_request_header::LoginRequestHeader, request::UserLoginApiRequest, +}; + +pub(crate) async fn send_login_request( + api_configs: &ApiConfigurations, + api_request: &UserLoginApiRequest, +) -> Result { + let identity_config = &api_configs.identity_config; + + let url = format!("{}/connect/token", &identity_config.base_path); + + let device_type_header = LoginRequestHeader::DeviceType(api_request.device_type); + + let mut request = identity_config + .client + .post(format!("{}/connect/token", &identity_config.base_path)) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded; charset=utf-8", + ) + .header(reqwest::header::ACCEPT, "application/json") + .header( + device_type_header.header_name(), + device_type_header.header_value(), + ); + + // let request: reqwest::RequestBuilder = configurations + // .identity_config + // .client + // .post(&url) + // .header(reqwest::header::ACCEPT, "application/json") + // .header(reqwest::header::CACHE_CONTROL, "no-store") + // .form(&api_request); + // return empty json for now + Ok(serde_json::json!({})) +} From 508978370132bc04d2eaed369ce1dce3652d810e Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 17:50:46 -0500 Subject: [PATCH 24/54] PM-14922 - Improve scope docs --- crates/bitwarden-auth/src/api/enums/scope.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-auth/src/api/enums/scope.rs b/crates/bitwarden-auth/src/api/enums/scope.rs index 70ab70bac..8d7a9a0b8 100644 --- a/crates/bitwarden-auth/src/api/enums/scope.rs +++ b/crates/bitwarden-auth/src/api/enums/scope.rs @@ -6,10 +6,10 @@ use serde::{Deserialize, Serialize}; /// resource server when the token is used. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Scope { - /// The scope for accessing the Bitwarden API. + /// The scope for accessing the Bitwarden API as a Bitwarden user. #[serde(rename = "api")] Api, - /// The scope for obtaining refresh tokens that allow offline access. + /// The scope for obtaining Bitwarden user scoped refresh tokens that allow offline access. #[serde(rename = "offline_access")] OfflineAccess, /// The scope for accessing send resources outside the context of a Bitwarden user. From 12488424b2a1b98df92a1e3881725248c4b4830a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 17:51:13 -0500 Subject: [PATCH 25/54] PM-14922 - Make login_via_password call send_login_request directly --- .../api_models/request/user_login_api_request.rs | 9 --------- .../identity/login_via_password/login_via_password.rs | 3 ++- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs index 791500b9b..149ede5ff 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs @@ -85,13 +85,4 @@ impl UserLoginApiRequest { login_mechanism_fields, } } - - // TODO: move LoginError from bitwarden-core and clean up - // TODO: move and call this directly in login_via_password - pub(crate) async fn send( - &self, - configurations: &ApiConfigurations, - ) -> Result { - send_login_request(configurations, self).await - } } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 0bf80019b..78efd12d0 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -4,6 +4,7 @@ use crate::identity::{ IdentityClient, api_models::request::UserLoginApiRequest, login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, + send_login_request::send_login_request, }; impl IdentityClient { @@ -30,7 +31,7 @@ impl IdentityClient { // make API call to login endpoint with api_request let api_configs = self.client.internal.get_api_configurations().await; - let response = api_request.send(&api_configs).await; + let response = send_login_request(&api_configs, &api_request).await; // TODO: figure out how to handle errors. } From bed7fd6bfb1a5759ee9923a0adb6cdb44e1e742f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 17:56:04 -0500 Subject: [PATCH 26/54] PM-14922 - Clean up UserLoginApiRequest of unused imports --- .../identity/api_models/request/user_login_api_request.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs index 149ede5ff..bfa6a3f2f 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs @@ -1,12 +1,9 @@ use std::fmt::Debug; -use bitwarden_core::{DeviceType, auth::login::LoginError, client::ApiConfigurations}; +use bitwarden_core::DeviceType; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use crate::{ - api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string}, - identity::send_login_request::send_login_request, -}; +use crate::api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string}; /// Standard scopes for user token requests: "api offline_access" pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAccess]; From a96f101e503254c807ff08712b4ac4ba44d504df Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 18:29:33 -0500 Subject: [PATCH 27/54] PM-14922 - Improve send_login_request --- .../api_models/login_request_header.rs | 3 --- .../src/identity/send_login_request.rs | 20 ++++++++----------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs b/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs index 7797e3aef..b3849d0c4 100644 --- a/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs +++ b/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs @@ -24,9 +24,6 @@ impl LoginRequestHeader { } } -// TODO: see if we can implement a to header tryInto trait for this instead of defining header_name -// and header_value methods - #[cfg(test)] mod tests { use super::*; diff --git a/crates/bitwarden-auth/src/identity/send_login_request.rs b/crates/bitwarden-auth/src/identity/send_login_request.rs index 042d6122f..2c79fa615 100644 --- a/crates/bitwarden-auth/src/identity/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/send_login_request.rs @@ -8,6 +8,7 @@ use crate::identity::api_models::{ login_request_header::LoginRequestHeader, request::UserLoginApiRequest, }; +/// A common function to send login requests to the Identity connect/token endpoint. pub(crate) async fn send_login_request( api_configs: &ApiConfigurations, api_request: &UserLoginApiRequest, @@ -21,23 +22,18 @@ pub(crate) async fn send_login_request( let mut request = identity_config .client .post(format!("{}/connect/token", &identity_config.base_path)) - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded; charset=utf-8", - ) .header(reqwest::header::ACCEPT, "application/json") + // Add custom device type header .header( device_type_header.header_name(), device_type_header.header_value(), - ); + ) + // per OAuth2 spec recommendation for token requests (https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1) + // we must include "no-store" cache control + .header(reqwest::header::CACHE_CONTROL, "no-store") + // use form to encode as application/x-www-form-urlencoded + .form(&api_request); - // let request: reqwest::RequestBuilder = configurations - // .identity_config - // .client - // .post(&url) - // .header(reqwest::header::ACCEPT, "application/json") - // .header(reqwest::header::CACHE_CONTROL, "no-store") - // .form(&api_request); // return empty json for now Ok(serde_json::json!({})) } From aa66ebd553157a0384070a093fab4ce6f0d9af11 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 18:54:24 -0500 Subject: [PATCH 28/54] PM-14922 - improve docs --- .../identity/api_models/request/user_login_api_request.rs | 6 ++---- crates/bitwarden-auth/src/identity/mod.rs | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs index bfa6a3f2f..85feae21b 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs @@ -9,9 +9,7 @@ use crate::api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string}; pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAccess]; /// The common payload properties to send to the /connect/token endpoint to obtain -/// tokens for a BW user. This is intended to be flattened into other api requests -/// that represent specific login mechanisms (e.g., password, SSO, etc) -/// in order to avoid duplication of common OAuth fields and custom BW fields. +/// tokens for a BW user. #[derive(Serialize, Deserialize, Debug)] #[serde(bound = "T: Serialize + DeserializeOwned + Debug")] // Ensure T meets trait bounds pub(crate) struct UserLoginApiRequest { @@ -53,7 +51,7 @@ pub(crate) struct UserLoginApiRequest { #[serde(rename = "twoFactorRemember")] pub two_factor_remember: Option, - // Specific login mechanism fields would go here (e.g., password, SSO, etc) + // Specific login mechanism fields will go here (e.g., password, SSO, etc) #[serde(flatten)] pub login_mechanism_fields: T, } diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index e5d434371..f76ce9071 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -1,5 +1,7 @@ //! Identity client module -//! The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. +//! The IdentityClient is used to authenticate a Bitwarden User. +//! This involves logging in via various mechanisms (password, SSO, etc.) to obtain +//! OAuth2 tokens from the BW Identity API. mod identity_client; pub use identity_client::IdentityClient; From e38787a9f23e44b707be09661b3b0022797d4ab6 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 18:54:40 -0500 Subject: [PATCH 29/54] PM-14922 - improve send_login_request --- .../src/identity/send_login_request.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/send_login_request.rs b/crates/bitwarden-auth/src/identity/send_login_request.rs index 2c79fa615..e5e5d8acc 100644 --- a/crates/bitwarden-auth/src/identity/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/send_login_request.rs @@ -15,11 +15,12 @@ pub(crate) async fn send_login_request( ) -> Result { let identity_config = &api_configs.identity_config; - let url = format!("{}/connect/token", &identity_config.base_path); + let url: String = format!("{}/connect/token", &identity_config.base_path); - let device_type_header = LoginRequestHeader::DeviceType(api_request.device_type); + let device_type_header: LoginRequestHeader = + LoginRequestHeader::DeviceType(api_request.device_type); - let mut request = identity_config + let mut request: reqwest::RequestBuilder = identity_config .client .post(format!("{}/connect/token", &identity_config.base_path)) .header(reqwest::header::ACCEPT, "application/json") @@ -34,6 +35,11 @@ pub(crate) async fn send_login_request( // use form to encode as application/x-www-form-urlencoded .form(&api_request); + let response: reqwest::Response = request + .send() + .await + .map_err(bitwarden_core::ApiError::from)?; + // return empty json for now Ok(serde_json::json!({})) } From a46c36e4fc5fa5ce8157fe483f674eabd497b6e4 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 19 Nov 2025 18:55:48 -0500 Subject: [PATCH 30/54] PM-14922 - Rename UserLoginApiRequest to just LoginApiRequest as that is sufficient --- .../{user_login_api_request.rs => login_api_request.rs} | 4 ++-- .../bitwarden-auth/src/identity/api_models/request/mod.rs | 4 ++-- .../src/identity/login_via_password/login_via_password.rs | 4 ++-- .../login_via_password/password_login_api_request.rs | 8 +++----- crates/bitwarden-auth/src/identity/send_login_request.rs | 4 ++-- 5 files changed, 11 insertions(+), 13 deletions(-) rename crates/bitwarden-auth/src/identity/api_models/request/{user_login_api_request.rs => login_api_request.rs} (94%) diff --git a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs similarity index 94% rename from crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs rename to crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs index 85feae21b..092285232 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/user_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs @@ -12,7 +12,7 @@ pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAc /// tokens for a BW user. #[derive(Serialize, Deserialize, Debug)] #[serde(bound = "T: Serialize + DeserializeOwned + Debug")] // Ensure T meets trait bounds -pub(crate) struct UserLoginApiRequest { +pub(crate) struct LoginApiRequest { // Standard OAuth2 fields /// The client ID for the SDK consuming client. /// Note: snake_case is intentional to match the API expectations. @@ -56,7 +56,7 @@ pub(crate) struct UserLoginApiRequest { pub login_mechanism_fields: T, } -impl UserLoginApiRequest { +impl LoginApiRequest { /// Creates a new UserLoginApiRequest with standard scopes ("api offline_access"). /// The scope can be overridden after construction if needed for specific auth flows. pub(crate) fn new( diff --git a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs index cd9a6503d..47cefb712 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/mod.rs @@ -3,5 +3,5 @@ //! client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. -mod user_login_api_request; -pub(crate) use user_login_api_request::UserLoginApiRequest; +mod login_api_request; +pub(crate) use login_api_request::LoginApiRequest; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 78efd12d0..9eefe8100 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -2,7 +2,7 @@ use bitwarden_core::key_management::MasterPasswordAuthenticationData; use crate::identity::{ IdentityClient, - api_models::request::UserLoginApiRequest, + api_models::request::LoginApiRequest, login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, send_login_request::send_login_request, }; @@ -25,7 +25,7 @@ impl IdentityClient { ); // construct API request - let api_request: UserLoginApiRequest = + let api_request: LoginApiRequest = (request, master_password_authentication.unwrap()).into(); // make API call to login endpoint with api_request diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs index 50dc03f08..b5b6bb127 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -3,9 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::{ api::enums::GrantType, - identity::{ - api_models::request::UserLoginApiRequest, login_via_password::PasswordLoginRequest, - }, + identity::{api_models::request::LoginApiRequest, login_via_password::PasswordLoginRequest}, }; /// Internal API request model for logging in via password. @@ -26,7 +24,7 @@ pub(crate) struct PasswordLoginApiRequest { /// Converts a `PasswordLoginRequest` and `MasterPasswordAuthenticationData` into a /// `PasswordLoginApiRequest` for making the API call. impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> - for UserLoginApiRequest + for LoginApiRequest { fn from( (request, master_password_authentication): ( @@ -43,7 +41,7 @@ impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> }; // Create the UserLoginApiRequest with standard scopes configuration and return - UserLoginApiRequest::new( + LoginApiRequest::new( request.login_request.client_id, GrantType::Password, request.login_request.device.device_type, diff --git a/crates/bitwarden-auth/src/identity/send_login_request.rs b/crates/bitwarden-auth/src/identity/send_login_request.rs index e5e5d8acc..c99f216df 100644 --- a/crates/bitwarden-auth/src/identity/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/send_login_request.rs @@ -5,13 +5,13 @@ use bitwarden_core::client::ApiConfigurations; use serde::{Serialize, de::DeserializeOwned}; use crate::identity::api_models::{ - login_request_header::LoginRequestHeader, request::UserLoginApiRequest, + login_request_header::LoginRequestHeader, request::LoginApiRequest, }; /// A common function to send login requests to the Identity connect/token endpoint. pub(crate) async fn send_login_request( api_configs: &ApiConfigurations, - api_request: &UserLoginApiRequest, + api_request: &LoginApiRequest, ) -> Result { let identity_config = &api_configs.identity_config; From 881aa5bbc04af54d44bbf5ff2357930b1b2aea33 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 20 Nov 2025 11:06:56 -0500 Subject: [PATCH 31/54] PM-14922 - Add DevicePushTokenSupport --- .../src/identity/api_models/request/login_api_request.rs | 6 ++++++ .../login_via_password/password_login_api_request.rs | 1 + .../src/identity/models/login_device_request.rs | 3 +++ 3 files changed, 10 insertions(+) diff --git a/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs b/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs index 092285232..3ccc5a512 100644 --- a/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs @@ -38,6 +38,10 @@ pub(crate) struct LoginApiRequest { #[serde(rename = "deviceName")] pub device_name: String, + /// The push notification registration token for mobile devices. + #[serde(rename = "devicePushToken")] + pub device_push_token: Option, + // Two-factor authentication fields /// The two-factor authentication token. #[serde(rename = "twoFactorToken")] @@ -65,6 +69,7 @@ impl LoginApiRequest { device_type: DeviceType, device_identifier: String, device_name: String, + device_push_token: Option, login_mechanism_fields: T, ) -> Self { Self { @@ -74,6 +79,7 @@ impl LoginApiRequest { device_type, device_identifier, device_name, + device_push_token, two_factor_token: None, two_factor_provider: None, two_factor_remember: None, diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs index b5b6bb127..bc21a284f 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -47,6 +47,7 @@ impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> request.login_request.device.device_type, request.login_request.device.device_identifier, request.login_request.device.device_name, + request.login_request.device.device_push_token, password_login_api_request, ) } diff --git a/crates/bitwarden-auth/src/identity/models/login_device_request.rs b/crates/bitwarden-auth/src/identity/models/login_device_request.rs index 1456acf56..29ba8a846 100644 --- a/crates/bitwarden-auth/src/identity/models/login_device_request.rs +++ b/crates/bitwarden-auth/src/identity/models/login_device_request.rs @@ -28,4 +28,7 @@ pub struct LoginDeviceRequest { /// Human-readable name of the device pub device_name: String, + + /// Push notification token for the device (only for mobile devices) + pub device_push_token: Option, } From 056615f06c0c9365c801e5f84f960a9a990bc6ff Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 20 Nov 2025 11:21:12 -0500 Subject: [PATCH 32/54] PM-14922 - WIP on LoginApiSuccess --- .../api_models/response/login_api_success.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs b/crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs new file mode 100644 index 000000000..c463b28d7 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs @@ -0,0 +1,48 @@ +/// API response model for a successful login via the Identity API. +/// OAuth 2.0 Successful Response RFC reference: +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub(crate) struct LoginApiSuccessResponse { + /// The access token string. + pub access_token: String, + /// The duration in seconds until the token expires. + pub expires_in: u64, + /// The scope of the access token. + /// OAuth 2.0 RFC reference: + pub scope: String, + + /// The type of the token. + /// This will be "Bearer" for send access tokens. + /// OAuth 2.0 RFC reference: + pub token_type: String, + + /// The optional refresh token string. + /// This token can be used to obtain new access tokens when the current one expires. + pub refresh_token: Option, + + #[serde(rename = "privateKey", alias = "PrivateKey")] + pub(crate) private_key: Option, + #[serde(alias = "Key")] + pub(crate) key: Option, + #[serde(rename = "twoFactorToken")] + two_factor_token: Option, + #[serde(alias = "Kdf")] + kdf: KdfType, + #[serde( + rename = "kdfIterations", + alias = "KdfIterations", + default = "bitwarden_crypto::default_pbkdf2_iterations" + )] + kdf_iterations: NonZeroU32, + + #[serde(rename = "resetMasterPassword", alias = "ResetMasterPassword")] + pub reset_master_password: bool, + #[serde(rename = "forcePasswordReset", alias = "ForcePasswordReset")] + pub force_password_reset: bool, + #[serde(rename = "apiUseKeyConnector", alias = "ApiUseKeyConnector")] + api_use_key_connector: Option, + #[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")] + key_connector_url: Option, + + #[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")] + pub(crate) user_decryption_options: Option, +} From 2a568388ec81d3e69666ec6409ffdb79490d41ab Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 20 Nov 2025 11:31:10 -0500 Subject: [PATCH 33/54] PM-14922 - LoginApiSuccess - more fleshed out and building --- Cargo.lock | 1 + crates/bitwarden-auth/Cargo.toml | 1 + ...i_success.rs => login_api_success_response.rs} | 9 ++++++++- .../src/identity/api_models/response/mod.rs | 5 +++++ .../response/user_decryption_options_response.rs | 15 +++++++++++++++ 5 files changed, 30 insertions(+), 1 deletion(-) rename crates/bitwarden-auth/src/identity/api_models/response/{login_api_success.rs => login_api_success_response.rs} (88%) create mode 100644 crates/bitwarden-auth/src/identity/api_models/response/user_decryption_options_response.rs diff --git a/Cargo.lock b/Cargo.lock index 0f982f7c0..45a53a3c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,7 @@ dependencies = [ name = "bitwarden-auth" version = "1.0.0" dependencies = [ + "bitwarden-api-api", "bitwarden-api-identity", "bitwarden-core", "bitwarden-crypto", diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index ffb743990..1c71bff1f 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -24,6 +24,7 @@ wasm = [ # Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline. [dependencies] +bitwarden-api-api = { workspace = true } bitwarden-api-identity = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-crypto = { workspace = true } diff --git a/crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs b/crates/bitwarden-auth/src/identity/api_models/response/login_api_success_response.rs similarity index 88% rename from crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs rename to crates/bitwarden-auth/src/identity/api_models/response/login_api_success_response.rs index c463b28d7..e3f75898f 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/login_api_success.rs +++ b/crates/bitwarden-auth/src/identity/api_models/response/login_api_success_response.rs @@ -1,3 +1,9 @@ +use bitwarden_api_identity::models::KdfType; +use serde::{Deserialize, Serialize}; +use std::num::NonZeroU32; + +use crate::identity::api_models::response::UserDecryptionOptionsResponse; + /// API response model for a successful login via the Identity API. /// OAuth 2.0 Successful Response RFC reference: #[derive(Serialize, Deserialize, Debug, PartialEq)] @@ -19,6 +25,7 @@ pub(crate) struct LoginApiSuccessResponse { /// This token can be used to obtain new access tokens when the current one expires. pub refresh_token: Option, + // Custom Bitwarden connect/token response fields: #[serde(rename = "privateKey", alias = "PrivateKey")] pub(crate) private_key: Option, #[serde(alias = "Key")] @@ -44,5 +51,5 @@ pub(crate) struct LoginApiSuccessResponse { key_connector_url: Option, #[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")] - pub(crate) user_decryption_options: Option, + pub(crate) user_decryption_options: Option, } diff --git a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs index 6c5087b02..bae89b7ee 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs @@ -3,3 +3,8 @@ //! client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. +mod login_api_success_response; +pub(crate) use login_api_success_response::LoginApiSuccessResponse; + +mod user_decryption_options_response; +pub(crate) use user_decryption_options_response::UserDecryptionOptionsResponse; diff --git a/crates/bitwarden-auth/src/identity/api_models/response/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/api_models/response/user_decryption_options_response.rs new file mode 100644 index 000000000..3045f19c4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api_models/response/user_decryption_options_response.rs @@ -0,0 +1,15 @@ +use bitwarden_api_api::models::MasterPasswordUnlockResponseModel; +use serde::{Deserialize, Serialize}; + +/// Provides user decryption options used to unlock user's vault. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub(crate) struct UserDecryptionOptionsResponse { + /// Contains information needed to unlock user's vault with master password. + /// None when user have no master password. + #[serde( + rename = "masterPasswordUnlock", + alias = "MasterPasswordUnlock", + skip_serializing_if = "Option::is_none" + )] + pub(crate) master_password_unlock: Option, +} From e5967f21089a3b3105f8b813cdc61b9b35007ea3 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 20 Nov 2025 13:33:45 -0500 Subject: [PATCH 34/54] PM-14922 - more WIP on login via password success and error models --- .../response/login_error_api_response.rs | 117 ++++++++++++++++++ ...ponse.rs => login_success_api_response.rs} | 2 +- .../src/identity/api_models/response/mod.rs | 7 +- .../src/identity/models/login_success.rs | 2 + .../src/identity/send_login_request.rs | 32 +++-- 5 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs rename crates/bitwarden-auth/src/identity/api_models/response/{login_api_success_response.rs => login_success_api_response.rs} (98%) create mode 100644 crates/bitwarden-auth/src/identity/models/login_success.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs b/crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs new file mode 100644 index 000000000..fe18e1756 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs @@ -0,0 +1,117 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use tsify::Tsify; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +pub enum PasswordInvalidGrantError { + InvalidUsernameOrPassword, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +pub enum InvalidGrantError { + // Password grant specific errors + Password(PasswordInvalidGrantError), + + // TODO: other grant specific errors can go here + /// Fallback for unknown variants for forward compatibility + #[serde(other)] + Unknown, +} + +// TODO: add invalid request error enums for password as well + +/// Per RFC 6749 Section 5.2, these are the standard error responses for OAuth 2.0 token requests. +/// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +#[serde(tag = "error")] +pub enum OAuth2ErrorApiResponse { + /// Invalid request error, typically due to missing parameters for a specific + /// credential flow. Ex. `password` is required. + InvalidRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid request errors. + error_description: Option, + // #[serde(default, skip_serializing_if = "Option::is_none")] + // #[cfg_attr(feature = "wasm", tsify(optional))] + // /// The optional specific error type for invalid request errors. + // send_access_error_type: Option, + }, + + /// Invalid grant error, typically due to invalid credentials. + InvalidGrant { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid grant errors. + error_description: Option, + // #[serde(default, skip_serializing_if = "Option::is_none")] + // #[cfg_attr(feature = "wasm", tsify(optional))] + // /// The optional specific error type for invalid grant errors. + // send_access_error_type: Option, + + // We need to handle invalid_username_or_password for password grant errors + }, + + /// Invalid client error, typically due to an invalid client secret or client ID. + InvalidClient { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid client errors. + error_description: Option, + }, + + /// Unauthorized client error, typically due to an unauthorized client. + UnauthorizedClient { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for unauthorized client errors. + error_description: Option, + }, + + /// Unsupported grant type error, typically due to an unsupported credential flow. + /// Note: during initial feature rollout, this will be used to indicate that the + /// feature flag is disabled. + UnsupportedGrantType { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for unsupported grant type errors. + error_description: Option, + }, + + /// Invalid scope error, typically due to an invalid scope requested. + InvalidScope { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid scope errors. + error_description: Option, + }, + + /// Invalid target error which is shown if the requested + /// resource is invalid, missing, unknown, or malformed. + InvalidTarget { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid target errors. + error_description: Option, + }, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub enum LoginErrorApiResponse { + OAuth2Error(OAuth2ErrorApiResponse), + UnexpectedError(String), +} + +// This is just a utility function so that the ? operator works correctly without manual mapping +impl From for LoginErrorApiResponse { + fn from(value: reqwest::Error) -> Self { + Self::UnexpectedError(format!("{value:?}")) + } +} diff --git a/crates/bitwarden-auth/src/identity/api_models/response/login_api_success_response.rs b/crates/bitwarden-auth/src/identity/api_models/response/login_success_api_response.rs similarity index 98% rename from crates/bitwarden-auth/src/identity/api_models/response/login_api_success_response.rs rename to crates/bitwarden-auth/src/identity/api_models/response/login_success_api_response.rs index e3f75898f..d24740068 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/login_api_success_response.rs +++ b/crates/bitwarden-auth/src/identity/api_models/response/login_success_api_response.rs @@ -7,7 +7,7 @@ use crate::identity::api_models::response::UserDecryptionOptionsResponse; /// API response model for a successful login via the Identity API. /// OAuth 2.0 Successful Response RFC reference: #[derive(Serialize, Deserialize, Debug, PartialEq)] -pub(crate) struct LoginApiSuccessResponse { +pub(crate) struct LoginSuccessApiResponse { /// The access token string. pub access_token: String, /// The duration in seconds until the token expires. diff --git a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs index bae89b7ee..3e77ffad0 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs +++ b/crates/bitwarden-auth/src/identity/api_models/response/mod.rs @@ -3,8 +3,11 @@ //! client //! //! For standard controller endpoints, use the `bitwarden-api-identity` crate. -mod login_api_success_response; -pub(crate) use login_api_success_response::LoginApiSuccessResponse; +mod login_success_api_response; +pub(crate) use login_success_api_response::LoginSuccessApiResponse; mod user_decryption_options_response; pub(crate) use user_decryption_options_response::UserDecryptionOptionsResponse; + +mod login_error_api_response; +pub(crate) use login_error_api_response::LoginErrorApiResponse; diff --git a/crates/bitwarden-auth/src/identity/models/login_success.rs b/crates/bitwarden-auth/src/identity/models/login_success.rs new file mode 100644 index 000000000..97eac4ccd --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_success.rs @@ -0,0 +1,2 @@ +// TODO: investigate if it is worth implementing another layer of abstraction for SDK response models for +// login success and failure. diff --git a/crates/bitwarden-auth/src/identity/send_login_request.rs b/crates/bitwarden-auth/src/identity/send_login_request.rs index c99f216df..1ce145ea1 100644 --- a/crates/bitwarden-auth/src/identity/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/send_login_request.rs @@ -5,14 +5,16 @@ use bitwarden_core::client::ApiConfigurations; use serde::{Serialize, de::DeserializeOwned}; use crate::identity::api_models::{ - login_request_header::LoginRequestHeader, request::LoginApiRequest, + login_request_header::LoginRequestHeader, + request::LoginApiRequest, + response::{LoginErrorApiResponse, LoginSuccessApiResponse}, }; /// A common function to send login requests to the Identity connect/token endpoint. pub(crate) async fn send_login_request( api_configs: &ApiConfigurations, api_request: &LoginApiRequest, -) -> Result { +) -> Result { let identity_config = &api_configs.identity_config; let url: String = format!("{}/connect/token", &identity_config.base_path); @@ -20,9 +22,9 @@ pub(crate) async fn send_login_request( let device_type_header: LoginRequestHeader = LoginRequestHeader::DeviceType(api_request.device_type); - let mut request: reqwest::RequestBuilder = identity_config + let request: reqwest::RequestBuilder = identity_config .client - .post(format!("{}/connect/token", &identity_config.base_path)) + .post(url) .header(reqwest::header::ACCEPT, "application/json") // Add custom device type header .header( @@ -35,11 +37,21 @@ pub(crate) async fn send_login_request( // use form to encode as application/x-www-form-urlencoded .form(&api_request); - let response: reqwest::Response = request - .send() - .await - .map_err(bitwarden_core::ApiError::from)?; + let response: reqwest::Response = request.send().await?; - // return empty json for now - Ok(serde_json::json!({})) + let response_status = response.status(); + + if response_status.is_success() { + let login_success_api_response: LoginSuccessApiResponse = response.json().await?; + + // TODO: define LoginSuccessResponse model in SDK layer and add into trait from + // LoginSuccessApiResponse to convert between API model and SDK model + + return Ok(login_success_api_response); + } + + // Handle error response + let login_error_api_response: LoginErrorApiResponse = response.json().await?; + + Err(login_error_api_response) } From 2c409e1c480e25c26110788456d7514367fa2a36 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 20 Nov 2025 18:50:47 -0500 Subject: [PATCH 35/54] PM-14922 - (1) Rename api_models to just api (2) Move common send_login_request to api as it makes more sense as an api service layer esque thing. --- .../{api_models => api}/login_request_header.rs | 0 crates/bitwarden-auth/src/identity/api/mod.rs | 8 ++++++++ .../{api_models => api}/request/login_api_request.rs | 0 .../src/identity/{api_models => api}/request/mod.rs | 0 .../response/login_error_api_response.rs | 12 ------------ .../response/login_success_api_response.rs | 2 +- .../src/identity/{api_models => api}/response/mod.rs | 0 .../response/user_decryption_options_response.rs | 0 .../src/identity/{ => api}/send_login_request.rs | 2 +- crates/bitwarden-auth/src/identity/api_models/mod.rs | 4 ---- .../login_via_password/login_via_password.rs | 3 +-- .../login_via_password/password_login_api_request.rs | 2 +- crates/bitwarden-auth/src/identity/mod.rs | 5 +---- 13 files changed, 13 insertions(+), 25 deletions(-) rename crates/bitwarden-auth/src/identity/{api_models => api}/login_request_header.rs (100%) create mode 100644 crates/bitwarden-auth/src/identity/api/mod.rs rename crates/bitwarden-auth/src/identity/{api_models => api}/request/login_api_request.rs (100%) rename crates/bitwarden-auth/src/identity/{api_models => api}/request/mod.rs (100%) rename crates/bitwarden-auth/src/identity/{api_models => api}/response/login_error_api_response.rs (85%) rename crates/bitwarden-auth/src/identity/{api_models => api}/response/login_success_api_response.rs (96%) rename crates/bitwarden-auth/src/identity/{api_models => api}/response/mod.rs (100%) rename crates/bitwarden-auth/src/identity/{api_models => api}/response/user_decryption_options_response.rs (100%) rename crates/bitwarden-auth/src/identity/{ => api}/send_login_request.rs (98%) delete mode 100644 crates/bitwarden-auth/src/identity/api_models/mod.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/login_request_header.rs b/crates/bitwarden-auth/src/identity/api/login_request_header.rs similarity index 100% rename from crates/bitwarden-auth/src/identity/api_models/login_request_header.rs rename to crates/bitwarden-auth/src/identity/api/login_request_header.rs diff --git a/crates/bitwarden-auth/src/identity/api/mod.rs b/crates/bitwarden-auth/src/identity/api/mod.rs new file mode 100644 index 000000000..7fa15ab08 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/mod.rs @@ -0,0 +1,8 @@ +//! API related modules for Identity endpoints +pub(crate) mod login_request_header; +pub(crate) mod request; +pub(crate) mod response; + +/// Common send function for login requests +mod send_login_request; +pub(crate) use send_login_request::send_login_request; diff --git a/crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs b/crates/bitwarden-auth/src/identity/api/request/login_api_request.rs similarity index 100% rename from crates/bitwarden-auth/src/identity/api_models/request/login_api_request.rs rename to crates/bitwarden-auth/src/identity/api/request/login_api_request.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/request/mod.rs b/crates/bitwarden-auth/src/identity/api/request/mod.rs similarity index 100% rename from crates/bitwarden-auth/src/identity/api_models/request/mod.rs rename to crates/bitwarden-auth/src/identity/api/request/mod.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs similarity index 85% rename from crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs rename to crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs index fe18e1756..111bad4be 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/login_error_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs @@ -22,8 +22,6 @@ pub enum InvalidGrantError { Unknown, } -// TODO: add invalid request error enums for password as well - /// Per RFC 6749 Section 5.2, these are the standard error responses for OAuth 2.0 token requests. /// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] @@ -38,10 +36,6 @@ pub enum OAuth2ErrorApiResponse { #[cfg_attr(feature = "wasm", tsify(optional))] /// The optional error description for invalid request errors. error_description: Option, - // #[serde(default, skip_serializing_if = "Option::is_none")] - // #[cfg_attr(feature = "wasm", tsify(optional))] - // /// The optional specific error type for invalid request errors. - // send_access_error_type: Option, }, /// Invalid grant error, typically due to invalid credentials. @@ -50,12 +44,6 @@ pub enum OAuth2ErrorApiResponse { #[cfg_attr(feature = "wasm", tsify(optional))] /// The optional error description for invalid grant errors. error_description: Option, - // #[serde(default, skip_serializing_if = "Option::is_none")] - // #[cfg_attr(feature = "wasm", tsify(optional))] - // /// The optional specific error type for invalid grant errors. - // send_access_error_type: Option, - - // We need to handle invalid_username_or_password for password grant errors }, /// Invalid client error, typically due to an invalid client secret or client ID. diff --git a/crates/bitwarden-auth/src/identity/api_models/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs similarity index 96% rename from crates/bitwarden-auth/src/identity/api_models/response/login_success_api_response.rs rename to crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index d24740068..f6d28463c 100644 --- a/crates/bitwarden-auth/src/identity/api_models/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -2,7 +2,7 @@ use bitwarden_api_identity::models::KdfType; use serde::{Deserialize, Serialize}; use std::num::NonZeroU32; -use crate::identity::api_models::response::UserDecryptionOptionsResponse; +use crate::identity::api::response::UserDecryptionOptionsResponse; /// API response model for a successful login via the Identity API. /// OAuth 2.0 Successful Response RFC reference: diff --git a/crates/bitwarden-auth/src/identity/api_models/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs similarity index 100% rename from crates/bitwarden-auth/src/identity/api_models/response/mod.rs rename to crates/bitwarden-auth/src/identity/api/response/mod.rs diff --git a/crates/bitwarden-auth/src/identity/api_models/response/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs similarity index 100% rename from crates/bitwarden-auth/src/identity/api_models/response/user_decryption_options_response.rs rename to crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs diff --git a/crates/bitwarden-auth/src/identity/send_login_request.rs b/crates/bitwarden-auth/src/identity/api/send_login_request.rs similarity index 98% rename from crates/bitwarden-auth/src/identity/send_login_request.rs rename to crates/bitwarden-auth/src/identity/api/send_login_request.rs index 1ce145ea1..5dafb234f 100644 --- a/crates/bitwarden-auth/src/identity/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -4,7 +4,7 @@ use bitwarden_core::client::ApiConfigurations; use serde::{Serialize, de::DeserializeOwned}; -use crate::identity::api_models::{ +use crate::identity::api::{ login_request_header::LoginRequestHeader, request::LoginApiRequest, response::{LoginErrorApiResponse, LoginSuccessApiResponse}, diff --git a/crates/bitwarden-auth/src/identity/api_models/mod.rs b/crates/bitwarden-auth/src/identity/api_models/mod.rs deleted file mode 100644 index b38aae319..000000000 --- a/crates/bitwarden-auth/src/identity/api_models/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! API models for Identity endpoints -pub(crate) mod login_request_header; -pub(crate) mod request; -pub(crate) mod response; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 9eefe8100..36d24908f 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -2,9 +2,8 @@ use bitwarden_core::key_management::MasterPasswordAuthenticationData; use crate::identity::{ IdentityClient, - api_models::request::LoginApiRequest, + api::{request::LoginApiRequest, send_login_request}, login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, - send_login_request::send_login_request, }; impl IdentityClient { diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs index bc21a284f..85054458a 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::{ api::enums::GrantType, - identity::{api_models::request::LoginApiRequest, login_via_password::PasswordLoginRequest}, + identity::{api::request::LoginApiRequest, login_via_password::PasswordLoginRequest}, }; /// Internal API request model for logging in via password. diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index f76ce9071..2ddd981e7 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -13,7 +13,4 @@ pub mod models; pub mod login_via_password; // API models should be private to the identity module as they are only used internally. -pub(crate) mod api_models; - -/// Common send function for login requests -mod send_login_request; +pub(crate) mod api; From fec2ed32e1c496cc670e55ab928773b8ee379585 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 20 Nov 2025 19:37:40 -0500 Subject: [PATCH 36/54] PM-14922 - WIP on documenting login_success_api_response and figuring out more models that I have to build --- .../response/login_success_api_response.rs | 16 ++++ .../user_decryption_options_response.rs | 7 +- .../src/identity/models/login_success.rs | 2 - .../identity/models/login_success_response.rs | 84 +++++++++++++++++++ .../bitwarden-auth/src/identity/models/mod.rs | 2 + 5 files changed, 107 insertions(+), 4 deletions(-) delete mode 100644 crates/bitwarden-auth/src/identity/models/login_success.rs create mode 100644 crates/bitwarden-auth/src/identity/models/login_success_response.rs diff --git a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index f6d28463c..1c24c18c1 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -26,12 +26,20 @@ pub(crate) struct LoginSuccessApiResponse { pub refresh_token: Option, // Custom Bitwarden connect/token response fields: + /// The user's user key encrypted private key #[serde(rename = "privateKey", alias = "PrivateKey")] pub(crate) private_key: Option, + + /// The user's master key encrypted user key. #[serde(alias = "Key")] pub(crate) key: Option, + + /// Two factor remember me token to be used for future requests to bypass 2FA prompts + /// for a limited time. #[serde(rename = "twoFactorToken")] two_factor_token: Option, + + /// Master key derivation function type #[serde(alias = "Kdf")] kdf: KdfType, #[serde( @@ -39,12 +47,20 @@ pub(crate) struct LoginSuccessApiResponse { alias = "KdfIterations", default = "bitwarden_crypto::default_pbkdf2_iterations" )] + /// Master key derivation function iterations kdf_iterations: NonZeroU32, + // TODO: can we just not include this as it should be deprecated #[serde(rename = "resetMasterPassword", alias = "ResetMasterPassword")] pub reset_master_password: bool, + + // TODO: do we want to pass this along unchanged or should we convert to + // an enum for ForceSetPasswordReason like we have in clients? + /// If an admin has forced a password reset for the user, this will be true. #[serde(rename = "forcePasswordReset", alias = "ForcePasswordReset")] pub force_password_reset: bool, + + /// #[serde(rename = "apiUseKeyConnector", alias = "ApiUseKeyConnector")] api_use_key_connector: Option, #[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")] diff --git a/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs index 3045f19c4..d5ee58b8e 100644 --- a/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// Provides user decryption options used to unlock user's vault. #[derive(Serialize, Deserialize, Debug, PartialEq)] -pub(crate) struct UserDecryptionOptionsResponse { +pub struct UserDecryptionOptionsResponse { /// Contains information needed to unlock user's vault with master password. /// None when user have no master password. #[serde( @@ -11,5 +11,8 @@ pub(crate) struct UserDecryptionOptionsResponse { alias = "MasterPasswordUnlock", skip_serializing_if = "Option::is_none" )] - pub(crate) master_password_unlock: Option, + pub master_password_unlock: Option, + // TODO: I have to build out all other unlock options here. + + // pub trusted_device_ } diff --git a/crates/bitwarden-auth/src/identity/models/login_success.rs b/crates/bitwarden-auth/src/identity/models/login_success.rs deleted file mode 100644 index 97eac4ccd..000000000 --- a/crates/bitwarden-auth/src/identity/models/login_success.rs +++ /dev/null @@ -1,2 +0,0 @@ -// TODO: investigate if it is worth implementing another layer of abstraction for SDK response models for -// login success and failure. diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs new file mode 100644 index 000000000..485ef5768 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -0,0 +1,84 @@ +use std::fmt::Debug; + +use bitwarden_api_identity::models::KdfType; +use std::num::NonZeroU32; + +use crate::identity::api::response::{LoginSuccessApiResponse, UserDecryptionOptionsResponse}; + +/// SDK response model for a successful login. +/// This is the model that will be exposed to consuming applications. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +#[derive(Debug)] +pub struct LoginSuccessResponse { + /// The access token string. + pub access_token: String, + /// The duration in seconds until the token expires. + pub expires_in: u64, + /// The timestamp in milliseconds when the token expires. + pub expires_at: i64, + /// The scope of the access token. + pub scope: String, + /// The type of the token (typically "Bearer"). + pub token_type: String, + /// The optional refresh token string. + pub refresh_token: Option, + + // TODO: port over docs from API response + // but also RENAME things to be more clear. + /// The user's encrypted private key. + pub private_key: Option, + /// The user's encrypted symmetric key. + pub key: Option, + /// Two-factor authentication token for future requests. + pub two_factor_token: Option, + /// The key derivation function type. + pub kdf: KdfType, + /// The number of iterations for the key derivation function. + pub kdf_iterations: NonZeroU32, + /// Whether the user needs to reset their master password. + pub reset_master_password: bool, + /// Whether the user is forced to reset their password. + pub force_password_reset: bool, + /// Whether the API uses Key Connector. + pub api_use_key_connector: Option, + /// The URL for the Key Connector service. + pub key_connector_url: Option, + /// User decryption options for the account. + pub user_decryption_options: Option, +} + +impl From for LoginSuccessResponse { + fn from(response: LoginSuccessApiResponse) -> Self { + // We want to convert the expires_in from seconds to a millisecond timestamp to have a + // concrete time the token will expire. This makes it easier to build logic around a + // concrete time rather than a duration. We keep expires_in as well for backward + // compatibility and convenience. + let expires_at = + chrono::Utc::now().timestamp_millis() + (response.expires_in * 1000) as i64; + + LoginSuccessResponse { + access_token: response.access_token, + expires_in: response.expires_in, + expires_at, + scope: response.scope, + token_type: response.token_type, + refresh_token: response.refresh_token, + private_key: response.private_key, + key: response.key, + two_factor_token: response.two_factor_token, + kdf: response.kdf, + kdf_iterations: response.kdf_iterations, + reset_master_password: response.reset_master_password, + force_password_reset: response.force_password_reset, + api_use_key_connector: response.api_use_key_connector, + key_connector_url: response.key_connector_url, + user_decryption_options: response.user_decryption_options, + } + } +} diff --git a/crates/bitwarden-auth/src/identity/models/mod.rs b/crates/bitwarden-auth/src/identity/models/mod.rs index 4437bd0d6..7dc1ac88c 100644 --- a/crates/bitwarden-auth/src/identity/models/mod.rs +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -2,6 +2,8 @@ mod login_device_request; mod login_request; +mod login_success_response; pub use login_device_request::LoginDeviceRequest; pub use login_request::LoginRequest; +pub use login_success_response::LoginSuccessResponse; From 7f8053638faa3ff49acefa53f3d3bd1eb29fc6a0 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 1 Dec 2025 17:40:25 -0500 Subject: [PATCH 37/54] PM-14922 - more WIP docs --- .../api/response/login_error_api_response.rs | 4 ++++ .../api/response/login_success_api_response.rs | 16 +++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs index 111bad4be..9135c3973 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs @@ -7,6 +7,10 @@ use tsify::Tsify; #[serde(rename_all = "snake_case")] pub enum PasswordInvalidGrantError { InvalidUsernameOrPassword, + + /// Fallback for unknown variants for forward compatibility + #[serde(other)] + Unknown, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] diff --git a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index 1c24c18c1..9989db687 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -34,8 +34,8 @@ pub(crate) struct LoginSuccessApiResponse { #[serde(alias = "Key")] pub(crate) key: Option, - /// Two factor remember me token to be used for future requests to bypass 2FA prompts - /// for a limited time. + /// Two factor remember me token to be used for future requests + /// to bypass 2FA prompts for a limited time. #[serde(rename = "twoFactorToken")] two_factor_token: Option, @@ -60,12 +60,18 @@ pub(crate) struct LoginSuccessApiResponse { #[serde(rename = "forcePasswordReset", alias = "ForcePasswordReset")] pub force_password_reset: bool, - /// + /// Optional + // TODO: rename this to be clear that it's only for user API key logins + // for users who have key connector enabled on their account. + // They have to have their key connector url configured locally in their + // CLI environment to decrypt. + // TODO: Ask Oscar why we allow users to configure a local + // key connector URL when we always send the URL from server? #[serde(rename = "apiUseKeyConnector", alias = "ApiUseKeyConnector")] api_use_key_connector: Option, - #[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")] - key_connector_url: Option, + // #[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")] + // key_connector_url: Option, #[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")] pub(crate) user_decryption_options: Option, } From ffdc4ce76da9da0ce6d65254d0dcff41c93f4dc8 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 5 Dec 2025 11:34:37 -0500 Subject: [PATCH 38/54] PM-14922 - Progress on building out login_success_api_response + user decryption options --- ...tor_user_decryption_option_api_response.rs | 11 +++++ .../response/login_success_api_response.rs | 47 +++++++++---------- .../src/identity/api/response/mod.rs | 13 ++++- ...ice_user_decryption_option_api_response.rs | 34 ++++++++++++++ .../user_decryption_options_api_response.rs | 36 ++++++++++++++ .../user_decryption_options_response.rs | 18 ------- ...prf_user_decryption_option_api_response.rs | 15 ++++++ .../login_via_password/login_via_password.rs | 3 ++ .../identity/models/login_success_response.rs | 4 +- 9 files changed, 135 insertions(+), 46 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/api/response/key_connector_user_decryption_option_api_response.rs create mode 100644 crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs create mode 100644 crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs delete mode 100644 crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs create mode 100644 crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs diff --git a/crates/bitwarden-auth/src/identity/api/response/key_connector_user_decryption_option_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/key_connector_user_decryption_option_api_response.rs new file mode 100644 index 000000000..5513c6901 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/key_connector_user_decryption_option_api_response.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +/// Key Connector User Decryption Option API response. +/// Indicates that Key Connector is used for user decryption and +/// it contains all required fields for Key Connector decryption. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct KeyConnectorUserDecryptionOptionApiResponse { + /// URL of the Key Connector server to use for decryption. + #[serde(rename = "KeyConnectorUrl")] + pub key_connector_url: String, +} diff --git a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index 9989db687..eec4d7e63 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -2,7 +2,7 @@ use bitwarden_api_identity::models::KdfType; use serde::{Deserialize, Serialize}; use std::num::NonZeroU32; -use crate::identity::api::response::UserDecryptionOptionsResponse; +use crate::identity::api::response::UserDecryptionOptionsApiResponse; /// API response model for a successful login via the Identity API. /// OAuth 2.0 Successful Response RFC reference: @@ -42,36 +42,35 @@ pub(crate) struct LoginSuccessApiResponse { /// Master key derivation function type #[serde(alias = "Kdf")] kdf: KdfType, - #[serde( - rename = "kdfIterations", - alias = "KdfIterations", - default = "bitwarden_crypto::default_pbkdf2_iterations" - )] + + // TODO: ensure we convert to NonZeroU32 for the SDK model + // for any Some values + #[serde(rename = "kdfIterations", alias = "KdfIterations")] /// Master key derivation function iterations - kdf_iterations: NonZeroU32, + kdf_iterations: Option, + + /// Master key derivation function memory + #[serde(rename = "kdfMemory", alias = "KdfMemory")] + kdf_memory: Option, - // TODO: can we just not include this as it should be deprecated - #[serde(rename = "resetMasterPassword", alias = "ResetMasterPassword")] - pub reset_master_password: bool, + /// Master key derivation function parallelism + #[serde(rename = "kdfParallelism", alias = "KdfParallelism")] + kdf_parallelism: Option, - // TODO: do we want to pass this along unchanged or should we convert to - // an enum for ForceSetPasswordReason like we have in clients? - /// If an admin has forced a password reset for the user, this will be true. + /// Indicates whether an admin has reset the user's master password, + /// requiring them to set a new password upon next login. #[serde(rename = "forcePasswordReset", alias = "ForcePasswordReset")] - pub force_password_reset: bool, + pub force_password_reset: Option, - /// Optional - // TODO: rename this to be clear that it's only for user API key logins - // for users who have key connector enabled on their account. - // They have to have their key connector url configured locally in their - // CLI environment to decrypt. - // TODO: Ask Oscar why we allow users to configure a local - // key connector URL when we always send the URL from server? + /// Indicates whether the user uses Key Connector and if the client should have a locally + /// configured Key Connector URL in their environment. + /// Note: This is currently only applicable for client_credential grant type logins and + /// is only expected to be relevant for the CLI #[serde(rename = "apiUseKeyConnector", alias = "ApiUseKeyConnector")] api_use_key_connector: Option, - // #[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")] - // key_connector_url: Option, + /// The user's decryption options for their vault. #[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")] - pub(crate) user_decryption_options: Option, + pub(crate) user_decryption_options: Option, + // TODO: add MasterPasswordPolicy } diff --git a/crates/bitwarden-auth/src/identity/api/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs index 3e77ffad0..d6bbeaf74 100644 --- a/crates/bitwarden-auth/src/identity/api/response/mod.rs +++ b/crates/bitwarden-auth/src/identity/api/response/mod.rs @@ -6,8 +6,17 @@ mod login_success_api_response; pub(crate) use login_success_api_response::LoginSuccessApiResponse; -mod user_decryption_options_response; -pub(crate) use user_decryption_options_response::UserDecryptionOptionsResponse; +mod user_decryption_options_api_response; +pub(crate) use user_decryption_options_api_response::UserDecryptionOptionsApiResponse; + +mod trusted_device_user_decryption_option_api_response; +pub(crate) use trusted_device_user_decryption_option_api_response::TrustedDeviceUserDecryptionOptionApiResponse; + +mod key_connector_user_decryption_option_api_response; +pub(crate) use key_connector_user_decryption_option_api_response::KeyConnectorUserDecryptionOptionApiResponse; + +mod webauthn_prf_user_decryption_option_api_response; +pub(crate) use webauthn_prf_user_decryption_option_api_response::WebAuthnPrfUserDecryptionOptionApiResponse; mod login_error_api_response; pub(crate) use login_error_api_response::LoginErrorApiResponse; diff --git a/crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs new file mode 100644 index 000000000..d0b1f021e --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs @@ -0,0 +1,34 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +/// Trusted Device User Decryption Option API response. +/// Contains settings and encrypted keys for trusted device decryption. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct TrustedDeviceUserDecryptionOptionApiResponse { + /// Whether the user has admin approval for device login. + #[serde(rename = "HasAdminApproval")] + pub has_admin_approval: bool, + + /// Whether the user has a device that can approve logins. + #[serde(rename = "HasLoginApprovingDevice")] + pub has_login_approving_device: bool, + + /// Whether the user has permission to manage password reset for other users. + #[serde(rename = "HasManageResetPasswordPermission")] + pub has_manage_reset_password_permission: bool, + + /// Whether the user is in TDE offboarding. + #[serde(rename = "IsTdeOffboarding")] + pub is_tde_offboarding: bool, + + /// The device key encrypted device private key. Only present if the device is trusted. + #[serde( + rename = "EncryptedPrivateKey", + skip_serializing_if = "Option::is_none" + )] + pub encrypted_private_key: Option, + + /// The device private key encrypted user key. Only present if the device is trusted. + #[serde(rename = "EncryptedUserKey", skip_serializing_if = "Option::is_none")] + pub encrypted_user_key: Option, +} diff --git a/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs new file mode 100644 index 000000000..729cf1ff6 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs @@ -0,0 +1,36 @@ +use bitwarden_api_api::models::MasterPasswordUnlockResponseModel; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::{ + KeyConnectorUserDecryptionOptionApiResponse, TrustedDeviceUserDecryptionOptionApiResponse, + WebAuthnPrfUserDecryptionOptionApiResponse, +}; + +/// Provides user decryption options used to unlock user's vault. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub(crate) struct UserDecryptionOptionsApiResponse { + /// Contains information needed to unlock user's vault with master password. + /// None when user does not have a master password. + #[serde( + rename = "MasterPasswordUnlock", + skip_serializing_if = "Option::is_none" + )] + pub master_password_unlock: Option, + + /// Trusted Device Decryption Option. + #[serde( + rename = "TrustedDeviceOption", + skip_serializing_if = "Option::is_none" + )] + pub trusted_device_option: Option, + + /// Key Connector Decryption Option. + /// This option is mutually exlusive with the Trusted Device option as you + /// must configure one or the other in the Organization SSO configuration. + #[serde(rename = "KeyConnectorOption", skip_serializing_if = "Option::is_none")] + pub key_connector_option: Option, + + /// WebAuthn PRF Decryption Option. + #[serde(rename = "WebAuthnPrfOption", skip_serializing_if = "Option::is_none")] + pub webauthn_prf_option: Option, +} diff --git a/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs deleted file mode 100644 index d5ee58b8e..000000000 --- a/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_response.rs +++ /dev/null @@ -1,18 +0,0 @@ -use bitwarden_api_api::models::MasterPasswordUnlockResponseModel; -use serde::{Deserialize, Serialize}; - -/// Provides user decryption options used to unlock user's vault. -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct UserDecryptionOptionsResponse { - /// Contains information needed to unlock user's vault with master password. - /// None when user have no master password. - #[serde( - rename = "masterPasswordUnlock", - alias = "MasterPasswordUnlock", - skip_serializing_if = "Option::is_none" - )] - pub master_password_unlock: Option, - // TODO: I have to build out all other unlock options here. - - // pub trusted_device_ -} diff --git a/crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs new file mode 100644 index 000000000..f47e2fdd8 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs @@ -0,0 +1,15 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +/// WebAuthn PRF User Decryption Option API response. +/// Contains all required fields for WebAuthn PRF decryption. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct WebAuthnPrfUserDecryptionOptionApiResponse { + /// PRF key encrypted private key + #[serde(rename = "EncryptedPrivateKey")] + pub encrypted_private_key: EncString, + + /// Private Key encrypted user key + #[serde(rename = "EncryptedUserKey")] + pub encrypted_user_key: EncString, +} diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 36d24908f..eca915616 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -32,6 +32,9 @@ impl IdentityClient { let response = send_login_request(&api_configs, &api_request).await; + // if success, we must validate that user decryption options are present as if they are missing + // we cannot proceed with unlocking the user's vault. + // TODO: figure out how to handle errors. } } diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs index 485ef5768..3a715dbf9 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -3,7 +3,7 @@ use std::fmt::Debug; use bitwarden_api_identity::models::KdfType; use std::num::NonZeroU32; -use crate::identity::api::response::{LoginSuccessApiResponse, UserDecryptionOptionsResponse}; +use crate::identity::api::response::{LoginSuccessApiResponse, UserDecryptionOptionsApiResponse}; /// SDK response model for a successful login. /// This is the model that will be exposed to consuming applications. @@ -50,7 +50,7 @@ pub struct LoginSuccessResponse { /// The URL for the Key Connector service. pub key_connector_url: Option, /// User decryption options for the account. - pub user_decryption_options: Option, + // pub user_decryption_options: UserDecryptionOptionsResponse, } impl From for LoginSuccessResponse { From a317b4617eb748d7b14c166505f066c24aa9dcdf Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 5 Dec 2025 11:57:10 -0500 Subject: [PATCH 39/54] PM-14922 - Make MasterPasswordUnlockData available outside of the crate so Login can return it to clients --- crates/bitwarden-core/src/key_management/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-core/src/key_management/mod.rs b/crates/bitwarden-core/src/key_management/mod.rs index 9341735e6..d7fd718ef 100644 --- a/crates/bitwarden-core/src/key_management/mod.rs +++ b/crates/bitwarden-core/src/key_management/mod.rs @@ -26,7 +26,7 @@ pub use master_password::MasterPasswordAuthenticationData; #[cfg(feature = "internal")] pub use master_password::MasterPasswordError; #[cfg(feature = "internal")] -pub(crate) use master_password::MasterPasswordUnlockData; +pub use master_password::MasterPasswordUnlockData; #[cfg(feature = "internal")] mod security_state; #[cfg(feature = "internal")] From 8a784b22b96f4c466d3d0a0df789f267be5d6cce Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 5 Dec 2025 12:38:51 -0500 Subject: [PATCH 40/54] PM-14922 - Make MasterPasswordUnlockData implement partial eq for test usage --- crates/bitwarden-core/src/key_management/master_password.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-core/src/key_management/master_password.rs b/crates/bitwarden-core/src/key_management/master_password.rs index ef9393339..25645fdc6 100644 --- a/crates/bitwarden-core/src/key_management/master_password.rs +++ b/crates/bitwarden-core/src/key_management/master_password.rs @@ -35,7 +35,7 @@ pub enum MasterPasswordError { } /// Represents the data required to unlock with the master password. -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr( From 304ab5cdf5f239ab7f16010f2c4cee4e071b2136 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 5 Dec 2025 15:03:19 -0500 Subject: [PATCH 41/54] PM-14922 - Build UserDecryptionOption domain models, conversion traits from api models, and tests for the conversions --- .../key_connector_user_decryption_option.rs | 41 ++++ .../bitwarden-auth/src/identity/models/mod.rs | 8 + .../trusted_device_user_decryption_option.rs | 80 +++++++ .../user_decryption_options_response.rs | 203 ++++++++++++++++++ .../webauthn_prf_user_decryption_option.rs | 48 +++++ 5 files changed, 380 insertions(+) create mode 100644 crates/bitwarden-auth/src/identity/models/key_connector_user_decryption_option.rs create mode 100644 crates/bitwarden-auth/src/identity/models/trusted_device_user_decryption_option.rs create mode 100644 crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs create mode 100644 crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs diff --git a/crates/bitwarden-auth/src/identity/models/key_connector_user_decryption_option.rs b/crates/bitwarden-auth/src/identity/models/key_connector_user_decryption_option.rs new file mode 100644 index 000000000..e6ac2ce1b --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/key_connector_user_decryption_option.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::KeyConnectorUserDecryptionOptionApiResponse; + +/// SDK domain model for Key Connector user decryption option. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct KeyConnectorUserDecryptionOption { + /// URL of the Key Connector server to use for decryption. + pub key_connector_url: String, +} + +impl From for KeyConnectorUserDecryptionOption { + fn from(api: KeyConnectorUserDecryptionOptionApiResponse) -> Self { + Self { + key_connector_url: api.key_connector_url, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_key_connector_conversion() { + let api = KeyConnectorUserDecryptionOptionApiResponse { + key_connector_url: "https://key-connector.example.com".to_string(), + }; + + let domain: KeyConnectorUserDecryptionOption = api.clone().into(); + + assert_eq!(domain.key_connector_url, api.key_connector_url); + } +} diff --git a/crates/bitwarden-auth/src/identity/models/mod.rs b/crates/bitwarden-auth/src/identity/models/mod.rs index 7dc1ac88c..10b1220f8 100644 --- a/crates/bitwarden-auth/src/identity/models/mod.rs +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -1,9 +1,17 @@ //! SDK models shared across multiple identity features +mod key_connector_user_decryption_option; mod login_device_request; mod login_request; mod login_success_response; +mod trusted_device_user_decryption_option; +mod user_decryption_options_response; +mod webauthn_prf_user_decryption_option; +pub use key_connector_user_decryption_option::KeyConnectorUserDecryptionOption; pub use login_device_request::LoginDeviceRequest; pub use login_request::LoginRequest; pub use login_success_response::LoginSuccessResponse; +pub use trusted_device_user_decryption_option::TrustedDeviceUserDecryptionOption; +pub use user_decryption_options_response::UserDecryptionOptionsResponse; +pub use webauthn_prf_user_decryption_option::WebAuthnPrfUserDecryptionOption; diff --git a/crates/bitwarden-auth/src/identity/models/trusted_device_user_decryption_option.rs b/crates/bitwarden-auth/src/identity/models/trusted_device_user_decryption_option.rs new file mode 100644 index 000000000..b0b4acde4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/trusted_device_user_decryption_option.rs @@ -0,0 +1,80 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::TrustedDeviceUserDecryptionOptionApiResponse; + +/// SDK domain model for Trusted Device user decryption option. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct TrustedDeviceUserDecryptionOption { + /// Whether the user has admin approval for device login. + pub has_admin_approval: bool, + + /// Whether the user has a device that can approve logins. + pub has_login_approving_device: bool, + + /// Whether the user has permission to manage password reset for other users. + pub has_manage_reset_password_permission: bool, + + /// Whether the user is in TDE offboarding. + pub is_tde_offboarding: bool, + + /// The device key encrypted device private key. Only present if the device is trusted. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_private_key: Option, + + /// The device private key encrypted user key. Only present if the device is trusted. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_user_key: Option, +} + +impl From for TrustedDeviceUserDecryptionOption { + fn from(api: TrustedDeviceUserDecryptionOptionApiResponse) -> Self { + Self { + has_admin_approval: api.has_admin_approval, + has_login_approving_device: api.has_login_approving_device, + has_manage_reset_password_permission: api.has_manage_reset_password_permission, + is_tde_offboarding: api.is_tde_offboarding, + encrypted_private_key: api.encrypted_private_key, + encrypted_user_key: api.encrypted_user_key, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trusted_device_conversion() { + let api = TrustedDeviceUserDecryptionOptionApiResponse { + has_admin_approval: true, + has_login_approving_device: false, + has_manage_reset_password_permission: true, + is_tde_offboarding: false, + encrypted_private_key: Some("2.test|encrypted".parse().unwrap()), + encrypted_user_key: Some("2.test|encrypted2".parse().unwrap()), + }; + + let domain: TrustedDeviceUserDecryptionOption = api.clone().into(); + + assert_eq!(domain.has_admin_approval, api.has_admin_approval); + assert_eq!( + domain.has_login_approving_device, + api.has_login_approving_device + ); + assert_eq!( + domain.has_manage_reset_password_permission, + api.has_manage_reset_password_permission + ); + assert_eq!(domain.is_tde_offboarding, api.is_tde_offboarding); + assert_eq!(domain.encrypted_private_key, api.encrypted_private_key); + assert_eq!(domain.encrypted_user_key, api.encrypted_user_key); + } +} diff --git a/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs new file mode 100644 index 000000000..839c81c6b --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs @@ -0,0 +1,203 @@ +use bitwarden_core::key_management::{MasterPasswordError, MasterPasswordUnlockData}; +use serde::{Deserialize, Serialize}; + +use crate::identity::{ + api::response::UserDecryptionOptionsApiResponse, + models::{ + KeyConnectorUserDecryptionOption, TrustedDeviceUserDecryptionOption, + WebAuthnPrfUserDecryptionOption, + }, +}; + +/// SDK domain model for user decryption options. +/// Provides the various methods available to unlock a user's vault. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct UserDecryptionOptionsResponse { + /// Master password unlock option. None if user doesn't have a master password. + #[serde(skip_serializing_if = "Option::is_none")] + pub master_password_unlock: Option, + + /// Trusted Device decryption option. + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_device_option: Option, + + /// Key Connector decryption option. + /// Mutually exclusive with Trusted Device option. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_connector_option: Option, + + /// WebAuthn PRF decryption option. + #[serde(skip_serializing_if = "Option::is_none")] + pub webauthn_prf_option: Option, +} + +impl TryFrom for UserDecryptionOptionsResponse { + type Error = MasterPasswordError; + + fn try_from(api: UserDecryptionOptionsApiResponse) -> Result { + Ok(Self { + master_password_unlock: match api.master_password_unlock { + Some(ref mp) => Some(MasterPasswordUnlockData::try_from(mp)?), + None => None, + }, + trusted_device_option: match api.trusted_device_option { + Some(tde) => Some(tde.into()), + None => None, + }, + key_connector_option: match api.key_connector_option { + Some(kc) => Some(kc.into()), + None => None, + }, + webauthn_prf_option: match api.webauthn_prf_option { + Some(wa) => Some(wa.into()), + None => None, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::models::{ + KdfType, MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel, + }; + use bitwarden_crypto::Kdf; + + use crate::identity::api::response::{ + KeyConnectorUserDecryptionOptionApiResponse, TrustedDeviceUserDecryptionOptionApiResponse, + WebAuthnPrfUserDecryptionOptionApiResponse, + }; + + use super::*; + + #[test] + fn test_user_decryption_options_conversion_with_master_password() { + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: Some(MasterPasswordUnlockResponseModel { + kdf: Box::new(MasterPasswordUnlockKdfResponseModel { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 600000, + memory: None, + parallelism: None, + }), + master_key_encrypted_user_key: Some( + "2.q/2tw0ANVGbyBaS+RxLdNw==|mIreJLpxs/pkCCWEn/L/CA==".to_string(), + ), + salt: Some("test@example.com".to_string()), + }), + trusted_device_option: None, + key_connector_option: None, + webauthn_prf_option: None, + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + assert!(domain.master_password_unlock.is_some()); + let mp_unlock = domain.master_password_unlock.unwrap(); + assert_eq!(mp_unlock.salt, "test@example.com"); + match mp_unlock.kdf { + Kdf::PBKDF2 { iterations } => { + assert_eq!(iterations.get(), 600000); + } + _ => panic!("Expected PBKDF2 KDF"), + } + assert!(domain.trusted_device_option.is_none()); + assert!(domain.key_connector_option.is_none()); + assert!(domain.webauthn_prf_option.is_none()); + } + + #[test] + fn test_user_decryption_options_conversion_with_all_options() { + // Test data constants + const SALT: &str = "test@example.com"; + const KDF_ITERATIONS: u32 = 600000; + const TDE_ENCRYPTED_PRIVATE_KEY: &str = "2.test|encrypted"; + const TDE_ENCRYPTED_USER_KEY: &str = "2.test|encrypted2"; + const KEY_CONNECTOR_URL: &str = "https://key-connector.bitwarden.com"; + const WEBAUTHN_ENCRYPTED_PRIVATE_KEY: &str = "2.test|encrypted3"; + const WEBAUTHN_ENCRYPTED_USER_KEY: &str = "2.test|encrypted4"; + + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: Some(MasterPasswordUnlockResponseModel { + kdf: Box::new(MasterPasswordUnlockKdfResponseModel { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: KDF_ITERATIONS as i32, + memory: None, + parallelism: None, + }), + master_key_encrypted_user_key: Some( + "2.q/2tw0ANVGbyBaS+RxLdNw==|mIreJLpxs/pkCCWEn/L/CA==".to_string(), + ), + salt: Some(SALT.to_string()), + }), + trusted_device_option: Some(TrustedDeviceUserDecryptionOptionApiResponse { + has_admin_approval: true, + has_login_approving_device: false, + has_manage_reset_password_permission: false, + is_tde_offboarding: false, + encrypted_private_key: Some(TDE_ENCRYPTED_PRIVATE_KEY.parse().unwrap()), + encrypted_user_key: Some(TDE_ENCRYPTED_USER_KEY.parse().unwrap()), + }), + key_connector_option: Some(KeyConnectorUserDecryptionOptionApiResponse { + key_connector_url: KEY_CONNECTOR_URL.to_string(), + }), + webauthn_prf_option: Some(WebAuthnPrfUserDecryptionOptionApiResponse { + encrypted_private_key: WEBAUTHN_ENCRYPTED_PRIVATE_KEY.parse().unwrap(), + encrypted_user_key: WEBAUTHN_ENCRYPTED_USER_KEY.parse().unwrap(), + }), + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + // Verify master password unlock + assert!(domain.master_password_unlock.is_some()); + let mp_unlock = domain.master_password_unlock.unwrap(); + assert_eq!(mp_unlock.salt, SALT); + match mp_unlock.kdf { + Kdf::PBKDF2 { iterations } => { + assert_eq!(iterations.get(), KDF_ITERATIONS); + } + _ => panic!("Expected PBKDF2 KDF"), + } + + // Verify trusted device option + assert!(domain.trusted_device_option.is_some()); + let tde = domain.trusted_device_option.unwrap(); + assert!(tde.has_admin_approval); + assert!(!tde.has_login_approving_device); + assert!(!tde.has_manage_reset_password_permission); + assert!(!tde.is_tde_offboarding); + assert_eq!( + tde.encrypted_private_key, + Some(TDE_ENCRYPTED_PRIVATE_KEY.parse().unwrap()) + ); + assert_eq!( + tde.encrypted_user_key, + Some(TDE_ENCRYPTED_USER_KEY.parse().unwrap()) + ); + + // Verify key connector option + assert!(domain.key_connector_option.is_some()); + let kc = domain.key_connector_option.unwrap(); + assert_eq!(kc.key_connector_url, KEY_CONNECTOR_URL); + + // Verify webauthn prf option + assert!(domain.webauthn_prf_option.is_some()); + let webauthn = domain.webauthn_prf_option.unwrap(); + assert_eq!( + webauthn.encrypted_private_key, + WEBAUTHN_ENCRYPTED_PRIVATE_KEY.parse().unwrap() + ); + assert_eq!( + webauthn.encrypted_user_key, + WEBAUTHN_ENCRYPTED_USER_KEY.parse().unwrap() + ); + } +} diff --git a/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs b/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs new file mode 100644 index 000000000..02b15fa64 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs @@ -0,0 +1,48 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::WebAuthnPrfUserDecryptionOptionApiResponse; + +/// SDK domain model for WebAuthn PRF user decryption option. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct WebAuthnPrfUserDecryptionOption { + /// PRF key encrypted private key + pub encrypted_private_key: EncString, + + /// Private Key encrypted user key + pub encrypted_user_key: EncString, +} + +impl From for WebAuthnPrfUserDecryptionOption { + fn from(api: WebAuthnPrfUserDecryptionOptionApiResponse) -> Self { + Self { + encrypted_private_key: api.encrypted_private_key, + encrypted_user_key: api.encrypted_user_key, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_webauthn_prf_conversion() { + let api = WebAuthnPrfUserDecryptionOptionApiResponse { + encrypted_private_key: "2.test|encrypted".parse().unwrap(), + encrypted_user_key: "2.test|encrypted2".parse().unwrap(), + }; + + let domain: WebAuthnPrfUserDecryptionOption = api.clone().into(); + + assert_eq!(domain.encrypted_private_key, api.encrypted_private_key); + assert_eq!(domain.encrypted_user_key, api.encrypted_user_key); + } +} From 83ea87f37def264e4470f82610984e74ca815f35 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 5 Dec 2025 17:45:38 -0500 Subject: [PATCH 42/54] PM-14922 - Further work on the LoginSuccessResponse --- .../response/login_success_api_response.rs | 48 ++++++++------ .../identity/models/login_success_response.rs | 64 +++++++++++++------ 2 files changed, 72 insertions(+), 40 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index eec4d7e63..91457a9f1 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -1,6 +1,6 @@ +use bitwarden_api_api::models::MasterPasswordPolicyResponseModel; use bitwarden_api_identity::models::KdfType; use serde::{Deserialize, Serialize}; -use std::num::NonZeroU32; use crate::identity::api::response::UserDecryptionOptionsApiResponse; @@ -26,51 +26,57 @@ pub(crate) struct LoginSuccessApiResponse { pub refresh_token: Option, // Custom Bitwarden connect/token response fields: + // We send down uppercase fields today so we have to map them accordingly + + // we add aliases for deserialization flexibility. /// The user's user key encrypted private key - #[serde(rename = "privateKey", alias = "PrivateKey")] - pub(crate) private_key: Option, + #[serde(rename = "PrivateKey", alias = "privateKey")] + pub private_key: Option, /// The user's master key encrypted user key. - #[serde(alias = "Key")] - pub(crate) key: Option, + #[serde(rename = "Key", alias = "key")] + pub key: Option, /// Two factor remember me token to be used for future requests /// to bypass 2FA prompts for a limited time. - #[serde(rename = "twoFactorToken")] - two_factor_token: Option, + #[serde(rename = "TwoFactorToken", alias = "twoFactorToken")] + pub two_factor_token: Option, /// Master key derivation function type - #[serde(alias = "Kdf")] - kdf: KdfType, + #[serde(rename = "Kdf", alias = "kdf")] + pub kdf: KdfType, // TODO: ensure we convert to NonZeroU32 for the SDK model // for any Some values - #[serde(rename = "kdfIterations", alias = "KdfIterations")] /// Master key derivation function iterations - kdf_iterations: Option, + #[serde(rename = "KdfIterations", alias = "kdfIterations")] + pub kdf_iterations: Option, /// Master key derivation function memory - #[serde(rename = "kdfMemory", alias = "KdfMemory")] - kdf_memory: Option, + #[serde(rename = "KdfMemory", alias = "kdfMemory")] + pub kdf_memory: Option, /// Master key derivation function parallelism - #[serde(rename = "kdfParallelism", alias = "KdfParallelism")] - kdf_parallelism: Option, + #[serde(rename = "KdfParallelism", alias = "kdfParallelism")] + pub kdf_parallelism: Option, /// Indicates whether an admin has reset the user's master password, /// requiring them to set a new password upon next login. - #[serde(rename = "forcePasswordReset", alias = "ForcePasswordReset")] + #[serde(rename = "ForcePasswordReset", alias = "forcePasswordReset")] pub force_password_reset: Option, /// Indicates whether the user uses Key Connector and if the client should have a locally /// configured Key Connector URL in their environment. /// Note: This is currently only applicable for client_credential grant type logins and /// is only expected to be relevant for the CLI - #[serde(rename = "apiUseKeyConnector", alias = "ApiUseKeyConnector")] - api_use_key_connector: Option, + #[serde(rename = "ApiUseKeyConnector", alias = "apiUseKeyConnector")] + pub api_use_key_connector: Option, /// The user's decryption options for their vault. - #[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")] - pub(crate) user_decryption_options: Option, - // TODO: add MasterPasswordPolicy + #[serde(rename = "UserDecryptionOptions", alias = "userDecryptionOptions")] + pub user_decryption_options: Option, + + /// If the user is subject to an organization master password policy, + /// this field contains the requirements of that policy. + #[serde(rename = "MasterPasswordPolicy", alias = "masterPasswordPolicy")] + pub master_password_policy: Option, } diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs index 3a715dbf9..5ee35afd0 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -1,9 +1,13 @@ use std::fmt::Debug; +use bitwarden_api_api::models::MasterPasswordPolicyResponseModel; use bitwarden_api_identity::models::KdfType; use std::num::NonZeroU32; -use crate::identity::api::response::{LoginSuccessApiResponse, UserDecryptionOptionsApiResponse}; +use crate::identity::{ + api::response::{LoginSuccessApiResponse, UserDecryptionOptionsApiResponse}, + models::UserDecryptionOptionsResponse, +}; /// SDK response model for a successful login. /// This is the model that will be exposed to consuming applications. @@ -18,39 +22,63 @@ use crate::identity::api::response::{LoginSuccessApiResponse, UserDecryptionOpti pub struct LoginSuccessResponse { /// The access token string. pub access_token: String, + /// The duration in seconds until the token expires. pub expires_in: u64, + /// The timestamp in milliseconds when the token expires. + /// We calculate this for more convenient token expiration handling. pub expires_at: i64, + /// The scope of the access token. + /// OAuth 2.0 RFC reference: pub scope: String, - /// The type of the token (typically "Bearer"). + + /// The type of the token. + /// This will be "Bearer" for send access tokens. + /// OAuth 2.0 RFC reference: pub token_type: String, + /// The optional refresh token string. + /// This token can be used to obtain new access tokens when the current one expires. pub refresh_token: Option, // TODO: port over docs from API response // but also RENAME things to be more clear. - /// The user's encrypted private key. - pub private_key: Option, - /// The user's encrypted symmetric key. - pub key: Option, + /// The user key encrypted private key. + /// Note: previously known as "private_key". + pub user_key_encrypted_user_private_key: Option, + + /// The master key encrypted user key. + /// Note: previously known as "key". + pub master_key_encrypted_user_key: Option, + /// Two-factor authentication token for future requests. pub two_factor_token: Option, + /// The key derivation function type. pub kdf: KdfType, - /// The number of iterations for the key derivation function. + + /// Master key derivation function iterations pub kdf_iterations: NonZeroU32, - /// Whether the user needs to reset their master password. - pub reset_master_password: bool, - /// Whether the user is forced to reset their password. + + /// Indicates whether an admin has reset the user's master password, + /// requiring them to set a new password upon next login. pub force_password_reset: bool, - /// Whether the API uses Key Connector. + + /// Indicates whether the user uses Key Connector and if the client should have a locally + /// configured Key Connector URL in their environment. + /// Note: This is currently only applicable for client_credential grant type logins and + /// is only expected to be relevant for the CLI pub api_use_key_connector: Option, - /// The URL for the Key Connector service. - pub key_connector_url: Option, - /// User decryption options for the account. - // pub user_decryption_options: UserDecryptionOptionsResponse, + + /// The user's decryption options for unlocking their vault. + pub user_decryption_options: UserDecryptionOptionsResponse, + + // TODO: there isn't a top level domain model for this. Create one? or keep as is? + /// If the user is subject to an organization master password policy, + /// this field contains the requirements of that policy. + pub master_password_policy: Option, } impl From for LoginSuccessResponse { @@ -69,15 +97,13 @@ impl From for LoginSuccessResponse { scope: response.scope, token_type: response.token_type, refresh_token: response.refresh_token, - private_key: response.private_key, - key: response.key, + user_key_encrypted_user_private_key: response.private_key, + master_key_encrypted_user_key: response.key, two_factor_token: response.two_factor_token, kdf: response.kdf, kdf_iterations: response.kdf_iterations, - reset_master_password: response.reset_master_password, force_password_reset: response.force_password_reset, api_use_key_connector: response.api_use_key_connector, - key_connector_url: response.key_connector_url, user_decryption_options: response.user_decryption_options, } } From 813b383db4112377025507e96eac5afd9c6ad583 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 8 Dec 2025 12:05:58 -0500 Subject: [PATCH 43/54] PM-14922 - Further work on the LoginSuccessResponse - more docs --- .../api/response/login_success_api_response.rs | 4 ++-- .../src/identity/models/login_success_response.rs | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index 91457a9f1..7976c3fca 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -28,11 +28,11 @@ pub(crate) struct LoginSuccessApiResponse { // Custom Bitwarden connect/token response fields: // We send down uppercase fields today so we have to map them accordingly + // we add aliases for deserialization flexibility. - /// The user's user key encrypted private key + /// The user key wrapped user private key #[serde(rename = "PrivateKey", alias = "privateKey")] pub private_key: Option, - /// The user's master key encrypted user key. + /// The master key wrapped user key. #[serde(rename = "Key", alias = "key")] pub key: Option, diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs index 5ee35afd0..61f0c03fa 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -43,15 +43,13 @@ pub struct LoginSuccessResponse { /// This token can be used to obtain new access tokens when the current one expires. pub refresh_token: Option, - // TODO: port over docs from API response - // but also RENAME things to be more clear. - /// The user key encrypted private key. + /// The user key wrapped user private key. /// Note: previously known as "private_key". - pub user_key_encrypted_user_private_key: Option, + pub user_key_wrapped_user_private_key: Option, - /// The master key encrypted user key. + /// The master key wrapped user key. /// Note: previously known as "key". - pub master_key_encrypted_user_key: Option, + pub master_key_wrapped_user_key: Option, /// Two-factor authentication token for future requests. pub two_factor_token: Option, From 934286f57a1a5dc2f9e53c84a2b4ac6328e9333b Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 8 Dec 2025 18:45:37 -0500 Subject: [PATCH 44/54] PM-14922 - Build MasterPasswordPolicyResponse domain model and add to LoginSuccessResponse --- Cargo.lock | 6 + crates/bitwarden-auth/Cargo.toml | 5 +- .../identity/models/login_success_response.rs | 14 +- crates/bitwarden-auth/src/lib.rs | 3 + crates/bitwarden-policies/Cargo.toml | 13 ++ crates/bitwarden-policies/src/lib.rs | 5 + .../src/master_password_policy_response.rs | 137 ++++++++++++++++++ 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 crates/bitwarden-policies/src/master_password_policy_response.rs diff --git a/Cargo.lock b/Cargo.lock index 66851bf84..ef460f769 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,7 @@ dependencies = [ "bitwarden-core", "bitwarden-crypto", "bitwarden-error", + "bitwarden-policies", "bitwarden-test", "chrono", "reqwest", @@ -521,6 +522,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tsify", + "uniffi", "wasm-bindgen", "wasm-bindgen-futures", "wiremock", @@ -814,7 +816,11 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "tsify", + "uniffi", "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 1c71bff1f..9678d080e 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -19,8 +19,9 @@ wasm = [ "bitwarden-core/wasm", "dep:tsify", "dep:wasm-bindgen", - "dep:wasm-bindgen-futures", + "dep:wasm-bindgen-futures" ] # WASM support +uniffi = ["bitwarden-core/uniffi", "dep:uniffi"] # Uniffi bindings # Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline. [dependencies] @@ -29,6 +30,7 @@ bitwarden-api-identity = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } +bitwarden-policies = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } schemars = { workspace = true } @@ -37,6 +39,7 @@ serde_json = { workspace = true } serde_repr = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } +uniffi = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs index 61f0c03fa..065e828e7 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; -use bitwarden_api_api::models::MasterPasswordPolicyResponseModel; use bitwarden_api_identity::models::KdfType; +use bitwarden_policies::MasterPasswordPolicyResponse; use std::num::NonZeroU32; use crate::identity::{ @@ -13,6 +13,7 @@ use crate::identity::{ /// This is the model that will be exposed to consuming applications. #[derive(serde::Serialize, serde::Deserialize, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr( feature = "wasm", derive(tsify::Tsify), @@ -73,10 +74,9 @@ pub struct LoginSuccessResponse { /// The user's decryption options for unlocking their vault. pub user_decryption_options: UserDecryptionOptionsResponse, - // TODO: there isn't a top level domain model for this. Create one? or keep as is? /// If the user is subject to an organization master password policy, /// this field contains the requirements of that policy. - pub master_password_policy: Option, + pub master_password_policy: Option, } impl From for LoginSuccessResponse { @@ -95,14 +95,18 @@ impl From for LoginSuccessResponse { scope: response.scope, token_type: response.token_type, refresh_token: response.refresh_token, - user_key_encrypted_user_private_key: response.private_key, - master_key_encrypted_user_key: response.key, + user_key_wrapped_user_private_key: response.private_key, + master_key_wrapped_user_key: response.key, two_factor_token: response.two_factor_token, kdf: response.kdf, kdf_iterations: response.kdf_iterations, force_password_reset: response.force_password_reset, api_use_key_connector: response.api_use_key_connector, user_decryption_options: response.user_decryption_options, + master_password_policy: match response.master_password_policy { + Some(policy) => Some(policy.into()), + None => None, + }, } } } diff --git a/crates/bitwarden-auth/src/lib.rs b/crates/bitwarden-auth/src/lib.rs index db5dc561f..87c20820e 100644 --- a/crates/bitwarden-auth/src/lib.rs +++ b/crates/bitwarden-auth/src/lib.rs @@ -1,5 +1,8 @@ #![doc = include_str!("../README.md")] +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); + mod auth_client; pub mod identity; diff --git a/crates/bitwarden-policies/Cargo.toml b/crates/bitwarden-policies/Cargo.toml index 1633d5629..82654ad01 100644 --- a/crates/bitwarden-policies/Cargo.toml +++ b/crates/bitwarden-policies/Cargo.toml @@ -10,13 +10,26 @@ license-file.workspace = true readme.workspace = true keywords.workspace = true +[features] +uniffi = ["bitwarden-core/uniffi", "dep:uniffi"] # Uniffi bindings +wasm = [ + "bitwarden-core/wasm", + "dep:tsify", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures" +] # WASM support + [dependencies] bitwarden-api-api = { workspace = true } bitwarden-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } +tsify = { workspace = true, optional = true } +uniffi = { workspace = true, optional = true } uuid = { workspace = true } +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/bitwarden-policies/src/lib.rs b/crates/bitwarden-policies/src/lib.rs index 4fcbfb80c..4b886495c 100644 --- a/crates/bitwarden-policies/src/lib.rs +++ b/crates/bitwarden-policies/src/lib.rs @@ -1,5 +1,10 @@ #![doc = include_str!("../README.md")] +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); + +mod master_password_policy_response; mod policy; +pub use master_password_policy_response::MasterPasswordPolicyResponse; pub use policy::Policy; diff --git a/crates/bitwarden-policies/src/master_password_policy_response.rs b/crates/bitwarden-policies/src/master_password_policy_response.rs new file mode 100644 index 000000000..3538df357 --- /dev/null +++ b/crates/bitwarden-policies/src/master_password_policy_response.rs @@ -0,0 +1,137 @@ +use bitwarden_api_api::models::MasterPasswordPolicyResponseModel; +use serde::{Deserialize, Serialize}; + +/// SDK domain model for master password policy requirements. +/// Defines the complexity requirements for a user's master password +/// when enforced by an organization policy. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct MasterPasswordPolicyResponse { + /// The minimum complexity score required for the master password. + /// Complexity is calculated based on password strength metrics. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_complexity: Option, + + /// The minimum length required for the master password. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_length: Option, + + /// Whether the master password must contain at least one lowercase letter. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_lower: Option, + + /// Whether the master password must contain at least one uppercase letter. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_upper: Option, + + /// Whether the master password must contain at least one numeric digit. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_numbers: Option, + + /// Whether the master password must contain at least one special character. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_special: Option, + + /// Whether this policy should be enforced when the user logs in. + /// If true, the user will be required to update their master password + /// if it doesn't meet the policy requirements. + #[serde(skip_serializing_if = "Option::is_none")] + pub enforce_on_login: Option, +} + +impl From for MasterPasswordPolicyResponse { + fn from(api: MasterPasswordPolicyResponseModel) -> Self { + Self { + min_complexity: api.min_complexity, + min_length: api.min_length, + require_lower: api.require_lower, + require_upper: api.require_upper, + require_numbers: api.require_numbers, + require_special: api.require_special, + enforce_on_login: api.enforce_on_login, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_master_password_policy_conversion_full() { + let api = MasterPasswordPolicyResponseModel { + object: Some("masterPasswordPolicy".to_string()), + min_complexity: Some(4), + min_length: Some(12), + require_lower: Some(true), + require_upper: Some(true), + require_numbers: Some(true), + require_special: Some(true), + enforce_on_login: Some(true), + }; + + let domain: MasterPasswordPolicyResponse = api.into(); + + assert_eq!(domain.min_complexity, Some(4)); + assert_eq!(domain.min_length, Some(12)); + assert_eq!(domain.require_lower, Some(true)); + assert_eq!(domain.require_upper, Some(true)); + assert_eq!(domain.require_numbers, Some(true)); + assert_eq!(domain.require_special, Some(true)); + assert_eq!(domain.enforce_on_login, Some(true)); + } + + #[test] + fn test_master_password_policy_conversion_minimal() { + let api = MasterPasswordPolicyResponseModel { + object: Some("masterPasswordPolicy".to_string()), + min_complexity: None, + min_length: Some(8), + require_lower: None, + require_upper: None, + require_numbers: None, + require_special: None, + enforce_on_login: Some(false), + }; + + let domain: MasterPasswordPolicyResponse = api.into(); + + assert_eq!(domain.min_complexity, None); + assert_eq!(domain.min_length, Some(8)); + assert_eq!(domain.require_lower, None); + assert_eq!(domain.require_upper, None); + assert_eq!(domain.require_numbers, None); + assert_eq!(domain.require_special, None); + assert_eq!(domain.enforce_on_login, Some(false)); + } + + #[test] + fn test_master_password_policy_conversion_empty() { + let api = MasterPasswordPolicyResponseModel { + object: Some("masterPasswordPolicy".to_string()), + min_complexity: None, + min_length: None, + require_lower: None, + require_upper: None, + require_numbers: None, + require_special: None, + enforce_on_login: None, + }; + + let domain: MasterPasswordPolicyResponse = api.into(); + + assert_eq!(domain.min_complexity, None); + assert_eq!(domain.min_length, None); + assert_eq!(domain.require_lower, None); + assert_eq!(domain.require_upper, None); + assert_eq!(domain.require_numbers, None); + assert_eq!(domain.require_special, None); + assert_eq!(domain.enforce_on_login, None); + } +} From d1c563362dfed1f83c500feb520511ba7a3af29a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 9 Dec 2025 17:33:28 -0500 Subject: [PATCH 45/54] PM-14922 - Finish getting uniffi setup properly in bitwarden auth and then fix send access errors. --- .../src/send_access/access_token_response.rs | 5 +++++ .../src/send_access/api/token_api_error_response.rs | 3 +++ crates/bitwarden-auth/uniffi.toml | 9 +++++++++ 3 files changed, 17 insertions(+) create mode 100644 crates/bitwarden-auth/uniffi.toml diff --git a/crates/bitwarden-auth/src/send_access/access_token_response.rs b/crates/bitwarden-auth/src/send_access/access_token_response.rs index 29e7cdbc8..43dd56a8f 100644 --- a/crates/bitwarden-auth/src/send_access/access_token_response.rs +++ b/crates/bitwarden-auth/src/send_access/access_token_response.rs @@ -10,6 +10,7 @@ use crate::send_access::api::{SendAccessTokenApiErrorResponse, SendAccessTokenAp derive(tsify::Tsify), tsify(into_wasm_abi, from_wasm_abi) )] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug)] pub struct SendAccessTokenResponse { /// The actual token string. @@ -73,3 +74,7 @@ impl From for SendAccessTokenError { tsify(into_wasm_abi, from_wasm_abi) )] pub struct UnexpectedIdentityError(pub String); + +// Newtype wrapper for unexpected identity errors for uniffi compatibility. +#[cfg(feature = "uniffi")] // only compile this when uniffi feature is enabled +uniffi::custom_newtype!(UnexpectedIdentityError, String); diff --git a/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs index 1c17cca0c..8308dabec 100644 --- a/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs +++ b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs @@ -5,6 +5,7 @@ use tsify::Tsify; #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] /// Invalid request errors - typically due to missing parameters. pub enum SendAccessTokenInvalidRequestError { #[allow(missing_docs)] @@ -27,6 +28,7 @@ pub enum SendAccessTokenInvalidRequestError { #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] /// Invalid grant errors - typically due to invalid credentials. pub enum SendAccessTokenInvalidGrantError { #[allow(missing_docs)] @@ -53,6 +55,7 @@ pub enum SendAccessTokenInvalidGrantError { #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "snake_case")] #[serde(tag = "error")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] // ^ "error" becomes the variant discriminator which matches against the rename annotations; // "error_description" is the payload for that variant which can be optional. /// Represents the possible, expected errors that can occur when requesting a send access token. diff --git a/crates/bitwarden-auth/uniffi.toml b/crates/bitwarden-auth/uniffi.toml new file mode 100644 index 000000000..34b842428 --- /dev/null +++ b/crates/bitwarden-auth/uniffi.toml @@ -0,0 +1,9 @@ +[bindings.kotlin] +package_name = "com.bitwarden.auth" +generate_immutable_records = true +android = true + +[bindings.swift] +ffi_module_name = "BitwardenAuthFFI" +module_name = "BitwardenAuth" +generate_immutable_records = true From fe68b1800d2316385257c7fcf7ecdb395ed08fb6 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 9 Dec 2025 17:33:58 -0500 Subject: [PATCH 46/54] PM-14922 - Finish getting uniffi setup properly in bitwarden-policies --- crates/bitwarden-policies/uniffi.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 crates/bitwarden-policies/uniffi.toml diff --git a/crates/bitwarden-policies/uniffi.toml b/crates/bitwarden-policies/uniffi.toml new file mode 100644 index 000000000..9421ccc0e --- /dev/null +++ b/crates/bitwarden-policies/uniffi.toml @@ -0,0 +1,9 @@ +[bindings.kotlin] +package_name = "com.bitwarden.policies" +generate_immutable_records = true +android = true + +[bindings.swift] +ffi_module_name = "BitwardenPoliciesFFI" +module_name = "BitwardenPolicies" +generate_immutable_records = true From ea66dab99c08ac32d188032f84ee2b89f66d205f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 9 Dec 2025 17:34:30 -0500 Subject: [PATCH 47/54] PM-14922 - Finish building LoginSuccessResponse --- .../response/login_success_api_response.rs | 7 ++-- .../identity/models/login_success_response.rs | 33 ++++++------------- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs index 7976c3fca..90d3d4028 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -33,6 +33,7 @@ pub(crate) struct LoginSuccessApiResponse { pub private_key: Option, /// The master key wrapped user key. + #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] #[serde(rename = "Key", alias = "key")] pub key: Option, @@ -42,20 +43,22 @@ pub(crate) struct LoginSuccessApiResponse { pub two_factor_token: Option, /// Master key derivation function type + #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] #[serde(rename = "Kdf", alias = "kdf")] pub kdf: KdfType, - // TODO: ensure we convert to NonZeroU32 for the SDK model - // for any Some values /// Master key derivation function iterations + #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] #[serde(rename = "KdfIterations", alias = "kdfIterations")] pub kdf_iterations: Option, /// Master key derivation function memory + #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] #[serde(rename = "KdfMemory", alias = "kdfMemory")] pub kdf_memory: Option, /// Master key derivation function parallelism + #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] #[serde(rename = "KdfParallelism", alias = "kdfParallelism")] pub kdf_parallelism: Option, diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs index 065e828e7..85b063210 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -1,12 +1,10 @@ use std::fmt::Debug; -use bitwarden_api_identity::models::KdfType; +use bitwarden_core::{key_management::MasterPasswordError, require}; use bitwarden_policies::MasterPasswordPolicyResponse; -use std::num::NonZeroU32; use crate::identity::{ - api::response::{LoginSuccessApiResponse, UserDecryptionOptionsApiResponse}, - models::UserDecryptionOptionsResponse, + api::response::LoginSuccessApiResponse, models::UserDecryptionOptionsResponse, }; /// SDK response model for a successful login. @@ -48,22 +46,12 @@ pub struct LoginSuccessResponse { /// Note: previously known as "private_key". pub user_key_wrapped_user_private_key: Option, - /// The master key wrapped user key. - /// Note: previously known as "key". - pub master_key_wrapped_user_key: Option, - /// Two-factor authentication token for future requests. pub two_factor_token: Option, - /// The key derivation function type. - pub kdf: KdfType, - - /// Master key derivation function iterations - pub kdf_iterations: NonZeroU32, - /// Indicates whether an admin has reset the user's master password, /// requiring them to set a new password upon next login. - pub force_password_reset: bool, + pub force_password_reset: Option, /// Indicates whether the user uses Key Connector and if the client should have a locally /// configured Key Connector URL in their environment. @@ -79,8 +67,9 @@ pub struct LoginSuccessResponse { pub master_password_policy: Option, } -impl From for LoginSuccessResponse { - fn from(response: LoginSuccessApiResponse) -> Self { +impl TryFrom for LoginSuccessResponse { + type Error = MasterPasswordError; + fn try_from(response: LoginSuccessApiResponse) -> Result { // We want to convert the expires_in from seconds to a millisecond timestamp to have a // concrete time the token will expire. This makes it easier to build logic around a // concrete time rather than a duration. We keep expires_in as well for backward @@ -88,7 +77,7 @@ impl From for LoginSuccessResponse { let expires_at = chrono::Utc::now().timestamp_millis() + (response.expires_in * 1000) as i64; - LoginSuccessResponse { + Ok(LoginSuccessResponse { access_token: response.access_token, expires_in: response.expires_in, expires_at, @@ -96,17 +85,15 @@ impl From for LoginSuccessResponse { token_type: response.token_type, refresh_token: response.refresh_token, user_key_wrapped_user_private_key: response.private_key, - master_key_wrapped_user_key: response.key, two_factor_token: response.two_factor_token, - kdf: response.kdf, - kdf_iterations: response.kdf_iterations, force_password_reset: response.force_password_reset, api_use_key_connector: response.api_use_key_connector, - user_decryption_options: response.user_decryption_options, + // User decryption options are required on successful login responses + user_decryption_options: require!(response.user_decryption_options).try_into()?, master_password_policy: match response.master_password_policy { Some(policy) => Some(policy.into()), None => None, }, - } + }) } } From 49b77e7dc64724c9c12c3de1bfe3b1f27afd9531 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 9 Dec 2025 19:36:15 -0500 Subject: [PATCH 48/54] PM-14922 - Update password prelogin based on latest server bindings --- .../src/identity/login_via_password/mod.rs | 4 +- .../password_login_request.rs | 4 +- .../login_via_password/password_prelogin.rs | 343 ++++++++++++++++++ .../login_via_password/prelogin_password.rs | 248 ------------- 4 files changed, 347 insertions(+), 252 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs delete mode 100644 crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs diff --git a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs index 0753e7c62..a8fa719f6 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -1,8 +1,8 @@ mod login_via_password; mod password_login_api_request; mod password_login_request; -mod prelogin_password; +mod password_prelogin; pub(crate) use password_login_api_request::PasswordLoginApiRequest; pub use password_login_request::PasswordLoginRequest; -pub use prelogin_password::{PreloginPasswordData, PreloginPasswordError}; +pub use password_prelogin::{PasswordPreloginData, PasswordPreloginError}; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs index 406c1d5e5..6c269123e 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -1,7 +1,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::identity::{login_via_password::PreloginPasswordData, models::LoginRequest}; +use crate::identity::{login_via_password::PasswordPreloginData, models::LoginRequest}; /// Public SDK request model for logging in via password #[derive(Serialize, Deserialize, JsonSchema)] @@ -23,5 +23,5 @@ pub struct PasswordLoginRequest { /// Prelogin data required for password authentication /// (e.g., KDF configuration for deriving the master key) - pub prelogin_data: PreloginPasswordData, + pub prelogin_data: PasswordPreloginData, } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs new file mode 100644 index 000000000..5af33ab4a --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs @@ -0,0 +1,343 @@ +use bitwarden_api_identity::models::{ + KdfSettings, KdfType, PasswordPreloginRequestModel, PasswordPreloginResponseModel, +}; +use bitwarden_core::{ApiError, MissingFieldError, require}; +use bitwarden_crypto::Kdf; +use bitwarden_error::bitwarden_error; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::identity::IdentityClient; + +/// Error type for password prelogin operations +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordPreloginError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), +} + +/// Response containing the data required before password-based authentication +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct PasswordPreloginData { + /// The Key Derivation Function (KDF) configuration for the user + pub kdf: Kdf, + + /// The salt used in the KDF process + pub salt: String, +} + +impl IdentityClient { + /// Retrieves the data required before authenticating with a password. + /// This includes the user's KDF configuration needed to properly derive the master key. + /// + /// # Arguments + /// * `email` - The user's email address + /// + /// # Returns + /// * `PreloginPasswordData` - Contains the KDF configuration for the user + pub async fn get_password_prelogin_data( + &self, + email: String, + ) -> Result { + let request_model = PasswordPreloginRequestModel::new(email); + let config = self.client.internal.get_api_configurations().await; + let response = config + .identity_client + .accounts_api() + .post_password_prelogin(Some(request_model)) + .await + .map_err(ApiError::from)?; + + let prelogin_data = PasswordPreloginData::try_from(response)?; + Ok(prelogin_data) + } +} + +impl TryFrom for PasswordPreloginData { + type Error = MissingFieldError; + + fn try_from(response: PasswordPreloginResponseModel) -> Result { + use std::num::NonZeroU32; + + use bitwarden_crypto::{ + default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + + let kdf_settings = require!(response.kdf_settings); + + let kdf = match kdf_settings.kdf_type { + KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { + iterations: NonZeroU32::new(kdf_settings.iterations as u32) + .unwrap_or_else(default_pbkdf2_iterations), + }, + KdfType::Argon2id => Kdf::Argon2id { + iterations: NonZeroU32::new(kdf_settings.iterations as u32) + .unwrap_or_else(default_argon2_iterations), + memory: kdf_settings + .memory + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_memory), + parallelism: kdf_settings + .parallelism + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_parallelism), + }, + }; + + Ok(PasswordPreloginData { + kdf, + salt: require!(response.salt), + }) + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use bitwarden_api_identity::models::{KdfSettings, KdfType, PasswordPreloginResponseModel}; + use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + + use super::*; + + const TEST_SALT: &str = "test-salt"; + + #[test] + fn test_parse_prelogin_pbkdf2_with_iterations() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 100000, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: NonZeroU32::new(100000).unwrap() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_parse_prelogin_pbkdf2_default_iterations() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 0, // Zero will trigger default + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_parse_prelogin_argon2id_with_all_params() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 4, + memory: Some(64), + parallelism: Some(4), + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: NonZeroU32::new(4).unwrap(), + memory: NonZeroU32::new(64).unwrap(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_parse_prelogin_argon2id_default_params() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 0, // Zero will trigger default + memory: None, // None will trigger default + parallelism: None, // None will trigger default + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: default_argon2_parallelism(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_parse_prelogin_missing_kdf_settings() { + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: None, // Missing kdf_settings + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_parse_prelogin_missing_salt() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 100000, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: None, // Missing salt + }; + + let result = PasswordPreloginData::try_from(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_parse_prelogin_zero_iterations_uses_default() { + // When the server returns 0, NonZeroU32::new returns None, so defaults should be used + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 0, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_parse_prelogin_argon2id_partial_zero_values() { + // Test that zero values fall back to defaults for Argon2id + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 0, // Zero will trigger default + memory: Some(0), // Zero will trigger default + parallelism: Some(4), + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginData::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } +} diff --git a/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs deleted file mode 100644 index c6eb494d5..000000000 --- a/crates/bitwarden-auth/src/identity/login_via_password/prelogin_password.rs +++ /dev/null @@ -1,248 +0,0 @@ -use bitwarden_api_identity::models::{KdfType, PreloginRequestModel, PreloginResponseModel}; -use bitwarden_core::{ApiError, MissingFieldError, require}; -use bitwarden_crypto::Kdf; -use bitwarden_error::bitwarden_error; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::identity::IdentityClient; - -/// Error type for password prelogin operations -#[allow(missing_docs)] -#[bitwarden_error(flat)] -#[derive(Debug, Error)] -pub enum PreloginPasswordError { - #[error(transparent)] - Api(#[from] ApiError), - #[error(transparent)] - MissingField(#[from] MissingFieldError), -} - -/// Response containing the data required before password-based authentication -#[derive(Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support -#[cfg_attr( - feature = "wasm", - derive(tsify::Tsify), - tsify(into_wasm_abi, from_wasm_abi) -)] // add wasm support -pub struct PreloginPasswordData { - /// The Key Derivation Function (KDF) configuration for the user - pub kdf: Kdf, -} - -impl IdentityClient { - /// Retrieves the data required before authenticating with a password. - /// This includes the user's KDF configuration needed to properly derive the master key. - /// - /// # Arguments - /// * `email` - The user's email address - /// - /// # Returns - /// * `PreloginPasswordData` - Contains the KDF configuration for the user - pub async fn get_prelogin_password_data( - &self, - email: String, - ) -> Result { - let request_model = PreloginRequestModel::new(email); - let config = self.client.internal.get_api_configurations().await; - let response = config - .identity_client - .accounts_api() - .post_prelogin(Some(request_model)) - .await - .map_err(ApiError::from)?; - - let kdf = parse_prelogin_password_response(response)?; - Ok(PreloginPasswordData { kdf }) - } -} - -/// Parses the prelogin password API response into a KDF configuration -fn parse_prelogin_password_response( - response: PreloginResponseModel, -) -> Result { - use std::num::NonZeroU32; - - use bitwarden_crypto::{ - default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, - default_pbkdf2_iterations, - }; - - let kdf = require!(response.kdf); - - Ok(match kdf { - KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { - iterations: response - .kdf_iterations - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_pbkdf2_iterations), - }, - KdfType::Argon2id => Kdf::Argon2id { - iterations: response - .kdf_iterations - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_iterations), - memory: response - .kdf_memory - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_memory), - parallelism: response - .kdf_parallelism - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_parallelism), - }, - }) -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroU32; - - use bitwarden_api_identity::models::{KdfType, PreloginResponseModel}; - use bitwarden_crypto::{ - Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, - default_pbkdf2_iterations, - }; - - use super::*; - - #[test] - fn test_parse_prelogin_pbkdf2_with_iterations() { - let response = PreloginResponseModel { - kdf: Some(KdfType::PBKDF2_SHA256), - kdf_iterations: Some(100000), - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_prelogin_password_response(response).unwrap(); - - assert_eq!( - result, - Kdf::PBKDF2 { - iterations: NonZeroU32::new(100000).unwrap() - } - ); - } - - #[test] - fn test_parse_prelogin_pbkdf2_default_iterations() { - let response = PreloginResponseModel { - kdf: Some(KdfType::PBKDF2_SHA256), - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_prelogin_password_response(response).unwrap(); - - assert_eq!( - result, - Kdf::PBKDF2 { - iterations: default_pbkdf2_iterations() - } - ); - } - - #[test] - fn test_parse_prelogin_argon2id_with_all_params() { - let response = PreloginResponseModel { - kdf: Some(KdfType::Argon2id), - kdf_iterations: Some(4), - kdf_memory: Some(64), - kdf_parallelism: Some(4), - }; - - let result = parse_prelogin_password_response(response).unwrap(); - - assert_eq!( - result, - Kdf::Argon2id { - iterations: NonZeroU32::new(4).unwrap(), - memory: NonZeroU32::new(64).unwrap(), - parallelism: NonZeroU32::new(4).unwrap(), - } - ); - } - - #[test] - fn test_parse_prelogin_argon2id_default_params() { - let response = PreloginResponseModel { - kdf: Some(KdfType::Argon2id), - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_prelogin_password_response(response).unwrap(); - - assert_eq!( - result, - Kdf::Argon2id { - iterations: default_argon2_iterations(), - memory: default_argon2_memory(), - parallelism: default_argon2_parallelism(), - } - ); - } - - #[test] - fn test_parse_prelogin_missing_kdf_type() { - let response = PreloginResponseModel { - kdf: None, - kdf_iterations: Some(100000), - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_prelogin_password_response(response); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); - } - - #[test] - fn test_parse_prelogin_zero_iterations_uses_default() { - // When the server returns 0, NonZeroU32::new returns None, so defaults should be used - let response = PreloginResponseModel { - kdf: Some(KdfType::PBKDF2_SHA256), - kdf_iterations: Some(0), - kdf_memory: None, - kdf_parallelism: None, - }; - - let result = parse_prelogin_password_response(response).unwrap(); - - assert_eq!( - result, - Kdf::PBKDF2 { - iterations: default_pbkdf2_iterations() - } - ); - } - - #[test] - fn test_parse_prelogin_argon2id_partial_zero_values() { - // Test that zero values fall back to defaults for Argon2id - let response = PreloginResponseModel { - kdf: Some(KdfType::Argon2id), - kdf_iterations: Some(0), - kdf_memory: Some(0), - kdf_parallelism: Some(4), - }; - - let result = parse_prelogin_password_response(response).unwrap(); - - assert_eq!( - result, - Kdf::Argon2id { - iterations: default_argon2_iterations(), - memory: default_argon2_memory(), - parallelism: NonZeroU32::new(4).unwrap(), - } - ); - } -} From ce3a57b28dc81df31cf88f8175b4a31f519de475 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 10 Dec 2025 15:51:33 -0500 Subject: [PATCH 49/54] PM-14922 - Refactor prelogin naming yet again --- .../login_via_password/login_via_password.rs | 2 +- .../src/identity/login_via_password/mod.rs | 5 +- .../password_login_request.rs | 4 +- .../login_via_password/password_prelogin.rs | 310 +----------------- .../password_prelogin_response.rs | 289 ++++++++++++++++ 5 files changed, 301 insertions(+), 309 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index eca915616..0b2c6ad45 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -19,7 +19,7 @@ impl IdentityClient { bitwarden_core::key_management::MasterPasswordError, > = MasterPasswordAuthenticationData::derive( &request.password, - &request.prelogin_data.kdf, + &request.prelogin_response.kdf, &request.email, ); diff --git a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs index a8fa719f6..2b48f3e11 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -5,4 +5,7 @@ mod password_prelogin; pub(crate) use password_login_api_request::PasswordLoginApiRequest; pub use password_login_request::PasswordLoginRequest; -pub use password_prelogin::{PasswordPreloginData, PasswordPreloginError}; +pub use password_prelogin::PasswordPreloginError; + +mod password_prelogin_response; +pub use password_prelogin_response::PasswordPreloginResponse; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs index 6c269123e..16c0cc53c 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -1,7 +1,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::identity::{login_via_password::PasswordPreloginData, models::LoginRequest}; +use crate::identity::{login_via_password::PasswordPreloginResponse, models::LoginRequest}; /// Public SDK request model for logging in via password #[derive(Serialize, Deserialize, JsonSchema)] @@ -23,5 +23,5 @@ pub struct PasswordLoginRequest { /// Prelogin data required for password authentication /// (e.g., KDF configuration for deriving the master key) - pub prelogin_data: PasswordPreloginData, + pub prelogin_response: PasswordPreloginResponse, } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs index 5af33ab4a..96d272fec 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs @@ -1,14 +1,9 @@ -use bitwarden_api_identity::models::{ - KdfSettings, KdfType, PasswordPreloginRequestModel, PasswordPreloginResponseModel, -}; -use bitwarden_core::{ApiError, MissingFieldError, require}; -use bitwarden_crypto::Kdf; +use bitwarden_api_identity::models::PasswordPreloginRequestModel; +use bitwarden_core::{ApiError, MissingFieldError}; use bitwarden_error::bitwarden_error; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::identity::IdentityClient; +use crate::identity::{IdentityClient, login_via_password::PasswordPreloginResponse}; /// Error type for password prelogin operations #[allow(missing_docs)] @@ -21,23 +16,6 @@ pub enum PasswordPreloginError { MissingField(#[from] MissingFieldError), } -/// Response containing the data required before password-based authentication -#[derive(Serialize, Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support -#[cfg_attr( - feature = "wasm", - derive(tsify::Tsify), - tsify(into_wasm_abi, from_wasm_abi) -)] // add wasm support -pub struct PasswordPreloginData { - /// The Key Derivation Function (KDF) configuration for the user - pub kdf: Kdf, - - /// The salt used in the KDF process - pub salt: String, -} - impl IdentityClient { /// Retrieves the data required before authenticating with a password. /// This includes the user's KDF configuration needed to properly derive the master key. @@ -50,7 +28,7 @@ impl IdentityClient { pub async fn get_password_prelogin_data( &self, email: String, - ) -> Result { + ) -> Result { let request_model = PasswordPreloginRequestModel::new(email); let config = self.client.internal.get_api_configurations().await; let response = config @@ -60,284 +38,6 @@ impl IdentityClient { .await .map_err(ApiError::from)?; - let prelogin_data = PasswordPreloginData::try_from(response)?; - Ok(prelogin_data) - } -} - -impl TryFrom for PasswordPreloginData { - type Error = MissingFieldError; - - fn try_from(response: PasswordPreloginResponseModel) -> Result { - use std::num::NonZeroU32; - - use bitwarden_crypto::{ - default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, - default_pbkdf2_iterations, - }; - - let kdf_settings = require!(response.kdf_settings); - - let kdf = match kdf_settings.kdf_type { - KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { - iterations: NonZeroU32::new(kdf_settings.iterations as u32) - .unwrap_or_else(default_pbkdf2_iterations), - }, - KdfType::Argon2id => Kdf::Argon2id { - iterations: NonZeroU32::new(kdf_settings.iterations as u32) - .unwrap_or_else(default_argon2_iterations), - memory: kdf_settings - .memory - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_memory), - parallelism: kdf_settings - .parallelism - .and_then(|e| NonZeroU32::new(e as u32)) - .unwrap_or_else(default_argon2_parallelism), - }, - }; - - Ok(PasswordPreloginData { - kdf, - salt: require!(response.salt), - }) - } -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroU32; - - use bitwarden_api_identity::models::{KdfSettings, KdfType, PasswordPreloginResponseModel}; - use bitwarden_crypto::{ - Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, - default_pbkdf2_iterations, - }; - - use super::*; - - const TEST_SALT: &str = "test-salt"; - - #[test] - fn test_parse_prelogin_pbkdf2_with_iterations() { - let kdf_settings = KdfSettings { - kdf_type: KdfType::PBKDF2_SHA256, - iterations: 100000, - memory: None, - parallelism: None, - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response).unwrap(); - - assert_eq!( - result.kdf, - Kdf::PBKDF2 { - iterations: NonZeroU32::new(100000).unwrap() - } - ); - assert_eq!(result.salt, TEST_SALT); - } - - #[test] - fn test_parse_prelogin_pbkdf2_default_iterations() { - let kdf_settings = KdfSettings { - kdf_type: KdfType::PBKDF2_SHA256, - iterations: 0, // Zero will trigger default - memory: None, - parallelism: None, - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response).unwrap(); - - assert_eq!( - result.kdf, - Kdf::PBKDF2 { - iterations: default_pbkdf2_iterations() - } - ); - assert_eq!(result.salt, TEST_SALT); - } - - #[test] - fn test_parse_prelogin_argon2id_with_all_params() { - let kdf_settings = KdfSettings { - kdf_type: KdfType::Argon2id, - iterations: 4, - memory: Some(64), - parallelism: Some(4), - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response).unwrap(); - - assert_eq!( - result.kdf, - Kdf::Argon2id { - iterations: NonZeroU32::new(4).unwrap(), - memory: NonZeroU32::new(64).unwrap(), - parallelism: NonZeroU32::new(4).unwrap(), - } - ); - assert_eq!(result.salt, TEST_SALT); - } - - #[test] - fn test_parse_prelogin_argon2id_default_params() { - let kdf_settings = KdfSettings { - kdf_type: KdfType::Argon2id, - iterations: 0, // Zero will trigger default - memory: None, // None will trigger default - parallelism: None, // None will trigger default - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response).unwrap(); - - assert_eq!( - result.kdf, - Kdf::Argon2id { - iterations: default_argon2_iterations(), - memory: default_argon2_memory(), - parallelism: default_argon2_parallelism(), - } - ); - assert_eq!(result.salt, TEST_SALT); - } - - #[test] - fn test_parse_prelogin_missing_kdf_settings() { - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: None, // Missing kdf_settings - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); - } - - #[test] - fn test_parse_prelogin_missing_salt() { - let kdf_settings = KdfSettings { - kdf_type: KdfType::PBKDF2_SHA256, - iterations: 100000, - memory: None, - parallelism: None, - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: None, // Missing salt - }; - - let result = PasswordPreloginData::try_from(response); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); - } - - #[test] - fn test_parse_prelogin_zero_iterations_uses_default() { - // When the server returns 0, NonZeroU32::new returns None, so defaults should be used - let kdf_settings = KdfSettings { - kdf_type: KdfType::PBKDF2_SHA256, - iterations: 0, - memory: None, - parallelism: None, - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response).unwrap(); - - assert_eq!( - result.kdf, - Kdf::PBKDF2 { - iterations: default_pbkdf2_iterations() - } - ); - assert_eq!(result.salt, TEST_SALT); - } - - #[test] - fn test_parse_prelogin_argon2id_partial_zero_values() { - // Test that zero values fall back to defaults for Argon2id - let kdf_settings = KdfSettings { - kdf_type: KdfType::Argon2id, - iterations: 0, // Zero will trigger default - memory: Some(0), // Zero will trigger default - parallelism: Some(4), - }; - - let response = PasswordPreloginResponseModel { - kdf: None, - kdf_iterations: None, - kdf_memory: None, - kdf_parallelism: None, - kdf_settings: Some(Box::new(kdf_settings)), - salt: Some(TEST_SALT.to_string()), - }; - - let result = PasswordPreloginData::try_from(response).unwrap(); - - assert_eq!( - result.kdf, - Kdf::Argon2id { - iterations: default_argon2_iterations(), - memory: default_argon2_memory(), - parallelism: NonZeroU32::new(4).unwrap(), - } - ); - assert_eq!(result.salt, TEST_SALT); + Ok(PasswordPreloginResponse::try_from(response)?) } } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs new file mode 100644 index 000000000..b6cf41be3 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs @@ -0,0 +1,289 @@ +use std::num::NonZeroU32; + +use bitwarden_api_identity::models::{KdfSettings, KdfType, PasswordPreloginResponseModel}; +use bitwarden_core::{MissingFieldError, require}; +use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Response containing the data required before password-based authentication +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct PasswordPreloginResponse { + /// The Key Derivation Function (KDF) configuration for the user + pub kdf: Kdf, + + /// The salt used in the KDF process + pub salt: String, +} + +impl TryFrom for PasswordPreloginResponse { + type Error = MissingFieldError; + + fn try_from(response: PasswordPreloginResponseModel) -> Result { + let kdf_settings = require!(response.kdf_settings); + + let kdf = match kdf_settings.kdf_type { + KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { + iterations: NonZeroU32::new(kdf_settings.iterations as u32) + .unwrap_or_else(default_pbkdf2_iterations), + }, + KdfType::Argon2id => Kdf::Argon2id { + iterations: NonZeroU32::new(kdf_settings.iterations as u32) + .unwrap_or_else(default_argon2_iterations), + memory: kdf_settings + .memory + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_memory), + parallelism: kdf_settings + .parallelism + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_parallelism), + }, + }; + + Ok(PasswordPreloginResponse { + kdf, + salt: require!(response.salt), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SALT: &str = "test-salt"; + + #[test] + fn test_try_from_pbkdf2_with_iterations() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 100000, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: NonZeroU32::new(100000).unwrap() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_pbkdf2_default_iterations() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 0, // Zero will trigger default + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_argon2id_with_all_params() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 4, + memory: Some(64), + parallelism: Some(4), + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: NonZeroU32::new(4).unwrap(), + memory: NonZeroU32::new(64).unwrap(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_argon2id_default_params() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 0, // Zero will trigger default + memory: None, // None will trigger default + parallelism: None, // None will trigger default + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: default_argon2_parallelism(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_missing_kdf_settings() { + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: None, // Missing kdf_settings + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_try_from_missing_salt() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 100000, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: None, // Missing salt + }; + + let result = PasswordPreloginResponse::try_from(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_try_from_zero_iterations_uses_default() { + // When the server returns 0, NonZeroU32::new returns None, so defaults should be used + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 0, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_argon2id_partial_zero_values() { + // Test that zero values fall back to defaults for Argon2id + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 0, // Zero will trigger default + memory: Some(0), // Zero will trigger default + parallelism: Some(4), + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } +} From b8b85adc7687bf4956d967aa9c62aa99ceda5855 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 10 Dec 2025 15:59:37 -0500 Subject: [PATCH 50/54] PM-14922 - BW-auth cargo.toml - add "bitwarden-policies/uniffi" to uniffi features array to get LoginSuccessResponse.master_password_policy compiling and not complaining about uniffi bindings --- crates/bitwarden-auth/Cargo.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 9678d080e..7e95743db 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -21,7 +21,11 @@ wasm = [ "dep:wasm-bindgen", "dep:wasm-bindgen-futures" ] # WASM support -uniffi = ["bitwarden-core/uniffi", "dep:uniffi"] # Uniffi bindings +uniffi = [ + "bitwarden-core/uniffi", + "bitwarden-policies/uniffi", + "dep:uniffi" +] # Uniffi bindings # Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline. [dependencies] From 583dfcb8da9933ece1bbf0409bfe0aad99c84a4a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 10 Dec 2025 16:00:54 -0500 Subject: [PATCH 51/54] PM-14922 - Precommit formatting --- .../src/identity/login_via_password/login_via_password.rs | 4 ++-- .../login_via_password/password_prelogin_response.rs | 8 ++++---- .../identity/models/user_decryption_options_response.rs | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 0b2c6ad45..3676dface 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -32,8 +32,8 @@ impl IdentityClient { let response = send_login_request(&api_configs, &api_request).await; - // if success, we must validate that user decryption options are present as if they are missing - // we cannot proceed with unlocking the user's vault. + // if success, we must validate that user decryption options are present as if they are + // missing we cannot proceed with unlocking the user's vault. // TODO: figure out how to handle errors. } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs index b6cf41be3..07af1b54b 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs @@ -157,8 +157,8 @@ mod tests { fn test_try_from_argon2id_default_params() { let kdf_settings = KdfSettings { kdf_type: KdfType::Argon2id, - iterations: 0, // Zero will trigger default - memory: None, // None will trigger default + iterations: 0, // Zero will trigger default + memory: None, // None will trigger default parallelism: None, // None will trigger default }; @@ -260,8 +260,8 @@ mod tests { // Test that zero values fall back to defaults for Argon2id let kdf_settings = KdfSettings { kdf_type: KdfType::Argon2id, - iterations: 0, // Zero will trigger default - memory: Some(0), // Zero will trigger default + iterations: 0, // Zero will trigger default + memory: Some(0), // Zero will trigger default parallelism: Some(4), }; diff --git a/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs index 839c81c6b..48a14ad36 100644 --- a/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs +++ b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs @@ -70,13 +70,12 @@ mod tests { }; use bitwarden_crypto::Kdf; + use super::*; use crate::identity::api::response::{ KeyConnectorUserDecryptionOptionApiResponse, TrustedDeviceUserDecryptionOptionApiResponse, WebAuthnPrfUserDecryptionOptionApiResponse, }; - use super::*; - #[test] fn test_user_decryption_options_conversion_with_master_password() { let api = UserDecryptionOptionsApiResponse { From 2f6ccca45574e306c4ac9a0762c6f21c3ac4bb7c Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 10 Dec 2025 16:25:06 -0500 Subject: [PATCH 52/54] PM-14922 - Add Password prelogin tests --- .../login_via_password/password_prelogin.rs | 198 +++++++++++++++++- 1 file changed, 196 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs index 96d272fec..9418c0a4d 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs @@ -24,8 +24,8 @@ impl IdentityClient { /// * `email` - The user's email address /// /// # Returns - /// * `PreloginPasswordData` - Contains the KDF configuration for the user - pub async fn get_password_prelogin_data( + /// * `PasswordPreloginResponse` - Contains the KDF configuration for the user + pub async fn get_password_prelogin( &self, email: String, ) -> Result { @@ -41,3 +41,197 @@ impl IdentityClient { Ok(PasswordPreloginResponse::try_from(response)?) } } + +#[cfg(test)] +mod tests { + use bitwarden_api_identity::models::KdfType; + use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType}; + use bitwarden_crypto::Kdf; + use bitwarden_test::start_api_mock; + use wiremock::{Mock, ResponseTemplate, matchers}; + + use super::*; + + const TEST_EMAIL: &str = "test@example.com"; + const TEST_SALT_PBKDF2: &str = "test-salt-value"; + const TEST_SALT_ARGON2: &str = "argon2-salt-value"; + const PBKDF2_ITERATIONS: u32 = 600000; + const ARGON2_ITERATIONS: u32 = 3; + const ARGON2_MEMORY: u32 = 64; + const ARGON2_PARALLELISM: u32 = 4; + + fn make_identity_client(mock_server: &wiremock::MockServer) -> IdentityClient { + let settings = ClientSettings { + identity_url: format!("http://{}/identity", mock_server.address()), + api_url: format!("http://{}/api", mock_server.address()), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + let core_client = CoreClient::new(Some(settings)); + IdentityClient::new(core_client) + } + + #[tokio::test] + async fn test_get_password_prelogin_pbkdf2_success() { + // Create a mock success response with PBKDF2 + let raw_success = serde_json::json!({ + "kdfSettings": { + "kdfType": KdfType::PBKDF2_SHA256 as i32, + "iterations": PBKDF2_ITERATIONS + }, + "salt": TEST_SALT_PBKDF2 + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/accounts/prelogin/password")) + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/json", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await + .unwrap(); + + assert_eq!(result.salt, TEST_SALT_PBKDF2); + match result.kdf { + Kdf::PBKDF2 { iterations } => { + assert_eq!(iterations.get(), PBKDF2_ITERATIONS); + } + _ => panic!("Expected PBKDF2 KDF type"), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_argon2id_success() { + // Create a mock success response with Argon2id + let raw_success = serde_json::json!({ + "kdfSettings": { + "kdfType": KdfType::Argon2id as i32, + "iterations": ARGON2_ITERATIONS, + "memory": ARGON2_MEMORY, + "parallelism": ARGON2_PARALLELISM + }, + "salt": TEST_SALT_ARGON2 + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/accounts/prelogin/password")) + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/json", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await + .unwrap(); + + assert_eq!(result.salt, TEST_SALT_ARGON2); + match result.kdf { + Kdf::Argon2id { + iterations, + memory, + parallelism, + } => { + assert_eq!(iterations.get(), ARGON2_ITERATIONS); + assert_eq!(memory.get(), ARGON2_MEMORY); + assert_eq!(parallelism.get(), ARGON2_PARALLELISM); + } + _ => panic!("Expected Argon2id KDF type"), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_missing_kdf_settings() { + // Create a mock response missing kdf_settings + let raw_response = serde_json::json!({ + "salt": TEST_SALT_PBKDF2 + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/accounts/prelogin/password")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + PasswordPreloginError::MissingField(err) => { + assert_eq!(err.0, "response.kdf_settings"); + } + other => panic!("Expected MissingField error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_missing_salt() { + // Create a mock response missing salt + let raw_response = serde_json::json!({ + "kdfSettings": { + "kdfType": KdfType::PBKDF2_SHA256 as i32, + "iterations": PBKDF2_ITERATIONS + } + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("/identity/accounts/prelogin/password")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + PasswordPreloginError::MissingField(err) => { + assert_eq!(err.0, "response.salt"); + } + other => panic!("Expected MissingField error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_api_error() { + // Create a mock 500 error + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("/identity/accounts/prelogin/password")) + .respond_with(ResponseTemplate::new(500)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + PasswordPreloginError::Api(bitwarden_core::ApiError::ResponseContent { + status, + message: _, + }) => { + assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR); + } + other => panic!("Expected Api ResponseContent error, got {:?}", other), + } + } +} From 24b0a01b604b724d70c45b18ca5e06a0076b124e Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 10 Dec 2025 16:31:36 -0500 Subject: [PATCH 53/54] PM-14922 - Fix warnings about not using map into --- .../src/identity/models/login_success_response.rs | 5 +---- .../models/user_decryption_options_response.rs | 15 +++------------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs index 85b063210..d3ab9e054 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -90,10 +90,7 @@ impl TryFrom for LoginSuccessResponse { api_use_key_connector: response.api_use_key_connector, // User decryption options are required on successful login responses user_decryption_options: require!(response.user_decryption_options).try_into()?, - master_password_policy: match response.master_password_policy { - Some(policy) => Some(policy.into()), - None => None, - }, + master_password_policy: response.master_password_policy.map(|policy| policy.into()), }) } } diff --git a/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs index 48a14ad36..0c54714ee 100644 --- a/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs +++ b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs @@ -47,18 +47,9 @@ impl TryFrom for UserDecryptionOptionsResponse Some(ref mp) => Some(MasterPasswordUnlockData::try_from(mp)?), None => None, }, - trusted_device_option: match api.trusted_device_option { - Some(tde) => Some(tde.into()), - None => None, - }, - key_connector_option: match api.key_connector_option { - Some(kc) => Some(kc.into()), - None => None, - }, - webauthn_prf_option: match api.webauthn_prf_option { - Some(wa) => Some(wa.into()), - None => None, - }, + trusted_device_option: api.trusted_device_option.map(|tde| tde.into()), + key_connector_option: api.key_connector_option.map(|kc| kc.into()), + webauthn_prf_option: api.webauthn_prf_option.map(|wa| wa.into()), }) } } From 242a56af5cae07a268a90c4acaea5b96241f1632 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 11 Dec 2025 14:10:12 -0500 Subject: [PATCH 54/54] PM-14922 - WIP on Login response processing and error handling --- .../api/response/login_error_api_response.rs | 2 -- .../src/identity/api/send_login_request.rs | 23 +++++++++++++------ .../login_via_password/login_via_password.rs | 7 +++++- .../src/identity/models/login_error.rs | 1 + .../src/identity/models/login_response.rs | 11 +++++++++ .../bitwarden-auth/src/identity/models/mod.rs | 2 ++ 6 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/models/login_error.rs create mode 100644 crates/bitwarden-auth/src/identity/models/login_response.rs diff --git a/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs index 9135c3973..477372b89 100644 --- a/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs +++ b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs @@ -67,8 +67,6 @@ pub enum OAuth2ErrorApiResponse { }, /// Unsupported grant type error, typically due to an unsupported credential flow. - /// Note: during initial feature rollout, this will be used to indicate that the - /// feature flag is disabled. UnsupportedGrantType { #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "wasm", tsify(optional))] diff --git a/crates/bitwarden-auth/src/identity/api/send_login_request.rs b/crates/bitwarden-auth/src/identity/api/send_login_request.rs index 5dafb234f..c7d7d0fde 100644 --- a/crates/bitwarden-auth/src/identity/api/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -1,20 +1,23 @@ // Cleanest idea for allowing access to data needed for sending login requests // Make this function accept the commmon model and flatten the specific -use bitwarden_core::client::ApiConfigurations; +use bitwarden_core::{auth::login, client::ApiConfigurations}; use serde::{Serialize, de::DeserializeOwned}; -use crate::identity::api::{ - login_request_header::LoginRequestHeader, - request::LoginApiRequest, - response::{LoginErrorApiResponse, LoginSuccessApiResponse}, +use crate::identity::{ + api::{ + login_request_header::LoginRequestHeader, + request::LoginApiRequest, + response::{LoginErrorApiResponse, LoginSuccessApiResponse}, + }, + models::{LoginError, LoginResponse, LoginSuccessResponse}, }; /// A common function to send login requests to the Identity connect/token endpoint. pub(crate) async fn send_login_request( api_configs: &ApiConfigurations, api_request: &LoginApiRequest, -) -> Result { +) -> Result { let identity_config = &api_configs.identity_config; let url: String = format!("{}/connect/token", &identity_config.base_path); @@ -47,11 +50,17 @@ pub(crate) async fn send_login_request( // TODO: define LoginSuccessResponse model in SDK layer and add into trait from // LoginSuccessApiResponse to convert between API model and SDK model - return Ok(login_success_api_response); + let login_success_response: LoginSuccessResponse = login_success_api_response.try_into()?; + + let login_response = LoginResponse::Authenticated (login_success_response); + + return Ok(login_response); } // Handle error response let login_error_api_response: LoginErrorApiResponse = response.json().await?; Err(login_error_api_response) + + todo!() } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs index 3676dface..4de03bd3f 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -4,6 +4,7 @@ use crate::identity::{ IdentityClient, api::{request::LoginApiRequest, send_login_request}, login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, + models::{LoginError, LoginResponse}, }; impl IdentityClient { @@ -12,7 +13,10 @@ impl IdentityClient { /// This function derives the necessary master password authentication data /// using the provided prelogin data, constructs the appropriate API request, /// and sends the request to the Identity connect/token endpoint to log the user in. - pub async fn login_via_password(&self, request: PasswordLoginRequest) { + pub async fn login_via_password( + &self, + request: PasswordLoginRequest, + ) -> Result { // use request password prelogin data to derive master password authentication data: let master_password_authentication: Result< MasterPasswordAuthenticationData, @@ -36,5 +40,6 @@ impl IdentityClient { // missing we cannot proceed with unlocking the user's vault. // TODO: figure out how to handle errors. + todo!() } } diff --git a/crates/bitwarden-auth/src/identity/models/login_error.rs b/crates/bitwarden-auth/src/identity/models/login_error.rs new file mode 100644 index 000000000..c95ea9ee8 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_error.rs @@ -0,0 +1 @@ +// TODO: try to figure out what this error should look like diff --git a/crates/bitwarden-auth/src/identity/models/login_response.rs b/crates/bitwarden-auth/src/identity/models/login_response.rs new file mode 100644 index 000000000..e92fd9efa --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_response.rs @@ -0,0 +1,11 @@ +use crate::identity::models::LoginSuccessResponse; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum LoginResponse { + Authenticated(LoginSuccessResponse), + // Payload(IdentityTokenPayloadResponse), TBD for secrets manager use + // Refreshed(LoginRefreshResponse), + // TwoFactorRequired(Box), + // TODO: add new device verification response +} diff --git a/crates/bitwarden-auth/src/identity/models/mod.rs b/crates/bitwarden-auth/src/identity/models/mod.rs index 10b1220f8..20572c5e4 100644 --- a/crates/bitwarden-auth/src/identity/models/mod.rs +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -3,6 +3,7 @@ mod key_connector_user_decryption_option; mod login_device_request; mod login_request; +mod login_response; mod login_success_response; mod trusted_device_user_decryption_option; mod user_decryption_options_response; @@ -11,6 +12,7 @@ mod webauthn_prf_user_decryption_option; pub use key_connector_user_decryption_option::KeyConnectorUserDecryptionOption; pub use login_device_request::LoginDeviceRequest; pub use login_request::LoginRequest; +pub use login_response::LoginResponse; pub use login_success_response::LoginSuccessResponse; pub use trusted_device_user_decryption_option::TrustedDeviceUserDecryptionOption; pub use user_decryption_options_response::UserDecryptionOptionsResponse;