From 4e31b9870f90253af2b2bd2271469f477f07ccac Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Nov 2025 20:03:35 -0500 Subject: [PATCH 01/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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; From cbef898130b0be79428a206de026b57aa430fee9 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 12 Dec 2025 17:47:37 -0500 Subject: [PATCH 55/66] PM-14922 - WIP on login via password error handling --- .../api/response/login_error_api_response.rs | 49 +++++++++++ .../src/identity/api/response/mod.rs | 4 +- .../src/identity/api/send_login_request.rs | 23 ++--- .../login_via_password/login_via_password.rs | 12 +-- .../src/identity/login_via_password/mod.rs | 3 + .../password_login_error.rs | 86 +++++++++++++++++++ .../src/identity/models/login_error.rs | 1 - .../src/identity/models/login_response.rs | 2 + 8 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs delete mode 100644 crates/bitwarden-auth/src/identity/models/login_error.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 477372b89..98eb34e51 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 @@ -1,3 +1,4 @@ +use bitwarden_core::key_management::MasterPasswordError; use serde::{Deserialize, Serialize}; #[cfg(feature = "wasm")] use tsify::Tsify; @@ -13,13 +14,55 @@ pub enum PasswordInvalidGrantError { Unknown, } +// Actual 2fa rejection response for future use in TwoFactorInvalidGrantError +// { +// "error": "invalid_grant", +// "error_description": "Two factor required.", +// "TwoFactorProviders": [ +// "1", +// "3" +// ], +// "TwoFactorProviders2": { +// "1": { +// "Email": "test*****@bitwarden.com" +// }, +// "3": { +// "Nfc": true +// } +// }, +// "SsoEmail2faSessionToken": "BwSsoEmail2FaSessionToken_stuff", +// "Email": "test*****@bitwarden.com", +// "MasterPasswordPolicy": { +// "MinComplexity": 4, +// "RequireLower": false, +// "RequireUpper": false, +// "RequireNumbers": false, +// "RequireSpecial": false, +// "EnforceOnLogin": true, +// "Object": "masterPasswordPolicy" +// } +// } + #[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 { + // TODO: how does the error get mapped into this variant? + + // { + // "error": "invalid_grant", + // "error_description": "invalid_username_or_password", + // "ErrorModel": { + // "Message": "Username or password is incorrect. Try again.", + // "Object": "error" + // } + // } + // Password grant specific errors Password(PasswordInvalidGrantError), + // TwoFactorRequired(TwoFactorInvalidGrantError) + // TODO: other grant specific errors can go here /// Fallback for unknown variants for forward compatibility #[serde(other)] @@ -105,3 +148,9 @@ impl From for LoginErrorApiResponse { Self::UnexpectedError(format!("{value:?}")) } } + +impl From for LoginErrorApiResponse { + fn from(value: MasterPasswordError) -> Self { + Self::UnexpectedError(format!("{value:?}")) + } +} diff --git a/crates/bitwarden-auth/src/identity/api/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs index d6bbeaf74..efcd795d1 100644 --- a/crates/bitwarden-auth/src/identity/api/response/mod.rs +++ b/crates/bitwarden-auth/src/identity/api/response/mod.rs @@ -19,4 +19,6 @@ 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; +pub(crate) use login_error_api_response::{ + InvalidGrantError, LoginErrorApiResponse, OAuth2ErrorApiResponse, PasswordInvalidGrantError, +}; 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 c7d7d0fde..5c68641d7 100644 --- a/crates/bitwarden-auth/src/identity/api/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -1,7 +1,7 @@ // 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::{auth::login, client::ApiConfigurations}; +use bitwarden_core::client::ApiConfigurations; use serde::{Serialize, de::DeserializeOwned}; use crate::identity::{ @@ -10,14 +10,17 @@ use crate::identity::{ request::LoginApiRequest, response::{LoginErrorApiResponse, LoginSuccessApiResponse}, }, - models::{LoginError, LoginResponse, LoginSuccessResponse}, + models::{LoginResponse, LoginSuccessResponse}, }; /// A common function to send login requests to the Identity connect/token endpoint. +/// Returns a common success model which has already been converted from the API response, +/// or a common error model representing the login error which allows for conversion to specific error types +/// based on the login method used. 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); @@ -34,9 +37,15 @@ pub(crate) async fn send_login_request( device_type_header.header_name(), device_type_header.header_value(), ) + // TODO: investigate if this is only needed for GET requests // 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") + // TODO: investigate missing headers from api.service implementation like: + // Bitwarden-Client-Name + // Bitwarden-Client-Version + // Bitwarden-Package-Type + // TODO: investigate if I have to worry about credentials here // use form to encode as application/x-www-form-urlencoded .form(&api_request); @@ -47,20 +56,14 @@ pub(crate) async fn send_login_request( 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 - let login_success_response: LoginSuccessResponse = login_success_api_response.try_into()?; - let login_response = LoginResponse::Authenticated (login_success_response); + 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 4de03bd3f..64fc1cce3 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 @@ -3,8 +3,8 @@ use bitwarden_core::key_management::MasterPasswordAuthenticationData; use crate::identity::{ IdentityClient, api::{request::LoginApiRequest, send_login_request}, - login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, - models::{LoginError, LoginResponse}, + login_via_password::{PasswordLoginApiRequest, PasswordLoginError, PasswordLoginRequest}, + models::LoginResponse, }; impl IdentityClient { @@ -16,7 +16,7 @@ impl IdentityClient { pub async fn login_via_password( &self, request: PasswordLoginRequest, - ) -> Result { + ) -> Result { // use request password prelogin data to derive master password authentication data: let master_password_authentication: Result< MasterPasswordAuthenticationData, @@ -36,10 +36,6 @@ 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. - todo!() + response.map_err(Into::into) } } 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 2b48f3e11..a40f446c9 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/mod.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -9,3 +9,6 @@ pub use password_prelogin::PasswordPreloginError; mod password_prelogin_response; pub use password_prelogin_response::PasswordPreloginResponse; + +mod password_login_error; +pub use password_login_error::PasswordLoginError; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs new file mode 100644 index 000000000..bdce990c7 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -0,0 +1,86 @@ +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::identity::api::response::{ + InvalidGrantError, LoginErrorApiResponse, OAuth2ErrorApiResponse, PasswordInvalidGrantError, +}; + +/// Represents errors that can occur when attempting to log in. +#[bitwarden_error(basic)] +#[derive(Debug, Error)] +pub enum PasswordLoginError { + #[error("Invalid username or password provided.")] + InvalidUsernameOrPassword, + + /// Fallback for unknown variants for forward compatibility + #[error("Unknown password login error: {0}")] + Unknown(String), +} + +// TODO: talk with Dani about trying to avoid having every login mechanism have to implement a conversion for 2FA errors as that is common +// TODO: investigate adding a display property for each error variant that maps to unknown so we don't have to +// manually build the string each time here and in each login mechanism error file. + +impl From for PasswordLoginError { + fn from(error: LoginErrorApiResponse) -> Self { + match error { + LoginErrorApiResponse::OAuth2Error(oauth_error) => match oauth_error { + OAuth2ErrorApiResponse::InvalidGrant { error_description } => { + match error_description { + Some(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword, + )) => Self::InvalidUsernameOrPassword, + Some(InvalidGrantError::Password(PasswordInvalidGrantError::Unknown)) => { + Self::Unknown("Invalid grant - password unknown error".to_string()) + } + Some(InvalidGrantError::Unknown) => { + Self::Unknown("Invalid grant - unknown error".to_string()) + } + None => { + Self::Unknown("Invalid grant with no error description".to_string()) + } + } + } + OAuth2ErrorApiResponse::InvalidRequest { error_description } => { + Self::Unknown(format!( + "Invalid request: {}", + error_description.unwrap_or("no error description".to_string()) + )) + } + OAuth2ErrorApiResponse::InvalidClient { error_description } => { + Self::Unknown(format!( + "Invalid client: {}", + error_description.unwrap_or("no error description".to_string()) + )) + } + OAuth2ErrorApiResponse::UnauthorizedClient { error_description } => { + Self::Unknown(format!( + "Unauthorized client: {}", + error_description.unwrap_or("no error description".to_string()) + )) + } + OAuth2ErrorApiResponse::UnsupportedGrantType { error_description } => { + Self::Unknown(format!( + "Unsupported grant type: {}", + error_description.unwrap_or("no error description".to_string()) + )) + } + OAuth2ErrorApiResponse::InvalidScope { error_description } => { + Self::Unknown(format!( + "Invalid scope: {}", + error_description.unwrap_or("no error description".to_string()) + )) + } + OAuth2ErrorApiResponse::InvalidTarget { error_description } => { + Self::Unknown(format!( + "Invalid target: {}", + error_description.unwrap_or("no error description".to_string()) + )) + } + }, + LoginErrorApiResponse::UnexpectedError(msg) => { + Self::Unknown(format!("Unexpected error: {}", msg)) + } + } + } +} diff --git a/crates/bitwarden-auth/src/identity/models/login_error.rs b/crates/bitwarden-auth/src/identity/models/login_error.rs deleted file mode 100644 index c95ea9ee8..000000000 --- a/crates/bitwarden-auth/src/identity/models/login_error.rs +++ /dev/null @@ -1 +0,0 @@ -// 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 index e92fd9efa..0c07b7827 100644 --- a/crates/bitwarden-auth/src/identity/models/login_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_response.rs @@ -1,8 +1,10 @@ use crate::identity::models::LoginSuccessResponse; use serde::{Deserialize, Serialize}; +/// Common login response model used across different login methods. #[derive(Debug, Serialize, Deserialize)] pub enum LoginResponse { + /// Successful authentication response. Authenticated(LoginSuccessResponse), // Payload(IdentityTokenPayloadResponse), TBD for secrets manager use // Refreshed(LoginRefreshResponse), From 29912ed385e3037e928548bbcd85c09f2b94616f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Sat, 13 Dec 2025 13:52:39 -0500 Subject: [PATCH 56/66] PM-14922 - PasswordLoginError - add docs --- .../src/identity/login_via_password/password_login_error.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs index bdce990c7..297fe5b18 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -9,6 +9,7 @@ use crate::identity::api::response::{ #[bitwarden_error(basic)] #[derive(Debug, Error)] pub enum PasswordLoginError { + /// The username or password provided was invalid. #[error("Invalid username or password provided.")] InvalidUsernameOrPassword, From 3f2b77faeb13765334c23a9623b17b4cdee83ef7 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Sat, 13 Dec 2025 13:53:20 -0500 Subject: [PATCH 57/66] PM-14922 - Login Error API Response - fix deserialization with custom deserializer + add tests with real world cases. --- .../api/response/login_error_api_response.rs | 432 ++++++++++++++++-- 1 file changed, 391 insertions(+), 41 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 98eb34e51..9775e289d 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 @@ -1,12 +1,9 @@ use bitwarden_core::key_management::MasterPasswordError; -use serde::{Deserialize, Serialize}; -#[cfg(feature = "wasm")] -use tsify::Tsify; +use serde::Deserialize; -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "snake_case")] +#[derive(Deserialize, PartialEq, Eq, Debug)] pub enum PasswordInvalidGrantError { + /// The username or password provided was invalid. InvalidUsernameOrPassword, /// Fallback for unknown variants for forward compatibility @@ -43,84 +40,87 @@ pub enum PasswordInvalidGrantError { // } // } -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "snake_case")] +#[derive(PartialEq, Eq, Debug)] pub enum InvalidGrantError { - // TODO: how does the error get mapped into this variant? - - // { - // "error": "invalid_grant", - // "error_description": "invalid_username_or_password", - // "ErrorModel": { - // "Message": "Username or password is incorrect. Try again.", - // "Object": "error" - // } - // } - // Password grant specific errors Password(PasswordInvalidGrantError), - // TwoFactorRequired(TwoFactorInvalidGrantError) - // TODO: other grant specific errors can go here + // TwoFactorRequired(TwoFactorInvalidGrantError) /// Fallback for unknown variants for forward compatibility - #[serde(other)] Unknown, } +// Custom deserializer to parse error_description string into InvalidGrantError enum +impl<'de> Deserialize<'de> for InvalidGrantError { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + + // Parse the error_description string and map to appropriate variant + match s.as_str() { + // Password-related errors + "invalid_username_or_password" => Ok(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword, + )), + + // Other grant-specific errors can be added here + + // Unknown/fallback for forward compatibility + _ => Ok(InvalidGrantError::Unknown), + } + } +} + /// 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))] +#[derive(Deserialize, PartialEq, Eq, Debug)] #[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))] + // we need default b/c we don't want deserialization to fail if error_description is missing. + // we want it to be None in that case. + #[serde(default)] /// The optional error description for invalid request errors. error_description: Option, }, /// Invalid grant error, typically due to invalid credentials. InvalidGrant { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "wasm", tsify(optional))] + #[serde(default)] /// The optional error description for invalid grant errors. error_description: Option, }, /// 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))] + #[serde(default)] /// 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))] + #[serde(default)] /// The optional error description for unauthorized client errors. error_description: Option, }, /// Unsupported grant type error, typically due to an unsupported credential flow. UnsupportedGrantType { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "wasm", tsify(optional))] + #[serde(default)] /// 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))] + #[serde(default)] /// The optional error description for invalid scope errors. error_description: Option, }, @@ -128,15 +128,17 @@ pub enum OAuth2ErrorApiResponse { /// 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))] + #[serde(default)] /// 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))] +#[derive(Deserialize, PartialEq, Eq, Debug)] +// Use untagged so serde tries each variant in order without expecting a wrapper object. +// This allows us to deserialize directly from { "error": "invalid_grant", ... } instead of +// requiring { "OAuth2Error": { "error": "invalid_grant", ... } }. +#[serde(untagged)] pub enum LoginErrorApiResponse { OAuth2Error(OAuth2ErrorApiResponse), UnexpectedError(String), @@ -154,3 +156,351 @@ impl From for LoginErrorApiResponse { Self::UnexpectedError(format!("{value:?}")) } } + +#[cfg(test)] +mod tests { + use super::*; + + // Test constants for common error values + const ERROR_INVALID_USERNAME_OR_PASSWORD: &str = "invalid_username_or_password"; + const ERROR_TYPE_INVALID_GRANT: &str = "invalid_grant"; + + mod invalid_grant_error_tests { + use serde_json::{from_str, json}; + + use super::*; + + #[test] + fn password_invalid_username_or_password_deserializes() { + let json = format!(r#""{ERROR_INVALID_USERNAME_OR_PASSWORD}""#); + let error: InvalidGrantError = from_str(&json).unwrap(); + assert_eq!( + error, + InvalidGrantError::Password(PasswordInvalidGrantError::InvalidUsernameOrPassword) + ); + } + + #[test] + fn unknown_error_description_maps_to_unknown() { + let json = r#""some_new_error_code""#; + let error: InvalidGrantError = from_str(json).unwrap(); + assert_eq!(error, InvalidGrantError::Unknown); + } + + #[test] + fn full_invalid_grant_response_with_invalid_username_or_password() { + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD + }) + .to_string(); + + let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + OAuth2ErrorApiResponse::InvalidGrant { error_description } => { + assert_eq!( + error_description, + Some(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword + )) + ); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_without_error_description_is_allowed() { + let payload = json!({ "error": ERROR_TYPE_INVALID_GRANT }).to_string(); + let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + OAuth2ErrorApiResponse::InvalidGrant { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_null_error_description_becomes_none() { + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": null + }) + .to_string(); + + let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + OAuth2ErrorApiResponse::InvalidGrant { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_with_unknown_error_description() { + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": "brand_new_error_type" + }) + .to_string(); + + let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + OAuth2ErrorApiResponse::InvalidGrant { error_description } => { + assert_eq!(error_description, Some(InvalidGrantError::Unknown)); + } + _ => panic!("expected invalid_grant"), + } + } + } + + mod login_error_api_response_tests { + use serde_json::{from_str, json}; + + use super::*; + + #[test] + fn full_server_response_with_error_model_deserializes() { + // This is the actual server response format with ErrorModel + // which we don't care about but need to handle during deserialization. + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD, + "ErrorModel": { + "Message": "Username or password is incorrect. Try again.", + "Object": "error" + } + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description, + }) => { + assert_eq!( + error_description, + Some(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword + )) + ); + } + _ => panic!("expected OAuth2Error(InvalidGrant)"), + } + } + + #[test] + fn oauth2_error_without_error_model_deserializes() { + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description, + }) => { + assert_eq!( + error_description, + Some(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword + )) + ); + } + _ => panic!("expected OAuth2Error(InvalidGrant)"), + } + } + + #[test] + fn invalid_request_error_deserializes() { + let payload = json!({ + "error": "invalid_request", + "error_description": "password is required" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidRequest { + error_description, + }) => { + assert_eq!(error_description.as_deref(), Some("password is required")); + } + _ => panic!("expected OAuth2Error(InvalidRequest)"), + } + } + + #[test] + fn invalid_client_error_deserializes() { + let payload = json!({ + "error": "invalid_client", + "error_description": "Invalid client credentials" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidClient { + error_description, + }) => { + assert_eq!( + error_description.as_deref(), + Some("Invalid client credentials") + ); + } + _ => panic!("expected OAuth2Error(InvalidClient)"), + } + } + + #[test] + fn unauthorized_client_error_deserializes() { + let payload = json!({ + "error": "unauthorized_client" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error( + OAuth2ErrorApiResponse::UnauthorizedClient { error_description }, + ) => { + assert!(error_description.is_none()); + } + _ => panic!("expected OAuth2Error(UnauthorizedClient)"), + } + } + + #[test] + fn unsupported_grant_type_error_deserializes() { + let payload = json!({ + "error": "unsupported_grant_type", + "error_description": "This grant type is not supported" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error( + OAuth2ErrorApiResponse::UnsupportedGrantType { error_description }, + ) => { + assert_eq!( + error_description.as_deref(), + Some("This grant type is not supported") + ); + } + _ => panic!("expected OAuth2Error(UnsupportedGrantType)"), + } + } + + #[test] + fn invalid_scope_error_deserializes() { + let payload = json!({ + "error": "invalid_scope" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidScope { + error_description, + }) => { + assert!(error_description.is_none()); + } + _ => panic!("expected OAuth2Error(InvalidScope)"), + } + } + + #[test] + fn invalid_target_error_deserializes() { + let payload = json!({ + "error": "invalid_target", + "error_description": "Resource not found" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidTarget { + error_description, + }) => { + assert_eq!(error_description.as_deref(), Some("Resource not found")); + } + _ => panic!("expected OAuth2Error(InvalidTarget)"), + } + } + + #[test] + fn missing_or_null_error_description_deserializes_to_none() { + // Test both missing field and null value + let test_cases = vec![ + json!({ "error": ERROR_TYPE_INVALID_GRANT }), + json!({ "error": ERROR_TYPE_INVALID_GRANT, "error_description": null }), + ]; + + for payload in test_cases { + let parsed: LoginErrorApiResponse = from_str(&payload.to_string()).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description, + }) => { + assert!(error_description.is_none()); + } + _ => panic!("expected OAuth2Error(InvalidGrant)"), + } + } + } + + #[test] + fn unknown_error_description_value_maps_to_unknown() { + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": "some_future_error_code" + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description, + }) => { + assert_eq!(error_description, Some(InvalidGrantError::Unknown)); + } + _ => panic!("expected OAuth2Error(InvalidGrant)"), + } + } + + #[test] + fn error_with_extra_fields_ignores_them() { + let payload = json!({ + "error": ERROR_TYPE_INVALID_GRANT, + "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD, + "extra_field": "should be ignored", + "another_field": 123, + "ErrorModel": { + "Message": "Some message", + "Object": "error" + } + }) + .to_string(); + + let parsed: LoginErrorApiResponse = from_str(&payload).unwrap(); + match parsed { + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description, + }) => { + assert_eq!( + error_description, + Some(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword + )) + ); + } + _ => panic!("expected OAuth2Error(InvalidGrant)"), + } + } + } +} From db17cf8b9068a63f78ce56cce9e579507b395711 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 17:06:59 -0500 Subject: [PATCH 58/66] PM-14922 - Per PR feedback from Dani, adjust deserialization to use untagged instead of custom deserializer. --- .../api/response/login_error_api_response.rs | 59 ++++++++----------- .../password_login_error.rs | 7 +-- 2 files changed, 28 insertions(+), 38 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 9775e289d..8785ab162 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 @@ -2,13 +2,10 @@ use bitwarden_core::key_management::MasterPasswordError; use serde::Deserialize; #[derive(Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "snake_case")] pub enum PasswordInvalidGrantError { /// The username or password provided was invalid. InvalidUsernameOrPassword, - - /// Fallback for unknown variants for forward compatibility - #[serde(other)] - Unknown, } // Actual 2fa rejection response for future use in TwoFactorInvalidGrantError @@ -40,38 +37,21 @@ pub enum PasswordInvalidGrantError { // } // } -#[derive(PartialEq, Eq, Debug)] +// Use untagged so serde tries to deserialize into each variant in order. +// For "invalid_username_or_password", it tries Password(PasswordInvalidGrantError) first, +// which succeeds via the #[serde(rename_all = "snake_case")] on PasswordInvalidGrantError. +// For unknown values like "new_error_code", Password variant fails, so it falls back to Unknown(String). +#[derive(Deserialize, PartialEq, Eq, Debug)] +#[serde(untagged)] pub enum InvalidGrantError { // Password grant specific errors Password(PasswordInvalidGrantError), // TODO: other grant specific errors can go here // TwoFactorRequired(TwoFactorInvalidGrantError) - /// Fallback for unknown variants for forward compatibility - Unknown, -} - -// Custom deserializer to parse error_description string into InvalidGrantError enum -impl<'de> Deserialize<'de> for InvalidGrantError { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - - // Parse the error_description string and map to appropriate variant - match s.as_str() { - // Password-related errors - "invalid_username_or_password" => Ok(InvalidGrantError::Password( - PasswordInvalidGrantError::InvalidUsernameOrPassword, - )), - - // Other grant-specific errors can be added here - - // Unknown/fallback for forward compatibility - _ => Ok(InvalidGrantError::Unknown), - } - } + /// Fallback for unknown variants for forward compatibility. + /// Must be last in the enum due to untagged deserialization trying variants in order. + Unknown(String), } /// Per RFC 6749 Section 5.2, these are the standard error responses for OAuth 2.0 token requests. @@ -184,7 +164,10 @@ mod tests { fn unknown_error_description_maps_to_unknown() { let json = r#""some_new_error_code""#; let error: InvalidGrantError = from_str(json).unwrap(); - assert_eq!(error, InvalidGrantError::Unknown); + assert_eq!( + error, + InvalidGrantError::Unknown("some_new_error_code".to_string()) + ); } #[test] @@ -249,7 +232,12 @@ mod tests { let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap(); match parsed { OAuth2ErrorApiResponse::InvalidGrant { error_description } => { - assert_eq!(error_description, Some(InvalidGrantError::Unknown)); + assert_eq!( + error_description, + Some(InvalidGrantError::Unknown( + "brand_new_error_type".to_string() + )) + ); } _ => panic!("expected invalid_grant"), } @@ -467,7 +455,12 @@ mod tests { LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { error_description, }) => { - assert_eq!(error_description, Some(InvalidGrantError::Unknown)); + assert_eq!( + error_description, + Some(InvalidGrantError::Unknown( + "some_future_error_code".to_string() + )) + ); } _ => panic!("expected OAuth2Error(InvalidGrant)"), } diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs index 297fe5b18..2d01910e1 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -31,11 +31,8 @@ impl From for PasswordLoginError { Some(InvalidGrantError::Password( PasswordInvalidGrantError::InvalidUsernameOrPassword, )) => Self::InvalidUsernameOrPassword, - Some(InvalidGrantError::Password(PasswordInvalidGrantError::Unknown)) => { - Self::Unknown("Invalid grant - password unknown error".to_string()) - } - Some(InvalidGrantError::Unknown) => { - Self::Unknown("Invalid grant - unknown error".to_string()) + Some(InvalidGrantError::Unknown(error_code)) => { + Self::Unknown(format!("Invalid grant - unknown error: {}", error_code)) } None => { Self::Unknown("Invalid grant with no error description".to_string()) From 3e2b498e077211b7f3a8ae41bd317ee1055d6252 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 17:16:46 -0500 Subject: [PATCH 59/66] PM-14922 - Add proper uniffi and wasm bindings for response models --- crates/bitwarden-auth/src/identity/identity_client.rs | 1 + .../src/identity/login_via_password/login_via_password.rs | 3 +++ .../src/identity/login_via_password/password_prelogin.rs | 3 +++ crates/bitwarden-auth/src/identity/models/login_response.rs | 6 ++++++ .../src/identity/models/login_success_response.rs | 3 +-- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-auth/src/identity/identity_client.rs b/crates/bitwarden-auth/src/identity/identity_client.rs index 61de3a4d5..b2af77295 100644 --- a/crates/bitwarden-auth/src/identity/identity_client.rs +++ b/crates/bitwarden-auth/src/identity/identity_client.rs @@ -5,6 +5,7 @@ use wasm_bindgen::prelude::*; /// The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. #[derive(Clone)] #[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct IdentityClient { pub(crate) client: Client, } 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 64fc1cce3..9c3fa866e 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,4 +1,5 @@ use bitwarden_core::key_management::MasterPasswordAuthenticationData; +use wasm_bindgen::prelude::wasm_bindgen; use crate::identity::{ IdentityClient, @@ -7,6 +8,8 @@ use crate::identity::{ models::LoginResponse, }; +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", uniffi::export)] impl IdentityClient { /// Logs in a user via their email and master password. /// 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 9418c0a4d..8d369da9a 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 @@ -4,6 +4,7 @@ use bitwarden_error::bitwarden_error; use thiserror::Error; use crate::identity::{IdentityClient, login_via_password::PasswordPreloginResponse}; +use wasm_bindgen::prelude::wasm_bindgen; /// Error type for password prelogin operations #[allow(missing_docs)] @@ -16,6 +17,8 @@ pub enum PasswordPreloginError { MissingField(#[from] MissingFieldError), } +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", uniffi::export)] 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. diff --git a/crates/bitwarden-auth/src/identity/models/login_response.rs b/crates/bitwarden-auth/src/identity/models/login_response.rs index 0c07b7827..2c948df41 100644 --- a/crates/bitwarden-auth/src/identity/models/login_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_response.rs @@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize}; /// Common login response model used across different login methods. #[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] pub enum LoginResponse { /// Successful authentication response. Authenticated(LoginSuccessResponse), 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 d3ab9e054..fd4ffb12c 100644 --- a/crates/bitwarden-auth/src/identity/models/login_success_response.rs +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -9,7 +9,7 @@ use crate::identity::{ /// SDK response model for a successful login. /// This is the model that will be exposed to consuming applications. -#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr( @@ -17,7 +17,6 @@ use crate::identity::{ derive(tsify::Tsify), tsify(into_wasm_abi, from_wasm_abi) )] -#[derive(Debug)] pub struct LoginSuccessResponse { /// The access token string. pub access_token: String, From 3943cd4047dadabf7f4a61a0542ee37d1b520a64 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 17:54:23 -0500 Subject: [PATCH 60/66] PM-14922 - PasswordLoginError - swap to #[bitwarden_error(flat)] as basic doesn't have UNIFFI support --- .../src/identity/login_via_password/password_login_error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs index 2d01910e1..948d53d9b 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -6,7 +6,7 @@ use crate::identity::api::response::{ }; /// Represents errors that can occur when attempting to log in. -#[bitwarden_error(basic)] +#[bitwarden_error(flat)] #[derive(Debug, Error)] pub enum PasswordLoginError { /// The username or password provided was invalid. From 304f362bb65bac8b654da232c6621dda417d4be2 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 18:15:04 -0500 Subject: [PATCH 61/66] PM-14922 - PasswordLoginError - update comments --- .../src/identity/login_via_password/password_login_error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs index 948d53d9b..568e9dfee 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -19,7 +19,7 @@ pub enum PasswordLoginError { } // TODO: talk with Dani about trying to avoid having every login mechanism have to implement a conversion for 2FA errors as that is common -// TODO: investigate adding a display property for each error variant that maps to unknown so we don't have to +// TODO: per discussion with Dani, investigate adding a display property for each error variant that maps to unknown so we don't have to // manually build the string each time here and in each login mechanism error file. impl From for PasswordLoginError { From c4410315c9c71ebfbe6637771e5b5c17940d34af Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 18:26:31 -0500 Subject: [PATCH 62/66] PM-14922 - PasswordLoginError - test From for PasswordLoginError --- .../password_login_error.rs | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs index 568e9dfee..78671c2ec 100644 --- a/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -82,3 +82,302 @@ impl From for PasswordLoginError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Test constants for strings used multiple times + const ERROR_DESC_NO_DESCRIPTION: &str = "no error description"; + const TEST_ERROR_DESC: &str = "Test error description"; + + mod from_login_error_api_response { + use super::*; + + #[test] + fn invalid_grant_with_invalid_username_or_password() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description: Some(InvalidGrantError::Password( + PasswordInvalidGrantError::InvalidUsernameOrPassword, + )), + }); + + let result: PasswordLoginError = api_error.into(); + + assert!(matches!( + result, + PasswordLoginError::InvalidUsernameOrPassword + )); + } + + #[test] + fn invalid_grant_with_unknown_error() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description: Some(InvalidGrantError::Unknown( + "unknown_error_code".to_string(), + )), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, "Invalid grant - unknown error: unknown_error_code"); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_grant_with_no_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, "Invalid grant with no error description"); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_request_with_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidRequest { + error_description: Some(TEST_ERROR_DESC.to_string()), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Invalid request: {}", TEST_ERROR_DESC)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_request_without_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidRequest { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!( + msg, + format!("Invalid request: {}", ERROR_DESC_NO_DESCRIPTION) + ); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_client_with_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidClient { + error_description: Some(TEST_ERROR_DESC.to_string()), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Invalid client: {}", TEST_ERROR_DESC)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_client_without_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidClient { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!( + msg, + format!("Invalid client: {}", ERROR_DESC_NO_DESCRIPTION) + ); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn unauthorized_client_with_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::UnauthorizedClient { + error_description: Some(TEST_ERROR_DESC.to_string()), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Unauthorized client: {}", TEST_ERROR_DESC)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn unauthorized_client_without_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::UnauthorizedClient { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!( + msg, + format!("Unauthorized client: {}", ERROR_DESC_NO_DESCRIPTION) + ); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn unsupported_grant_type_with_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::UnsupportedGrantType { + error_description: Some(TEST_ERROR_DESC.to_string()), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Unsupported grant type: {}", TEST_ERROR_DESC)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn unsupported_grant_type_without_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::UnsupportedGrantType { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!( + msg, + format!("Unsupported grant type: {}", ERROR_DESC_NO_DESCRIPTION) + ); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_scope_with_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidScope { + error_description: Some(TEST_ERROR_DESC.to_string()), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Invalid scope: {}", TEST_ERROR_DESC)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_scope_without_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidScope { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Invalid scope: {}", ERROR_DESC_NO_DESCRIPTION)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_target_with_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidTarget { + error_description: Some(TEST_ERROR_DESC.to_string()), + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, format!("Invalid target: {}", TEST_ERROR_DESC)); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn invalid_target_without_error_description() { + let api_error = + LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidTarget { + error_description: None, + }); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!( + msg, + format!("Invalid target: {}", ERROR_DESC_NO_DESCRIPTION) + ); + } + _ => panic!("Expected Unknown variant"), + } + } + + #[test] + fn unexpected_error() { + let api_error = LoginErrorApiResponse::UnexpectedError("Network timeout".to_string()); + + let result: PasswordLoginError = api_error.into(); + + match result { + PasswordLoginError::Unknown(msg) => { + assert_eq!(msg, "Unexpected error: Network timeout"); + } + _ => panic!("Expected Unknown variant"), + } + } + } +} From 2baa668d30fd1fcb068ed165897ecc8b39737e4a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 19:15:49 -0500 Subject: [PATCH 63/66] PM-14922 - LoginSuccessApiResponse - KDF is always sent but technically it is optional and we want to treat it as deprecated --- .../src/identity/api/response/login_success_api_response.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 90d3d4028..532812bd0 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 @@ -45,7 +45,7 @@ pub(crate) struct LoginSuccessApiResponse { /// Master key derivation function type #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] #[serde(rename = "Kdf", alias = "kdf")] - pub kdf: KdfType, + pub kdf: Option, /// Master key derivation function iterations #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] From e577f60ee864f576f3772ab7c6d405ff57a82733 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Mon, 15 Dec 2025 19:16:11 -0500 Subject: [PATCH 64/66] PM-14922 - LoginViaPassword - add first draft of tests --- .../login_via_password/login_via_password.rs | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) 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 9c3fa866e..c47454fdd 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 @@ -42,3 +42,347 @@ impl IdentityClient { response.map_err(Into::into) } } + +// TODO: these tests will have to be updated once send_login_request settles +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + 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::*; + use crate::identity::{ + login_via_password::{PasswordLoginRequest, PasswordPreloginResponse}, + models::{LoginDeviceRequest, LoginRequest, LoginResponse}, + }; + + const TEST_EMAIL: &str = "test@example.com"; + const TEST_PASSWORD: &str = "test-password-123"; + const TEST_SALT: &str = "test-salt-value"; + const TEST_CLIENT_ID: &str = "connector"; + const TEST_DEVICE_IDENTIFIER: &str = "test-device-id"; + const TEST_DEVICE_NAME: &str = "Test Device"; + const PBKDF2_ITERATIONS: u32 = 600000; + + 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) + } + + fn make_password_login_request() -> PasswordLoginRequest { + PasswordLoginRequest { + login_request: LoginRequest { + client_id: TEST_CLIENT_ID.to_string(), + device: LoginDeviceRequest { + device_type: DeviceType::SDK, + device_identifier: TEST_DEVICE_IDENTIFIER.to_string(), + device_name: TEST_DEVICE_NAME.to_string(), + device_push_token: None, + }, + }, + email: TEST_EMAIL.to_string(), + password: TEST_PASSWORD.to_string(), + prelogin_response: PasswordPreloginResponse { + kdf: Kdf::PBKDF2 { + iterations: NonZeroU32::new(PBKDF2_ITERATIONS).unwrap(), + }, + salt: TEST_SALT.to_string(), + }, + } + } + + fn make_mock_success_response() -> serde_json::Value { + serde_json::json!({ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "api offline_access", + "refresh_token": "test_refresh_token", + "UserDecryptionOptions": { + "HasMasterPassword": true, + "Object": "userDecryptionOptions" + } + }) + } + + #[tokio::test] + async fn test_login_via_password_success() { + let raw_success = make_mock_success_response(); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/x-www-form-urlencoded", + )) + .and(matchers::header( + reqwest::header::ACCEPT.as_str(), + "application/json", + )) + .and(matchers::header( + reqwest::header::CACHE_CONTROL.as_str(), + "no-store", + )) + .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 request = make_password_login_request(); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_ok()); + let login_response = result.unwrap(); + + match login_response { + LoginResponse::Authenticated(success_response) => { + assert_eq!(success_response.access_token, "test_access_token"); + assert_eq!(success_response.token_type, "Bearer"); + assert_eq!(success_response.expires_in, 3600); + assert_eq!(success_response.scope, "api offline_access"); + assert_eq!( + success_response.refresh_token, + Some("test_refresh_token".to_string()) + ); + } + } + } + + #[tokio::test] + async fn test_login_via_password_invalid_credentials() { + let error_response = serde_json::json!({ + "error": "invalid_grant", + "error_description": "invalid_username_or_password" + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(error_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let request = make_password_login_request(); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + + assert!(matches!( + error, + PasswordLoginError::InvalidUsernameOrPassword + )); + } + + #[tokio::test] + async fn test_login_via_password_with_argon2id_kdf() { + let raw_success = make_mock_success_response(); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .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 mut request = make_password_login_request(); + request.prelogin_response.kdf = Kdf::Argon2id { + iterations: NonZeroU32::new(3).unwrap(), + memory: NonZeroU32::new(64).unwrap(), + parallelism: NonZeroU32::new(4).unwrap(), + }; + + let result = identity_client.login_via_password(request).await; + + assert!(result.is_ok()); + let login_response = result.unwrap(); + + match login_response { + LoginResponse::Authenticated(success_response) => { + assert_eq!(success_response.access_token, "test_access_token"); + } + } + } + + #[tokio::test] + async fn test_login_via_password_invalid_request() { + let error_response = serde_json::json!({ + "error": "invalid_request", + "error_description": "Missing required parameter" + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(error_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let request = make_password_login_request(); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + + match error { + PasswordLoginError::Unknown(msg) => { + assert!(msg.contains("Invalid request")); + assert!(msg.contains("Missing required parameter")); + } + _ => panic!("Expected Unknown error variant"), + } + } + + #[tokio::test] + async fn test_login_via_password_invalid_client() { + let error_response = serde_json::json!({ + "error": "invalid_client", + "error_description": "Client authentication failed" + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(error_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let request = make_password_login_request(); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + + match error { + PasswordLoginError::Unknown(msg) => { + assert!(msg.contains("Invalid client")); + assert!(msg.contains("Client authentication failed")); + } + _ => panic!("Expected Unknown error variant"), + } + } + + #[tokio::test] + async fn test_login_via_password_unexpected_error() { + let error_response = serde_json::json!({ + "unexpected_field": "unexpected_value" + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(500).set_body_json(error_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let request = make_password_login_request(); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + + match error { + PasswordLoginError::Unknown(msg) => { + assert!(msg.contains("Unexpected error")); + } + _ => panic!("Expected Unknown error variant"), + } + } + + // TODO: figure out why this is a test? + #[tokio::test] + async fn test_login_via_password_with_device_push_token() { + let raw_success = make_mock_success_response(); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .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 mut request = make_password_login_request(); + request.login_request.device.device_push_token = Some("test_push_token".to_string()); + + let result = identity_client.login_via_password(request).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_login_via_password_with_different_device_types() { + let device_types = [ + DeviceType::Android, + DeviceType::iOS, + DeviceType::ChromeExtension, + DeviceType::FirefoxExtension, + DeviceType::OperaExtension, + DeviceType::EdgeExtension, + DeviceType::WindowsDesktop, + DeviceType::MacOsDesktop, + DeviceType::LinuxDesktop, + DeviceType::ChromeBrowser, + DeviceType::FirefoxBrowser, + DeviceType::OperaBrowser, + DeviceType::EdgeBrowser, + DeviceType::IEBrowser, + DeviceType::UnknownBrowser, + DeviceType::AndroidAmazon, + DeviceType::UWP, + DeviceType::SafariBrowser, + DeviceType::VivaldiBrowser, + DeviceType::VivaldiExtension, + DeviceType::SafariExtension, + DeviceType::SDK, + ]; + + for device_type in device_types { + let raw_success = make_mock_success_response(); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .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 mut request = make_password_login_request(); + request.login_request.device.device_type = device_type; + + let result = identity_client.login_via_password(request).await; + + assert!(result.is_ok(), "Failed for device type: {:?}", device_type); + } + } + + #[tokio::test] + async fn test_login_via_password_verifies_request_body_contents() { + let raw_success = make_mock_success_response(); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .and(matchers::body_string_contains("grant_type")) + .and(matchers::body_string_contains("password")) + .and(matchers::body_string_contains("username")) + .and(matchers::body_string_contains("client_id")) + .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 request = make_password_login_request(); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_ok()); + } +} From 676a70bb11bdb97e62a47cb4d2c9d1c63e652b4b Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 16 Dec 2025 18:24:18 -0500 Subject: [PATCH 65/66] PM-14922 - (1) Convert identity to login client (2) try to figure out building out own API client and adapting to existing methods --- Cargo.lock | 2 + crates/bitwarden-auth/Cargo.toml | 5 + crates/bitwarden-auth/src/auth_client.rs | 6 +- .../src/identity/api/send_login_request.rs | 15 +- .../src/identity/identity_client.rs | 33 ---- .../src/identity/login_client.rs | 163 ++++++++++++++++++ .../login_via_password/login_via_password.rs | 15 +- .../login_via_password/password_prelogin.rs | 26 ++- crates/bitwarden-auth/src/identity/mod.rs | 8 +- .../src/identity/models/login_request.rs | 8 +- .../src/client/client_settings.rs | 4 +- crates/bitwarden-core/src/client/mod.rs | 2 +- crates/bitwarden-core/src/lib.rs | 2 +- 13 files changed, 209 insertions(+), 80 deletions(-) delete mode 100644 crates/bitwarden-auth/src/identity/identity_client.rs create mode 100644 crates/bitwarden-auth/src/identity/login_client.rs diff --git a/Cargo.lock b/Cargo.lock index ef460f769..c544dffd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,6 +515,8 @@ dependencies = [ "bitwarden-test", "chrono", "reqwest", + "rustls", + "rustls-platform-verifier", "schemars 1.0.0", "serde", "serde_json", diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 7e95743db..7a6e64787 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -47,6 +47,11 @@ uniffi = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } +[target.'cfg(not(target_arch="wasm32"))'.dependencies] +# TLS stack for HTTP client - WASM uses browser's fetch API instead +rustls = { version = "0.23.19", default-features = false } +rustls-platform-verifier = "0.6.0" + [dev-dependencies] bitwarden-test = { workspace = true } tokio = { workspace = true, features = ["rt"] } diff --git a/crates/bitwarden-auth/src/auth_client.rs b/crates/bitwarden-auth/src/auth_client.rs index c6f09c57c..34834afbc 100644 --- a/crates/bitwarden-auth/src/auth_client.rs +++ b/crates/bitwarden-auth/src/auth_client.rs @@ -3,7 +3,7 @@ use bitwarden_core::Client; use wasm_bindgen::prelude::*; use crate::{ - identity::IdentityClient, registration::RegistrationClient, send_access::SendAccessClient, + identity::LoginClient, registration::RegistrationClient, send_access::SendAccessClient, }; /// Subclient containing auth functionality. @@ -26,8 +26,8 @@ impl AuthClient { #[cfg_attr(feature = "wasm", wasm_bindgen)] impl AuthClient { /// Client for identity functionality - pub fn identity(&self) -> IdentityClient { - IdentityClient::new(self.client.clone()) + pub fn login(&self, client_settings: bitwarden_core::ClientSettings) -> LoginClient { + LoginClient::new(client_settings) } /// Client for send access functionality 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 5c68641d7..e56b28490 100644 --- a/crates/bitwarden-auth/src/identity/api/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -1,7 +1,3 @@ -// 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::{ @@ -13,16 +9,15 @@ use crate::identity::{ models::{LoginResponse, LoginSuccessResponse}, }; +// TODO: should this be on the LoginClient struct instead of being a standalone function? /// A common function to send login requests to the Identity connect/token endpoint. /// Returns a common success model which has already been converted from the API response, /// or a common error model representing the login error which allows for conversion to specific error types /// based on the login method used. pub(crate) async fn send_login_request( - api_configs: &ApiConfigurations, + identity_config: &bitwarden_api_identity::apis::configuration::Configuration, api_request: &LoginApiRequest, ) -> Result { - let identity_config = &api_configs.identity_config; - let url: String = format!("{}/connect/token", &identity_config.base_path); let device_type_header: LoginRequestHeader = @@ -37,14 +32,16 @@ pub(crate) async fn send_login_request( device_type_header.header_name(), device_type_header.header_value(), ) - // TODO: investigate if this is only needed for GET requests // 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 + // we include no-cache headers to prevent browser caching sensistive token requests / responses. .header(reqwest::header::CACHE_CONTROL, "no-store") + .header(reqwest::header::PRAGMA, "no-cache") // TODO: investigate missing headers from api.service implementation like: + // .header("Bitwarden-Client-Name") // Bitwarden-Client-Name // Bitwarden-Client-Version // Bitwarden-Package-Type + // User-Agent // TODO: investigate if I have to worry about credentials here // use form to encode as application/x-www-form-urlencoded .form(&api_request); diff --git a/crates/bitwarden-auth/src/identity/identity_client.rs b/crates/bitwarden-auth/src/identity/identity_client.rs deleted file mode 100644 index b2af77295..000000000 --- a/crates/bitwarden-auth/src/identity/identity_client.rs +++ /dev/null @@ -1,33 +0,0 @@ -use bitwarden_core::Client; -#[cfg(feature = "wasm")] -use wasm_bindgen::prelude::*; - -/// The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. -#[derive(Clone)] -#[cfg_attr(feature = "wasm", wasm_bindgen)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] -pub struct IdentityClient { - pub(crate) client: Client, -} - -impl IdentityClient { - /// Create a new IdentityClient with the given Client. - pub(crate) fn new(client: Client) -> Self { - Self { client } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_identity_client_creation() { - let client: Client = Client::new(None); - let identity_client = IdentityClient::new(client); - - // Verify the identity client was created successfully - // The client field is present and accessible - let _ = identity_client.client; - } -} diff --git a/crates/bitwarden-auth/src/identity/login_client.rs b/crates/bitwarden-auth/src/identity/login_client.rs new file mode 100644 index 000000000..02a2305ac --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_client.rs @@ -0,0 +1,163 @@ +use std::sync::Arc; + +use bitwarden_core::{ClientName, ClientSettings}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +// TODO: consider initializing the Identity Client with the device and and client information directly and save on the client itself +// Doesn't make sense to pass core client to identity. Identity client should be more standalone and it should instantiate +// the core client in the future. + +// TODO: rename this to LoginClient +// TODO: re-use ClientSettings from core crate + +/// The LoginClient is used to obtain identity / access tokens from the Bitwarden Identity API. +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct LoginClient { + pub(crate) identity_api_client: bitwarden_api_identity::apis::ApiClient, + // TODO: we have to save this since the ApiClient doesn't expose its configuration publicly + pub(crate) identity_config: bitwarden_api_identity::apis::configuration::Configuration, +} + +impl LoginClient { + /// Create a new LoginClient with the given client settings + pub(crate) fn new(settings: ClientSettings) -> Self { + // Build headers for the HTTP client + let mut headers = reqwest::header::HeaderMap::new(); + headers.append( + "Device-Type", + reqwest::header::HeaderValue::from_str(&(settings.device_type as u8).to_string()) + .expect("All numbers are valid ASCII"), + ); + + if let Some(client_type) = Into::>::into(settings.device_type) { + headers.append( + "Bitwarden-Client-Name", + reqwest::header::HeaderValue::from_str(&client_type.to_string()) + .expect("All ASCII strings are valid header values"), + ); + } + + if let Some(version) = &settings.bitwarden_client_version { + headers.append( + "Bitwarden-Client-Version", + reqwest::header::HeaderValue::from_str(version) + .expect("Version should be a valid header value"), + ); + } + + let http_client_builder = Self::new_http_client_builder().default_headers(headers); + let http_client = http_client_builder + .build() + .expect("Failed to build HTTP client"); + + // Create identity API configuration + let identity_config = bitwarden_api_identity::apis::configuration::Configuration { + base_path: settings.identity_url, + user_agent: Some(settings.user_agent), + client: http_client, + basic_auth: None, + oauth_access_token: None, + bearer_access_token: None, + api_key: None, + }; + + // Arc required for ApiClient's thread-safe configuration sharing + let identity_config_arc: Arc = + Arc::new(identity_config.clone()); + let identity_api_client = + bitwarden_api_identity::apis::ApiClient::new(&identity_config_arc); + + Self { + identity_api_client, + identity_config, + } + } + + /// Create an HTTP client builder with proper TLS configuration + fn new_http_client_builder() -> reqwest::ClientBuilder { + #[allow(unused_mut)] + let mut http_client_builder = reqwest::Client::builder(); + + // TLS configuration for non-wasm targets + #[cfg(not(target_arch = "wasm32"))] + { + use rustls::ClientConfig; + use rustls_platform_verifier::ConfigVerifierExt; + http_client_builder = http_client_builder.use_preconfigured_tls( + ClientConfig::with_platform_verifier().expect("Failed to create platform verifier"), + ); + + // Enforce HTTPS for all requests in non-debug builds + #[cfg(not(debug_assertions))] + { + http_client_builder = http_client_builder.https_only(true); + } + } + + http_client_builder + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_login_client_creation() { + let client_settings: ClientSettings = ClientSettings::default(); + let login_client = LoginClient::new(client_settings.clone()); + + // Verify identity_config fields + assert_eq!( + login_client.identity_config.base_path, + "https://identity.bitwarden.com" + ); + assert!(login_client.identity_config.user_agent.is_some()); + assert_eq!( + login_client.identity_config.user_agent.unwrap(), + client_settings.user_agent + ); + + // Verify optional auth fields are None (initially) + assert!(login_client.identity_config.basic_auth.is_none()); + assert!(login_client.identity_config.oauth_access_token.is_none()); + assert!(login_client.identity_config.bearer_access_token.is_none()); + assert!(login_client.identity_config.api_key.is_none()); + + // Verify the API client exists (type check) + let _api_client = &login_client.identity_api_client; + // The fact that this compiles and doesn't panic means it was created + } + + #[test] + fn test_login_client_with_custom_settings() { + use bitwarden_core::DeviceType; + + let client_settings = ClientSettings { + identity_url: "https://custom.identity.com".to_string(), + api_url: "https://custom.api.com".to_string(), + user_agent: "TestAgent/1.0.0".to_string(), + device_type: DeviceType::SDK, + bitwarden_client_version: Some("1.2.3".to_string()), + }; + + let login_client = LoginClient::new(client_settings.clone()); + + assert_eq!( + login_client.identity_config.base_path, + "https://custom.identity.com" + ); + assert_eq!( + login_client.identity_config.user_agent, + Some("TestAgent/1.0.0".to_string()) + ); + + // Verify optional auth fields are None (initially) + assert!(login_client.identity_config.basic_auth.is_none()); + assert!(login_client.identity_config.oauth_access_token.is_none()); + assert!(login_client.identity_config.bearer_access_token.is_none()); + assert!(login_client.identity_config.api_key.is_none()); + } +} 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 c47454fdd..9defc8dab 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 wasm_bindgen::prelude::wasm_bindgen; use crate::identity::{ - IdentityClient, + LoginClient, api::{request::LoginApiRequest, send_login_request}, login_via_password::{PasswordLoginApiRequest, PasswordLoginError, PasswordLoginRequest}, models::LoginResponse, @@ -10,7 +10,7 @@ use crate::identity::{ #[cfg_attr(feature = "wasm", wasm_bindgen)] #[cfg_attr(feature = "uniffi", uniffi::export)] -impl IdentityClient { +impl LoginClient { /// Logs in a user via their email and master password. /// /// This function derives the necessary master password authentication data @@ -35,9 +35,7 @@ impl IdentityClient { (request, master_password_authentication.unwrap()).into(); // make API call to login endpoint with api_request - let api_configs = self.client.internal.get_api_configurations().await; - - let response = send_login_request(&api_configs, &api_request).await; + let response = send_login_request(&self.identity_config, &api_request).await; response.map_err(Into::into) } @@ -48,7 +46,7 @@ impl IdentityClient { mod tests { use std::num::NonZeroU32; - use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType}; + use bitwarden_core::{ClientSettings, DeviceType}; use bitwarden_crypto::Kdf; use bitwarden_test::start_api_mock; use wiremock::{Mock, ResponseTemplate, matchers}; @@ -67,7 +65,7 @@ mod tests { const TEST_DEVICE_NAME: &str = "Test Device"; const PBKDF2_ITERATIONS: u32 = 600000; - fn make_identity_client(mock_server: &wiremock::MockServer) -> IdentityClient { + fn make_identity_client(mock_server: &wiremock::MockServer) -> LoginClient { let settings = ClientSettings { identity_url: format!("http://{}/identity", mock_server.address()), api_url: format!("http://{}/api", mock_server.address()), @@ -75,8 +73,7 @@ mod tests { device_type: DeviceType::SDK, bitwarden_client_version: None, }; - let core_client = CoreClient::new(Some(settings)); - IdentityClient::new(core_client) + LoginClient::new(settings) } fn make_password_login_request() -> PasswordLoginRequest { 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 8d369da9a..ffdb4f256 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 @@ -3,7 +3,7 @@ use bitwarden_core::{ApiError, MissingFieldError}; use bitwarden_error::bitwarden_error; use thiserror::Error; -use crate::identity::{IdentityClient, login_via_password::PasswordPreloginResponse}; +use crate::identity::{LoginClient, login_via_password::PasswordPreloginResponse}; use wasm_bindgen::prelude::wasm_bindgen; /// Error type for password prelogin operations @@ -19,7 +19,7 @@ pub enum PasswordPreloginError { #[cfg_attr(feature = "wasm", wasm_bindgen)] #[cfg_attr(feature = "uniffi", uniffi::export)] -impl IdentityClient { +impl LoginClient { /// Retrieves the data required before authenticating with a password. /// This includes the user's KDF configuration needed to properly derive the master key. /// @@ -33,9 +33,8 @@ impl IdentityClient { email: String, ) -> Result { let request_model = PasswordPreloginRequestModel::new(email); - let config = self.client.internal.get_api_configurations().await; - let response = config - .identity_client + let response = self + .identity_api_client .accounts_api() .post_password_prelogin(Some(request_model)) .await @@ -48,7 +47,7 @@ impl IdentityClient { #[cfg(test)] mod tests { use bitwarden_api_identity::models::KdfType; - use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType}; + use bitwarden_core::{ClientSettings, DeviceType}; use bitwarden_crypto::Kdf; use bitwarden_test::start_api_mock; use wiremock::{Mock, ResponseTemplate, matchers}; @@ -63,7 +62,7 @@ mod tests { const ARGON2_MEMORY: u32 = 64; const ARGON2_PARALLELISM: u32 = 4; - fn make_identity_client(mock_server: &wiremock::MockServer) -> IdentityClient { + fn make_login_client(mock_server: &wiremock::MockServer) -> LoginClient { let settings = ClientSettings { identity_url: format!("http://{}/identity", mock_server.address()), api_url: format!("http://{}/api", mock_server.address()), @@ -71,8 +70,7 @@ mod tests { device_type: DeviceType::SDK, bitwarden_client_version: None, }; - let core_client = CoreClient::new(Some(settings)); - IdentityClient::new(core_client) + LoginClient::new(settings) } #[tokio::test] @@ -95,7 +93,7 @@ mod tests { .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 identity_client = make_login_client(&mock_server); let result = identity_client .get_password_prelogin(TEST_EMAIL.to_string()) @@ -133,7 +131,7 @@ mod tests { .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 identity_client = make_login_client(&mock_server); let result = identity_client .get_password_prelogin(TEST_EMAIL.to_string()) @@ -167,7 +165,7 @@ mod tests { .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 identity_client = make_login_client(&mock_server); let result = identity_client .get_password_prelogin(TEST_EMAIL.to_string()) @@ -197,7 +195,7 @@ mod tests { .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 identity_client = make_login_client(&mock_server); let result = identity_client .get_password_prelogin(TEST_EMAIL.to_string()) @@ -220,7 +218,7 @@ mod tests { .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 identity_client = make_login_client(&mock_server); let result = identity_client .get_password_prelogin(TEST_EMAIL.to_string()) diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index 2ddd981e7..85930ddbb 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -1,10 +1,10 @@ -//! Identity client module -//! The IdentityClient is used to authenticate a Bitwarden User. +//! Login client module +//! The LoginClient 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; +mod login_client; -pub use identity_client::IdentityClient; +pub use login_client::LoginClient; /// Models used by the identity module pub mod models; diff --git a/crates/bitwarden-auth/src/identity/models/login_request.rs b/crates/bitwarden-auth/src/identity/models/login_request.rs index 5535c98a4..24c35ab2b 100644 --- a/crates/bitwarden-auth/src/identity/models/login_request.rs +++ b/crates/bitwarden-auth/src/identity/models/login_request.rs @@ -18,8 +18,8 @@ pub struct LoginRequest { 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, + pub device: LoginDeviceRequest, // TODO: move this to identity client + // TODO: add two factor support + // Two-factor authentication + // pub two_factor: Option, } diff --git a/crates/bitwarden-core/src/client/client_settings.rs b/crates/bitwarden-core/src/client/client_settings.rs index f42ecba4d..e1523848f 100644 --- a/crates/bitwarden-core/src/client/client_settings.rs +++ b/crates/bitwarden-core/src/client/client_settings.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; /// }; /// let default = ClientSettings::default(); /// ``` -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr( @@ -91,7 +91,7 @@ pub enum DeviceType { } #[derive(Copy, Clone, Debug)] -pub(crate) enum ClientName { +pub enum ClientName { Web, Browser, Desktop, diff --git a/crates/bitwarden-core/src/client/mod.rs b/crates/bitwarden-core/src/client/mod.rs index a5f81caa8..1ec4e7aef 100644 --- a/crates/bitwarden-core/src/client/mod.rs +++ b/crates/bitwarden-core/src/client/mod.rs @@ -18,7 +18,7 @@ pub(crate) use login_method::{LoginMethod, UserLoginMethod}; mod flags; pub use client::Client; -pub use client_settings::{ClientSettings, DeviceType}; +pub use client_settings::{ClientName, ClientSettings, DeviceType}; #[allow(missing_docs)] #[cfg(feature = "internal")] diff --git a/crates/bitwarden-core/src/lib.rs b/crates/bitwarden-core/src/lib.rs index 5c7527788..de60cee47 100644 --- a/crates/bitwarden-core/src/lib.rs +++ b/crates/bitwarden-core/src/lib.rs @@ -20,7 +20,7 @@ pub mod platform; pub mod secrets_manager; pub use bitwarden_crypto::ZeroizingAllocator; -pub use client::{Client, ClientSettings, DeviceType}; +pub use client::{Client, ClientName, ClientSettings, DeviceType}; mod ids; pub use ids::*; From 2b356e49d0e599cb31a9e346846bef9d6cc78d9f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 18 Dec 2025 17:29:53 -0500 Subject: [PATCH 66/66] PM-14922 - Go back to having a core client in the login client per discussion with Dani --- .../src/identity/api/send_login_request.rs | 9 +- .../src/identity/login_client.rs | 147 ++---------------- .../login_via_password/login_via_password.rs | 4 +- .../login_via_password/password_prelogin.rs | 5 +- 4 files changed, 20 insertions(+), 145 deletions(-) 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 e56b28490..ba474f0f0 100644 --- a/crates/bitwarden-auth/src/identity/api/send_login_request.rs +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -27,6 +27,8 @@ pub(crate) async fn send_login_request( .client .post(url) .header(reqwest::header::ACCEPT, "application/json") + // TODO: this will be a default header with latest work in core client + // so it can be removed. // Add custom device type header .header( device_type_header.header_name(), @@ -36,13 +38,8 @@ pub(crate) async fn send_login_request( // we include no-cache headers to prevent browser caching sensistive token requests / responses. .header(reqwest::header::CACHE_CONTROL, "no-store") .header(reqwest::header::PRAGMA, "no-cache") - // TODO: investigate missing headers from api.service implementation like: - // .header("Bitwarden-Client-Name") - // Bitwarden-Client-Name - // Bitwarden-Client-Version - // Bitwarden-Package-Type - // User-Agent // TODO: investigate if I have to worry about credentials here + // Nope: need to solve in core client. // use form to encode as application/x-www-form-urlencoded .form(&api_request); diff --git a/crates/bitwarden-auth/src/identity/login_client.rs b/crates/bitwarden-auth/src/identity/login_client.rs index 02a2305ac..a4b38516f 100644 --- a/crates/bitwarden-auth/src/identity/login_client.rs +++ b/crates/bitwarden-auth/src/identity/login_client.rs @@ -1,102 +1,24 @@ -use std::sync::Arc; - -use bitwarden_core::{ClientName, ClientSettings}; +use bitwarden_core::{Client, ClientSettings}; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; -// TODO: consider initializing the Identity Client with the device and and client information directly and save on the client itself -// Doesn't make sense to pass core client to identity. Identity client should be more standalone and it should instantiate -// the core client in the future. - -// TODO: rename this to LoginClient -// TODO: re-use ClientSettings from core crate - /// The LoginClient is used to obtain identity / access tokens from the Bitwarden Identity API. #[cfg_attr(feature = "wasm", wasm_bindgen)] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct LoginClient { - pub(crate) identity_api_client: bitwarden_api_identity::apis::ApiClient, - // TODO: we have to save this since the ApiClient doesn't expose its configuration publicly - pub(crate) identity_config: bitwarden_api_identity::apis::configuration::Configuration, + pub(crate) client: Client, } impl LoginClient { - /// Create a new LoginClient with the given client settings + /// Create a new LoginClient with the given core client settings pub(crate) fn new(settings: ClientSettings) -> Self { - // Build headers for the HTTP client - let mut headers = reqwest::header::HeaderMap::new(); - headers.append( - "Device-Type", - reqwest::header::HeaderValue::from_str(&(settings.device_type as u8).to_string()) - .expect("All numbers are valid ASCII"), - ); - - if let Some(client_type) = Into::>::into(settings.device_type) { - headers.append( - "Bitwarden-Client-Name", - reqwest::header::HeaderValue::from_str(&client_type.to_string()) - .expect("All ASCII strings are valid header values"), - ); - } - - if let Some(version) = &settings.bitwarden_client_version { - headers.append( - "Bitwarden-Client-Version", - reqwest::header::HeaderValue::from_str(version) - .expect("Version should be a valid header value"), - ); - } - - let http_client_builder = Self::new_http_client_builder().default_headers(headers); - let http_client = http_client_builder - .build() - .expect("Failed to build HTTP client"); - - // Create identity API configuration - let identity_config = bitwarden_api_identity::apis::configuration::Configuration { - base_path: settings.identity_url, - user_agent: Some(settings.user_agent), - client: http_client, - basic_auth: None, - oauth_access_token: None, - bearer_access_token: None, - api_key: None, - }; + // build new client from client settings - // Arc required for ApiClient's thread-safe configuration sharing - let identity_config_arc: Arc = - Arc::new(identity_config.clone()); - let identity_api_client = - bitwarden_api_identity::apis::ApiClient::new(&identity_config_arc); + let core_client = Client::new(Some(settings.clone())); Self { - identity_api_client, - identity_config, - } - } - - /// Create an HTTP client builder with proper TLS configuration - fn new_http_client_builder() -> reqwest::ClientBuilder { - #[allow(unused_mut)] - let mut http_client_builder = reqwest::Client::builder(); - - // TLS configuration for non-wasm targets - #[cfg(not(target_arch = "wasm32"))] - { - use rustls::ClientConfig; - use rustls_platform_verifier::ConfigVerifierExt; - http_client_builder = http_client_builder.use_preconfigured_tls( - ClientConfig::with_platform_verifier().expect("Failed to create platform verifier"), - ); - - // Enforce HTTPS for all requests in non-debug builds - #[cfg(not(debug_assertions))] - { - http_client_builder = http_client_builder.https_only(true); - } + client: core_client, } - - http_client_builder } } @@ -106,58 +28,11 @@ mod tests { #[test] fn test_login_client_creation() { - let client_settings: ClientSettings = ClientSettings::default(); - let login_client = LoginClient::new(client_settings.clone()); - - // Verify identity_config fields - assert_eq!( - login_client.identity_config.base_path, - "https://identity.bitwarden.com" - ); - assert!(login_client.identity_config.user_agent.is_some()); - assert_eq!( - login_client.identity_config.user_agent.unwrap(), - client_settings.user_agent - ); - - // Verify optional auth fields are None (initially) - assert!(login_client.identity_config.basic_auth.is_none()); - assert!(login_client.identity_config.oauth_access_token.is_none()); - assert!(login_client.identity_config.bearer_access_token.is_none()); - assert!(login_client.identity_config.api_key.is_none()); - - // Verify the API client exists (type check) - let _api_client = &login_client.identity_api_client; - // The fact that this compiles and doesn't panic means it was created - } - - #[test] - fn test_login_client_with_custom_settings() { - use bitwarden_core::DeviceType; - - let client_settings = ClientSettings { - identity_url: "https://custom.identity.com".to_string(), - api_url: "https://custom.api.com".to_string(), - user_agent: "TestAgent/1.0.0".to_string(), - device_type: DeviceType::SDK, - bitwarden_client_version: Some("1.2.3".to_string()), - }; - - let login_client = LoginClient::new(client_settings.clone()); - - assert_eq!( - login_client.identity_config.base_path, - "https://custom.identity.com" - ); - assert_eq!( - login_client.identity_config.user_agent, - Some("TestAgent/1.0.0".to_string()) - ); + let client_settings = ClientSettings::default(); + let login_client = LoginClient::new(client_settings); - // Verify optional auth fields are None (initially) - assert!(login_client.identity_config.basic_auth.is_none()); - assert!(login_client.identity_config.oauth_access_token.is_none()); - assert!(login_client.identity_config.bearer_access_token.is_none()); - assert!(login_client.identity_config.api_key.is_none()); + // Verify the internal client exists (type check) + let _client = &login_client.client; + // The fact that this compiles and doesn't panic means the client was created successfully } } 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 9defc8dab..b6d123354 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 @@ -34,8 +34,10 @@ impl LoginClient { let api_request: LoginApiRequest = (request, master_password_authentication.unwrap()).into(); + let api_configs = self.client.internal.get_api_configurations().await; + // make API call to login endpoint with api_request - let response = send_login_request(&self.identity_config, &api_request).await; + let response = send_login_request(&api_configs.identity_config, &api_request).await; response.map_err(Into::into) } 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 ffdb4f256..963a7f599 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 @@ -33,8 +33,9 @@ impl LoginClient { email: String, ) -> Result { let request_model = PasswordPreloginRequestModel::new(email); - let response = self - .identity_api_client + let api_configs = self.client.internal.get_api_configurations().await; + let response = api_configs + .identity_client .accounts_api() .post_password_prelogin(Some(request_model)) .await