diff --git a/Cargo.lock b/Cargo.lock index 1a95e2cae..30cd0546b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,16 +527,22 @@ name = "bitwarden-auth" version = "2.0.0" dependencies = [ "bitwarden-api-api", + "bitwarden-api-identity", "bitwarden-core", "bitwarden-crypto", "bitwarden-encoding", "bitwarden-error", + "bitwarden-policies", "bitwarden-test", "chrono", "reqwest", + "rustls", + "rustls-platform-verifier", "serde", "serde_bytes", "serde_json", + "serde_repr", + "serde_urlencoded", "thiserror 2.0.12", "tokio", "tracing", @@ -836,7 +842,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 acf4d9440..2bc08126e 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -18,23 +18,31 @@ keywords.workspace = true wasm = [ "bitwarden-core/wasm", "bitwarden-crypto/wasm", + "bitwarden-policies/wasm", "dep:tsify", "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] bitwarden-api-api = { workspace = true } +bitwarden-api-identity = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-crypto = { workspace = true } bitwarden-encoding = { workspace = true } bitwarden-error = { workspace = true } +bitwarden-policies = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_bytes = { workspace = true } +serde_repr = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tsify = { workspace = true, optional = true } @@ -43,10 +51,16 @@ uuid = { workspace = 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-api-api = { workspace = true, features = ["mockall"] } bitwarden-test = { workspace = true } serde_json = { workspace = true } +serde_urlencoded = ">=0.7.1, <0.8" tokio = { workspace = true, features = ["rt"] } wiremock = "0.6.0" diff --git a/crates/bitwarden-auth/README.md b/crates/bitwarden-auth/README.md index 3656d696f..2ca0578f4 100644 --- a/crates/bitwarden-auth/README.md +++ b/crates/bitwarden-auth/README.md @@ -5,3 +5,66 @@ Contains the implementation of the auth functionality for the Bitwarden Password ## Send Access - Manages obtaining send access tokens for accessing secured send endpoints. + +## Identity / Login + +**LoginClient**: Authenticates Bitwarden users to obtain access tokens. + +### Available Login Methods + +- **Password**: Email and master password authentication (2FA not yet supported) + - See + [`login_via_password`](https://docs.rs/bitwarden-auth/latest/bitwarden_auth/identity/login_via_password/index.html) + module for details and examples +- **Future**: SSO, device-based, etc. + +### Quick Example + +```rust,no_run +# use bitwarden_auth::{AuthClient, identity::login_via_password::PasswordLoginRequest}; +# use bitwarden_auth::identity::models::{LoginRequest, LoginDeviceRequest, LoginResponse}; +# use bitwarden_core::{Client, ClientSettings, DeviceType}; +# async fn example(email: String, password: String) -> Result<(), Box> { +# let client = Client::new(None); +# let auth_client = AuthClient::new(client); +# let settings = ClientSettings { +# identity_url: "https://identity.bitwarden.com".to_string(), +# api_url: "https://api.bitwarden.com".to_string(), +# user_agent: "MyApp/1.0".to_string(), +# device_type: DeviceType::SDK, +# device_identifier: None, +# bitwarden_client_version: None, +# bitwarden_package_type: None, +# }; +# let login_client = auth_client.login(settings); +// 1. Get user's KDF config +let prelogin = login_client.get_password_prelogin(email.clone()).await?; + +// 2. Login with credentials +let response = login_client.login_via_password(PasswordLoginRequest { + login_request: LoginRequest { + client_id: "connector".to_string(), + device: LoginDeviceRequest { + device_type: DeviceType::SDK, + device_identifier: "device-id".to_string(), + device_name: "My Device".to_string(), + device_push_token: None, + }, + }, + email, + password, + prelogin_response: prelogin, +}).await?; + +// 3. Use tokens from response for authenticated requests +match response { + LoginResponse::Authenticated(success) => { + let access_token = success.access_token; + // Use access_token for authenticated requests + } +} +# Ok(()) +# } +``` + +See module documentation for complete examples and security details. diff --git a/crates/bitwarden-auth/src/api/enums/grant_type.rs b/crates/bitwarden-auth/src/api/enums/grant_type.rs index 757a21cdd..9d05b87c9 100644 --- a/crates/bitwarden-auth/src/api/enums/grant_type.rs +++ b/crates/bitwarden-auth/src/api/enums/grant_type.rs @@ -5,11 +5,12 @@ use serde::{Deserialize, Serialize}; /// as defined in [RFC 6749, Section 4](https://datatracker.ietf.org/doc/html/rfc6749#section-4) /// or by custom Bitwarden extensions. The value is sent in the `grant_type` parameter /// of a token request. -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub(crate) enum GrantType { /// A custom extension grant type for requesting send access tokens outside the context of a /// Bitwarden user. SendAccess, // TODO: Add other grant types as needed. + Password, } diff --git a/crates/bitwarden-auth/src/api/enums/mod.rs b/crates/bitwarden-auth/src/api/enums/mod.rs index 48bc05872..97a1eb683 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 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..8d7a9a0b8 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 as a Bitwarden user. + #[serde(rename = "api")] + Api, + /// 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. #[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/api/enums/two_factor_provider.rs b/crates/bitwarden-auth/src/api/enums/two_factor_provider.rs new file mode 100644 index 000000000..10d27b3da --- /dev/null +++ b/crates/bitwarden-auth/src/api/enums/two_factor_provider.rs @@ -0,0 +1,18 @@ +use serde_repr::{Deserialize_repr, Serialize_repr}; + +// TODO: This likely won't be limited to just API usage so consider moving to a more general +// location when implementing 2FA support + +/// Represents the two-factor authentication providers supported by Bitwarden. +#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Clone)] +#[repr(u8)] +pub enum TwoFactorProvider { + Authenticator = 0, + Email = 1, + Duo = 2, + Yubikey = 3, + U2f = 4, + Remember = 5, + OrganizationDuo = 6, + WebAuthn = 7, +} 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/auth_client.rs b/crates/bitwarden-auth/src/auth_client.rs index c6f09c57c..990a6aac4 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. @@ -25,9 +25,12 @@ impl AuthClient { #[cfg_attr(feature = "wasm", wasm_bindgen)] impl AuthClient { + // TODO: in a future PR, we need to figure out a consistent mechanism for CoreClient + // vs ClientSettings instantiation across all subclients. + /// 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/mod.rs b/crates/bitwarden-auth/src/identity/api/mod.rs new file mode 100644 index 000000000..5e79f0b37 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/mod.rs @@ -0,0 +1,11 @@ +//! This module contains request/response models used internally when communicating +//! with the Bitwarden Identity API. These are implementation details and should not +//! be exposed in the public SDK surface. + +/// API related modules for Identity endpoints +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/request/login_api_request.rs b/crates/bitwarden-auth/src/identity/api/request/login_api_request.rs new file mode 100644 index 000000000..b234da8df --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/request/login_api_request.rs @@ -0,0 +1,354 @@ +use std::fmt::Debug; + +use bitwarden_core::DeviceType; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +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. +#[derive(Serialize, Deserialize, Debug)] +#[serde(bound = "T: Serialize + DeserializeOwned + Debug")] // Ensure T meets trait bounds +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. + pub client_id: String, + + /// The grant type for the token request. + /// Note: snake_case is intentional to match the API expectations. + pub grant_type: GrantType, + + /// 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")] + pub device_type: DeviceType, + + /// The identifier of the device. + #[serde(rename = "deviceIdentifier")] + pub device_identifier: String, + + /// The name of the device. + #[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")] + pub two_factor_token: Option, + + /// The two-factor authentication provider. + #[serde(rename = "twoFactorProvider")] + pub two_factor_provider: Option, + + /// Whether to remember two-factor authentication on this device. + #[serde(rename = "twoFactorRemember")] + pub two_factor_remember: Option, + + // Specific login mechanism fields will go here (e.g., password, SSO, etc) + #[serde(flatten)] + pub login_mechanism_fields: T, +} + +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( + client_id: String, + grant_type: GrantType, + device_type: DeviceType, + device_identifier: String, + device_name: String, + device_push_token: Option, + login_mechanism_fields: T, + ) -> Self { + Self { + client_id, + grant_type, + scope: scopes_to_string(STANDARD_USER_SCOPES), + device_type, + device_identifier, + device_name, + device_push_token, + two_factor_token: None, + two_factor_provider: None, + two_factor_remember: None, + login_mechanism_fields, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test constants + const TEST_CLIENT_ID: &str = "test-client-id"; + const TEST_DEVICE_IDENTIFIER: &str = "test-device-identifier"; + const TEST_DEVICE_NAME: &str = "Test Device"; + const TEST_DEVICE_PUSH_TOKEN: &str = "test-push-token"; + + // Simple test struct for testing the generic type parameter + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct MockLoginMechanismFields { + username: String, + password: String, + } + + // Another test struct to verify the generic works with different types + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct AlternativeMechanismFields { + token: String, + } + + #[test] + fn test_constructor_creates_proper_defaults() { + let mock_fields = MockLoginMechanismFields { + username: "user@example.com".to_string(), + password: "hashed-password".to_string(), + }; + + let request = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + Some(TEST_DEVICE_PUSH_TOKEN.to_string()), + mock_fields, + ); + + // Verify standard scopes are set correctly + assert_eq!( + request.scope, + scopes_to_string(STANDARD_USER_SCOPES), + "Should use standard user scopes" + ); + assert_eq!(request.scope, "api offline_access"); + + // Verify 2FA fields default to None + assert_eq!(request.two_factor_token, None); + assert_eq!(request.two_factor_provider, None); + assert_eq!(request.two_factor_remember, None); + + // Verify all constructor parameters are set correctly + assert_eq!(request.client_id, TEST_CLIENT_ID); + assert_eq!(request.grant_type, GrantType::Password); + assert_eq!(request.device_type, DeviceType::SDK); + assert_eq!(request.device_identifier, TEST_DEVICE_IDENTIFIER); + assert_eq!(request.device_name, TEST_DEVICE_NAME); + assert_eq!( + request.device_push_token, + Some(TEST_DEVICE_PUSH_TOKEN.to_string()) + ); + } + + #[test] + fn test_constructor_without_device_push_token() { + let mock_fields = MockLoginMechanismFields { + username: "user@example.com".to_string(), + password: "hashed-password".to_string(), + }; + + let request = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + None, // No push token + mock_fields, + ); + + assert_eq!(request.device_push_token, None); + } + + #[test] + fn test_serialization_field_names() { + let mock_fields = MockLoginMechanismFields { + username: "user@example.com".to_string(), + password: "hashed-password".to_string(), + }; + + let request = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + Some(TEST_DEVICE_PUSH_TOKEN.to_string()), + mock_fields, + ); + + let serialized = + serde_urlencoded::to_string(&request).expect("Failed to serialize LoginApiRequest"); + + // Verify OAuth2 standard fields use snake_case + assert!( + serialized.contains("client_id="), + "OAuth2 field should use snake_case" + ); + assert!( + serialized.contains("grant_type="), + "OAuth2 field should use snake_case" + ); + assert!( + serialized.contains("scope="), + "OAuth2 field should use snake_case" + ); + + // Verify Bitwarden custom fields use camelCase + assert!( + serialized.contains("deviceType="), + "Custom field should use camelCase" + ); + assert!( + serialized.contains("deviceIdentifier="), + "Custom field should use camelCase" + ); + assert!( + serialized.contains("deviceName="), + "Custom field should use camelCase" + ); + assert!( + serialized.contains("devicePushToken="), + "Custom field should use camelCase" + ); + + // Verify 2FA fields use camelCase + // Note: These are None, so they won't appear in the serialization + // But we can verify they would use camelCase by checking field omission + assert!( + !serialized.contains("two_factor_token"), + "2FA fields should use camelCase, not snake_case" + ); + assert!( + !serialized.contains("twoFactorToken"), + "2FA fields should be omitted when None" + ); + + // Verify flattened login mechanism fields are present + assert!( + serialized.contains("username="), + "Flattened fields should be included" + ); + assert!( + serialized.contains("password="), + "Flattened fields should be included" + ); + } + + #[test] + fn test_generic_type_parameter_with_different_types() { + // Test with MockLoginMechanismFields + let mock_fields = MockLoginMechanismFields { + username: "user@example.com".to_string(), + password: "password-hash".to_string(), + }; + + let request1 = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + None, + mock_fields, + ); + + assert_eq!(request1.login_mechanism_fields.username, "user@example.com"); + assert_eq!(request1.login_mechanism_fields.password, "password-hash"); + + // Test with AlternativeMechanismFields + let alternative_fields = AlternativeMechanismFields { + token: "some-token".to_string(), + }; + + let request2 = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + None, + alternative_fields, + ); + + assert_eq!(request2.login_mechanism_fields.token, "some-token"); + } + + #[test] + fn test_serialization_with_2fa_fields() { + let mock_fields = MockLoginMechanismFields { + username: "user@example.com".to_string(), + password: "hashed-password".to_string(), + }; + + let mut request = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + None, + mock_fields, + ); + + // Manually set 2FA fields to verify they serialize correctly + request.two_factor_token = Some("2fa-token".to_string()); + request.two_factor_provider = Some(TwoFactorProvider::Authenticator); + request.two_factor_remember = Some(true); + + let serialized = + serde_urlencoded::to_string(&request).expect("Failed to serialize LoginApiRequest"); + + // Verify 2FA fields are present and use camelCase + assert!( + serialized.contains("twoFactorToken=2fa-token"), + "2FA token should be serialized with camelCase" + ); + assert!( + serialized.contains("twoFactorProvider="), + "2FA provider should be serialized with camelCase" + ); + assert!( + serialized.contains("twoFactorRemember=true"), + "2FA remember should be serialized with camelCase" + ); + } + + #[test] + fn test_scope_can_be_overridden() { + let mock_fields = MockLoginMechanismFields { + username: "user@example.com".to_string(), + password: "hashed-password".to_string(), + }; + + let mut request = LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_IDENTIFIER.to_string(), + TEST_DEVICE_NAME.to_string(), + None, + mock_fields, + ); + + // Verify default scope + assert_eq!(request.scope, "api offline_access"); + + // Override scope for a custom auth flow + request.scope = "custom_scope".to_string(); + assert_eq!(request.scope, "custom_scope"); + } +} 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..c78a678a0 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/request/mod.rs @@ -0,0 +1,9 @@ +//! 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 +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. +mod login_api_request; +// STANDARD_USER_SCOPES is used in tests in password_login_api_request.rs +#[allow(unused_imports)] +pub(crate) use login_api_request::{LoginApiRequest, STANDARD_USER_SCOPES}; 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_error_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs new file mode 100644 index 000000000..23bf3ff21 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs @@ -0,0 +1,500 @@ +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, +} + +// 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" +// } +// } + +// 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. + /// 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. +/// +#[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 { + // 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)] + /// 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)] + /// The optional error description for invalid client errors. + error_description: Option, + }, + + /// Unauthorized client error, typically due to an unauthorized client. + UnauthorizedClient { + #[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)] + /// 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)] + /// 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)] + /// The optional error description for invalid target errors. + error_description: Option, + }, +} + +#[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), +} + +// 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:?}")) + } +} + +impl From for LoginErrorApiResponse { + fn from(value: MasterPasswordError) -> Self { + 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("some_new_error_code".to_string()) + ); + } + + #[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( + "brand_new_error_type".to_string() + )) + ); + } + _ => 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( + "some_future_error_code".to_string() + )) + ); + } + _ => 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)"), + } + } + } +} 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 new file mode 100644 index 000000000..c3c7a768f --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -0,0 +1,92 @@ +use bitwarden_api_api::models::{MasterPasswordPolicyResponseModel, PrivateKeysResponseModel}; +use bitwarden_api_identity::models::KdfType; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::UserDecryptionOptionsApiResponse; + +/// 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 LoginSuccessApiResponse { + /// 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, + + // 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 key wrapped user private key + /// Deprecated in favor of the `AccountKeys` field but still present for backward + /// compatibility. and we can't expose AccountKeys in our LoginSuccessResponse until we get + /// a PrivateKeysResponseModel SDK response model from KM with WASM / uniffi support. + #[serde(rename = "PrivateKey", alias = "privateKey")] + pub private_key: Option, + + /// The user's asymmetric encryption keys and signature keys + #[serde(rename = "AccountKeys", alias = "accountKeys")] + pub account_keys: 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, + + /// Two factor remember me token to be used for future requests + /// to bypass 2FA prompts for a limited time. + #[serde(rename = "TwoFactorToken", alias = "twoFactorToken")] + 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: Option, + + /// 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, + + /// 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: 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")] + pub api_use_key_connector: Option, + + /// The user's decryption options for their vault. + #[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/api/response/mod.rs b/crates/bitwarden-auth/src/identity/api/response/mod.rs new file mode 100644 index 000000000..efcd795d1 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/mod.rs @@ -0,0 +1,24 @@ +//! 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. +mod login_success_api_response; +pub(crate) use login_success_api_response::LoginSuccessApiResponse; + +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::{ + InvalidGrantError, LoginErrorApiResponse, OAuth2ErrorApiResponse, PasswordInvalidGrantError, +}; 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/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..5f5e7acd3 --- /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, UnsignedSharedKey}; +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, + + /// Public Key encrypted user key + #[serde(rename = "EncryptedUserKey")] + pub encrypted_user_key: UnsignedSharedKey, +} diff --git a/crates/bitwarden-auth/src/identity/api/send_login_request.rs b/crates/bitwarden-auth/src/identity/api/send_login_request.rs new file mode 100644 index 000000000..f066d295b --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -0,0 +1,423 @@ +use serde::{Serialize, de::DeserializeOwned}; + +use crate::identity::{ + api::{ + request::LoginApiRequest, + response::{LoginErrorApiResponse, LoginSuccessApiResponse}, + }, + 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( + identity_config: &bitwarden_api_identity::apis::configuration::Configuration, + api_request: &LoginApiRequest, +) -> Result { + let url: String = format!("{}/connect/token", &identity_config.base_path); + + let request: reqwest::RequestBuilder = identity_config + .client + .post(url) + .header(reqwest::header::ACCEPT, "application/json") + // per OAuth2 spec recommendation for token requests (https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1) + // we include no-cache headers to prevent browser caching sensitive token requests / + // responses. + .header(reqwest::header::CACHE_CONTROL, "no-store") + .header(reqwest::header::PRAGMA, "no-cache") + // If we run into authN issues, it could be due to https://bitwarden.atlassian.net/browse/PM-29974 + // not being done yet. In the clients repo, we add credentials: "include" for all + // non web clients or any self hosted deployments. However, we want to solve that at the + // core client layer and not here. + // use form to encode as application/x-www-form-urlencoded + .form(&api_request); + + let response: reqwest::Response = request.send().await?; + + let response_status = response.status(); + + if response_status.is_success() { + let login_success_api_response: LoginSuccessApiResponse = response.json().await?; + + let login_success_response: LoginSuccessResponse = login_success_api_response.try_into()?; + + let login_response = LoginResponse::Authenticated(login_success_response); + + return Ok(login_response); + } + + let login_error_api_response: LoginErrorApiResponse = response.json().await?; + + Err(login_error_api_response) +} + +#[cfg(test)] +mod tests { + //! # Testing Philosophy for `send_login_request` + //! + //! This test module focuses on **HTTP/protocol layer concerns** for the low-level + //! `send_login_request` utility function. These tests verify that the HTTP machinery + //! works correctly, not comprehensive error scenario testing. + //! + //! ## What These Tests Cover + //! + //! 1. **HTTP Success Path** - Response parsing and conversion to domain types + //! 2. **OAuth2 Error Discrimination** - Different OAuth2 error types are correctly deserialized + //! and preserved (invalid_grant, invalid_request, invalid_client) + //! 3. **Error Propagation Mechanism** - One representative test confirming that lower-layer + //! errors (reqwest, serde) are converted to `LoginErrorApiResponse` + //! 4. **Response Validation** - One test for incomplete data (different code path from JSON + //! parsing failures) + //! 5. **HTTP Headers** - Verification that required headers are set correctly + //! + //! ## What These Tests DON'T Cover + //! + //! **Comprehensive error scenario testing** is intentionally done at the integration + //! level in `login_via_password_impl.rs` (and other login method implementations). + //! This includes: + //! - Multiple network error types (DNS, timeout, connection refused, etc.) + //! - Multiple malformed response types (empty body, invalid JSON, wrong content-type, etc.) + //! - Unexpected HTTP status codes + //! - Domain-specific error conversion and handling + //! + //! ## Rationale + //! + //! `send_login_request` is a **shared utility** used by multiple login methods. + //! Testing every error permutation here would: + //! - Create maintenance burden (updating tests in multiple places) + //! - Provide false confidence (many tests covering the same code paths) + //! - Obscure the function's actual responsibilities + //! + //! Instead, we test **what this function is responsible for** (HTTP mechanics and + //! error type discrimination), and rely on integration tests to verify end-to-end + //! error handling through the complete stack. + + use bitwarden_api_identity::apis::configuration::Configuration; + use bitwarden_core::DeviceType; + use bitwarden_test::start_api_mock; + use wiremock::{Mock, ResponseTemplate, matchers}; + + use super::*; + use crate::{ + api::enums::GrantType, + identity::{api::request::LoginApiRequest, models::LoginResponse}, + }; + + // Test constants + const TEST_CLIENT_ID: &str = "test-client"; + const TEST_DEVICE_ID: &str = "test-device-id"; + const TEST_DEVICE_NAME: &str = "Test Device"; + + // Simple mock login mechanism fields for testing + #[derive(Serialize, serde::Deserialize, Debug)] + struct MockLoginFields { + username: String, + password: String, + } + + // ==================== Test Helper Functions ==================== + + fn create_test_login_request() -> LoginApiRequest { + LoginApiRequest::new( + TEST_CLIENT_ID.to_string(), + GrantType::Password, + DeviceType::SDK, + TEST_DEVICE_ID.to_string(), + TEST_DEVICE_NAME.to_string(), + None, + MockLoginFields { + username: "user@example.com".to_string(), + password: "hashed-password".to_string(), + }, + ) + } + + fn create_identity_config(mock_server: &wiremock::MockServer) -> Configuration { + Configuration { + base_path: format!("http://{}/identity", mock_server.address()), + client: reqwest::Client::new(), + ..Default::default() + } + } + + fn add_standard_request_matchers(mock_builder: wiremock::MockBuilder) -> wiremock::MockBuilder { + mock_builder + .and(matchers::header( + reqwest::header::ACCEPT.as_str(), + "application/json", + )) + .and(matchers::header( + reqwest::header::CACHE_CONTROL.as_str(), + "no-store", + )) + .and(matchers::header( + reqwest::header::PRAGMA.as_str(), + "no-cache", + )) + } + + fn create_mock_success_response() -> serde_json::Value { + serde_json::json!({ + "access_token": "test_access_token_abc123", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "test_refresh_token_xyz789", + "scope": "api offline_access", + "Key": "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=", + "PrivateKey": "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=", + "Kdf": 0, + "KdfIterations": 600000, + "ForcePasswordReset": false, + "MasterPasswordPolicy": { + "Object": "masterPasswordPolicy" + }, + "UserDecryptionOptions": { + "HasMasterPassword": true, + "MasterPasswordUnlock": { + "Kdf": { + "KdfType": 0, + "Iterations": 600000 + }, + "MasterKeyEncryptedUserKey": "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=", + "Salt": "user@example.com" + }, + "Object": "userDecryptionOptions" + } + }) + } + + fn assert_login_success_response(login_response: &LoginResponse) { + match login_response { + LoginResponse::Authenticated(success) => { + assert_eq!(success.access_token, "test_access_token_abc123"); + assert_eq!(success.token_type, "Bearer"); + assert_eq!(success.expires_in, 3600); + assert_eq!(success.scope, "api offline_access"); + assert_eq!( + success.refresh_token, + Some("test_refresh_token_xyz789".to_string()) + ); + assert_eq!(success.two_factor_token, None); + assert_eq!(success.force_password_reset, Some(false)); + assert_eq!(success.api_use_key_connector, None); + + // Verify user decryption options + let decryption_options = &success.user_decryption_options; + assert!(decryption_options.master_password_unlock.is_some()); + let mp_unlock = decryption_options.master_password_unlock.as_ref().unwrap(); + assert_eq!( + mp_unlock.master_key_wrapped_user_key.to_string(), + "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=" + ); + assert_eq!(mp_unlock.salt, "user@example.com"); + + // Verify master password policy is present + assert!(success.master_password_policy.is_some()); + } + } + } + + // ==================== Success Tests ==================== + + #[tokio::test] + async fn test_send_login_request_success() { + let success_response = create_mock_success_response(); + let mock = add_standard_request_matchers( + Mock::given(matchers::method("POST")).and(matchers::path("/identity/connect/token")), + ) + .respond_with(ResponseTemplate::new(200).set_body_json(success_response)); + + let (mock_server, _) = start_api_mock(vec![mock]).await; + let identity_config = create_identity_config(&mock_server); + let login_request = create_test_login_request(); + + let result = send_login_request(&identity_config, &login_request).await; + + assert!(result.is_ok(), "Expected success response"); + let login_response = result.unwrap(); + assert_login_success_response(&login_response); + } + + // ==================== OAuth2 Error Tests ==================== + + #[tokio::test] + async fn test_send_login_request_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, _) = start_api_mock(vec![mock]).await; + let identity_config = create_identity_config(&mock_server); + let login_request = create_test_login_request(); + + let result = send_login_request(&identity_config, &login_request).await; + + assert!(result.is_err(), "Expected error response"); + let error = result.unwrap_err(); + match error { + LoginErrorApiResponse::OAuth2Error(oauth_error) => { + assert!(matches!( + oauth_error, + crate::identity::api::response::OAuth2ErrorApiResponse::InvalidGrant { .. } + )); + } + _ => panic!("Expected OAuth2Error variant"), + } + } + + #[tokio::test] + async fn test_send_login_request_invalid_request() { + let error_response = serde_json::json!({ + "error": "invalid_request", + "error_description": "Missing required parameter: 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, _) = start_api_mock(vec![mock]).await; + let identity_config = create_identity_config(&mock_server); + let login_request = create_test_login_request(); + + let result = send_login_request(&identity_config, &login_request).await; + + assert!(result.is_err(), "Expected error response"); + let error = result.unwrap_err(); + match error { + LoginErrorApiResponse::OAuth2Error(oauth_error) => { + assert!(matches!( + oauth_error, + crate::identity::api::response::OAuth2ErrorApiResponse::InvalidRequest { .. } + )); + } + _ => panic!("Expected OAuth2Error variant"), + } + } + + #[tokio::test] + async fn test_send_login_request_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, _) = start_api_mock(vec![mock]).await; + let identity_config = create_identity_config(&mock_server); + let login_request = create_test_login_request(); + + let result = send_login_request(&identity_config, &login_request).await; + + assert!(result.is_err(), "Expected error response"); + let error = result.unwrap_err(); + match error { + LoginErrorApiResponse::OAuth2Error(oauth_error) => { + assert!(matches!( + oauth_error, + crate::identity::api::response::OAuth2ErrorApiResponse::InvalidClient { .. } + )); + } + _ => panic!("Expected OAuth2Error variant"), + } + } + + // ==================== Error Propagation Tests ==================== + // These tests verify that errors from lower layers (reqwest, serde) are + // properly propagated and converted to LoginErrorApiResponse. + // Comprehensive error scenario testing is done in login_via_password_impl.rs + // to verify end-to-end error handling through the full stack. + + #[tokio::test] + async fn test_send_login_request_network_error() { + // Verify that network errors are propagated as UnexpectedError. + // This test confirms the error conversion mechanism works. + let identity_config = Configuration { + base_path: "http://127.0.0.1:1/identity".to_string(), // Port 1 will refuse connections + client: reqwest::Client::new(), + ..Default::default() + }; + + let login_request = create_test_login_request(); + let result = send_login_request(&identity_config, &login_request).await; + + assert!(result.is_err(), "Expected error due to network failure"); + match result.unwrap_err() { + LoginErrorApiResponse::UnexpectedError(msg) => { + assert!(!msg.is_empty(), "Error message should not be empty"); + } + _ => panic!("Expected UnexpectedError for network failure"), + } + } + + // ==================== Response Parsing Tests ==================== + + #[tokio::test] + async fn test_send_login_request_incomplete_success_response() { + // Verify that responses with missing required fields fail during + // deserialization/validation. This tests a different code path than + // JSON parsing errors - the JSON is valid but the data is incomplete. + let incomplete_response = serde_json::json!({ + "access_token": "token_without_required_fields" + // Missing expires_in, token_type, and other required fields + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("/identity/connect/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(incomplete_response)); + + let (mock_server, _) = start_api_mock(vec![mock]).await; + let identity_config = create_identity_config(&mock_server); + let login_request = create_test_login_request(); + + let result = send_login_request(&identity_config, &login_request).await; + + assert!( + result.is_err(), + "Expected error due to incomplete success response" + ); + match result.unwrap_err() { + LoginErrorApiResponse::UnexpectedError(msg) => { + assert!(!msg.is_empty(), "Error message should not be empty"); + } + _ => panic!("Expected UnexpectedError for incomplete response"), + } + } + + // ==================== Header Verification Tests ==================== + + #[tokio::test] + async fn test_send_login_request_verifies_headers() { + let success_response = create_mock_success_response(); + let mock = add_standard_request_matchers( + Mock::given(matchers::method("POST")).and(matchers::path("/identity/connect/token")), + ) + // Verify all required headers including content-type + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/x-www-form-urlencoded", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(success_response)); + + let (mock_server, _) = start_api_mock(vec![mock]).await; + let identity_config = create_identity_config(&mock_server); + let login_request = create_test_login_request(); + + let result = send_login_request(&identity_config, &login_request).await; + + assert!( + result.is_ok(), + "Request should succeed with correct headers" + ); + } +} diff --git a/crates/bitwarden-auth/src/identity/client.rs b/crates/bitwarden-auth/src/identity/client.rs deleted file mode 100644 index b2ae75e95..000000000 --- a/crates/bitwarden-auth/src/identity/client.rs +++ /dev/null @@ -1,38 +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)] -pub struct IdentityClient { - #[allow(dead_code)] // TODO: Remove when methods using client are implemented - pub(crate) client: Client, -} - -impl IdentityClient { - /// Create a new IdentityClient with the given Client. - pub(crate) fn new(client: Client) -> Self { - Self { client } - } -} - -#[cfg_attr(feature = "wasm", wasm_bindgen)] -impl IdentityClient { - // TODO: Add methods to interact with the Identity API. -} - -#[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..8ad2e60b4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_client.rs @@ -0,0 +1,53 @@ +use bitwarden_core::{Client, ClientSettings}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +/// Client for authenticating Bitwarden users. +/// +/// Handles unauthenticated operations to obtain access tokens from the Identity API. +/// After successful authentication, use the returned tokens to create an authenticated core client. +/// +/// # Lifecycle +/// +/// 1. Create `LoginClient` via `AuthClient` → 2. Call login method → 3. Use returned tokens with +/// authenticated core client +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct LoginClient { + pub(crate) client: Client, +} + +impl LoginClient { + /// Creates a new `LoginClient` with the given client settings. + /// + /// # Arguments + /// + /// * `settings` - Configuration for API endpoints, user agent, and device information + /// + /// # Note + /// + /// This method is `pub(crate)` because `LoginClient` instances should be obtained through + /// the AuthClient. Direct instantiation is internal to the crate. + pub(crate) fn new(settings: ClientSettings) -> Self { + let core_client = Client::new(Some(settings)); + + Self { + client: core_client, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_login_client_creation() { + let client_settings = ClientSettings::default(); + let login_client = LoginClient::new(client_settings); + + // 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_impl.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password_impl.rs new file mode 100644 index 000000000..3de98efbe --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password_impl.rs @@ -0,0 +1,581 @@ +use bitwarden_core::key_management::MasterPasswordAuthenticationData; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::identity::{ + LoginClient, + api::{request::LoginApiRequest, send_login_request}, + login_via_password::{PasswordLoginApiRequest, PasswordLoginError, PasswordLoginRequest}, + models::LoginResponse, +}; + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl LoginClient { + /// Authenticates a user via email and master password. + /// + /// Derives the master password hash using KDF settings from prelogin, then sends + /// the authentication request to obtain access tokens and vault keys. + pub async fn login_via_password( + &self, + request: PasswordLoginRequest, + ) -> Result { + let master_password_authentication = MasterPasswordAuthenticationData::derive( + &request.password, + &request.prelogin_response.kdf, + &request.email, + )?; + + let api_request: LoginApiRequest = + (request, master_password_authentication).into(); + + let api_configs = self.client.internal.get_api_configurations().await; + + let response = send_login_request(&api_configs.identity_config, &api_request).await; + + response.map_err(Into::into) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_core::{ClientSettings, DeviceType}; + use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + 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"; + + #[derive(Debug, Clone, Copy)] + enum TestKdfType { + Pbkdf2, + Argon2id, + } + + // Mock success response constants (using real-world valid encrypted data format) + const TEST_ACCESS_TOKEN: &str = "test_access_token"; + const TEST_TOKEN_TYPE: &str = "Bearer"; + const TEST_EXPIRES_IN: u64 = 3600; + const TEST_SCOPE: &str = "api offline_access"; + const TEST_REFRESH_TOKEN: &str = "test_refresh_token"; + const TEST_PRIVATE_KEY: &str = "2.SVgjObXyZZKLDVxM3y197w==|tUHZ+bo2o7Y9NyAPPqWOhhuaDiiYT26R2vPI0ILqg8W1vtjq+kzsGHPRZhA1nOXAcJ/ACe77YGFicueH+tryWZHgF1whGZxXza8JPYVtd4k8vO2NE7j8MUZ0FHHq7O+mUiVql0+mC1Af9gM5xp8W022aWgobyu4IZQi6l5hmJZ76NvzUbxDRFadzd8/sxFh+g3I4lEl5kQfzIi3IT0PmX3h75I/8jyGzgWxuUpLiko8hNkIwcjLXesCE641hH8oCtTtwzowZfuRUTO6O/WSR5fHMR2nR2IKf+YvK3SvlywvFTbOAzi7GLNd6NPOZ5ohJrJWtThUZ+65N3CFIczhjj/KvtR5NYVlXlCKWGRLjMsG5Aj8MPCAtAGH8AT6qRoDyh7jXF8SjMo/7BpFay9Xp+kd8M79LEFyUVMybShJ/1Es1qDNCZlnYP8iy1uQe1osLIzSk4IcH2uAD91jvWAOaJGw+HuAOjhqBlP2I7hI8jST5pJAeAzZeY1mnfryYB92wdDVPWKHp+nFcDl34w9lwQRAxken+yxCaepJCRyTXYzpzDNW7Si47PKndchSof9j27MBXTjoOgcsCN2s/V6mNomNybwfN/8J5ts8BNatTnCfiDhV/zrHP9N7wjRXjYoVTLTHXBJqehnLXCNFjnWWmbUTz0fMIRC5q4iNRnSmGMuuCGZfCvlhaIaSVbw35K7ksjTvakJQ8npZU+ULq0Z49jw10GULUbXrP0h/VG+ScKGsRG3E1AOYtd2ff2oe8ht03IpopQWKKk8vqofhDKG++E+SYd/VgMo2O9tuOKilrKCoOBW17/FIftCpWqdGmbG3OBnKiXNOeelqd51i0n9G2ddYhgt+a++8J3UfmrNTX5483+g2usJeJBkKfIbB87FaCxBRSBdvy+bPIPqm6dEWLhk5m3GGkPCndpZywef+tpV7NkC6J8cUDQS0ah1w7r9DG5kNdoSWHbvwhuPR8Ytk8uPdAHI2vOcO/4E6CCPGlsGbXq6egZ39XypO7QJ4+NWTzGDiNGSVOB4Mrxe23++GYRqaMS3bGX0cLKXvCuR1sjYYiM8kechXcmIBGKavs3JrZcT7qEJ8bEpnFQcV+F0iW1bvRTCclVM8XSTbeX6SktHs6fO3vrV+bfkVJsWUAbqR/2di0B9Ye97kJign/03oKUUpg8ksapMfr+IE4CVdHeEC4Xq/y5I+R5TRP/EXiIu2mDIgx7nITj0oTysl070t0OC8QLFrpUkZxjx7ELq76NjMc0IIgumWsivRyBeqz6r3lIA25b6H/3+9xrpjZFb/K/M/NMXFdenjflhYaQLzzsO9Cz7EAorYTf6bV0+g43GyUOC6w0D8R7rerfsVSnwIENlEwpd4s5TC+rWjNPG1r1w91E+It1UbuvBDBTMIZw4BRrCd5/2G0nQyNnNWxn5WLkg3xRCmPYqcVFygagJLh6baYGLb1SVmRu8NF2QMggRsYDkckql6gseq5gGGCfcaFLtAHgfdlfV4jnSZ0tuYpjsLRYhUD/oFGlM56sxnMe/EX6DdDnoGFlAxkRNeHuiY6tdlNhbOAyRjJwQL1Vnweip5vvrHpbEsR6z71E05dwEDnK+2Gz7gVq2x4BIzkLm3MwlOmZFsbLewHr6vB5mm+rgM=|YfKU1iB2Yn/pqeBDbE2IXnpVIlGUR0Sjv9twpnNklHU="; + const TEST_PUBLIC_KEY: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqRwZGmKLN34tUq+lLT50JoXJaEJh2E13g8IMFYd5xaywJxA63rnQ5rDa6HFrjjyhg0kbhY60Igv7tpeR7Hq6VTU2CnsRmT47+3ZKm2Y8w/h8Dk0X/a8QcxMbvJZP+2wQ0/6lIbfxRYm7cCi8KZz03mz79lUBJxioy8N+46rMwlj9HQCb8tle5gyEYtF+XtWeAP3JpVvRs3unNvlgThCETnusAIruIJzNX8e+0z7HkzNyFQ3/jY+MyZZUTz3X+r3werc8r94W/4EgoLdjg4651KBQbJuMiknlRzpN+gipClDyjgILxiswtGjuCr80Dyk+jhpDmYhytRcpinnjqkLlzwIDAQAB"; + const TEST_ENCRYPTED_USER_KEY: &str = "2.EvwbalCwa3ba6j/eEtGOLA==|Nd+7WgEZpd3fsGmpDHOknPhS9e8SVeXpmeJQDTLI3Ki9S7BB/L+k0TxzRnUtcMx646d4Nfco5mz7Q1mMrGO/PGtf4FNleyCR9LMIzHneiRI=|B9bEzJ4LLh0Vz2zexhBwZBQSmXWsPdRKL+haJG/KB6c="; + const TEST_KDF_TYPE: i32 = 0; + const TEST_KDF_ITERATIONS: i32 = 600000; + const TEST_PUSH_TOKEN: &str = "test_push_token"; + + 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()), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + device_identifier: None, + bitwarden_client_version: None, + bitwarden_package_type: None, + }; + LoginClient::new(settings) + } + + fn make_password_login_request(kdf_type: TestKdfType) -> PasswordLoginRequest { + let kdf = match kdf_type { + TestKdfType::Pbkdf2 => Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations(), + }, + TestKdfType::Argon2id => Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: default_argon2_parallelism(), + }, + }; + + 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: Some(TEST_PUSH_TOKEN.to_string()), + }, + }, + email: TEST_EMAIL.to_string(), + password: TEST_PASSWORD.to_string(), + prelogin_response: PasswordPreloginResponse { + kdf, + salt: TEST_SALT.to_string(), + }, + } + } + + fn add_standard_login_headers(mock_builder: wiremock::MockBuilder) -> wiremock::MockBuilder { + mock_builder + .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", + )) + .and(matchers::header( + reqwest::header::PRAGMA.as_str(), + "no-cache", + )) + } + + fn make_mock_success_response() -> serde_json::Value { + serde_json::json!({ + "access_token": TEST_ACCESS_TOKEN, + "expires_in": TEST_EXPIRES_IN, + "token_type": TEST_TOKEN_TYPE, + "refresh_token": TEST_REFRESH_TOKEN, + "scope": TEST_SCOPE, + "PrivateKey": TEST_PRIVATE_KEY, + "AccountKeys": { + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": TEST_PRIVATE_KEY, + "publicKey": TEST_PUBLIC_KEY, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }, + "Key": TEST_ENCRYPTED_USER_KEY, + "MasterPasswordPolicy": { + "Object": "masterPasswordPolicy" + }, + "ForcePasswordReset": false, + "Kdf": TEST_KDF_TYPE, + "KdfIterations": TEST_KDF_ITERATIONS, + "KdfMemory": null, + "KdfParallelism": null, + "UserDecryptionOptions": { + "HasMasterPassword": true, + "MasterPasswordUnlock": { + "Kdf": { + "KdfType": TEST_KDF_TYPE, + "Iterations": TEST_KDF_ITERATIONS + }, + "MasterKeyEncryptedUserKey": TEST_ENCRYPTED_USER_KEY, + "Salt": TEST_EMAIL + }, + "Object": "userDecryptionOptions" + } + }) + } + + fn assert_login_success_response(login_response: &LoginResponse) { + match login_response { + LoginResponse::Authenticated(success_response) => { + assert_eq!(success_response.access_token, TEST_ACCESS_TOKEN); + assert_eq!(success_response.token_type, TEST_TOKEN_TYPE); + assert_eq!(success_response.expires_in, TEST_EXPIRES_IN); + assert_eq!(success_response.scope, TEST_SCOPE); + assert_eq!( + success_response.refresh_token, + Some(TEST_REFRESH_TOKEN.to_string()) + ); + assert_eq!( + success_response.user_key_wrapped_user_private_key, + Some(TEST_PRIVATE_KEY.to_string()) + ); + assert_eq!(success_response.two_factor_token, None); + assert_eq!(success_response.force_password_reset, Some(false)); + assert_eq!(success_response.api_use_key_connector, None); + + // Verify user decryption options + let decryption_options = &success_response.user_decryption_options; + assert!(decryption_options.master_password_unlock.is_some()); + let mp_unlock = decryption_options.master_password_unlock.as_ref().unwrap(); + assert_eq!( + mp_unlock.master_key_wrapped_user_key.to_string(), + TEST_ENCRYPTED_USER_KEY + ); + assert_eq!(mp_unlock.salt, TEST_EMAIL); + + // Verify master password policy is present + assert!(success_response.master_password_policy.is_some()); + } + } + } + + #[tokio::test] + async fn test_login_via_password_success() { + let kdf_types = [TestKdfType::Pbkdf2, TestKdfType::Argon2id]; + + for kdf_type in kdf_types { + let raw_success = make_mock_success_response(); + + let mock = add_standard_login_headers( + 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 request = make_password_login_request(kdf_type); + let result = identity_client.login_via_password(request).await; + + assert!(result.is_ok(), "Failed for KDF type: {:?}", kdf_type); + let login_response = result.unwrap(); + assert_login_success_response(&login_response); + } + } + + #[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(TestKdfType::Pbkdf2); + 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_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(TestKdfType::Pbkdf2); + 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(TestKdfType::Pbkdf2); + 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(TestKdfType::Pbkdf2); + 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"), + } + } + + #[tokio::test] + async fn test_login_via_password_invalid_kdf_configuration() { + // No mock server needed - error occurs during KDF derivation before API call + let (mock_server, _api_config) = start_api_mock(vec![]).await; + let identity_client = make_identity_client(&mock_server); + + // Create a request with PBKDF2 iterations below the minimum (5000) + // This will cause derive() to fail with InsufficientKdfParameters + let request = 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: Some(TEST_PUSH_TOKEN.to_string()), + }, + }, + email: TEST_EMAIL.to_string(), + password: TEST_PASSWORD.to_string(), + prelogin_response: PasswordPreloginResponse { + kdf: Kdf::PBKDF2 { + iterations: std::num::NonZeroU32::new(100).unwrap(), // Below minimum of 5000 + }, + salt: TEST_SALT.to_string(), + }, + }; + + let result = identity_client.login_via_password(request).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + + // Verify it's the PasswordAuthenticationDataDerivation error variant + assert!( + matches!( + error, + PasswordLoginError::PasswordAuthenticationDataDerivation(_) + ), + "Expected PasswordAuthenticationDataDerivation error, got: {:?}", + error + ); + } + + // ==================== Network Error Tests ==================== + + #[tokio::test] + async fn test_login_via_password_connection_refused() { + // Use an invalid port that will refuse connections + let settings = ClientSettings { + identity_url: "http://127.0.0.1:1".to_string(), // Port 1 will be refused + api_url: "http://127.0.0.1:1".to_string(), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + device_identifier: None, + bitwarden_client_version: None, + bitwarden_package_type: None, + }; + let identity_client = LoginClient::new(settings); + + let request = make_password_login_request(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to connection refused + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for connection refused, got: {:?}", + error + ); + } + + #[tokio::test] + async fn test_login_via_password_dns_failure() { + // Use a domain that doesn't exist + let settings = ClientSettings { + identity_url: "http://this-domain-definitely-does-not-exist-12345.invalid".to_string(), + api_url: "http://this-domain-definitely-does-not-exist-12345.invalid".to_string(), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + device_identifier: None, + bitwarden_client_version: None, + bitwarden_package_type: None, + }; + let identity_client = LoginClient::new(settings); + + let request = make_password_login_request(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to DNS failure + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for DNS failure, got: {:?}", + error + ); + } + + // ==================== Malformed Response Tests ==================== + + #[tokio::test] + async fn test_login_via_password_empty_response_body() { + // Server returns 200 but with empty body + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(200).set_body_string("")); + + 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(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to empty body + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for empty response, got: {:?}", + error + ); + } + + #[tokio::test] + async fn test_login_via_password_malformed_json() { + // Server returns 200 but with invalid JSON + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(200).set_body_string("{invalid json")); + + 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(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to malformed JSON + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for malformed JSON, got: {:?}", + error + ); + } + + #[tokio::test] + async fn test_login_via_password_incomplete_success_response() { + // Server returns 200 with valid JSON but missing required fields + let incomplete_response = serde_json::json!({ + "access_token": TEST_ACCESS_TOKEN, + // Missing expires_in, token_type, and other required fields + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(incomplete_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(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to missing required fields + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for incomplete response, got: {:?}", + error + ); + } + + #[tokio::test] + async fn test_login_via_password_wrong_content_type() { + // Server returns HTML instead of JSON + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("Error") + .insert_header("content-type", "text/html"), + ); + + 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(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to wrong content type + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for wrong content type, got: {:?}", + error + ); + } + + #[tokio::test] + async fn test_login_via_password_unexpected_status_code() { + // Server returns 418 I'm a teapot (unexpected status code) + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(418).set_body_string("I'm a teapot")); + + 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(TestKdfType::Pbkdf2); + let result = identity_client.login_via_password(request).await; + + // Should fail with Unknown error due to unexpected status code + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!( + matches!(error, PasswordLoginError::Unknown(_)), + "Expected Unknown error for unexpected status code, got: {:?}", + error + ); + } +} 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..99284ef49 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -0,0 +1,97 @@ +//! Password-based authentication for Bitwarden users. +//! +//! This module implements the password login flow, which requires two steps: +//! +//! 1. **Prelogin**: Retrieve the user's KDF configuration with +//! [`LoginClient::get_password_prelogin`] +//! 2. **Login**: Authenticate with [`LoginClient::login_via_password`] using the KDF settings +//! +//! # Security Model +//! +//! The master password is **never sent to the server**. Instead: +//! - User's KDF settings (PBKDF2 or Argon2id) are fetched during prelogin +//! - Master password is stretched with KDF to derive the master key +//! - Master key is stretched again into an AES256-CBC-HMAC key to unwrap the user key +//! - Master key is hashed with single-round PBKDF2 (using password as salt) to create the server +//! authentication hash +//! - Only the authentication hash is transmitted to the server +//! - All requests include no-cache headers to prevent sensitive data caching +//! +//! # Current Limitations +//! +//! - Two-factor authentication (2FA) not yet supported +//! - New device verification not yet implemented +//! +//! # Complete Example +//! +//! ```rust,no_run +//! # use bitwarden_auth::{AuthClient, AuthClientExt}; +//! # use bitwarden_auth::identity::login_via_password::PasswordLoginRequest; +//! # use bitwarden_auth::identity::models::{LoginRequest, LoginDeviceRequest, LoginResponse}; +//! # use bitwarden_core::{Client, ClientSettings, DeviceType}; +//! # async fn example() -> Result<(), Box> { +//! // Create the core client +//! let client = Client::new(None); +//! let auth_client = AuthClient::new(client); +//! +//! // Create login client with settings +//! let settings = ClientSettings { +//! identity_url: "https://identity.bitwarden.com".to_string(), +//! api_url: "https://api.bitwarden.com".to_string(), +//! user_agent: "MyApp/1.0".to_string(), +//! device_type: DeviceType::SDK, +//! device_identifier: None, +//! bitwarden_client_version: None, +//! bitwarden_package_type: None, +//! }; +//! let login_client = auth_client.login(settings); +//! +//! // Step 1: Get user's KDF configuration +//! let prelogin = login_client +//! .get_password_prelogin("user@example.com".to_string()) +//! .await?; +//! +//! // Step 2: Construct and send login request +//! let response = login_client.login_via_password(PasswordLoginRequest { +//! login_request: LoginRequest { +//! client_id: "connector".to_string(), +//! device: LoginDeviceRequest { +//! device_type: DeviceType::SDK, +//! device_identifier: "device-id".to_string(), +//! device_name: "My Device".to_string(), +//! device_push_token: None, +//! }, +//! }, +//! email: "user@example.com".to_string(), +//! password: "master-password".to_string(), +//! prelogin_response: prelogin, +//! }).await?; +//! +//! // Step 3: Use tokens from response for authenticated requests +//! match response { +//! LoginResponse::Authenticated(success) => { +//! let access_token = success.access_token; +//! // Use access_token for authenticated requests +//! } +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! [`LoginClient::get_password_prelogin`]: crate::identity::LoginClient::get_password_prelogin +//! [`LoginClient::login_via_password`]: crate::identity::LoginClient::login_via_password + +mod login_via_password_impl; +mod password_login_api_request; +mod password_login_request; +mod password_prelogin; + +pub(crate) use password_login_api_request::PasswordLoginApiRequest; +pub use password_login_request::PasswordLoginRequest; +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_api_request.rs b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs new file mode 100644 index 000000000..9ae07e4fc --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -0,0 +1,271 @@ +use bitwarden_core::key_management::MasterPasswordAuthenticationData; +use serde::{Deserialize, Serialize}; + +use crate::{ + api::enums::GrantType, + identity::{api::request::LoginApiRequest, login_via_password::PasswordLoginRequest}, +}; + +/// Internal API request model for logging in via password. +/// +/// This struct represents the password-specific fields sent to the Identity API's +/// `/connect/token` endpoint. It is combined with common login fields in [`LoginApiRequest`]. +/// +/// # Field Mappings +/// +/// The API expects OAuth2-style field names, so we rename our fields during serialization: +/// - `email` → `"username"` - The user's email address (OAuth2 uses "username") +/// - `master_password_hash` → `"password"` - The derived master password hash (not the raw +/// password) +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct PasswordLoginApiRequest { + /// Bitwarden user email address. + /// + /// Serialized as `"username"` to match OAuth2 conventions expected by the Identity API. + #[serde(rename = "username")] + pub email: String, + + /// Derived master password server authentication hash. + /// Serialized as `"password"` to match OAuth2 conventions expected by the Identity API. + #[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 LoginApiRequest +{ + 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 + LoginApiRequest::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, + request.login_request.device.device_push_token, + password_login_api_request, + ) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_core::DeviceType; + use bitwarden_crypto::{Kdf, default_pbkdf2_iterations}; + + use super::*; + use crate::identity::{ + login_via_password::PasswordPreloginResponse, + models::{LoginDeviceRequest, LoginRequest}, + }; + + 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 TEST_DEVICE_PUSH_TOKEN: &str = "test-push-token"; + + fn make_test_password_login_request(with_push_token: bool) -> 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: if with_push_token { + Some(TEST_DEVICE_PUSH_TOKEN.to_string()) + } else { + None + }, + }, + }, + email: TEST_EMAIL.to_string(), + password: TEST_PASSWORD.to_string(), + prelogin_response: PasswordPreloginResponse { + kdf: Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations(), + }, + salt: TEST_SALT.to_string(), + }, + } + } + + fn make_test_master_password_auth() -> MasterPasswordAuthenticationData { + let request = make_test_password_login_request(false); + MasterPasswordAuthenticationData::derive( + &request.password, + &request.prelogin_response.kdf, + &request.email, + ) + .unwrap() + } + + #[test] + fn test_password_login_request_conversion() { + let request = make_test_password_login_request(true); + let master_password_auth = make_test_master_password_auth(); + let expected_hash = master_password_auth + .master_password_authentication_hash + .to_string(); + + let api_request: LoginApiRequest = + (request, master_password_auth).into(); + + // Verify grant type is set to password + assert_eq!(api_request.grant_type, GrantType::Password); + + // Verify standard scopes + assert_eq!(api_request.scope, "api offline_access"); + + // Verify common fields + assert_eq!(api_request.client_id, TEST_CLIENT_ID); + assert_eq!(api_request.device_type, DeviceType::SDK); + assert_eq!(api_request.device_identifier, TEST_DEVICE_IDENTIFIER); + assert_eq!(api_request.device_name, TEST_DEVICE_NAME); + assert_eq!( + api_request.device_push_token, + Some(TEST_DEVICE_PUSH_TOKEN.to_string()) + ); + + // Verify password-specific fields + assert_eq!(api_request.login_mechanism_fields.email, TEST_EMAIL); + assert_eq!( + api_request.login_mechanism_fields.master_password_hash, + expected_hash + ); + assert!( + !api_request + .login_mechanism_fields + .master_password_hash + .is_empty() + ); + } + + #[test] + fn test_password_login_api_request_serialization() { + use crate::{api::enums::scopes_to_string, identity::api::request::STANDARD_USER_SCOPES}; + + // Create a complete API request with all fields + let request = make_test_password_login_request(true); + let master_password_auth = make_test_master_password_auth(); + + let api_request: LoginApiRequest = + (request, master_password_auth).into(); + + // Serialize to URL-encoded form data (as used by the API) + let serialized = + serde_urlencoded::to_string(&api_request).expect("Failed to serialize LoginApiRequest"); + + // Verify OAuth2 standard fields use snake_case + // Serialize GrantType::Password to get the actual string value + let expected_grant_type = + serde_urlencoded::to_string([("grant_type", &GrantType::Password)]) + .expect("Failed to serialize GrantType"); + assert!( + serialized.contains(&expected_grant_type), + "Should contain {}, got: {}", + expected_grant_type, + serialized + ); + assert!( + serialized.contains(&format!("client_id={}", TEST_CLIENT_ID)), + "Should contain client_id, got: {}", + serialized + ); + // Verify scope matches the standard scopes (space becomes + in URL encoding) + let expected_scope = scopes_to_string(STANDARD_USER_SCOPES).replace(' ', "+"); + assert!( + serialized.contains(&format!("scope={}", expected_scope)), + "Should contain scope={}, got: {}", + expected_scope, + serialized + ); + + // Verify password-specific fields use snake_case (OAuth2 convention) + // Email is URL-encoded (@ becomes %40) + let url_encoded_email = TEST_EMAIL.replace('@', "%40"); + assert!( + serialized.contains(&format!("username={}", url_encoded_email)), + "Email should be serialized as 'username' per OAuth2 convention, got: {}", + serialized + ); + assert!( + serialized.contains("password="), + "Should contain password field with hash, got: {}", + serialized + ); + // Verify the actual hash is present (check for the hash in the serialized output) + // The hash may be URL-encoded, so we just verify the field exists with content + let password_field_present = serialized + .split('&') + .any(|pair| pair.starts_with("password=") && pair.len() > "password=".len()); + assert!( + password_field_present, + "Should contain password field with hash value, got: {}", + serialized + ); + + // Verify Bitwarden custom fields use camelCase + // DeviceType serializes using Debug format (variant name) + let expected_device_type = format!("deviceType={:?}", DeviceType::SDK); + assert!( + serialized.contains(&expected_device_type), + "Should contain {}, got: {}", + expected_device_type, + serialized + ); + assert!( + serialized.contains(&format!("deviceIdentifier={}", TEST_DEVICE_IDENTIFIER)), + "Should contain deviceIdentifier field, got: {}", + serialized + ); + // Device name is URL-encoded (space becomes +) + let url_encoded_device_name = TEST_DEVICE_NAME.replace(' ', "+"); + assert!( + serialized.contains(&format!("deviceName={}", url_encoded_device_name)), + "Should contain deviceName={}, got: {}", + url_encoded_device_name, + serialized + ); + assert!( + serialized.contains(&format!("devicePushToken={}", TEST_DEVICE_PUSH_TOKEN)), + "Should contain devicePushToken field, got: {}", + serialized + ); + + // Verify optional fields are not present when None + assert!( + !serialized.contains("twoFactorToken"), + "Should not contain twoFactorToken when None, got: {}", + serialized + ); + assert!( + !serialized.contains("twoFactorProvider"), + "Should not contain twoFactorProvider when None, got: {}", + serialized + ); + assert!( + !serialized.contains("twoFactorRemember"), + "Should not contain twoFactorRemember when None, got: {}", + serialized + ); + } +} 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..f3ac3c845 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -0,0 +1,418 @@ +use bitwarden_core::key_management::MasterPasswordError; +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::identity::api::response::{ + InvalidGrantError, LoginErrorApiResponse, OAuth2ErrorApiResponse, PasswordInvalidGrantError, +}; + +/// Errors that can occur during password-based login. +/// +/// This enum covers errors specific to the password authentication flow, including +/// credential validation, KDF processing, and API communication errors. +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordLoginError { + /// The username (email) or password provided was invalid. + /// + /// This error is returned by the server when: + /// - The email address doesn't exist in the system + /// - The master password hash doesn't match the stored hash + /// + /// # Note + /// For security reasons, the server doesn't distinguish between "user not found" + /// and "wrong password" to prevent user enumeration attacks. + #[error("Invalid username or password provided.")] + InvalidUsernameOrPassword, + + /// Failed to derive master password authentication data from the provided password and KDF + /// settings. + /// + /// This error can occur during local cryptographic processing before the API call when: + /// - The KDF parameters are invalid (e.g., iterations below minimum threshold) + /// - The KDF algorithm is unsupported or corrupted + /// - Memory allocation fails during Argon2id processing + #[error(transparent)] + PasswordAuthenticationDataDerivation(#[from] MasterPasswordError), + + /// An unknown or unexpected error occurred during login. + /// + /// This variant captures errors that don't fit other categories, including: + /// - Unexpected OAuth2 error codes from the server + /// - Network errors (timeouts, connection refused, DNS failures) + /// - Malformed server responses + /// - Future error types not yet handled by this SDK version + /// + /// The contained string provides details about what went wrong. + /// + /// # Forward Compatibility + /// This variant ensures the SDK can handle new error types introduced by the server + /// without breaking existing client code. + #[error("Unknown password login error: {0}")] + Unknown(String), +} + +// TODO: When adding 2FA support, consider how we can avoid having each login mechanism have to +// implement a conversion for 2FA errors 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 { + 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::Unknown(error_code)) => { + Self::Unknown(format!("Invalid grant - unknown error: {}", error_code)) + } + 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)) + } + } + } +} + +#[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"), + } + } + } +} 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..831252a70 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +use crate::identity::{login_via_password::PasswordPreloginResponse, models::LoginRequest}; + +/// Public SDK request model for logging in via password +#[derive(Serialize, Deserialize)] +#[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 { + /// Common login request fields + 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_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 new file mode 100644 index 000000000..b1bcfcbfd --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs @@ -0,0 +1,242 @@ +use bitwarden_api_identity::models::PasswordPreloginRequestModel; +use bitwarden_core::{ApiError, MissingFieldError}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::identity::{LoginClient, login_via_password::PasswordPreloginResponse}; + +/// Error type for password prelogin operations +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordPreloginError { + /// API error occurred during the prelogin request + #[error(transparent)] + Api(#[from] ApiError), + /// A required field was missing in the response + #[error(transparent)] + MissingField(#[from] MissingFieldError), +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +#[cfg_attr(feature = "uniffi", uniffi::export)] +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. + /// + /// # Arguments + /// * `email` - The user's email address + /// + /// # Returns + /// * `PasswordPreloginResponse` - Contains the KDF configuration for the user + pub async fn get_password_prelogin( + &self, + email: String, + ) -> Result { + let request_model = PasswordPreloginRequestModel::new(email); + 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 + .map_err(ApiError::from)?; + + Ok(PasswordPreloginResponse::try_from(response)?) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_identity::models::KdfType; + use bitwarden_core::{ClientSettings, DeviceType}; + use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, + }; + 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"; + + 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()), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + device_identifier: None, + bitwarden_client_version: None, + bitwarden_package_type: None, + }; + LoginClient::new(settings) + } + + #[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": default_pbkdf2_iterations().get() + }, + "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_login_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, default_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": default_argon2_iterations().get(), + "memory": default_argon2_memory().get(), + "parallelism": default_argon2_parallelism().get() + }, + "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_login_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, default_argon2_iterations()); + assert_eq!(memory, default_argon2_memory()); + assert_eq!(parallelism, default_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_login_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": default_pbkdf2_iterations().get() + } + }); + + 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_login_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_login_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), + } + } +} 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..50922bfd6 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs @@ -0,0 +1,291 @@ +use std::num::NonZeroU32; + +use bitwarden_api_identity::models::{KdfType, PasswordPreloginResponseModel}; +use bitwarden_core::{MissingFieldError, require}; +use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, +}; +use serde::{Deserialize, Serialize}; + +/// Response containing the data required before password-based authentication +#[derive(Serialize, Deserialize, 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 + // TODO: PM-30183 - make this a type for safety + 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 bitwarden_api_identity::models::KdfSettings; + + 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); + } +} diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index e83fb83e5..85930ddbb 100644 --- a/crates/bitwarden-auth/src/identity/mod.rs +++ b/crates/bitwarden-auth/src/identity/mod.rs @@ -1,5 +1,16 @@ -//! Identity client module -//! The IdentityClient is used to obtain identity / access tokens from the Bitwarden Identity API. -mod client; +//! 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 login_client; -pub use client::IdentityClient; +pub use login_client::LoginClient; + +/// 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; 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/login_device_request.rs b/crates/bitwarden-auth/src/identity/models/login_device_request.rs new file mode 100644 index 000000000..09f4dbaaa --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_device_request.rs @@ -0,0 +1,33 @@ +use bitwarden_core::DeviceType; +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)] +#[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 + /// 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 + pub device_identifier: String, + + /// 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, +} 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..c72c4816a --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_request.rs @@ -0,0 +1,24 @@ +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)] +#[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, + + /// 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/login_response.rs b/crates/bitwarden-auth/src/identity/models/login_response.rs new file mode 100644 index 000000000..9f99ec85c --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_response.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +use crate::identity::models::LoginSuccessResponse; + +/// 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), + // 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/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs new file mode 100644 index 000000000..fd4ffb12c --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -0,0 +1,95 @@ +use std::fmt::Debug; + +use bitwarden_core::{key_management::MasterPasswordError, require}; +use bitwarden_policies::MasterPasswordPolicyResponse; + +use crate::identity::{ + api::response::LoginSuccessApiResponse, models::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, Debug)] +#[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 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. + /// 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, + + /// The user key wrapped user private key. + /// Note: previously known as "private_key". + pub user_key_wrapped_user_private_key: Option, + + /// Two-factor authentication token for future requests. + pub two_factor_token: Option, + + /// 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: 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 + pub api_use_key_connector: Option, + + /// The user's decryption options for unlocking their vault. + pub user_decryption_options: UserDecryptionOptionsResponse, + + /// 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 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 + // compatibility and convenience. + let expires_at = + chrono::Utc::now().timestamp_millis() + (response.expires_in * 1000) as i64; + + Ok(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, + user_key_wrapped_user_private_key: response.private_key, + two_factor_token: response.two_factor_token, + force_password_reset: response.force_password_reset, + 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: response.master_password_policy.map(|policy| policy.into()), + }) + } +} 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..20572c5e4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/mod.rs @@ -0,0 +1,19 @@ +//! SDK models shared across multiple identity features + +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; +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; +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..2943d0cb5 --- /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.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap()), + encrypted_user_key: Some("2.kTtIypq9OLzd5iMMbU11pQ==|J4i3hTtGVdg7EZ+AQv/ujg==|QJpSpotQVpIW8j8dR/8l015WJzAIxBaOmrz4Uj/V1JA=".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..1a9d8bbc0 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs @@ -0,0 +1,402 @@ +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: 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()), + }) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::models::{ + KdfType, MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel, + }; + use bitwarden_crypto::Kdf; + + use super::*; + use crate::identity::api::response::{ + KeyConnectorUserDecryptionOptionApiResponse, TrustedDeviceUserDecryptionOptionApiResponse, + WebAuthnPrfUserDecryptionOptionApiResponse, + }; + + const MASTER_KEY_ENCRYPTED_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE="; + + #[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(MASTER_KEY_ENCRYPTED_USER_KEY.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.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8="; + const TDE_ENCRYPTED_USER_KEY: &str = "2.kTtIypq9OLzd5iMMbU11pQ==|J4i3hTtGVdg7EZ+AQv/ujg==|QJpSpotQVpIW8j8dR/8l015WJzAIxBaOmrz4Uj/V1JA="; + const KEY_CONNECTOR_URL: &str = "https://key-connector.bitwarden.com"; + const WEBAUTHN_ENCRYPTED_PRIVATE_KEY: &str = "2.fkvl0+sL1lwtiOn1eewsvQ==|dT0TynLl8YERZ8x7dxC+DQ==|cWhiRSYHOi/AA2LiV/JBJWbO9C7pbUpOM6TMAcV47hE="; + const WEBAUTHN_ENCRYPTED_USER_KEY: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8="; + + 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(MASTER_KEY_ENCRYPTED_USER_KEY.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() + ); + } + + #[test] + fn test_user_decryption_options_with_trusted_device_only() { + const TDE_ENCRYPTED_PRIVATE_KEY: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8="; + const TDE_ENCRYPTED_USER_KEY: &str = "2.kTtIypq9OLzd5iMMbU11pQ==|J4i3hTtGVdg7EZ+AQv/ujg==|QJpSpotQVpIW8j8dR/8l015WJzAIxBaOmrz4Uj/V1JA="; + + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: None, + trusted_device_option: Some(TrustedDeviceUserDecryptionOptionApiResponse { + has_admin_approval: false, + has_login_approving_device: true, + 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: None, + webauthn_prf_option: None, + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + assert!(domain.master_password_unlock.is_none()); + assert!(domain.trusted_device_option.is_some()); + assert!(domain.key_connector_option.is_none()); + assert!(domain.webauthn_prf_option.is_none()); + + let tde = domain.trusted_device_option.unwrap(); + assert!(!tde.has_admin_approval); + assert!(tde.has_login_approving_device); + 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()) + ); + } + + #[test] + fn test_user_decryption_options_with_key_connector_only() { + const KEY_CONNECTOR_URL: &str = "https://key-connector.example.com"; + + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: None, + trusted_device_option: None, + key_connector_option: Some(KeyConnectorUserDecryptionOptionApiResponse { + key_connector_url: KEY_CONNECTOR_URL.to_string(), + }), + webauthn_prf_option: None, + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + assert!(domain.master_password_unlock.is_none()); + assert!(domain.trusted_device_option.is_none()); + assert!(domain.key_connector_option.is_some()); + assert!(domain.webauthn_prf_option.is_none()); + + let kc = domain.key_connector_option.unwrap(); + assert_eq!(kc.key_connector_url, KEY_CONNECTOR_URL); + } + + #[test] + fn test_user_decryption_options_with_webauthn_prf_only() { + const WEBAUTHN_ENCRYPTED_PRIVATE_KEY: &str = "2.fkvl0+sL1lwtiOn1eewsvQ==|dT0TynLl8YERZ8x7dxC+DQ==|cWhiRSYHOi/AA2LiV/JBJWbO9C7pbUpOM6TMAcV47hE="; + const WEBAUTHN_ENCRYPTED_USER_KEY: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8="; + + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: None, + trusted_device_option: None, + key_connector_option: None, + 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(); + + assert!(domain.master_password_unlock.is_none()); + assert!(domain.trusted_device_option.is_none()); + assert!(domain.key_connector_option.is_none()); + 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() + ); + } + + #[test] + fn test_user_decryption_options_with_no_options() { + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: None, + 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_none()); + 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_with_master_password_and_trusted_device() { + const TDE_ENCRYPTED_PRIVATE_KEY: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8="; + const TDE_ENCRYPTED_USER_KEY: &str = "2.kTtIypq9OLzd5iMMbU11pQ==|J4i3hTtGVdg7EZ+AQv/ujg==|QJpSpotQVpIW8j8dR/8l015WJzAIxBaOmrz4Uj/V1JA="; + + 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(MASTER_KEY_ENCRYPTED_USER_KEY.to_string()), + salt: Some("test@example.com".to_string()), + }), + trusted_device_option: Some(TrustedDeviceUserDecryptionOptionApiResponse { + has_admin_approval: true, + has_login_approving_device: false, + has_manage_reset_password_permission: true, + 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: None, + webauthn_prf_option: None, + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + assert!(domain.master_password_unlock.is_some()); + assert!(domain.trusted_device_option.is_some()); + assert!(domain.key_connector_option.is_none()); + assert!(domain.webauthn_prf_option.is_none()); + } + + #[test] + fn test_user_decryption_options_with_master_password_and_key_connector() { + const KEY_CONNECTOR_URL: &str = "https://key-connector.example.com"; + + 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(MASTER_KEY_ENCRYPTED_USER_KEY.to_string()), + salt: Some("test@example.com".to_string()), + }), + trusted_device_option: None, + key_connector_option: Some(KeyConnectorUserDecryptionOptionApiResponse { + key_connector_url: KEY_CONNECTOR_URL.to_string(), + }), + webauthn_prf_option: None, + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + assert!(domain.master_password_unlock.is_some()); + assert!(domain.trusted_device_option.is_none()); + assert!(domain.key_connector_option.is_some()); + assert!(domain.webauthn_prf_option.is_none()); + } + + #[test] + fn test_user_decryption_options_with_master_password_and_webauthn_prf() { + const WEBAUTHN_ENCRYPTED_PRIVATE_KEY: &str = "2.fkvl0+sL1lwtiOn1eewsvQ==|dT0TynLl8YERZ8x7dxC+DQ==|cWhiRSYHOi/AA2LiV/JBJWbO9C7pbUpOM6TMAcV47hE="; + const WEBAUTHN_ENCRYPTED_USER_KEY: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8="; + + 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(MASTER_KEY_ENCRYPTED_USER_KEY.to_string()), + salt: Some("test@example.com".to_string()), + }), + trusted_device_option: None, + key_connector_option: None, + 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(); + + assert!(domain.master_password_unlock.is_some()); + assert!(domain.trusted_device_option.is_none()); + assert!(domain.key_connector_option.is_none()); + assert!(domain.webauthn_prf_option.is_some()); + } +} 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..e9e422391 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs @@ -0,0 +1,48 @@ +use bitwarden_crypto::{EncString, UnsignedSharedKey}; +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, + + /// Public Key encrypted user key + pub encrypted_user_key: UnsignedSharedKey, +} + +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.fkvl0+sL1lwtiOn1eewsvQ==|dT0TynLl8YERZ8x7dxC+DQ==|cWhiRSYHOi/AA2LiV/JBJWbO9C7pbUpOM6TMAcV47hE=".parse().unwrap(), + encrypted_user_key: "4.DMD1D5r6BsDDd7C/FE1eZbMCKrmryvAsCKj6+bO54gJNUxisOI7SDcpPLRXf+JdhqY15pT+wimQ5cD9C+6OQ6s71LFQHewXPU29l9Pa1JxGeiKqp37KLYf+1IS6UB2K3ANN35C52ZUHh2TlzIS5RuntxnpCw7APbcfpcnmIdLPJBtuj/xbFd6eBwnI3GSe5qdS6/Ixdd0dgsZcpz3gHJBKmIlSo0YN60SweDq3kTJwox9xSqdCueIDg5U4khc7RhjYx8b33HXaNJj3DwgIH8iLj+lqpDekogr630OhHG3XRpvl4QzYO45bmHb8wAh67Dj70nsZcVg6bAEFHdSFohww==".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); + } +} diff --git a/crates/bitwarden-auth/uniffi.toml b/crates/bitwarden-auth/uniffi.toml index 5ebc54bae..34b842428 100644 --- a/crates/bitwarden-auth/uniffi.toml +++ b/crates/bitwarden-auth/uniffi.toml @@ -6,4 +6,4 @@ android = true [bindings.swift] ffi_module_name = "BitwardenAuthFFI" module_name = "BitwardenAuth" -generate_immutable_records = true \ No newline at end of file +generate_immutable_records = true diff --git a/crates/bitwarden-core/src/client/client_settings.rs b/crates/bitwarden-core/src/client/client_settings.rs index f6a7fc34c..15d1d2ac2 100644 --- a/crates/bitwarden-core/src/client/client_settings.rs +++ b/crates/bitwarden-core/src/client/client_settings.rs @@ -21,7 +21,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( @@ -64,7 +64,7 @@ impl Default for ClientSettings { } #[allow(non_camel_case_types, missing_docs)] -#[derive(Serialize, Deserialize, Copy, Clone, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug, JsonSchema, PartialEq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] #[cfg_attr( feature = "wasm", @@ -102,7 +102,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/key_management/master_password.rs b/crates/bitwarden-core/src/key_management/master_password.rs index 514fbaf5e..dd76678cf 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( @@ -126,11 +126,9 @@ pub struct MasterPasswordAuthenticationData { } impl MasterPasswordAuthenticationData { - pub(crate) fn derive( - password: &str, - kdf: &Kdf, - salt: &str, - ) -> Result { + /// Derives the authentication data for registration or unlock for an account, given the + /// password, kdf settings and salt of the user. + 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 bd7c140a5..e34734997 100644 --- a/crates/bitwarden-core/src/key_management/mod.rs +++ b/crates/bitwarden-core/src/key_management/mod.rs @@ -24,9 +24,9 @@ pub use crypto_client::CryptoClient; #[cfg(feature = "internal")] mod master_password; #[cfg(feature = "internal")] -pub use master_password::MasterPasswordError; -#[cfg(feature = "internal")] -pub(crate) use master_password::{MasterPasswordAuthenticationData, MasterPasswordUnlockData}; +pub use master_password::{ + MasterPasswordAuthenticationData, MasterPasswordError, MasterPasswordUnlockData, +}; #[cfg(feature = "internal")] mod security_state; #[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::*; diff --git a/crates/bitwarden-crypto/src/enc_string/asymmetric.rs b/crates/bitwarden-crypto/src/enc_string/asymmetric.rs index b748987fe..d09e74a4a 100644 --- a/crates/bitwarden-crypto/src/enc_string/asymmetric.rs +++ b/crates/bitwarden-crypto/src/enc_string/asymmetric.rs @@ -53,7 +53,7 @@ mod internal { /// - `[type]`: is a digit number representing the variant. /// - `[data]`: is the encrypted data. #[allow(missing_docs)] - #[derive(Clone, zeroize::ZeroizeOnDrop)] + #[derive(Clone, zeroize::ZeroizeOnDrop, PartialEq)] #[allow(unused, non_camel_case_types)] pub enum UnsignedSharedKey { /// 3 diff --git a/crates/bitwarden-policies/Cargo.toml b/crates/bitwarden-policies/Cargo.toml index 1633d5629..1e60209c5 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); + } +} 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