diff --git a/Cargo.lock b/Cargo.lock index f1fb58469..ef460f769 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -506,16 +506,23 @@ dependencies = [ name = "bitwarden-auth" version = "1.0.0" dependencies = [ + "bitwarden-api-api", + "bitwarden-api-identity", "bitwarden-core", + "bitwarden-crypto", "bitwarden-error", + "bitwarden-policies", "bitwarden-test", "chrono", "reqwest", + "schemars 1.0.0", "serde", "serde_json", + "serde_repr", "thiserror 2.0.12", "tokio", "tsify", + "uniffi", "wasm-bindgen", "wasm-bindgen-futures", "wiremock", @@ -809,7 +816,11 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "tsify", + "uniffi", "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 416b18449..7e95743db 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -21,22 +21,34 @@ wasm = [ "dep:wasm-bindgen", "dep:wasm-bindgen-futures" ] # WASM support +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-error = { workspace = true } +bitwarden-policies = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } +schemars = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } +serde_repr = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } +uniffi = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } [dev-dependencies] bitwarden-test = { workspace = true } -serde_json = { workspace = true } tokio = { workspace = true, features = ["rt"] } wiremock = "0.6.0" diff --git a/crates/bitwarden-auth/src/api/enums/grant_type.rs b/crates/bitwarden-auth/src/api/enums/grant_type.rs index 757a21cdd..8fd984de9 100644 --- a/crates/bitwarden-auth/src/api/enums/grant_type.rs +++ b/crates/bitwarden-auth/src/api/enums/grant_type.rs @@ -12,4 +12,5 @@ pub(crate) enum GrantType { /// Bitwarden user. SendAccess, // TODO: Add other grant types as needed. + Password, } 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..0ff1349d1 --- /dev/null +++ b/crates/bitwarden-auth/src/api/enums/two_factor_provider.rs @@ -0,0 +1,20 @@ +use schemars::JsonSchema; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +// TODO: this isn't likely to be only limited to API usage... so maybe move to a more general +// location? + +/// Represents the two-factor authentication providers supported by Bitwarden. +#[allow(missing_docs)] +#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, JsonSchema, Clone)] +#[repr(u8)] +pub enum TwoFactorProvider { + Authenticator = 0, + Email = 1, + Duo = 2, + Yubikey = 3, + U2f = 4, + Remember = 5, + OrganizationDuo = 6, + WebAuthn = 7, +} diff --git a/crates/bitwarden-auth/src/api/request/mod.rs b/crates/bitwarden-auth/src/api/request/mod.rs new file mode 100644 index 000000000..a76eb55de --- /dev/null +++ b/crates/bitwarden-auth/src/api/request/mod.rs @@ -0,0 +1,4 @@ +//! Request models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoints) and are shared across multiple clients. +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/api/response/mod.rs b/crates/bitwarden-auth/src/api/response/mod.rs new file mode 100644 index 000000000..f5ed686d6 --- /dev/null +++ b/crates/bitwarden-auth/src/api/response/mod.rs @@ -0,0 +1,4 @@ +//! Response models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoint) and are shared across multiple clients. +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. diff --git a/crates/bitwarden-auth/src/identity/api/login_request_header.rs b/crates/bitwarden-auth/src/identity/api/login_request_header.rs new file mode 100644 index 000000000..b3849d0c4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/login_request_header.rs @@ -0,0 +1,54 @@ +use bitwarden_core::DeviceType; + +/// Custom headers used in login requests to the connect/token endpoint +/// - distinct from standard HTTP headers available in `reqwest::header`. +#[derive(Debug, Clone)] +pub enum LoginRequestHeader { + /// The "Device-Type" header indicates the type of device making the request. + DeviceType(DeviceType), +} + +impl LoginRequestHeader { + /// Returns the header name as a string. + pub fn header_name(&self) -> &'static str { + match self { + Self::DeviceType(_) => "Device-Type", + } + } + + /// Returns the header value as a string. + pub fn header_value(&self) -> String { + match self { + Self::DeviceType(device_type) => (*device_type as u8).to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_type_header_name() { + let header = LoginRequestHeader::DeviceType(DeviceType::SDK); + assert_eq!(header.header_name(), "Device-Type"); + } + + #[test] + fn test_device_type_header_value() { + let header = LoginRequestHeader::DeviceType(DeviceType::SDK); + assert_eq!(header.header_value(), "21"); + } + + #[test] + fn test_device_type_header_value_android() { + let header = LoginRequestHeader::DeviceType(DeviceType::Android); + assert_eq!(header.header_value(), "0"); + } + + #[test] + fn test_device_type_header_value_mac_os_cli() { + let header = LoginRequestHeader::DeviceType(DeviceType::MacOsCLI); + assert_eq!(header.header_value(), "24"); + } +} diff --git a/crates/bitwarden-auth/src/identity/api/mod.rs b/crates/bitwarden-auth/src/identity/api/mod.rs new file mode 100644 index 000000000..7fa15ab08 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/mod.rs @@ -0,0 +1,8 @@ +//! API related modules for Identity endpoints +pub(crate) mod login_request_header; +pub(crate) mod request; +pub(crate) mod response; + +/// Common send function for login requests +mod send_login_request; +pub(crate) use send_login_request::send_login_request; diff --git a/crates/bitwarden-auth/src/identity/api/request/login_api_request.rs b/crates/bitwarden-auth/src/identity/api/request/login_api_request.rs new file mode 100644 index 000000000..3ccc5a512 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/request/login_api_request.rs @@ -0,0 +1,89 @@ +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, + } + } +} 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..47cefb712 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/request/mod.rs @@ -0,0 +1,7 @@ +//! Request models for Identity API endpoints that cannot be auto-generated +//! (e.g., connect/token endpoints) and are shared across multiple features within the identity +//! client +//! +//! For standard controller endpoints, use the `bitwarden-api-identity` crate. +mod login_api_request; +pub(crate) use login_api_request::LoginApiRequest; 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..477372b89 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/login_error_api_response.rs @@ -0,0 +1,107 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use tsify::Tsify; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +pub enum PasswordInvalidGrantError { + InvalidUsernameOrPassword, + + /// Fallback for unknown variants for forward compatibility + #[serde(other)] + Unknown, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +pub enum InvalidGrantError { + // Password grant specific errors + Password(PasswordInvalidGrantError), + + // TODO: other grant specific errors can go here + /// Fallback for unknown variants for forward compatibility + #[serde(other)] + Unknown, +} + +/// Per RFC 6749 Section 5.2, these are the standard error responses for OAuth 2.0 token requests. +/// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +#[serde(tag = "error")] +pub enum OAuth2ErrorApiResponse { + /// Invalid request error, typically due to missing parameters for a specific + /// credential flow. Ex. `password` is required. + InvalidRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid request errors. + error_description: Option, + }, + + /// Invalid grant error, typically due to invalid credentials. + InvalidGrant { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid grant errors. + error_description: Option, + }, + + /// Invalid client error, typically due to an invalid client secret or client ID. + InvalidClient { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid client errors. + error_description: Option, + }, + + /// Unauthorized client error, typically due to an unauthorized client. + UnauthorizedClient { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for unauthorized client errors. + error_description: Option, + }, + + /// Unsupported grant type error, typically due to an unsupported credential flow. + UnsupportedGrantType { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for unsupported grant type errors. + error_description: Option, + }, + + /// Invalid scope error, typically due to an invalid scope requested. + InvalidScope { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid scope errors. + error_description: Option, + }, + + /// Invalid target error which is shown if the requested + /// resource is invalid, missing, unknown, or malformed. + InvalidTarget { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid target errors. + error_description: Option, + }, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub enum LoginErrorApiResponse { + OAuth2Error(OAuth2ErrorApiResponse), + UnexpectedError(String), +} + +// This is just a utility function so that the ? operator works correctly without manual mapping +impl From for LoginErrorApiResponse { + fn from(value: reqwest::Error) -> Self { + Self::UnexpectedError(format!("{value:?}")) + } +} diff --git a/crates/bitwarden-auth/src/identity/api/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..90d3d4028 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/login_success_api_response.rs @@ -0,0 +1,85 @@ +use bitwarden_api_api::models::MasterPasswordPolicyResponseModel; +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 + #[serde(rename = "PrivateKey", alias = "privateKey")] + pub private_key: Option, + + /// The master key wrapped user key. + #[deprecated(note = "Use `user_decryption_options.master_password_unlock` instead")] + #[serde(rename = "Key", alias = "key")] + pub key: Option, + + /// 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: KdfType, + + /// 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..d6bbeaf74 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/mod.rs @@ -0,0 +1,22 @@ +//! 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::LoginErrorApiResponse; diff --git a/crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs new file mode 100644 index 000000000..d0b1f021e --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/trusted_device_user_decryption_option_api_response.rs @@ -0,0 +1,34 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +/// Trusted Device User Decryption Option API response. +/// Contains settings and encrypted keys for trusted device decryption. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct TrustedDeviceUserDecryptionOptionApiResponse { + /// Whether the user has admin approval for device login. + #[serde(rename = "HasAdminApproval")] + pub has_admin_approval: bool, + + /// Whether the user has a device that can approve logins. + #[serde(rename = "HasLoginApprovingDevice")] + pub has_login_approving_device: bool, + + /// Whether the user has permission to manage password reset for other users. + #[serde(rename = "HasManageResetPasswordPermission")] + pub has_manage_reset_password_permission: bool, + + /// Whether the user is in TDE offboarding. + #[serde(rename = "IsTdeOffboarding")] + pub is_tde_offboarding: bool, + + /// The device key encrypted device private key. Only present if the device is trusted. + #[serde( + rename = "EncryptedPrivateKey", + skip_serializing_if = "Option::is_none" + )] + pub encrypted_private_key: Option, + + /// The device private key encrypted user key. Only present if the device is trusted. + #[serde(rename = "EncryptedUserKey", skip_serializing_if = "Option::is_none")] + pub encrypted_user_key: Option, +} diff --git a/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs new file mode 100644 index 000000000..729cf1ff6 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/user_decryption_options_api_response.rs @@ -0,0 +1,36 @@ +use bitwarden_api_api::models::MasterPasswordUnlockResponseModel; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::{ + KeyConnectorUserDecryptionOptionApiResponse, TrustedDeviceUserDecryptionOptionApiResponse, + WebAuthnPrfUserDecryptionOptionApiResponse, +}; + +/// Provides user decryption options used to unlock user's vault. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub(crate) struct UserDecryptionOptionsApiResponse { + /// Contains information needed to unlock user's vault with master password. + /// None when user does not have a master password. + #[serde( + rename = "MasterPasswordUnlock", + skip_serializing_if = "Option::is_none" + )] + pub master_password_unlock: Option, + + /// Trusted Device Decryption Option. + #[serde( + rename = "TrustedDeviceOption", + skip_serializing_if = "Option::is_none" + )] + pub trusted_device_option: Option, + + /// Key Connector Decryption Option. + /// This option is mutually exlusive with the Trusted Device option as you + /// must configure one or the other in the Organization SSO configuration. + #[serde(rename = "KeyConnectorOption", skip_serializing_if = "Option::is_none")] + pub key_connector_option: Option, + + /// WebAuthn PRF Decryption Option. + #[serde(rename = "WebAuthnPrfOption", skip_serializing_if = "Option::is_none")] + pub webauthn_prf_option: Option, +} diff --git a/crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs b/crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs new file mode 100644 index 000000000..f47e2fdd8 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/response/webauthn_prf_user_decryption_option_api_response.rs @@ -0,0 +1,15 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +/// WebAuthn PRF User Decryption Option API response. +/// Contains all required fields for WebAuthn PRF decryption. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub(crate) struct WebAuthnPrfUserDecryptionOptionApiResponse { + /// PRF key encrypted private key + #[serde(rename = "EncryptedPrivateKey")] + pub encrypted_private_key: EncString, + + /// Private Key encrypted user key + #[serde(rename = "EncryptedUserKey")] + pub encrypted_user_key: EncString, +} diff --git a/crates/bitwarden-auth/src/identity/api/send_login_request.rs b/crates/bitwarden-auth/src/identity/api/send_login_request.rs new file mode 100644 index 000000000..c7d7d0fde --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -0,0 +1,66 @@ +// Cleanest idea for allowing access to data needed for sending login requests +// Make this function accept the commmon model and flatten the specific + +use bitwarden_core::{auth::login, client::ApiConfigurations}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::identity::{ + api::{ + login_request_header::LoginRequestHeader, + request::LoginApiRequest, + response::{LoginErrorApiResponse, LoginSuccessApiResponse}, + }, + models::{LoginError, LoginResponse, LoginSuccessResponse}, +}; + +/// A common function to send login requests to the Identity connect/token endpoint. +pub(crate) async fn send_login_request( + api_configs: &ApiConfigurations, + api_request: &LoginApiRequest, +) -> Result { + let identity_config = &api_configs.identity_config; + + let url: String = format!("{}/connect/token", &identity_config.base_path); + + let device_type_header: LoginRequestHeader = + LoginRequestHeader::DeviceType(api_request.device_type); + + let request: reqwest::RequestBuilder = identity_config + .client + .post(url) + .header(reqwest::header::ACCEPT, "application/json") + // Add custom device type header + .header( + device_type_header.header_name(), + device_type_header.header_value(), + ) + // per OAuth2 spec recommendation for token requests (https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1) + // we must include "no-store" cache control + .header(reqwest::header::CACHE_CONTROL, "no-store") + // use form to encode as application/x-www-form-urlencoded + .form(&api_request); + + let 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?; + + // TODO: define LoginSuccessResponse model in SDK layer and add into trait from + // LoginSuccessApiResponse to convert between API model and SDK model + + let login_success_response: LoginSuccessResponse = login_success_api_response.try_into()?; + + let login_response = LoginResponse::Authenticated (login_success_response); + + return Ok(login_response); + } + + // Handle error response + let login_error_api_response: LoginErrorApiResponse = response.json().await?; + + Err(login_error_api_response) + + todo!() +} diff --git a/crates/bitwarden-auth/src/identity/client.rs b/crates/bitwarden-auth/src/identity/identity_client.rs similarity index 79% rename from crates/bitwarden-auth/src/identity/client.rs rename to crates/bitwarden-auth/src/identity/identity_client.rs index b2ae75e95..61de3a4d5 100644 --- a/crates/bitwarden-auth/src/identity/client.rs +++ b/crates/bitwarden-auth/src/identity/identity_client.rs @@ -6,7 +6,6 @@ use wasm_bindgen::prelude::*; #[derive(Clone)] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct IdentityClient { - #[allow(dead_code)] // TODO: Remove when methods using client are implemented pub(crate) client: Client, } @@ -17,11 +16,6 @@ impl IdentityClient { } } -#[cfg_attr(feature = "wasm", wasm_bindgen)] -impl IdentityClient { - // TODO: Add methods to interact with the Identity API. -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs new file mode 100644 index 000000000..4de03bd3f --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -0,0 +1,45 @@ +use bitwarden_core::key_management::MasterPasswordAuthenticationData; + +use crate::identity::{ + IdentityClient, + api::{request::LoginApiRequest, send_login_request}, + login_via_password::{PasswordLoginApiRequest, PasswordLoginRequest}, + models::{LoginError, LoginResponse}, +}; + +impl IdentityClient { + /// Logs in a user via their email and master password. + /// + /// This function derives the necessary master password authentication data + /// using the provided prelogin data, constructs the appropriate API request, + /// and sends the request to the Identity connect/token endpoint to log the user in. + pub async fn login_via_password( + &self, + request: PasswordLoginRequest, + ) -> Result { + // use request password prelogin data to derive master password authentication data: + let master_password_authentication: Result< + MasterPasswordAuthenticationData, + bitwarden_core::key_management::MasterPasswordError, + > = MasterPasswordAuthenticationData::derive( + &request.password, + &request.prelogin_response.kdf, + &request.email, + ); + + // construct API request + let api_request: LoginApiRequest = + (request, master_password_authentication.unwrap()).into(); + + // make API call to login endpoint with api_request + let api_configs = self.client.internal.get_api_configurations().await; + + let response = send_login_request(&api_configs, &api_request).await; + + // if success, we must validate that user decryption options are present as if they are + // missing we cannot proceed with unlocking the user's vault. + + // TODO: figure out how to handle errors. + todo!() + } +} 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..2b48f3e11 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -0,0 +1,11 @@ +mod login_via_password; +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; 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..85054458a --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -0,0 +1,54 @@ +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. +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct PasswordLoginApiRequest { + // // Common user token request payload + // #[serde(flatten)] + // user_login_api_request: UserLoginApiRequest, + /// Bitwarden user email address + #[serde(rename = "username")] + pub email: String, + + /// Bitwarden user master password hash + #[serde(rename = "password")] + pub master_password_hash: String, +} + +/// Converts a `PasswordLoginRequest` and `MasterPasswordAuthenticationData` into a +/// `PasswordLoginApiRequest` for making the API call. +impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)> + for 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, + ) + } +} 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..16c0cc53c --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_request.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +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, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct PasswordLoginRequest { + /// 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..9418c0a4d --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin.rs @@ -0,0 +1,237 @@ +use bitwarden_api_identity::models::PasswordPreloginRequestModel; +use bitwarden_core::{ApiError, MissingFieldError}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::identity::{IdentityClient, login_via_password::PasswordPreloginResponse}; + +/// Error type for password prelogin operations +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordPreloginError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), +} + +impl IdentityClient { + /// Retrieves the data required before authenticating with a password. + /// This includes the user's KDF configuration needed to properly derive the master key. + /// + /// # Arguments + /// * `email` - The user's email address + /// + /// # Returns + /// * `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 config = self.client.internal.get_api_configurations().await; + let response = config + .identity_client + .accounts_api() + .post_password_prelogin(Some(request_model)) + .await + .map_err(ApiError::from)?; + + Ok(PasswordPreloginResponse::try_from(response)?) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_identity::models::KdfType; + use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType}; + use bitwarden_crypto::Kdf; + use bitwarden_test::start_api_mock; + use wiremock::{Mock, ResponseTemplate, matchers}; + + use super::*; + + const TEST_EMAIL: &str = "test@example.com"; + const TEST_SALT_PBKDF2: &str = "test-salt-value"; + const TEST_SALT_ARGON2: &str = "argon2-salt-value"; + const PBKDF2_ITERATIONS: u32 = 600000; + const ARGON2_ITERATIONS: u32 = 3; + const ARGON2_MEMORY: u32 = 64; + const ARGON2_PARALLELISM: u32 = 4; + + fn make_identity_client(mock_server: &wiremock::MockServer) -> IdentityClient { + let settings = ClientSettings { + identity_url: format!("http://{}/identity", mock_server.address()), + api_url: format!("http://{}/api", mock_server.address()), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + let core_client = CoreClient::new(Some(settings)); + IdentityClient::new(core_client) + } + + #[tokio::test] + async fn test_get_password_prelogin_pbkdf2_success() { + // Create a mock success response with PBKDF2 + let raw_success = serde_json::json!({ + "kdfSettings": { + "kdfType": KdfType::PBKDF2_SHA256 as i32, + "iterations": PBKDF2_ITERATIONS + }, + "salt": TEST_SALT_PBKDF2 + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/accounts/prelogin/password")) + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/json", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await + .unwrap(); + + assert_eq!(result.salt, TEST_SALT_PBKDF2); + match result.kdf { + Kdf::PBKDF2 { iterations } => { + assert_eq!(iterations.get(), PBKDF2_ITERATIONS); + } + _ => panic!("Expected PBKDF2 KDF type"), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_argon2id_success() { + // Create a mock success response with Argon2id + let raw_success = serde_json::json!({ + "kdfSettings": { + "kdfType": KdfType::Argon2id as i32, + "iterations": ARGON2_ITERATIONS, + "memory": ARGON2_MEMORY, + "parallelism": ARGON2_PARALLELISM + }, + "salt": TEST_SALT_ARGON2 + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/accounts/prelogin/password")) + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/json", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await + .unwrap(); + + assert_eq!(result.salt, TEST_SALT_ARGON2); + match result.kdf { + Kdf::Argon2id { + iterations, + memory, + parallelism, + } => { + assert_eq!(iterations.get(), ARGON2_ITERATIONS); + assert_eq!(memory.get(), ARGON2_MEMORY); + assert_eq!(parallelism.get(), ARGON2_PARALLELISM); + } + _ => panic!("Expected Argon2id KDF type"), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_missing_kdf_settings() { + // Create a mock response missing kdf_settings + let raw_response = serde_json::json!({ + "salt": TEST_SALT_PBKDF2 + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/accounts/prelogin/password")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + PasswordPreloginError::MissingField(err) => { + assert_eq!(err.0, "response.kdf_settings"); + } + other => panic!("Expected MissingField error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_missing_salt() { + // Create a mock response missing salt + let raw_response = serde_json::json!({ + "kdfSettings": { + "kdfType": KdfType::PBKDF2_SHA256 as i32, + "iterations": PBKDF2_ITERATIONS + } + }); + + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("/identity/accounts/prelogin/password")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_response)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + PasswordPreloginError::MissingField(err) => { + assert_eq!(err.0, "response.salt"); + } + other => panic!("Expected MissingField error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_get_password_prelogin_api_error() { + // Create a mock 500 error + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("/identity/accounts/prelogin/password")) + .respond_with(ResponseTemplate::new(500)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let identity_client = make_identity_client(&mock_server); + + let result = identity_client + .get_password_prelogin(TEST_EMAIL.to_string()) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + PasswordPreloginError::Api(bitwarden_core::ApiError::ResponseContent { + status, + message: _, + }) => { + assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR); + } + other => panic!("Expected Api ResponseContent error, got {:?}", other), + } + } +} 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..07af1b54b --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_prelogin_response.rs @@ -0,0 +1,289 @@ +use std::num::NonZeroU32; + +use bitwarden_api_identity::models::{KdfSettings, KdfType, PasswordPreloginResponseModel}; +use bitwarden_core::{MissingFieldError, require}; +use bitwarden_crypto::{ + Kdf, default_argon2_iterations, default_argon2_memory, default_argon2_parallelism, + default_pbkdf2_iterations, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Response containing the data required before password-based authentication +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct PasswordPreloginResponse { + /// The Key Derivation Function (KDF) configuration for the user + pub kdf: Kdf, + + /// The salt used in the KDF process + pub salt: String, +} + +impl TryFrom for PasswordPreloginResponse { + type Error = MissingFieldError; + + fn try_from(response: PasswordPreloginResponseModel) -> Result { + let kdf_settings = require!(response.kdf_settings); + + let kdf = match kdf_settings.kdf_type { + KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 { + iterations: NonZeroU32::new(kdf_settings.iterations as u32) + .unwrap_or_else(default_pbkdf2_iterations), + }, + KdfType::Argon2id => Kdf::Argon2id { + iterations: NonZeroU32::new(kdf_settings.iterations as u32) + .unwrap_or_else(default_argon2_iterations), + memory: kdf_settings + .memory + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_memory), + parallelism: kdf_settings + .parallelism + .and_then(|e| NonZeroU32::new(e as u32)) + .unwrap_or_else(default_argon2_parallelism), + }, + }; + + Ok(PasswordPreloginResponse { + kdf, + salt: require!(response.salt), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SALT: &str = "test-salt"; + + #[test] + fn test_try_from_pbkdf2_with_iterations() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 100000, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: NonZeroU32::new(100000).unwrap() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_pbkdf2_default_iterations() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 0, // Zero will trigger default + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_argon2id_with_all_params() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 4, + memory: Some(64), + parallelism: Some(4), + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: NonZeroU32::new(4).unwrap(), + memory: NonZeroU32::new(64).unwrap(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_argon2id_default_params() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 0, // Zero will trigger default + memory: None, // None will trigger default + parallelism: None, // None will trigger default + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: default_argon2_parallelism(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_missing_kdf_settings() { + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: None, // Missing kdf_settings + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_try_from_missing_salt() { + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 100000, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: None, // Missing salt + }; + + let result = PasswordPreloginResponse::try_from(response); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MissingFieldError { .. })); + } + + #[test] + fn test_try_from_zero_iterations_uses_default() { + // When the server returns 0, NonZeroU32::new returns None, so defaults should be used + let kdf_settings = KdfSettings { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 0, + memory: None, + parallelism: None, + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::PBKDF2 { + iterations: default_pbkdf2_iterations() + } + ); + assert_eq!(result.salt, TEST_SALT); + } + + #[test] + fn test_try_from_argon2id_partial_zero_values() { + // Test that zero values fall back to defaults for Argon2id + let kdf_settings = KdfSettings { + kdf_type: KdfType::Argon2id, + iterations: 0, // Zero will trigger default + memory: Some(0), // Zero will trigger default + parallelism: Some(4), + }; + + let response = PasswordPreloginResponseModel { + kdf: None, + kdf_iterations: None, + kdf_memory: None, + kdf_parallelism: None, + kdf_settings: Some(Box::new(kdf_settings)), + salt: Some(TEST_SALT.to_string()), + }; + + let result = PasswordPreloginResponse::try_from(response).unwrap(); + + assert_eq!( + result.kdf, + Kdf::Argon2id { + iterations: default_argon2_iterations(), + memory: default_argon2_memory(), + parallelism: NonZeroU32::new(4).unwrap(), + } + ); + assert_eq!(result.salt, TEST_SALT); + } +} diff --git a/crates/bitwarden-auth/src/identity/mod.rs b/crates/bitwarden-auth/src/identity/mod.rs index e83fb83e5..2ddd981e7 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; +//! The IdentityClient is used to authenticate a Bitwarden User. +//! This involves logging in via various mechanisms (password, SSO, etc.) to obtain +//! OAuth2 tokens from the BW Identity API. +mod identity_client; -pub use client::IdentityClient; +pub use identity_client::IdentityClient; + +/// Models used by the identity module +pub mod models; + +/// Login via password functionality +pub mod login_via_password; + +// API models should be private to the identity module as they are only used internally. +pub(crate) mod api; 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..29ba8a846 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_device_request.rs @@ -0,0 +1,34 @@ +use bitwarden_core::DeviceType; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Device information for login requests. +/// This is common across all login mechanisms and describes the device +/// making the authentication request. +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] // add mobile support +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] // add wasm support +pub struct LoginDeviceRequest { + /// The type of device making the login request + /// 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_error.rs b/crates/bitwarden-auth/src/identity/models/login_error.rs new file mode 100644 index 000000000..c95ea9ee8 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_error.rs @@ -0,0 +1 @@ +// TODO: try to figure out what this error should look like diff --git a/crates/bitwarden-auth/src/identity/models/login_request.rs b/crates/bitwarden-auth/src/identity/models/login_request.rs new file mode 100644 index 000000000..5535c98a4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_request.rs @@ -0,0 +1,25 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::LoginDeviceRequest; + +/// The common bucket of login fields to be re-used across all login mechanisms +/// (e.g., password, SSO, etc.). This will include handling client_id and 2FA. +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[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..e92fd9efa --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_response.rs @@ -0,0 +1,11 @@ +use crate::identity::models::LoginSuccessResponse; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum LoginResponse { + Authenticated(LoginSuccessResponse), + // Payload(IdentityTokenPayloadResponse), TBD for secrets manager use + // Refreshed(LoginRefreshResponse), + // TwoFactorRequired(Box), + // TODO: add new device verification response +} diff --git a/crates/bitwarden-auth/src/identity/models/login_success_response.rs b/crates/bitwarden-auth/src/identity/models/login_success_response.rs new file mode 100644 index 000000000..d3ab9e054 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/login_success_response.rs @@ -0,0 +1,96 @@ +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)] +#[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) +)] +#[derive(Debug)] +pub struct LoginSuccessResponse { + /// The access token string. + pub access_token: String, + + /// The duration in seconds until the token expires. + pub expires_in: u64, + + /// The timestamp in milliseconds when the token expires. + /// 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..b0b4acde4 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/trusted_device_user_decryption_option.rs @@ -0,0 +1,80 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::TrustedDeviceUserDecryptionOptionApiResponse; + +/// SDK domain model for Trusted Device user decryption option. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct TrustedDeviceUserDecryptionOption { + /// Whether the user has admin approval for device login. + pub has_admin_approval: bool, + + /// Whether the user has a device that can approve logins. + pub has_login_approving_device: bool, + + /// Whether the user has permission to manage password reset for other users. + pub has_manage_reset_password_permission: bool, + + /// Whether the user is in TDE offboarding. + pub is_tde_offboarding: bool, + + /// The device key encrypted device private key. Only present if the device is trusted. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_private_key: Option, + + /// The device private key encrypted user key. Only present if the device is trusted. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_user_key: Option, +} + +impl From for TrustedDeviceUserDecryptionOption { + fn from(api: TrustedDeviceUserDecryptionOptionApiResponse) -> Self { + Self { + has_admin_approval: api.has_admin_approval, + has_login_approving_device: api.has_login_approving_device, + has_manage_reset_password_permission: api.has_manage_reset_password_permission, + is_tde_offboarding: api.is_tde_offboarding, + encrypted_private_key: api.encrypted_private_key, + encrypted_user_key: api.encrypted_user_key, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trusted_device_conversion() { + let api = TrustedDeviceUserDecryptionOptionApiResponse { + has_admin_approval: true, + has_login_approving_device: false, + has_manage_reset_password_permission: true, + is_tde_offboarding: false, + encrypted_private_key: Some("2.test|encrypted".parse().unwrap()), + encrypted_user_key: Some("2.test|encrypted2".parse().unwrap()), + }; + + let domain: TrustedDeviceUserDecryptionOption = api.clone().into(); + + assert_eq!(domain.has_admin_approval, api.has_admin_approval); + assert_eq!( + domain.has_login_approving_device, + api.has_login_approving_device + ); + assert_eq!( + domain.has_manage_reset_password_permission, + api.has_manage_reset_password_permission + ); + assert_eq!(domain.is_tde_offboarding, api.is_tde_offboarding); + assert_eq!(domain.encrypted_private_key, api.encrypted_private_key); + assert_eq!(domain.encrypted_user_key, api.encrypted_user_key); + } +} diff --git a/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs new file mode 100644 index 000000000..0c54714ee --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/user_decryption_options_response.rs @@ -0,0 +1,193 @@ +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, + }; + + #[test] + fn test_user_decryption_options_conversion_with_master_password() { + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: Some(MasterPasswordUnlockResponseModel { + kdf: Box::new(MasterPasswordUnlockKdfResponseModel { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: 600000, + memory: None, + parallelism: None, + }), + master_key_encrypted_user_key: Some( + "2.q/2tw0ANVGbyBaS+RxLdNw==|mIreJLpxs/pkCCWEn/L/CA==".to_string(), + ), + salt: Some("test@example.com".to_string()), + }), + trusted_device_option: None, + key_connector_option: None, + webauthn_prf_option: None, + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + assert!(domain.master_password_unlock.is_some()); + let mp_unlock = domain.master_password_unlock.unwrap(); + assert_eq!(mp_unlock.salt, "test@example.com"); + match mp_unlock.kdf { + Kdf::PBKDF2 { iterations } => { + assert_eq!(iterations.get(), 600000); + } + _ => panic!("Expected PBKDF2 KDF"), + } + assert!(domain.trusted_device_option.is_none()); + assert!(domain.key_connector_option.is_none()); + assert!(domain.webauthn_prf_option.is_none()); + } + + #[test] + fn test_user_decryption_options_conversion_with_all_options() { + // Test data constants + const SALT: &str = "test@example.com"; + const KDF_ITERATIONS: u32 = 600000; + const TDE_ENCRYPTED_PRIVATE_KEY: &str = "2.test|encrypted"; + const TDE_ENCRYPTED_USER_KEY: &str = "2.test|encrypted2"; + const KEY_CONNECTOR_URL: &str = "https://key-connector.bitwarden.com"; + const WEBAUTHN_ENCRYPTED_PRIVATE_KEY: &str = "2.test|encrypted3"; + const WEBAUTHN_ENCRYPTED_USER_KEY: &str = "2.test|encrypted4"; + + let api = UserDecryptionOptionsApiResponse { + master_password_unlock: Some(MasterPasswordUnlockResponseModel { + kdf: Box::new(MasterPasswordUnlockKdfResponseModel { + kdf_type: KdfType::PBKDF2_SHA256, + iterations: KDF_ITERATIONS as i32, + memory: None, + parallelism: None, + }), + master_key_encrypted_user_key: Some( + "2.q/2tw0ANVGbyBaS+RxLdNw==|mIreJLpxs/pkCCWEn/L/CA==".to_string(), + ), + salt: Some(SALT.to_string()), + }), + trusted_device_option: Some(TrustedDeviceUserDecryptionOptionApiResponse { + has_admin_approval: true, + has_login_approving_device: false, + has_manage_reset_password_permission: false, + is_tde_offboarding: false, + encrypted_private_key: Some(TDE_ENCRYPTED_PRIVATE_KEY.parse().unwrap()), + encrypted_user_key: Some(TDE_ENCRYPTED_USER_KEY.parse().unwrap()), + }), + key_connector_option: Some(KeyConnectorUserDecryptionOptionApiResponse { + key_connector_url: KEY_CONNECTOR_URL.to_string(), + }), + webauthn_prf_option: Some(WebAuthnPrfUserDecryptionOptionApiResponse { + encrypted_private_key: WEBAUTHN_ENCRYPTED_PRIVATE_KEY.parse().unwrap(), + encrypted_user_key: WEBAUTHN_ENCRYPTED_USER_KEY.parse().unwrap(), + }), + }; + + let domain: UserDecryptionOptionsResponse = api.try_into().unwrap(); + + // Verify master password unlock + assert!(domain.master_password_unlock.is_some()); + let mp_unlock = domain.master_password_unlock.unwrap(); + assert_eq!(mp_unlock.salt, SALT); + match mp_unlock.kdf { + Kdf::PBKDF2 { iterations } => { + assert_eq!(iterations.get(), KDF_ITERATIONS); + } + _ => panic!("Expected PBKDF2 KDF"), + } + + // Verify trusted device option + assert!(domain.trusted_device_option.is_some()); + let tde = domain.trusted_device_option.unwrap(); + assert!(tde.has_admin_approval); + assert!(!tde.has_login_approving_device); + assert!(!tde.has_manage_reset_password_permission); + assert!(!tde.is_tde_offboarding); + assert_eq!( + tde.encrypted_private_key, + Some(TDE_ENCRYPTED_PRIVATE_KEY.parse().unwrap()) + ); + assert_eq!( + tde.encrypted_user_key, + Some(TDE_ENCRYPTED_USER_KEY.parse().unwrap()) + ); + + // Verify key connector option + assert!(domain.key_connector_option.is_some()); + let kc = domain.key_connector_option.unwrap(); + assert_eq!(kc.key_connector_url, KEY_CONNECTOR_URL); + + // Verify webauthn prf option + assert!(domain.webauthn_prf_option.is_some()); + let webauthn = domain.webauthn_prf_option.unwrap(); + assert_eq!( + webauthn.encrypted_private_key, + WEBAUTHN_ENCRYPTED_PRIVATE_KEY.parse().unwrap() + ); + assert_eq!( + webauthn.encrypted_user_key, + WEBAUTHN_ENCRYPTED_USER_KEY.parse().unwrap() + ); + } +} diff --git a/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs b/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs new file mode 100644 index 000000000..02b15fa64 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/models/webauthn_prf_user_decryption_option.rs @@ -0,0 +1,48 @@ +use bitwarden_crypto::EncString; +use serde::{Deserialize, Serialize}; + +use crate::identity::api::response::WebAuthnPrfUserDecryptionOptionApiResponse; + +/// SDK domain model for WebAuthn PRF user decryption option. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct WebAuthnPrfUserDecryptionOption { + /// PRF key encrypted private key + pub encrypted_private_key: EncString, + + /// Private Key encrypted user key + pub encrypted_user_key: EncString, +} + +impl From for WebAuthnPrfUserDecryptionOption { + fn from(api: WebAuthnPrfUserDecryptionOptionApiResponse) -> Self { + Self { + encrypted_private_key: api.encrypted_private_key, + encrypted_user_key: api.encrypted_user_key, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_webauthn_prf_conversion() { + let api = WebAuthnPrfUserDecryptionOptionApiResponse { + encrypted_private_key: "2.test|encrypted".parse().unwrap(), + encrypted_user_key: "2.test|encrypted2".parse().unwrap(), + }; + + let domain: WebAuthnPrfUserDecryptionOption = api.clone().into(); + + assert_eq!(domain.encrypted_private_key, api.encrypted_private_key); + assert_eq!(domain.encrypted_user_key, api.encrypted_user_key); + } +} diff --git a/crates/bitwarden-auth/src/lib.rs b/crates/bitwarden-auth/src/lib.rs index db5dc561f..87c20820e 100644 --- a/crates/bitwarden-auth/src/lib.rs +++ b/crates/bitwarden-auth/src/lib.rs @@ -1,5 +1,8 @@ #![doc = include_str!("../README.md")] +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); + mod auth_client; pub mod identity; diff --git a/crates/bitwarden-auth/src/send_access/access_token_response.rs b/crates/bitwarden-auth/src/send_access/access_token_response.rs index 29e7cdbc8..43dd56a8f 100644 --- a/crates/bitwarden-auth/src/send_access/access_token_response.rs +++ b/crates/bitwarden-auth/src/send_access/access_token_response.rs @@ -10,6 +10,7 @@ use crate::send_access::api::{SendAccessTokenApiErrorResponse, SendAccessTokenAp derive(tsify::Tsify), tsify(into_wasm_abi, from_wasm_abi) )] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug)] pub struct SendAccessTokenResponse { /// The actual token string. @@ -73,3 +74,7 @@ impl From for SendAccessTokenError { tsify(into_wasm_abi, from_wasm_abi) )] pub struct UnexpectedIdentityError(pub String); + +// Newtype wrapper for unexpected identity errors for uniffi compatibility. +#[cfg(feature = "uniffi")] // only compile this when uniffi feature is enabled +uniffi::custom_newtype!(UnexpectedIdentityError, String); diff --git a/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs index 1c17cca0c..8308dabec 100644 --- a/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs +++ b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs @@ -5,6 +5,7 @@ use tsify::Tsify; #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] /// Invalid request errors - typically due to missing parameters. pub enum SendAccessTokenInvalidRequestError { #[allow(missing_docs)] @@ -27,6 +28,7 @@ pub enum SendAccessTokenInvalidRequestError { #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] /// Invalid grant errors - typically due to invalid credentials. pub enum SendAccessTokenInvalidGrantError { #[allow(missing_docs)] @@ -53,6 +55,7 @@ pub enum SendAccessTokenInvalidGrantError { #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "snake_case")] #[serde(tag = "error")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] // ^ "error" becomes the variant discriminator which matches against the rename annotations; // "error_description" is the payload for that variant which can be optional. /// Represents the possible, expected errors that can occur when requesting a send access token. diff --git a/crates/bitwarden-auth/uniffi.toml b/crates/bitwarden-auth/uniffi.toml new file mode 100644 index 000000000..34b842428 --- /dev/null +++ b/crates/bitwarden-auth/uniffi.toml @@ -0,0 +1,9 @@ +[bindings.kotlin] +package_name = "com.bitwarden.auth" +generate_immutable_records = true +android = true + +[bindings.swift] +ffi_module_name = "BitwardenAuthFFI" +module_name = "BitwardenAuth" +generate_immutable_records = true diff --git a/crates/bitwarden-core/src/key_management/master_password.rs b/crates/bitwarden-core/src/key_management/master_password.rs index 514fbaf5e..25645fdc6 100644 --- a/crates/bitwarden-core/src/key_management/master_password.rs +++ b/crates/bitwarden-core/src/key_management/master_password.rs @@ -35,7 +35,7 @@ pub enum MasterPasswordError { } /// Represents the data required to unlock with the master password. -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr( @@ -126,11 +126,8 @@ pub struct MasterPasswordAuthenticationData { } impl MasterPasswordAuthenticationData { - pub(crate) fn derive( - password: &str, - kdf: &Kdf, - salt: &str, - ) -> Result { + #[allow(missing_docs)] + pub fn derive(password: &str, kdf: &Kdf, salt: &str) -> Result { let master_key = MasterKey::derive(password, salt, kdf) .map_err(|_| MasterPasswordError::InvalidKdfConfiguration)?; let hash = master_key.derive_master_key_hash( diff --git a/crates/bitwarden-core/src/key_management/mod.rs b/crates/bitwarden-core/src/key_management/mod.rs index bd7c140a5..5219f7ffd 100644 --- a/crates/bitwarden-core/src/key_management/mod.rs +++ b/crates/bitwarden-core/src/key_management/mod.rs @@ -24,9 +24,11 @@ pub use crypto_client::CryptoClient; #[cfg(feature = "internal")] mod master_password; #[cfg(feature = "internal")] +pub use master_password::MasterPasswordAuthenticationData; +#[cfg(feature = "internal")] pub use master_password::MasterPasswordError; #[cfg(feature = "internal")] -pub(crate) use master_password::{MasterPasswordAuthenticationData, MasterPasswordUnlockData}; +pub use master_password::MasterPasswordUnlockData; #[cfg(feature = "internal")] mod security_state; #[cfg(feature = "internal")] diff --git a/crates/bitwarden-policies/Cargo.toml b/crates/bitwarden-policies/Cargo.toml index 1633d5629..82654ad01 100644 --- a/crates/bitwarden-policies/Cargo.toml +++ b/crates/bitwarden-policies/Cargo.toml @@ -10,13 +10,26 @@ license-file.workspace = true readme.workspace = true keywords.workspace = true +[features] +uniffi = ["bitwarden-core/uniffi", "dep:uniffi"] # Uniffi bindings +wasm = [ + "bitwarden-core/wasm", + "dep:tsify", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures" +] # WASM support + [dependencies] bitwarden-api-api = { workspace = true } bitwarden-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } +tsify = { workspace = true, optional = true } +uniffi = { workspace = true, optional = true } uuid = { workspace = true } +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/bitwarden-policies/src/lib.rs b/crates/bitwarden-policies/src/lib.rs index 4fcbfb80c..4b886495c 100644 --- a/crates/bitwarden-policies/src/lib.rs +++ b/crates/bitwarden-policies/src/lib.rs @@ -1,5 +1,10 @@ #![doc = include_str!("../README.md")] +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); + +mod master_password_policy_response; mod policy; +pub use master_password_policy_response::MasterPasswordPolicyResponse; pub use policy::Policy; diff --git a/crates/bitwarden-policies/src/master_password_policy_response.rs b/crates/bitwarden-policies/src/master_password_policy_response.rs new file mode 100644 index 000000000..3538df357 --- /dev/null +++ b/crates/bitwarden-policies/src/master_password_policy_response.rs @@ -0,0 +1,137 @@ +use bitwarden_api_api::models::MasterPasswordPolicyResponseModel; +use serde::{Deserialize, Serialize}; + +/// SDK domain model for master password policy requirements. +/// Defines the complexity requirements for a user's master password +/// when enforced by an organization policy. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct MasterPasswordPolicyResponse { + /// The minimum complexity score required for the master password. + /// Complexity is calculated based on password strength metrics. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_complexity: Option, + + /// The minimum length required for the master password. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_length: Option, + + /// Whether the master password must contain at least one lowercase letter. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_lower: Option, + + /// Whether the master password must contain at least one uppercase letter. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_upper: Option, + + /// Whether the master password must contain at least one numeric digit. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_numbers: Option, + + /// Whether the master password must contain at least one special character. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_special: Option, + + /// Whether this policy should be enforced when the user logs in. + /// If true, the user will be required to update their master password + /// if it doesn't meet the policy requirements. + #[serde(skip_serializing_if = "Option::is_none")] + pub enforce_on_login: Option, +} + +impl From for MasterPasswordPolicyResponse { + fn from(api: MasterPasswordPolicyResponseModel) -> Self { + Self { + min_complexity: api.min_complexity, + min_length: api.min_length, + require_lower: api.require_lower, + require_upper: api.require_upper, + require_numbers: api.require_numbers, + require_special: api.require_special, + enforce_on_login: api.enforce_on_login, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_master_password_policy_conversion_full() { + let api = MasterPasswordPolicyResponseModel { + object: Some("masterPasswordPolicy".to_string()), + min_complexity: Some(4), + min_length: Some(12), + require_lower: Some(true), + require_upper: Some(true), + require_numbers: Some(true), + require_special: Some(true), + enforce_on_login: Some(true), + }; + + let domain: MasterPasswordPolicyResponse = api.into(); + + assert_eq!(domain.min_complexity, Some(4)); + assert_eq!(domain.min_length, Some(12)); + assert_eq!(domain.require_lower, Some(true)); + assert_eq!(domain.require_upper, Some(true)); + assert_eq!(domain.require_numbers, Some(true)); + assert_eq!(domain.require_special, Some(true)); + assert_eq!(domain.enforce_on_login, Some(true)); + } + + #[test] + fn test_master_password_policy_conversion_minimal() { + let api = MasterPasswordPolicyResponseModel { + object: Some("masterPasswordPolicy".to_string()), + min_complexity: None, + min_length: Some(8), + require_lower: None, + require_upper: None, + require_numbers: None, + require_special: None, + enforce_on_login: Some(false), + }; + + let domain: MasterPasswordPolicyResponse = api.into(); + + assert_eq!(domain.min_complexity, None); + assert_eq!(domain.min_length, Some(8)); + assert_eq!(domain.require_lower, None); + assert_eq!(domain.require_upper, None); + assert_eq!(domain.require_numbers, None); + assert_eq!(domain.require_special, None); + assert_eq!(domain.enforce_on_login, Some(false)); + } + + #[test] + fn test_master_password_policy_conversion_empty() { + let api = MasterPasswordPolicyResponseModel { + object: Some("masterPasswordPolicy".to_string()), + min_complexity: None, + min_length: None, + require_lower: None, + require_upper: None, + require_numbers: None, + require_special: None, + enforce_on_login: None, + }; + + let domain: MasterPasswordPolicyResponse = api.into(); + + assert_eq!(domain.min_complexity, None); + assert_eq!(domain.min_length, None); + assert_eq!(domain.require_lower, None); + assert_eq!(domain.require_upper, None); + assert_eq!(domain.require_numbers, None); + assert_eq!(domain.require_special, None); + assert_eq!(domain.enforce_on_login, None); + } +} 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