diff --git a/Cargo.lock b/Cargo.lock index 6b803d06e..a220cd89a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,14 +349,20 @@ version = "1.0.0" dependencies = [ "bitwarden-core", "bitwarden-error", + "chrono", + "reqwest", "serde", "serde_json", "serde_qs", + "serde_urlencoded", "thiserror 1.0.69", + "tokio", "tsify", "uniffi", "wasm-bindgen", "wasm-bindgen-futures", + "wiremock", + "zeroize", ] [[package]] diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 787a5b2b1..7ca2f976c 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -26,8 +26,11 @@ wasm = [ [dependencies] bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-error = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_urlencoded = "0.7.1" serde_qs = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } @@ -35,5 +38,12 @@ uniffi = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } +[dev-dependencies] +tokio = { workspace = true, features = ["rt"] } +zeroize = { version = ">=1.7.0, <2.0", features = ["derive", "aarch64"] } +wiremock = "0.6.0" + [lints] workspace = true + + diff --git a/crates/bitwarden-auth/README.md b/crates/bitwarden-auth/README.md index 34f960281..16b198360 100644 --- a/crates/bitwarden-auth/README.md +++ b/crates/bitwarden-auth/README.md @@ -1,3 +1,7 @@ # Bitwarden Auth Contains the implementation of the auth functionality for the Bitwarden Password Manager. + +# Send Access + +- TODO: add context diff --git a/crates/bitwarden-auth/src/common/enums/grant_type.rs b/crates/bitwarden-auth/src/common/enums/grant_type.rs new file mode 100644 index 000000000..8df3b9bb6 --- /dev/null +++ b/crates/bitwarden-auth/src/common/enums/grant_type.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// Add a trait to this enum to allow for serialization and deserialization of the enum values. +#[derive(Serialize, Deserialize, Debug)] +/// Instructs deserialization to map the string "send_access" to the `SendAccess` variant. +#[serde(rename_all = "snake_case")] +pub enum GrantType { + SendAccess, + // TODO: Add other grant types as needed. +} diff --git a/crates/bitwarden-auth/src/common/enums/mod.rs b/crates/bitwarden-auth/src/common/enums/mod.rs new file mode 100644 index 000000000..0cea71a46 --- /dev/null +++ b/crates/bitwarden-auth/src/common/enums/mod.rs @@ -0,0 +1,5 @@ +mod grant_type; +mod scope; + +pub use grant_type::GrantType; +pub use scope::Scope; diff --git a/crates/bitwarden-auth/src/common/enums/scope.rs b/crates/bitwarden-auth/src/common/enums/scope.rs new file mode 100644 index 000000000..e3b48eb1d --- /dev/null +++ b/crates/bitwarden-auth/src/common/enums/scope.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum Scope { + #[serde(rename = "api.send")] + Send, + // TODO: Add other scopes as needed. +} diff --git a/crates/bitwarden-auth/src/common/mod.rs b/crates/bitwarden-auth/src/common/mod.rs new file mode 100644 index 000000000..e4e9fda93 --- /dev/null +++ b/crates/bitwarden-auth/src/common/mod.rs @@ -0,0 +1 @@ +pub mod enums; diff --git a/crates/bitwarden-auth/src/lib.rs b/crates/bitwarden-auth/src/lib.rs index 3353d1af9..84b479959 100644 --- a/crates/bitwarden-auth/src/lib.rs +++ b/crates/bitwarden-auth/src/lib.rs @@ -1,6 +1,10 @@ #![doc = include_str!("../README.md")] mod auth_client; -mod send_access; +mod common; + +/// Module for handling Send Access token requests and responses. +pub mod send_access; pub use auth_client::{AuthClient, AuthClientExt}; +pub use common::enums::{GrantType, Scope}; diff --git a/crates/bitwarden-auth/src/send_access/access_token_request.rs b/crates/bitwarden-auth/src/send_access/access_token_request.rs new file mode 100644 index 000000000..746a4b5d4 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/access_token_request.rs @@ -0,0 +1,97 @@ +#[cfg(feature = "wasm")] +use tsify::Tsify; + +/// Credentials for sending password secured access requests. +/// Clone auto implements the standard lib's Clone trait, allowing us to create copies of this +/// struct. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct SendPasswordCredentials { + /// A Base64-encoded hash of the password protecting the send. + pub password_hash_b64: String, +} + +/// Credentials for sending an OTP to the user's email address. +/// This is used when the send requires email verification with an OTP. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct SendEmailCredentials { + /// The email address to which the OTP will be sent. + pub email: String, +} + +/// Credentials for getting a send access token using an email and OTP. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct SendEmailOtpCredentials { + /// The email address to which the OTP will be sent. + pub email: String, + /// The one-time password (OTP) that the user has received via email. + pub otp: String, +} + +/// The credentials used for send access requests. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +// Use untagged so that each variant can be serialized without a type tag. +// For example, this allows us to serialize the password credentials as just +// {"password_hash_b64": "value"} instead of {"type": "password", "password_hash_b64": "value"}. +#[serde(untagged)] +pub enum SendAccessCredentials { + #[allow(missing_docs)] + Password(SendPasswordCredentials), + #[allow(missing_docs)] + Email(SendEmailCredentials), + #[allow(missing_docs)] + EmailOtp(SendEmailOtpCredentials), +} + +/// A request structure for requesting a send access token from the API. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +pub struct SendAccessTokenRequest { + /// The id of the send for which the access token is requested. + pub send_id: String, + + /// The optional send access credentials. + pub send_access_credentials: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + mod send_access_credentials_tests { + use serde_json; + + use super::*; + + #[test] + fn serialize_password_credentials() { + let creds = SendAccessCredentials::Password(SendPasswordCredentials { + password_hash_b64: "ha$h".into(), + }); + let json = serde_json::to_string(&creds).unwrap(); + assert_eq!(json, r#"{"password_hash_b64":"ha$h"}"#); + } + + #[test] + fn serialize_email_credentials() { + let creds = SendAccessCredentials::Email(SendEmailCredentials { + email: "user@example.com".into(), + }); + let json = serde_json::to_string(&creds).unwrap(); + assert_eq!(json, r#"{"email":"user@example.com"}"#); + } + + #[test] + fn serialize_email_otp_credentials() { + let creds = SendAccessCredentials::EmailOtp(SendEmailOtpCredentials { + email: "user@example.com".into(), + otp: "123456".into(), + }); + let json = serde_json::to_string(&creds).unwrap(); + assert_eq!(json, r#"{"email":"user@example.com","otp":"123456"}"#); + } + } +} diff --git a/crates/bitwarden-auth/src/send_access/access_token_response.rs b/crates/bitwarden-auth/src/send_access/access_token_response.rs new file mode 100644 index 000000000..e01fc17e5 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/access_token_response.rs @@ -0,0 +1,86 @@ +use std::fmt::Debug; + +use crate::send_access::api::{SendAccessTokenApiErrorResponse, SendAccessTokenApiSuccessResponse}; + +/// A send access token which can be used to access a send. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +#[derive(Debug)] +pub struct SendAccessTokenResponse { + /// The actual token string. + pub token: String, + /// The timestamp in milliseconds when the token expires. + pub expires_at: i64, +} + +impl From for SendAccessTokenResponse { + fn from(response: SendAccessTokenApiSuccessResponse) -> Self { + // We want to convert the expires_in from seconds to a millisecond timestamp to have a + // concrete time the token will expire as it is easier to build logic around a + // concrete time rather than a duration. + let expires_at = + chrono::Utc::now().timestamp_millis() + (response.expires_in * 1000) as i64; + + SendAccessTokenResponse { + token: response.access_token, + expires_at, + } + } +} + +#[allow(missing_docs)] +// We're using the full variant of the bitwarden-error macro because we want to keep the contents of +// SendAccessTokenApiErrorResponse +#[bitwarden_error::bitwarden_error(full)] +#[derive(Debug, thiserror::Error)] +pub enum SendAccessTokenError { + #[error("API Error: {0:?}")] + Api(AuthApiError), + + #[error("Send access token error response")] + Response(SendAccessTokenApiErrorResponse), +} + +// This is just a utility function so that the ? operator works correctly without manual mapping +impl From for SendAccessTokenError { + fn from(value: reqwest::Error) -> Self { + Self::Api(AuthApiError(value)) + } +} + +// This wrapper needs to exist because the `bitwarden_error(full)` macro requires every variant to +// implement serialize+tsify, which is not the case for the `Api` variant. We only really care about +// the contents of the `Response` variant, so ideally the macro would support a way of marking the +// `Api` variant somehow so it gets serialized as a plain string. +// As that is not the case, we have to implement it manually. + +#[derive(Debug)] +pub struct AuthApiError(reqwest::Error); + +#[cfg(feature = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)] +const TS_CUSTOM_TYPES: &'static str = r#" +export type AuthApiError = string; +"#; + +impl serde::Serialize for AuthApiError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&format!("{:?}", self.0)) + } +} + +impl<'de> serde::Deserialize<'de> for AuthApiError { + fn deserialize(_deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Err(serde::de::Error::custom("deserialization not supported")) + } +} diff --git a/crates/bitwarden-auth/src/send_access/api/mod.rs b/crates/bitwarden-auth/src/send_access/api/mod.rs new file mode 100644 index 000000000..c8646ace8 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/mod.rs @@ -0,0 +1,13 @@ +mod token_api_error_response; +mod token_api_success_response; +mod token_request_payload; + +pub use token_api_error_response::{ + SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError, + SendAccessTokenInvalidRequestError, +}; +pub use token_api_success_response::SendAccessTokenApiSuccessResponse; +// Keep payload types internal to the crate +pub(crate) use token_request_payload::{ + SendAccessTokenPayloadCredentials, SendAccessTokenRequestPayload, +}; diff --git a/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs new file mode 100644 index 000000000..aac7f1ca6 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs @@ -0,0 +1,191 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use tsify::Tsify; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +/// Invalid request errors - typically due to missing parameters. +pub enum SendAccessTokenInvalidRequestError { + #[serde(rename = "send_id is required.")] + #[allow(missing_docs)] + SendIdRequired, + + #[serde(rename = "password_hash is required.")] + #[allow(missing_docs)] + PasswordHashRequired, + + #[serde(rename = "Email is required.")] + #[allow(missing_docs)] + EmailRequired, + + #[serde( + rename = "Email and OTP are required. An OTP has been sent to the email address provided." + )] + #[allow(missing_docs)] + EmailAndOtpRequiredOtpSent, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +/// Invalid grant errors - typically due to invalid credentials. +pub enum SendAccessTokenInvalidGrantError { + #[allow(missing_docs)] + #[serde(rename = "Password_hash invalid.")] + InvalidPasswordHash, + + #[allow(missing_docs)] + #[serde(rename = "Email invalid.")] + InvalidEmail, + + #[allow(missing_docs)] + #[serde(rename = "OTP invalid.")] + InvalidOtp, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(tag = "error", content = "error_description")] +// ^ "error" becomes the variant discriminator which matches against the rename annotations; +// "error_description" is the payload for that variant which can be optional. +/// Represents the possible errors that can occur when requesting a send access token. +pub enum SendAccessTokenApiErrorResponse { + #[serde(rename = "invalid_request")] + /// Invalid request error, typically due to missing parameters for a specific + /// credential flow. Ex. `send_id` is required. + /// #[serde(default)] allows for inner error details to be optional. + InvalidRequest(#[serde(default)] Option), + + /// Invalid grant error, typically due to invalid credentials. + /// Ex. `Password_hash` is invalid. + /// #[serde(default)] allows for inner error details to be optional. + #[serde(rename = "invalid_grant")] + InvalidGrant(#[serde(default)] Option), +} + +#[cfg(test)] +mod tests { + use super::*; + + mod send_access_token_invalid_request_error_tests { + use super::*; + + #[test] + fn test_deserialize_send_token_error_desc_send_id_required() { + let error_desc: String = "\"send_id is required.\"".to_string(); + let result: SendAccessTokenInvalidRequestError = + serde_json::from_str(&error_desc).unwrap(); + + assert_eq!(result, SendAccessTokenInvalidRequestError::SendIdRequired); + } + + #[test] + fn test_deserialize_send_token_error_desc_password_hash_required() { + let error_desc: String = "\"Password_hash is required.\"".to_string(); + let result: SendAccessTokenInvalidRequestError = + serde_json::from_str(&error_desc).unwrap(); + assert_eq!( + result, + SendAccessTokenInvalidRequestError::PasswordHashRequired + ); + } + + #[test] + fn test_deserialize_send_token_error_desc_email_required() { + let error_desc: String = "\"Email is required.\"".to_string(); + let result: SendAccessTokenInvalidRequestError = + serde_json::from_str(&error_desc).unwrap(); + assert_eq!(result, SendAccessTokenInvalidRequestError::EmailRequired); + } + + #[test] + fn test_deserialize_send_token_error_desc_email_and_otp_required() { + let error_desc: String = + "\"Email and OTP are required. An OTP has been sent to the email address provided.\"" + .to_string(); + let result: SendAccessTokenInvalidRequestError = + serde_json::from_str(&error_desc).unwrap(); + assert_eq!( + result, + SendAccessTokenInvalidRequestError::EmailAndOtpRequiredOtpSent + ); + } + } + + mod send_access_token_invalid_grant_error_tests { + use super::*; + + #[test] + fn test_deserialize_send_token_error_desc_invalid_password_hash() { + let error_desc: String = "\"Password_hash invalid.\"".to_string(); + let result: SendAccessTokenInvalidGrantError = + serde_json::from_str(&error_desc).unwrap(); + assert_eq!( + result, + SendAccessTokenInvalidGrantError::InvalidPasswordHash + ); + } + + #[test] + fn test_deserialize_send_token_error_desc_invalid_email() { + let error_desc: String = "\"Email invalid.\"".to_string(); + let result: SendAccessTokenInvalidGrantError = + serde_json::from_str(&error_desc).unwrap(); + assert_eq!(result, SendAccessTokenInvalidGrantError::InvalidEmail); + } + + #[test] + fn test_deserialize_send_token_error_desc_invalid_otp() { + let error_desc: String = "\"OTP invalid.\"".to_string(); + let result: SendAccessTokenInvalidGrantError = + serde_json::from_str(&error_desc).unwrap(); + assert_eq!(result, SendAccessTokenInvalidGrantError::InvalidOtp); + } + } + + mod send_access_token_error_tests { + use super::*; + + #[test] + fn test_deserialize_send_access_token_error_invalid_request() { + let obj = r#"{ "error": "invalid_request" }"#; + let result: SendAccessTokenApiErrorResponse = serde_json::from_str(obj).unwrap(); + assert_eq!( + result, + SendAccessTokenApiErrorResponse::InvalidRequest(None) + ); + } + + #[test] + fn test_deserialize_send_access_token_error_invalid_grant() { + let obj = r#"{ "error": "invalid_grant" }"#; + let result: SendAccessTokenApiErrorResponse = serde_json::from_str(obj).unwrap(); + assert_eq!(result, SendAccessTokenApiErrorResponse::InvalidGrant(None)); + } + + #[test] + fn test_deserialize_send_access_token_error_invalid_request_with_details() { + let obj = + r#"{ "error": "invalid_request", "error_description": "send_id is required." }"#; + let result: SendAccessTokenApiErrorResponse = serde_json::from_str(obj).unwrap(); + assert_eq!( + result, + SendAccessTokenApiErrorResponse::InvalidRequest(Some( + SendAccessTokenInvalidRequestError::SendIdRequired + )) + ); + } + + #[test] + fn test_deserialize_send_access_token_error_invalid_grant_with_details() { + let obj = + r#"{ "error": "invalid_grant", "error_description": "Password_hash invalid." }"#; + let result: SendAccessTokenApiErrorResponse = serde_json::from_str(obj).unwrap(); + assert_eq!( + result, + SendAccessTokenApiErrorResponse::InvalidGrant(Some( + SendAccessTokenInvalidGrantError::InvalidPasswordHash + )) + ); + } + } +} diff --git a/crates/bitwarden-auth/src/send_access/api/token_api_success_response.rs b/crates/bitwarden-auth/src/send_access/api/token_api_success_response.rs new file mode 100644 index 000000000..8b4dc24e3 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/token_api_success_response.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +/// The server response for successful send access token request. +pub struct SendAccessTokenApiSuccessResponse { + /// 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. + /// RFC: https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 + pub scope: String, + /// The type of the token. + /// This will be "Bearer" for send access tokens. + /// More information can be found in the OAuth 2.0 authZ framework RFC: + /// https://datatracker.ietf.org/doc/html/rfc6749#section-7.1 + pub token_type: String, +} diff --git a/crates/bitwarden-auth/src/send_access/api/token_request_payload.rs b/crates/bitwarden-auth/src/send_access/api/token_request_payload.rs new file mode 100644 index 000000000..a5e31a126 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/token_request_payload.rs @@ -0,0 +1,148 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + common::enums::{GrantType, Scope}, + send_access::{SendAccessCredentials, SendAccessTokenRequest}, +}; + +/// Represents the shape of the credentials used in the send access token payload. +#[derive(Serialize, Debug)] +// untagged allows for different variants to be serialized without a type tag +// example: { "password_hash": "example_hash" } instead of { "Password": { "password_hash": +// "example_hash" } } +#[serde(untagged)] +pub enum SendAccessTokenPayloadCredentials { + /// Uses inline variant syntax for these as we don't need to reference them as independent + /// types elsewhere. + #[allow(missing_docs)] + Password { password_hash_b64: String }, + #[allow(missing_docs)] + Email { email: String }, + #[allow(missing_docs)] + EmailOtp { email: String, otp: String }, + /// Represents an anonymous request, which does not require credentials. + Anonymous, +} + +impl From> for SendAccessTokenPayloadCredentials { + fn from(credentials: Option) -> Self { + match credentials { + Some(SendAccessCredentials::Password(credentials)) => { + SendAccessTokenPayloadCredentials::Password { + password_hash_b64: credentials.password_hash_b64, + } + } + Some(SendAccessCredentials::Email(credentials)) => { + SendAccessTokenPayloadCredentials::Email { + email: credentials.email, + } + } + Some(SendAccessCredentials::EmailOtp(credentials)) => { + SendAccessTokenPayloadCredentials::EmailOtp { + email: credentials.email, + otp: credentials.otp, + } + } + None => SendAccessTokenPayloadCredentials::Anonymous, + } + } +} + +/// Enum representing the type of client requesting a send access token. +/// Eventually, this could / should be merged with the existing `ClientType` enum +#[derive(Serialize, Deserialize, Debug)] +pub enum SendAccessClientType { + /// Represents a Send client. + /// This is a standalone client that lives within the BW web app, but has no context of a BW + /// user. + #[serde(rename = "send")] + Send, +} + +/// Represents the actual request payload for requesting a send access token. +/// It converts the `SendAccessTokenRequest` into a format suitable for sending to the API. +#[derive(Serialize, Debug)] +pub struct SendAccessTokenRequestPayload { + // Standard OAuth2 fields + /// The client ID for the send access client. + pub client_id: SendAccessClientType, + + /// The grant type for the send access token request. + /// SendAccess is a custom grant type for send access tokens. + /// It is used to differentiate send access requests from other OAuth2 flows. + pub grant_type: GrantType, + + /// The scope for the send access token request. + /// This is set to "api.send" to indicate that the token is for send access. + /// It allows the token to be used for accessing send-related resources. + pub scope: Scope, + + // Custom fields + /// The ID of the send for which the access token is being requested. + pub send_id: String, + + /// The credentials used for the send access request. + /// This can be password, email, email OTP, or anonymous. + // Flatten allows us to serialize the variant directly into the payload without a wrapper + // example: { "password_hash": "example_hash" } instead of { "variant": { "password_hash": + // "example_hash" } } + #[serde(flatten)] + pub credentials: SendAccessTokenPayloadCredentials, +} + +const SEND_ACCESS_CLIENT_ID: SendAccessClientType = SendAccessClientType::Send; +const SEND_ACCESS_GRANT_TYPE: GrantType = GrantType::SendAccess; +const SEND_ACCESS_SCOPE: Scope = Scope::Send; + +/// Implement a way to convert from our request model to the payload model +impl From for SendAccessTokenRequestPayload { + fn from(request: SendAccessTokenRequest) -> Self { + // Returns a new instance of `SendAccessTokenPayload` based on the provided + // `SendAccessTokenRequest`. It extracts the necessary fields from the request and + // matches on the credentials to determine the variant + SendAccessTokenRequestPayload { + client_id: SEND_ACCESS_CLIENT_ID, + grant_type: SEND_ACCESS_GRANT_TYPE, + scope: SEND_ACCESS_SCOPE, + send_id: request.send_id, + credentials: request.send_access_credentials.into(), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json; + + use super::*; + + /// Unit tests for `SendAccessTokenPayload` serialization + mod send_access_token_payload_tests { + use super::*; + #[test] + fn test_serialize_send_access_token_payload() { + let payload = SendAccessTokenRequestPayload { + client_id: SendAccessClientType::Send, + grant_type: GrantType::SendAccess, + scope: Scope::Send, + send_id: "example_send_id".into(), + credentials: SendAccessTokenPayloadCredentials::Password { + password_hash_b64: "example_hash".into(), + }, + }; + + let serialized = serde_json::to_string_pretty(&payload).unwrap(); + println!("{}", serialized); + assert_eq!( + serialized, + r#"{ + "client_id": "send", + "grant_type": "send_access", + "scope": "api.send", + "send_id": "example_send_id", + "password_hash_b64": "example_hash" +}"# + ); + } + } +} diff --git a/crates/bitwarden-auth/src/send_access/client.rs b/crates/bitwarden-auth/src/send_access/client.rs index b6e5b99dc..e9549f5bb 100644 --- a/crates/bitwarden-auth/src/send_access/client.rs +++ b/crates/bitwarden-auth/src/send_access/client.rs @@ -2,6 +2,14 @@ use bitwarden_core::Client; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; +use crate::send_access::{ + api::{ + SendAccessTokenApiErrorResponse, SendAccessTokenApiSuccessResponse, + SendAccessTokenRequestPayload, + }, + SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse, +}; + #[derive(Clone)] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct SendAccessClient { @@ -16,10 +24,57 @@ impl SendAccessClient { #[cfg_attr(feature = "wasm", wasm_bindgen)] impl SendAccessClient { - /// Request an access token for the provided send - pub async fn request_send_access_token(&self, request: String) -> String { - // TODO: This is just here to silence some warnings - let _config = self.client.internal.get_api_configurations().await; - request + /// Requests a new send access token. + /// + /// # Arguments + /// * `request` - The request containing the necessary information to obtain a token. + pub async fn request_send_access_token( + &self, + request: SendAccessTokenRequest, + ) -> Result { + // Convert the request to the appropriate format for sending. + let payload: SendAccessTokenRequestPayload = request.into(); + + // When building other identity token requests, we used to send credentials: "include" on + // non-web clients or if the env had a base URL. See client's + // apiService.getCredentials() for example. However, it doesn't seem necessary for + // this request, so we are not including it here. If needed, we can revisit this and + // add it back in. + + let configurations: std::sync::Arc = + self.client.internal.get_api_configurations().await; + + let request: reqwest::RequestBuilder = configurations + .identity + .client + .post(format!( + "{}/connect/token", + &configurations.identity.base_path + )) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded; charset=utf-8", + ) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::CACHE_CONTROL, "no-store") + // We can use `serde_urlencoded` to serialize the payload into a URL-encoded string + // because we don't have complex nested structures in the payload. + // If we had nested structures, we have to use serde_qs::to_string instead. + .body(serde_urlencoded::to_string(&payload).expect("Serialize should be infallible")); + + let response: reqwest::Response = request.send().await?; + + // handle success and error responses + // If the response is 200, we can deserialize it into SendAccessToken + if response.status().is_success() { + let send_access_token: SendAccessTokenApiSuccessResponse = response.json().await?; + return Ok(send_access_token.into()); + } + + // If the response is not 200, we can deserialize it into SendAccessTokenApiErrorResponse + //and then convert it into SendAccessTokenError since we have implemented the required + // traits to do that conversion automatically. + let err_response: SendAccessTokenApiErrorResponse = response.json().await?; + Err(SendAccessTokenError::Response(err_response)) } } diff --git a/crates/bitwarden-auth/src/send_access/mod.rs b/crates/bitwarden-auth/src/send_access/mod.rs index eaabc7c25..855032a8d 100644 --- a/crates/bitwarden-auth/src/send_access/mod.rs +++ b/crates/bitwarden-auth/src/send_access/mod.rs @@ -1,2 +1,13 @@ +mod access_token_request; +mod access_token_response; mod client; + +/// Submodule containing the Send Access API request and response types. +pub mod api; + +pub use access_token_request::{ + SendAccessCredentials, SendAccessTokenRequest, SendEmailCredentials, SendEmailOtpCredentials, + SendPasswordCredentials, +}; +pub use access_token_response::{SendAccessTokenError, SendAccessTokenResponse}; pub use client::SendAccessClient; diff --git a/crates/bitwarden-auth/tests/send_access.rs b/crates/bitwarden-auth/tests/send_access.rs new file mode 100644 index 000000000..d3b23ef3b --- /dev/null +++ b/crates/bitwarden-auth/tests/send_access.rs @@ -0,0 +1,130 @@ +//! Integration tests for send access feature + +use bitwarden_auth::{ + send_access::{ + api::{SendAccessTokenApiErrorResponse, SendAccessTokenInvalidRequestError}, + SendAccessClient, SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse, + }, + AuthClientExt, +}; +use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType}; +use tokio; +use wiremock::{ + matchers::{self, body_string_contains}, + Mock, MockServer, ResponseTemplate, +}; + +async fn make_send_client(mock_server: &MockServer) -> SendAccessClient { + 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, + }; + let core_client = CoreClient::new(Some(settings)); + core_client.auth_new().send_access() +} + +#[tokio::test] +async fn request_send_access_token_success() { + // spin up mock server + let mock_server = MockServer::start().await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server).await; + + // Construct the real Request type + let req = SendAccessTokenRequest { + send_id: "test_send_id".into(), + send_access_credentials: None, // No credentials for this test + }; + + // Create a mock success response + let raw_success = serde_json::json!({ + "access_token": "token", + "token_type": "bearer", + "expires_in": 3600, + "scope": "api.send" + }); + + // Register the mock for the request + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + // expect the headers we set in the client + .and(matchers::header( + reqwest::header::CONTENT_TYPE.as_str(), + "application/x-www-form-urlencoded; charset=utf-8", + )) + .and(matchers::header( + reqwest::header::ACCEPT.as_str(), + "application/json", + )) + .and(matchers::header( + reqwest::header::CACHE_CONTROL.as_str(), + "no-store", + )) + // expect the body to contain the fields we set in our payload object + .and(body_string_contains("client_id=send")) + .and(body_string_contains("grant_type=send_access")) + .and(body_string_contains(format!("send_id={}", req.send_id))) + // respond with the mock success response + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + // Register the mock with the server + mock_server.register(mock).await; + + let token: SendAccessTokenResponse = send_access_client + .request_send_access_token(req) + .await + .unwrap(); + + assert_eq!(token.token, "token"); + assert!(token.expires_at > 0); +} + +#[tokio::test] +async fn request_send_access_token_invalid_request_send_id_required_error() { + // spin up mock server + let mock_server = MockServer::start().await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server).await; + + // Construct the request without a send_id to trigger an error + let req = SendAccessTokenRequest { + send_id: "".into(), + send_access_credentials: None, // No credentials for this test + }; + + // Create a mock error response + let raw_error = serde_json::json!({ + "error": "invalid_request", + "error_description": "send_id is required." + }); + + // Register the mock for the request + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(raw_error)); + + // Register the mock with the server + mock_server.register(mock).await; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Response(api_err) => { + // Now assert the inner enum: + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidRequest(Some( + SendAccessTokenInvalidRequestError::SendIdRequired + )) + ); + } + other => panic!("expected Response variant, got {:?}", other), + } +} diff --git a/crates/bitwarden-wasm-internal/src/lib.rs b/crates/bitwarden-wasm-internal/src/lib.rs index 253ec8ffd..e5b7ae2e5 100644 --- a/crates/bitwarden-wasm-internal/src/lib.rs +++ b/crates/bitwarden-wasm-internal/src/lib.rs @@ -1,5 +1,4 @@ #![doc = include_str!("../README.md")] - mod client; mod custom_types; mod init;