diff --git a/Cargo.lock b/Cargo.lock index e2eef8832..0f430f161 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", + "schemars 1.0.0", "serde", "serde_bytes", "serde_json", + "serde_repr", "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..6d97f8a5a 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -22,18 +22,27 @@ wasm = [ "dep:wasm-bindgen", "dep:wasm-bindgen-futures", ] # WASM support -uniffi = ["bitwarden-core/uniffi", "dep:uniffi"] # Uniffi bindings +uniffi = [ + "bitwarden-core/uniffi", + "bitwarden-policies/uniffi", + "dep:uniffi" +] # Uniffi bindings # Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline. [dependencies] 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 } +schemars = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } +serde_repr = { workspace = true } serde_bytes = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } @@ -43,10 +52,14 @@ 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 } 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..cf86596a1 100644 --- a/crates/bitwarden-auth/README.md +++ b/crates/bitwarden-auth/README.md @@ -5,3 +5,31 @@ 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 +// 1. Get user's KDF config +let prelogin = login_client.get_password_prelogin(email).await?; + +// 2. Login with credentials +let response = login_client.login_via_password(PasswordLoginRequest { + email, password, prelogin_response: prelogin, /* ... */ +}).await?; + +// 3. Use response.access_token for authenticated requests +``` + +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..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/auth_client.rs b/crates/bitwarden-auth/src/auth_client.rs index c6f09c57c..34834afbc 100644 --- a/crates/bitwarden-auth/src/auth_client.rs +++ b/crates/bitwarden-auth/src/auth_client.rs @@ -3,7 +3,7 @@ use bitwarden_core::Client; use wasm_bindgen::prelude::*; use crate::{ - identity::IdentityClient, registration::RegistrationClient, send_access::SendAccessClient, + identity::LoginClient, registration::RegistrationClient, send_access::SendAccessClient, }; /// Subclient containing auth functionality. @@ -26,8 +26,8 @@ impl AuthClient { #[cfg_attr(feature = "wasm", wasm_bindgen)] impl AuthClient { /// Client for identity functionality - pub fn identity(&self) -> IdentityClient { - IdentityClient::new(self.client.clone()) + pub fn login(&self, client_settings: bitwarden_core::ClientSettings) -> LoginClient { + LoginClient::new(client_settings) } /// Client for send access functionality diff --git a/crates/bitwarden-auth/src/identity/api/mod.rs b/crates/bitwarden-auth/src/identity/api/mod.rs new file mode 100644 index 000000000..287eacb0e --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/mod.rs @@ -0,0 +1,7 @@ +//! 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..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..838e63835 --- /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. +/// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +#[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..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..a55efeb90 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/api/send_login_request.rs @@ -0,0 +1,54 @@ +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 sensistive 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) +} 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..bdecc11cb --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_client.rs @@ -0,0 +1,59 @@ +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. +/// +/// # Available Methods +/// +/// - **Password login**: [`login_via_password`](Self::login_via_password) - See the +/// [`login_via_password`](crate::identity::login_via_password) module for examples +/// - **Future**: SSO, device-based authentication, etc. +/// +/// # Lifecycle +/// +/// 1. Create `LoginClient` → 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.rs b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs new file mode 100644 index 000000000..45bffd3eb --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/login_via_password.rs @@ -0,0 +1,592 @@ +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. + /// + /// # Errors + /// + /// - [`PasswordLoginError::InvalidUsernameOrPassword`] - Invalid credentials + /// - [`PasswordLoginError::PasswordAuthenticationDataDerivation`] - KDF processing failed + /// - [`PasswordLoginError::Unknown`] - Network error or unexpected server response + /// + /// # Example + /// + /// See the [`login_via_password`](crate::identity::login_via_password) module for + /// complete usage examples and security details. + 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..df71f1745 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/mod.rs @@ -0,0 +1,84 @@ +//! 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 hashed locally using these settings +//! - Only the derived hash is transmitted for authentication +//! - 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::identity::LoginClient; +//! # use bitwarden_auth::identity::login_via_password::PasswordLoginRequest; +//! # use bitwarden_auth::identity::models::{LoginRequest, LoginDeviceRequest}; +//! # use bitwarden_core::{ClientSettings, DeviceType}; +//! # async fn example() -> Result<(), Box> { +//! 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 = LoginClient::new(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 +//! let access_token = response.access_token; +//! # 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; +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..f80ccbd28 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_api_request.rs @@ -0,0 +1,171 @@ +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) +/// +/// # Security Note +/// +/// The `master_password_hash` field contains a cryptographically derived hash of the master +/// password, never the plaintext password. This hash is computed using the user's KDF +/// configuration. +#[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 authentication hash. + /// + /// This is the result of applying the user's KDF (PBKDF2 or Argon2id) to their master + /// password. The plaintext password is never sent to the server. + /// + /// 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() + ); + } +} 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..238bd0443 --- /dev/null +++ b/crates/bitwarden-auth/src/identity/login_via_password/password_login_error.rs @@ -0,0 +1,423 @@ +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 occurs during local cryptographic processing before the API call, typically 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 + /// + /// # Common Causes + /// - KDF iterations below the minimum security threshold (5000 for PBKDF2) + /// - Corrupted prelogin data + /// - System resource constraints (especially for Argon2id) + #[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..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..05c0aabfb --- /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 +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum PasswordPreloginError { + #[error(transparent)] + Api(#[from] ApiError), + #[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; + 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_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": 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_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.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_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.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_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": 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_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..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..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..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_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..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..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/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..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-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-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