diff --git a/Cargo.lock b/Cargo.lock index f7b3c829f..513703bc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,14 +349,20 @@ version = "1.0.0" dependencies = [ "bitwarden-core", "bitwarden-error", + "bitwarden-test", + "chrono", + "reqwest", "serde", "serde_json", "serde_qs", + "serde_urlencoded", "thiserror 2.0.12", + "tokio", "tsify", "uniffi", "wasm-bindgen", "wasm-bindgen-futures", + "wiremock", ] [[package]] diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index 787a5b2b1..88285b58d 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -23,17 +23,26 @@ wasm = [ "dep:wasm-bindgen-futures" ] # WASM support +# Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline.. [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_qs = { workspace = true } +serde_urlencoded = ">=0.7.1, <0.8" thiserror = { workspace = true } tsify = { workspace = true, optional = true } uniffi = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } +[dev-dependencies] +bitwarden-test = { workspace = true } +tokio = { workspace = true, features = ["rt"] } +wiremock = "0.6.0" + [lints] workspace = true diff --git a/crates/bitwarden-auth/README.md b/crates/bitwarden-auth/README.md index 34f960281..3656d696f 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 + +- Manages obtaining send access tokens for accessing secured send endpoints. diff --git a/crates/bitwarden-auth/src/api/enums/grant_type.rs b/crates/bitwarden-auth/src/api/enums/grant_type.rs new file mode 100644 index 000000000..757a21cdd --- /dev/null +++ b/crates/bitwarden-auth/src/api/enums/grant_type.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the OAuth 2.0 grant types recognized by the Bitwarden API. +/// A grant type specifies the method a client uses to obtain an access token, +/// 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)] +#[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. +} diff --git a/crates/bitwarden-auth/src/api/enums/mod.rs b/crates/bitwarden-auth/src/api/enums/mod.rs new file mode 100644 index 000000000..48bc05872 --- /dev/null +++ b/crates/bitwarden-auth/src/api/enums/mod.rs @@ -0,0 +1,7 @@ +//! Module for common auth enums + +mod grant_type; +mod scope; + +pub(crate) use grant_type::GrantType; +pub(crate) use scope::Scope; diff --git a/crates/bitwarden-auth/src/api/enums/scope.rs b/crates/bitwarden-auth/src/api/enums/scope.rs new file mode 100644 index 000000000..d016c17f1 --- /dev/null +++ b/crates/bitwarden-auth/src/api/enums/scope.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +/// The OAuth 2.0 scopes recognized by the Bitwarden API. +/// 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)] +pub(crate) enum Scope { + /// 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. +} diff --git a/crates/bitwarden-auth/src/api/mod.rs b/crates/bitwarden-auth/src/api/mod.rs new file mode 100644 index 000000000..c1f764793 --- /dev/null +++ b/crates/bitwarden-auth/src/api/mod.rs @@ -0,0 +1,5 @@ +//! Module for API specific types / enums / etc. +//! Note: API in the this case is generically used for any API calls. Not BW API vs BW Identity on +//! server. + +pub mod enums; diff --git a/crates/bitwarden-auth/src/lib.rs b/crates/bitwarden-auth/src/lib.rs index 3353d1af9..380f97d13 100644 --- a/crates/bitwarden-auth/src/lib.rs +++ b/crates/bitwarden-auth/src/lib.rs @@ -1,6 +1,9 @@ #![doc = include_str!("../README.md")] mod auth_client; -mod send_access; + +pub mod send_access; + +pub(crate) mod api; // keep internal to crate pub use auth_client::{AuthClient, AuthClientExt}; 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..500892a9e --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/access_token_request.rs @@ -0,0 +1,251 @@ +#[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, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[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, Debug)] +#[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, Debug)] +#[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, Debug)] +#[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, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[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, + + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional send access credentials. + pub send_access_credentials: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + mod send_access_token_request_tests { + use serde_json::{from_str, to_string}; + + use super::*; + + #[test] + fn deserialize_camelcase_request() { + let json = r#" + { + "sendId": "abc123", + "sendAccessCredentials": { "passwordHashB64": "ha$h" } + }"#; + + let req: SendAccessTokenRequest = from_str(json).unwrap(); + assert_eq!(req.send_id, "abc123"); + + let creds = req.send_access_credentials.expect("expected Some"); + match creds { + SendAccessCredentials::Password(p) => assert_eq!(p.password_hash_b64, "ha$h"), + _ => panic!("expected Password variant"), + } + } + + #[test] + fn serialize_camelcase_request_with_credentials() { + let req = SendAccessTokenRequest { + send_id: "abc123".into(), + send_access_credentials: Some(SendAccessCredentials::Password( + SendPasswordCredentials { + password_hash_b64: "ha$h".into(), + }, + )), + }; + let json = to_string(&req).unwrap(); + assert_eq!( + json, + r#"{"sendId":"abc123","sendAccessCredentials":{"passwordHashB64":"ha$h"}}"# + ); + } + + #[test] + fn serialize_omits_optional_credentials_when_none() { + let req = SendAccessTokenRequest { + send_id: "abc123".into(), + send_access_credentials: None, + }; + let json = to_string(&req).unwrap(); + assert_eq!(json, r#"{"sendId":"abc123"}"#); + } + + #[test] + fn roundtrip_camel_in_to_camel_out() { + let in_json = r#" + { + "sendId": "abc123", + "sendAccessCredentials": { "passwordHashB64": "ha$h" } + }"#; + + let req: SendAccessTokenRequest = from_str(in_json).unwrap(); + let out_json = to_string(&req).unwrap(); + assert_eq!( + out_json, + r#"{"sendId":"abc123","sendAccessCredentials":{"passwordHashB64":"ha$h"}}"# + ); + } + + #[test] + fn snakecase_top_level_keys_are_rejected() { + let json = r#" + { + "send_id": "abc123", + "sendAccessCredentials": { "passwordHashB64": "ha$h" } + }"#; + let err = from_str::(json).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("unknown field") && msg.contains("send_id"), + "unexpected: {msg}" + ); + } + + #[test] + fn extra_top_level_key_is_rejected() { + let json = r#" + { + "sendId": "abc123", + "sendAccessCredentials": { "passwordHashB64": "ha$h" }, + "extra": "nope" + }"#; + let err = from_str::(json).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("unknown field") && msg.contains("extra"), + "unexpected: {msg}" + ); + } + + #[test] + fn snakecase_nested_keys_are_rejected() { + let json = r#" + { + "sendId": "abc123", + "sendAccessCredentials": { "password_hash_b64": "ha$h" } + }"#; + + let err = serde_json::from_str::(json).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("did not match any variant"), + "unexpected: {msg}" + ); + } + + #[test] + fn extra_nested_key_is_rejected() { + let json = r#" + { + "sendId": "abc123", + "sendAccessCredentials": { + "passwordHashB64": "ha$h", + "extra": "nope" + } + }"#; + let err = from_str::(json).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("did not match any variant"), + "unexpected: {msg}" + ); + } + } + + mod send_access_credentials_tests { + use super::*; + + mod send_access_password_credentials_tests { + use serde_json::{from_str, to_string}; + + use super::*; + #[test] + fn deserializes_camelcase_from_ts() { + let json = r#"{ "passwordHashB64": "ha$h" }"#; + let s: SendPasswordCredentials = from_str(json).unwrap(); + assert_eq!(s.password_hash_b64, "ha$h"); + } + + #[test] + fn serializes_camelcase_to_wire() { + let s = SendPasswordCredentials { + password_hash_b64: "ha$h".into(), + }; + let json = to_string(&s).unwrap(); + assert_eq!(json, r#"{"passwordHashB64":"ha$h"}"#); + } + + #[test] + fn roundtrip_camel_in_to_camel_out() { + let in_json = r#"{ "passwordHashB64": "ha$h" }"#; + let parsed: SendPasswordCredentials = from_str(in_json).unwrap(); + let out_json = to_string(&parsed).unwrap(); + assert_eq!(out_json, r#"{"passwordHashB64":"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..29e7cdbc8 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/access_token_response.rs @@ -0,0 +1,75 @@ +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)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +#[derive(Debug)] +pub struct 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, + } + } +} + +// 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)] +#[serde(tag = "kind", content = "data", rename_all = "lowercase")] +/// Represents errors that can occur when requesting a send access token. +/// It includes expected and unexpected API errors. +pub enum SendAccessTokenError { + #[error("Unexpected Error response: {0:?}")] + /// Represents an unexpected error that occurred during the request. + /// This would typically be a transport-level error, such as network issues or serialization + /// problems. + Unexpected(UnexpectedIdentityError), + + #[error("Expected error response")] + /// Represents an expected error response from the API. + Expected(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::Unexpected(UnexpectedIdentityError(format!("{value:?}"))) + } +} + +/// Any unexpected error that occurs when making requests to identity. This could be +/// local/transport/decoding failure from the HTTP client (DNS/TLS/connect/read timeout, +/// connection reset, or JSON decode failure on a success response) or non-2xx response with an +/// unexpected body or status. Used when decoding the server's error payload into +/// `SendAccessTokenApiErrorResponse` fails, or for 5xx responses where no structured error is +/// available. +#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +pub struct UnexpectedIdentityError(pub String); 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..c58906894 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/mod.rs @@ -0,0 +1,12 @@ +//! Submodule containing the Send Access API request and response types. +mod token_api_error_response; +mod token_api_success_response; +mod token_request_payload; + +pub use token_api_error_response::{ + SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError, + SendAccessTokenInvalidRequestError, +}; +pub(crate) use token_api_success_response::SendAccessTokenApiSuccessResponse; +// Keep payload types internal to the crate +pub(crate) use token_request_payload::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..16d693f27 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/token_api_error_response.rs @@ -0,0 +1,989 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use tsify::Tsify; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +/// Invalid request errors - typically due to missing parameters. +pub enum SendAccessTokenInvalidRequestError { + #[allow(missing_docs)] + SendIdRequired, + + #[allow(missing_docs)] + PasswordHashB64Required, + + #[allow(missing_docs)] + EmailRequired, + + #[allow(missing_docs)] + EmailAndOtpRequiredOtpSent, + + /// Fallback for unknown variants for forward compatibility + #[serde(other)] + Unknown, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +/// Invalid grant errors - typically due to invalid credentials. +pub enum SendAccessTokenInvalidGrantError { + #[allow(missing_docs)] + SendIdInvalid, + + #[allow(missing_docs)] + PasswordHashB64Invalid, + + #[allow(missing_docs)] + EmailInvalid, + + #[allow(missing_docs)] + OtpInvalid, + + #[allow(missing_docs)] + OtpGenerationFailed, + + /// Fallback for unknown variants for forward compatibility + #[serde(other)] + Unknown, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "snake_case")] +#[serde(tag = "error")] +// ^ "error" becomes the variant discriminator which matches against the rename annotations; +// "error_description" is the payload for that variant which can be optional. +/// Represents the possible, expected errors that can occur when requesting a send access token. +pub enum SendAccessTokenApiErrorResponse { + /// Invalid request error, typically due to missing parameters for a specific + /// credential flow. Ex. `send_id` is required. + InvalidRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid request errors. + error_description: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional specific error type for invalid request errors. + send_access_error_type: Option, + }, + + /// Invalid grant error, typically due to invalid credentials. + InvalidGrant { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid grant errors. + error_description: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional specific error type for invalid grant errors. + send_access_error_type: Option, + }, + + /// Invalid client error, typically due to an invalid client secret or client ID. + InvalidClient { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid client errors. + error_description: Option, + }, + + /// Unauthorized client error, typically due to an unauthorized client. + UnauthorizedClient { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for unauthorized client errors. + error_description: Option, + }, + + /// Unsupported grant type error, typically due to an unsupported credential flow. + /// Note: during initial feature rollout, this will be used to indicate that the + /// feature flag is disabled. + UnsupportedGrantType { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for unsupported grant type errors. + error_description: Option, + }, + + /// Invalid scope error, typically due to an invalid scope requested. + InvalidScope { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid scope errors. + error_description: Option, + }, + + /// Invalid target error which is shown if the requested + /// resource is invalid, missing, unknown, or malformed. + InvalidTarget { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "wasm", tsify(optional))] + /// The optional error description for invalid target errors. + error_description: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + mod send_access_token_invalid_request_error_tests { + use serde_json::{from_str, json, to_string, to_value, Value}; + + use super::*; + + #[test] + fn invalid_request_variants_serde_tests() { + // (expected_variant, send_access_error_type) + let cases: &[(SendAccessTokenInvalidRequestError, &str)] = &[ + ( + SendAccessTokenInvalidRequestError::SendIdRequired, + "\"send_id_required\"", + ), + ( + SendAccessTokenInvalidRequestError::PasswordHashB64Required, + "\"password_hash_b64_required\"", + ), + ( + SendAccessTokenInvalidRequestError::EmailRequired, + "\"email_required\"", + ), + ( + SendAccessTokenInvalidRequestError::EmailAndOtpRequiredOtpSent, + "\"email_and_otp_required_otp_sent\"", + ), + ]; + + for (expected_variant, send_access_error_type_json) in cases { + // Deserialize from send_access_error_type to enum + let error_from_send_access_error_type: SendAccessTokenInvalidRequestError = + from_str(send_access_error_type_json).unwrap(); + assert_eq!( + &error_from_send_access_error_type, expected_variant, + "send_access_error_type should map to the expected variant" + ); + + // Serializing enum -> JSON string containing send_access_error_type + let json_from_variant = to_string(expected_variant).unwrap(); + assert_eq!( + json_from_variant, *send_access_error_type_json, + "serialization should emit the send_access_error_type_json" + ); + + // Type-safe check: to_value() → Value::String, then compare the + // code; this avoids formatting/quoting concerns from to_string(). + let value_from_variant = to_value(expected_variant).unwrap(); + assert_eq!( + value_from_variant, + Value::String(send_access_error_type_json.trim_matches('"').to_string()), + "serialization as value should match json generated from enum" + ); + + // Round-trip: send_access_error_type -> enum -> send_access_error_type + let round_tripped_code = to_string(&error_from_send_access_error_type).unwrap(); + assert_eq!( + round_tripped_code, *send_access_error_type_json, + "round-trip should preserve the send_access_error_type_json" + ); + } + } + + #[test] + fn invalid_request_full_payload_with_both_fields_parses() { + let payload = json!({ + "error": "invalid_request", + "error_description": "send_id is required.", + "send_access_error_type": "send_id_required" + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidRequest { + error_description, + send_access_error_type, + } => { + assert_eq!(error_description.as_deref(), Some("send_id is required.")); + assert_eq!( + send_access_error_type, + Some(SendAccessTokenInvalidRequestError::SendIdRequired) + ); + } + _ => panic!("expected invalid_request"), + } + } + + #[test] + fn invalid_request_payload_without_description_is_allowed() { + let payload = r#" + { + "error": "invalid_request", + "send_access_error_type": "email_required" + }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidRequest { + error_description, + send_access_error_type, + } => { + assert!(error_description.is_none()); + assert_eq!( + send_access_error_type, + Some(SendAccessTokenInvalidRequestError::EmailRequired) + ); + } + _ => panic!("expected invalid_request"), + } + } + + #[test] + fn invalid_request_unknown_code_maps_to_unknown() { + let payload = r#" + { + "error": "invalid_request", + "error_description": "something new", + "send_access_error_type": "brand_new_code" + }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidRequest { + error_description, + send_access_error_type, + } => { + assert_eq!(error_description.as_deref(), Some("something new")); + assert_eq!( + send_access_error_type, + Some(SendAccessTokenInvalidRequestError::Unknown) + ); + } + _ => panic!("expected invalid_request"), + } + } + + #[test] + fn invalid_request_minimal_payload_is_allowed() { + let payload = r#"{ "error": "invalid_request" }"#; + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidRequest { + error_description, + send_access_error_type, + } => { + assert!(error_description.is_none()); + assert!(send_access_error_type.is_none()); + } + _ => panic!("expected invalid_request"), + } + } + + #[test] + fn invalid_request_null_fields_become_none() { + let payload = r#" + { + "error": "invalid_request", + "error_description": null, + "send_access_error_type": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidRequest { + error_description, + send_access_error_type, + } => { + assert!(error_description.is_none()); + assert!(send_access_error_type.is_none()); + } + _ => panic!("expected invalid_request"), + } + } + } + + mod send_access_token_invalid_grant_error_tests { + use serde_json::{from_str, json, to_string, to_value, Value}; + + use super::*; + + #[test] + fn invalid_grant_variants_serde_tests() { + // (expected_variant, send_access_error_type) + let cases: &[(SendAccessTokenInvalidGrantError, &str)] = &[ + ( + SendAccessTokenInvalidGrantError::SendIdInvalid, + "\"send_id_invalid\"", + ), + ( + SendAccessTokenInvalidGrantError::PasswordHashB64Invalid, + "\"password_hash_b64_invalid\"", + ), + ( + SendAccessTokenInvalidGrantError::EmailInvalid, + "\"email_invalid\"", + ), + ( + SendAccessTokenInvalidGrantError::OtpInvalid, + "\"otp_invalid\"", + ), + ( + SendAccessTokenInvalidGrantError::OtpGenerationFailed, + "\"otp_generation_failed\"", + ), + ]; + + for (expected_variant, send_access_error_type_json) in cases { + // Deserialize from send_access_error_type to enum + let error_from_send_access_error_type: SendAccessTokenInvalidGrantError = + from_str(send_access_error_type_json).unwrap(); + assert_eq!( + &error_from_send_access_error_type, expected_variant, + "send_access_error_type should map to the expected variant" + ); + + // Serializing enum -> JSON string containing send_access_error_type + let json_from_variant = to_string(expected_variant).unwrap(); + assert_eq!( + json_from_variant, *send_access_error_type_json, + "serialization should emit the send_access_error_type_json" + ); + + // Type-safe check: to_value() → Value::String + let value_from_variant = to_value(expected_variant).unwrap(); + assert_eq!( + value_from_variant, + Value::String(send_access_error_type_json.trim_matches('"').to_string()), + "serialization as value should match json generated from enum" + ); + + // Round-trip: send_access_error_type -> enum -> send_access_error_type + let round_tripped_code = to_string(&error_from_send_access_error_type).unwrap(); + assert_eq!( + round_tripped_code, *send_access_error_type_json, + "round-trip should preserve the send_access_error_type_json" + ); + } + } + + #[test] + fn invalid_grant_full_payload_with_both_fields_parses() { + let payload = json!({ + "error": "invalid_grant", + "error_description": "password_hash_b64 is invalid.", + "send_access_error_type": "password_hash_b64_invalid" + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidGrant { + error_description, + send_access_error_type, + } => { + assert_eq!( + error_description.as_deref(), + Some("password_hash_b64 is invalid.") + ); + assert_eq!( + send_access_error_type, + Some(SendAccessTokenInvalidGrantError::PasswordHashB64Invalid) + ); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_payload_without_description_is_allowed() { + let payload = r#" + { + "error": "invalid_grant", + "send_access_error_type": "otp_invalid" + }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidGrant { + error_description, + send_access_error_type, + } => { + assert!(error_description.is_none()); + assert_eq!( + send_access_error_type, + Some(SendAccessTokenInvalidGrantError::OtpInvalid) + ); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_unknown_code_maps_to_unknown() { + let payload = r#" + { + "error": "invalid_grant", + "error_description": "new server-side reason", + "send_access_error_type": "brand_new_grant_code" + }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidGrant { + error_description, + send_access_error_type, + } => { + assert_eq!(error_description.as_deref(), Some("new server-side reason")); + assert_eq!( + send_access_error_type, + Some(SendAccessTokenInvalidGrantError::Unknown) + ); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_minimal_payload_is_allowed() { + let payload = r#"{ "error": "invalid_grant" }"#; + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidGrant { + error_description, + send_access_error_type, + } => { + assert!(error_description.is_none()); + assert!(send_access_error_type.is_none()); + } + _ => panic!("expected invalid_grant"), + } + } + + #[test] + fn invalid_grant_null_fields_become_none() { + let payload = r#" + { + "error": "invalid_grant", + "error_description": null, + "send_access_error_type": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidGrant { + error_description, + send_access_error_type, + } => { + assert!(error_description.is_none()); + assert!(send_access_error_type.is_none()); + } + _ => panic!("expected invalid_grant"), + } + } + } + + mod send_access_token_invalid_client_error_tests { + use serde_json::{from_str, json, to_value}; + + use super::*; + + #[test] + fn invalid_client_full_payload_with_description_parses() { + let payload = json!({ + "error": "invalid_client", + "error_description": "Invalid client credentials." + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidClient { error_description } => { + assert_eq!( + error_description.as_deref(), + Some("Invalid client credentials.") + ); + } + _ => panic!("expected invalid_client"), + } + } + + #[test] + fn invalid_client_without_description_is_allowed() { + let payload = r#"{ "error": "invalid_client" }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_client"), + } + } + + #[test] + fn invalid_client_serializes_back() { + let value = SendAccessTokenApiErrorResponse::InvalidClient { + error_description: Some("Invalid client credentials.".into()), + }; + let j = to_value(value).unwrap(); + assert_eq!( + j, + json!({ + "error": "invalid_client", + "error_description": "Invalid client credentials." + }) + ); + } + + #[test] + fn invalid_client_minimal_payload_is_allowed() { + let payload = r#"{ "error": "invalid_client" }"#; + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_client"), + } + } + + #[test] + fn invalid_client_null_description_becomes_none() { + let payload = r#" + { + "error": "invalid_client", + "error_description": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_client"), + } + } + + #[test] + fn invalid_client_ignores_send_access_error_type_and_extra_fields() { + let payload = r#" + { + "error": "invalid_client", + "send_access_error_type": "should_be_ignored", + "extra_field": 123, + "error_description": "desc" + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidClient { error_description } => { + assert_eq!(error_description.as_deref(), Some("desc")); + } + _ => panic!("expected invalid_client"), + } + } + } + + mod send_access_token_unauthorized_client_error_tests { + use serde_json::{from_str, json, to_value}; + + use super::*; + + #[test] + fn unauthorized_client_full_payload_with_description_parses() { + let payload = json!({ + "error": "unauthorized_client", + "error_description": "Client not permitted to use this grant." + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnauthorizedClient { error_description } => { + assert_eq!( + error_description.as_deref(), + Some("Client not permitted to use this grant.") + ); + } + _ => panic!("expected unauthorized_client"), + } + } + + #[test] + fn unauthorized_client_without_description_is_allowed() { + let payload = r#"{ "error": "unauthorized_client" }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnauthorizedClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unauthorized_client"), + } + } + + #[test] + fn unauthorized_client_serializes_back() { + let value = SendAccessTokenApiErrorResponse::UnauthorizedClient { + error_description: None, + }; + let j = to_value(value).unwrap(); + assert_eq!(j, json!({ "error": "unauthorized_client" })); + } + + #[test] + fn unauthorized_client_minimal_payload_is_allowed() { + let payload = r#"{ "error": "unauthorized_client" }"#; + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnauthorizedClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unauthorized_client"), + } + } + + #[test] + fn unauthorized_client_null_description_becomes_none() { + let payload = r#" + { + "error": "unauthorized_client", + "error_description": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnauthorizedClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unauthorized_client"), + } + } + + #[test] + fn unauthorized_client_ignores_send_access_error_type_and_extra_fields() { + let payload = r#" + { + "error": "unauthorized_client", + "send_access_error_type": "should_be_ignored", + "extra_field": true + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnauthorizedClient { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unauthorized_client"), + } + } + } + + mod send_access_token_unsupported_grant_type_error_tests { + use serde_json::{from_str, json, to_value}; + + use super::*; + + #[test] + fn unsupported_grant_type_full_payload_with_description_parses() { + let payload = json!({ + "error": "unsupported_grant_type", + "error_description": "This grant type is not enabled." + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnsupportedGrantType { error_description } => { + assert_eq!( + error_description.as_deref(), + Some("This grant type is not enabled.") + ); + } + _ => panic!("expected unsupported_grant_type"), + } + } + + #[test] + fn unsupported_grant_type_without_description_is_allowed() { + let payload = r#"{ "error": "unsupported_grant_type" }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnsupportedGrantType { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unsupported_grant_type"), + } + } + + #[test] + fn unsupported_grant_type_serializes_back() { + let value = SendAccessTokenApiErrorResponse::UnsupportedGrantType { + error_description: Some("Disabled by feature flag".into()), + }; + let j = to_value(value).unwrap(); + assert_eq!( + j, + json!({ + "error": "unsupported_grant_type", + "error_description": "Disabled by feature flag" + }) + ); + } + + #[test] + fn unsupported_grant_type_minimal_payload_is_allowed() { + let payload = r#"{ "error": "unsupported_grant_type" }"#; + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnsupportedGrantType { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unsupported_grant_type"), + } + } + + #[test] + fn unsupported_grant_type_null_description_becomes_none() { + let payload = r#" + { + "error": "unsupported_grant_type", + "error_description": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnsupportedGrantType { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unsupported_grant_type"), + } + } + + #[test] + fn unsupported_grant_type_ignores_send_access_error_type_and_extra_fields() { + let payload = r#" + { + "error": "unsupported_grant_type", + "send_access_error_type": "should_be_ignored", + "extra_field": "noise" + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::UnsupportedGrantType { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected unsupported_grant_type"), + } + } + } + + mod send_access_token_invalid_scope_error_tests { + use serde_json::{from_str, json, to_value}; + + use super::*; + + #[test] + fn invalid_scope_full_payload_with_description_parses() { + let payload = json!({ + "error": "invalid_scope", + "error_description": "Requested scope is not allowed." + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidScope { error_description } => { + assert_eq!( + error_description.as_deref(), + Some("Requested scope is not allowed.") + ); + } + _ => panic!("expected invalid_scope"), + } + } + + #[test] + fn invalid_scope_without_description_is_allowed() { + let payload = r#"{ "error": "invalid_scope" }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidScope { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_scope"), + } + } + + #[test] + fn invalid_scope_serializes_back() { + let value = SendAccessTokenApiErrorResponse::InvalidScope { + error_description: None, + }; + let j = to_value(value).unwrap(); + assert_eq!(j, json!({ "error": "invalid_scope" })); + } + + #[test] + fn invalid_scope_minimal_payload_is_allowed() { + let payload = r#"{ "error": "invalid_scope" }"#; + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidScope { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_scope"), + } + } + + #[test] + fn invalid_scope_null_description_becomes_none() { + let payload = r#" + { + "error": "invalid_scope", + "error_description": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidScope { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_scope"), + } + } + + #[test] + fn invalid_scope_ignores_send_access_error_type_and_extra_fields() { + let payload = r#" + { + "error": "invalid_scope", + "send_access_error_type": "should_be_ignored", + "extra_field": [1,2,3] + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidScope { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_scope"), + } + } + } + + mod send_access_token_invalid_target_error_tests { + use serde_json::{from_str, json, to_value}; + + use super::*; + + #[test] + fn invalid_target_full_payload_with_description_parses() { + let payload = json!({ + "error": "invalid_target", + "error_description": "Unknown or disallowed resource indicator." + }) + .to_string(); + + let parsed: SendAccessTokenApiErrorResponse = from_str(&payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidTarget { error_description } => { + assert_eq!( + error_description.as_deref(), + Some("Unknown or disallowed resource indicator.") + ); + } + _ => panic!("expected invalid_target"), + } + } + + #[test] + fn invalid_target_without_description_is_allowed() { + let payload = r#"{ "error": "invalid_target" }"#; + + let parsed: SendAccessTokenApiErrorResponse = serde_json::from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidTarget { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_target"), + } + } + + #[test] + fn invalid_target_serializes_back() { + let value = SendAccessTokenApiErrorResponse::InvalidTarget { + error_description: Some("Bad resource parameter".into()), + }; + let j = to_value(value).unwrap(); + assert_eq!( + j, + json!({ + "error": "invalid_target", + "error_description": "Bad resource parameter" + }) + ); + } + + #[test] + fn invalid_target_minimal_payload_is_allowed() { + let payload = r#"{ "error": "invalid_target" }"#; + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidTarget { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_target"), + } + } + + #[test] + fn invalid_target_null_description_becomes_none() { + let payload = r#" + { + "error": "invalid_target", + "error_description": null + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidTarget { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_target"), + } + } + + #[test] + fn invalid_target_ignores_send_access_error_type_and_extra_fields() { + let payload = r#" + { + "error": "invalid_target", + "send_access_error_type": "should_be_ignored", + "extra_field": {"k":"v"} + }"#; + + let parsed: SendAccessTokenApiErrorResponse = from_str(payload).unwrap(); + match parsed { + SendAccessTokenApiErrorResponse::InvalidTarget { error_description } => { + assert!(error_description.is_none()); + } + _ => panic!("expected invalid_target"), + } + } + } + + #[test] + fn unknown_top_level_error_rejects() { + let payload = r#"{ "error": "totally_new_error" }"#; + let err = serde_json::from_str::(payload).unwrap_err(); + let _ = err; + } +} 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..00dca7d33 --- /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(crate) struct SendAccessTokenApiSuccessResponse { + /// The access token string. + pub(crate) access_token: String, + /// The duration in seconds until the token expires. + pub(crate) expires_in: u64, + /// The scope of the access token. + /// RFC: + pub(crate) 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: + /// + pub(crate) 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..5a94631e0 --- /dev/null +++ b/crates/bitwarden-auth/src/send_access/api/token_request_payload.rs @@ -0,0 +1,203 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + api::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_b64": "example_hash" } instead of { "Password": { "password_hash_b64": +// "example_hash" } } +#[serde(untagged)] +pub(crate) 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(crate) 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(crate) struct SendAccessTokenRequestPayload { + // Standard OAuth2 fields + /// The client ID for the send access client. + pub(crate) 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(crate) 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(crate) scope: Scope, + + // Custom fields + /// The ID of the send for which the access token is being requested. + pub(crate) 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_b64": "example_hash" } instead of { "variant": { + // "password_hash_b64": "example_hash" } } + #[serde(flatten)] + pub(crate) credentials: SendAccessTokenPayloadCredentials, +} + +const SEND_ACCESS_CLIENT_ID: SendAccessClientType = SendAccessClientType::Send; +const SEND_ACCESS_GRANT_TYPE: GrantType = GrantType::SendAccess; +const SEND_ACCESS_SCOPE: Scope = Scope::ApiSendAccess; + +/// 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_password_payload() { + let payload = SendAccessTokenRequestPayload { + client_id: SendAccessClientType::Send, + grant_type: GrantType::SendAccess, + scope: Scope::ApiSendAccess, + send_id: "example_send_id".into(), + credentials: SendAccessTokenPayloadCredentials::Password { + password_hash_b64: "example_hash".into(), + }, + }; + + let serialized = serde_json::to_string_pretty(&payload).unwrap(); + + // Parse both sides to JSON values and compare structurally. + let got: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + let want = serde_json::json!({ + "client_id": "send", + "grant_type": "send_access", + "scope": "api.send.access", + "send_id": "example_send_id", + "password_hash_b64": "example_hash" + }); + + assert_eq!(got, want); + } + + #[test] + fn test_serialize_send_access_token_email_payload() { + let payload = SendAccessTokenRequestPayload { + client_id: SendAccessClientType::Send, + grant_type: GrantType::SendAccess, + scope: Scope::ApiSendAccess, + send_id: "example_send_id".into(), + credentials: SendAccessTokenPayloadCredentials::Email { + email: "example_email".into(), + }, + }; + + let serialized = serde_json::to_string_pretty(&payload).unwrap(); + + // Parse both sides to JSON values and compare structurally. + let got: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + let want = serde_json::json!({ + "client_id": "send", + "grant_type": "send_access", + "scope": "api.send.access", + "send_id": "example_send_id", + "email": "example_email" + }); + + assert_eq!(got, want); + } + + #[test] + fn test_serialize_send_access_token_email_otp_payload() { + let payload = SendAccessTokenRequestPayload { + client_id: SendAccessClientType::Send, + grant_type: GrantType::SendAccess, + scope: Scope::ApiSendAccess, + send_id: "example_send_id".into(), + credentials: SendAccessTokenPayloadCredentials::EmailOtp { + email: "example_email".into(), + otp: "example_otp".into(), + }, + }; + let serialized = serde_json::to_string_pretty(&payload).unwrap(); + // Parse both sides to JSON values and compare structurally. + let got: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + let want = serde_json::json!({ + "client_id": "send", + "grant_type": "send_access", + "scope": "api.send.access", + "send_id": "example_send_id", + "email": "example_email", + "otp": "example_otp" + }); + + assert_eq!(got, want); + } + } +} diff --git a/crates/bitwarden-auth/src/send_access/client.rs b/crates/bitwarden-auth/src/send_access/client.rs index b6e5b99dc..692564d45 100644 --- a/crates/bitwarden-auth/src/send_access/client.rs +++ b/crates/bitwarden-auth/src/send_access/client.rs @@ -2,6 +2,16 @@ use bitwarden_core::Client; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; +use crate::send_access::{ + access_token_response::UnexpectedIdentityError, + api::{ + SendAccessTokenApiErrorResponse, SendAccessTokenApiSuccessResponse, + SendAccessTokenRequestPayload, + }, + SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse, +}; + +/// The `SendAccessClient` is used to interact with the Bitwarden API to get send access tokens. #[derive(Clone)] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct SendAccessClient { @@ -16,10 +26,840 @@ 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. + 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 = self.client.internal.get_api_configurations().await; + + // save off url in variable for re-use + let url = format!("{}/connect/token", &configurations.identity.base_path); + + let request: reqwest::RequestBuilder = configurations + .identity + .client + .post(&url) + .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")); + + // Because of the ? operator, any errors from sending the request are automatically + // wrapped in SendAccessTokenError::Unexpected as an UnexpectedIdentityError::Reqwest + // variant and returned. + // note: we had to manually built a trait to map reqwest::Error to SendAccessTokenError. + let response: reqwest::Response = request.send().await?; + + let response_status = response.status(); + + // handle success and error responses + // If the response is 2xx, 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()); + } + + let err_response = match response.json::().await { + // If the response is a 400 with a specific error type, we can deserialize it into + // SendAccessTokenApiErrorResponse and then convert it into + // SendAccessTokenError::Expected later on. + Ok(err) => err, + Err(_) => { + // This handles any 4xx that aren't specifically handled above + // as well as any other non-2xx responses (5xx, etc). + + let error_string = format!( + "Received response status {} against {}", + response_status, url + ); + + return Err(SendAccessTokenError::Unexpected(UnexpectedIdentityError( + error_string, + ))); + } + }; + + Err(SendAccessTokenError::Expected(err_response)) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_core::{Client as CoreClient, ClientSettings, DeviceType}; + use bitwarden_test::start_api_mock; + use wiremock::{ + matchers::{self, body_string_contains}, + Mock, MockServer, ResponseTemplate, + }; + + use crate::{ + api::enums::{GrantType, Scope}, + send_access::{ + api::{ + SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError, + SendAccessTokenInvalidRequestError, + }, + SendAccessClient, SendAccessCredentials, SendAccessTokenError, SendAccessTokenRequest, + SendAccessTokenResponse, SendEmailCredentials, SendEmailOtpCredentials, + SendPasswordCredentials, UnexpectedIdentityError, + }, + AuthClientExt, + }; + + 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() + } + + mod request_send_access_token_success_tests { + + use super::*; + + #[tokio::test] + async fn request_send_access_token_anon_send_success() { + let scope_value = serde_json::to_value(Scope::ApiSendAccess).unwrap(); + let scope_str = scope_value.as_str().unwrap(); + + let grant_type_value = serde_json::to_value(GrantType::SendAccess).unwrap(); + let grant_type_str = grant_type_value.as_str().unwrap(); + + // Create a mock success response + let raw_success = serde_json::json!({ + "access_token": "token", + "token_type": "bearer", + "expires_in": 3600, + "scope": scope_str + }); + + // Construct the real Request type + let req = SendAccessTokenRequest { + send_id: "test_send_id".into(), + send_access_credentials: None, // No credentials for this test + }; + + 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(format!( + "grant_type={}", + grant_type_str + ))) + .and(body_string_contains(format!("scope={}", scope_str))) + .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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + 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_password_protected_send_success() { + let scope_value = serde_json::to_value(Scope::ApiSendAccess).unwrap(); + let scope_str = scope_value.as_str().unwrap(); + + let grant_type_value = serde_json::to_value(GrantType::SendAccess).unwrap(); + let grant_type_str = grant_type_value.as_str().unwrap(); + + // Create a mock success response + let raw_success = serde_json::json!({ + "access_token": "token", + "token_type": "bearer", + "expires_in": 3600, + "scope": scope_str + }); + + let password_hash_b64 = "valid-hash"; + + let password_credentials = SendPasswordCredentials { + password_hash_b64: password_hash_b64.into(), + }; + + let req = SendAccessTokenRequest { + send_id: "valid-send-id".into(), + send_access_credentials: Some(SendAccessCredentials::Password( + password_credentials, + )), + }; + + 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(format!( + "grant_type={}", + grant_type_str + ))) + .and(body_string_contains(format!("scope={}", scope_str))) + .and(body_string_contains(format!("send_id={}", req.send_id))) + .and(body_string_contains(format!( + "password_hash_b64={}", + password_hash_b64 + ))) + // respond with the mock success response + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + 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_email_otp_protected_send_success() { + let scope_value = serde_json::to_value(Scope::ApiSendAccess).unwrap(); + let scope_str = scope_value.as_str().unwrap(); + + let grant_type_value = serde_json::to_value(GrantType::SendAccess).unwrap(); + let grant_type_str = grant_type_value.as_str().unwrap(); + + // Create a mock success response + let raw_success = serde_json::json!({ + "access_token": "token", + "token_type": "bearer", + "expires_in": 3600, + "scope": scope_str + }); + + let email = "valid@email.com"; + let otp: &str = "valid_otp"; + + let email_otp_credentials = SendEmailOtpCredentials { + email: email.into(), + otp: otp.into(), + }; + + let email_param = serde_urlencoded::to_string([("email", email)]).unwrap(); // "email=valid%40email.com" + + let req = SendAccessTokenRequest { + send_id: "valid-send-id".into(), + send_access_credentials: Some(SendAccessCredentials::EmailOtp( + email_otp_credentials, + )), + }; + + 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(format!( + "grant_type={}", + grant_type_str + ))) + .and(body_string_contains(format!("scope={}", scope_str))) + .and(body_string_contains(format!("send_id={}", req.send_id))) + .and(body_string_contains(email_param)) + .and(body_string_contains(format!("otp={}", otp))) + // respond with the mock success response + .respond_with(ResponseTemplate::new(200).set_body_json(raw_success)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + let token: SendAccessTokenResponse = send_access_client + .request_send_access_token(req) + .await + .unwrap(); + + assert_eq!(token.token, "token"); + assert!(token.expires_at > 0); + } + } + + mod request_send_access_token_invalid_request_tests { + use super::*; + + #[tokio::test] + async fn request_send_access_token_invalid_request_send_id_required_error() { + // Create a mock error response + let error_description = "send_id is required.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_request", + "error_description": error_description, + "send_access_error_type": "send_id_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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // 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 + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidRequest { + send_access_error_type: Some( + SendAccessTokenInvalidRequestError::SendIdRequired + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_invalid_request_password_hash_required_error() { + // Create a mock error response + let error_description = "password_hash_b64 is required.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_request", + "error_description": error_description, + "send_access_error_type": "password_hash_b64_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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request with a send_id but no credentials to trigger the error + let req = SendAccessTokenRequest { + send_id: "test_send_id".into(), + send_access_credentials: None, // No credentials for this test + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidRequest { + send_access_error_type: Some( + SendAccessTokenInvalidRequestError::PasswordHashB64Required + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_invalid_request_email_required_error() { + // Create a mock error response + let error_description = "email is required.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_request", + "error_description": error_description, + "send_access_error_type": "email_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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request with a send_id but no credentials to trigger the error + let req = SendAccessTokenRequest { + send_id: "test_send_id".into(), + send_access_credentials: None, // No credentials for this test + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidRequest { + send_access_error_type: Some( + SendAccessTokenInvalidRequestError::EmailRequired + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_invalid_request_email_otp_required_error() { + // Create a mock error response + let error_description = + "email and otp are required. An OTP has been sent to the email address provided." + .into(); + let raw_error = serde_json::json!({ + "error": "invalid_request", + "error_description": error_description, + "send_access_error_type": "email_and_otp_required_otp_sent" + }); + + // Create 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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request with a send_id and email credential + let email_credentials = SendEmailCredentials { + email: "test@example.com".into(), + }; + + let req = SendAccessTokenRequest { + send_id: "test_send_id".into(), + send_access_credentials: Some(SendAccessCredentials::Email(email_credentials)), + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidRequest { + send_access_error_type: Some( + SendAccessTokenInvalidRequestError::EmailAndOtpRequiredOtpSent + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + } + + mod request_send_access_token_invalid_grant_tests { + + use super::*; + + #[tokio::test] + async fn request_send_access_token_invalid_grant_invalid_send_id_error() { + // Create a mock error response + let error_description = "send_id is invalid.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_grant", + "error_description": error_description, + "send_access_error_type": "send_id_invalid" + }); + + // Create 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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request without a send_id to trigger an error + let req = SendAccessTokenRequest { + send_id: "invalid-send-id".into(), + send_access_credentials: None, // No credentials for this test + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + // Now assert the inner enum: + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidGrant { + send_access_error_type: Some( + SendAccessTokenInvalidGrantError::SendIdInvalid + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_invalid_grant_invalid_password_hash_error() { + // Create a mock error response + let error_description = "password_hash_b64 is invalid.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_grant", + "error_description": error_description, + "send_access_error_type": "password_hash_b64_invalid" + }); + + // Create 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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request + let password_credentials = SendPasswordCredentials { + password_hash_b64: "invalid-hash".into(), + }; + + let req = SendAccessTokenRequest { + send_id: "valid-send-id".into(), + send_access_credentials: Some(SendAccessCredentials::Password( + password_credentials, + )), + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + // Now assert the inner enum: + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidGrant { + send_access_error_type: Some( + SendAccessTokenInvalidGrantError::PasswordHashB64Invalid + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_invalid_grant_invalid_email_error() { + // Create a mock error response + let error_description = "email is invalid.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_grant", + "error_description": error_description, + "send_access_error_type": "email_invalid" + }); + + // 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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request + let email_credentials = SendEmailCredentials { + email: "invalid-email".into(), + }; + let req = SendAccessTokenRequest { + send_id: "valid-send-id".into(), + send_access_credentials: Some(SendAccessCredentials::Email(email_credentials)), + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + // Now assert the inner enum: + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidGrant { + send_access_error_type: Some( + SendAccessTokenInvalidGrantError::EmailInvalid + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_invalid_grant_invalid_otp_error() { + // Create a mock error response + let error_description = "otp is invalid.".into(); + let raw_error = serde_json::json!({ + "error": "invalid_grant", + "error_description": error_description, + "send_access_error_type": "otp_invalid" + }); + + // Create 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)); + + // Spin up a server and register mock with it + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + + // Create a send access client + let send_access_client = make_send_client(&mock_server); + + // Construct the request + let email_otp_credentials = SendEmailOtpCredentials { + email: "valid@email.com".into(), + otp: "valid_otp".into(), + }; + let req = SendAccessTokenRequest { + send_id: "valid-send-id".into(), + send_access_credentials: Some(SendAccessCredentials::EmailOtp( + email_otp_credentials, + )), + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.unwrap_err(); + match err { + SendAccessTokenError::Expected(api_err) => { + // Now assert the inner enum: + assert_eq!( + api_err, + SendAccessTokenApiErrorResponse::InvalidGrant { + send_access_error_type: Some( + SendAccessTokenInvalidGrantError::OtpInvalid + ), + error_description: Some(error_description), + } + ); + } + other => panic!("expected Response variant, got {:?}", other), + } + } + } + + mod request_send_access_token_unexpected_error_tests { + + use super::*; + + async fn run_case(status_code: u16, reason: &str) { + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("identity/connect/token")) + .respond_with(ResponseTemplate::new(status_code)); + + let (mock_server, _api_config) = start_api_mock(vec![mock]).await; + let send_access_client = make_send_client(&mock_server); + + let req = SendAccessTokenRequest { + send_id: "test_send_id".into(), + send_access_credentials: None, + }; + + let result = send_access_client.request_send_access_token(req).await; + + assert!(result.is_err()); + + let err = result.expect_err(&format!( + "expected Err for status {} {} against http://{}/identity/connect/token", + status_code, + reason, + mock_server.address() + )); + + match err { + SendAccessTokenError::Unexpected(api_err) => { + let expected = UnexpectedIdentityError(format!( + "Received response status {} {} against http://{}/identity/connect/token", + status_code, + reason, + mock_server.address() + )); + assert_eq!(api_err, expected, "mismatch for status {}", status_code); + } + other => panic!("expected Unexpected variant, got {:?}", other), + } + } + + #[tokio::test] + async fn request_send_access_token_unexpected_statuses() { + let cases = [ + // 4xx (client errors) — excluding 400 Bad Request as we handle those as expected + // errors. + (401, "Unauthorized"), + (402, "Payment Required"), + (403, "Forbidden"), + (404, "Not Found"), + (405, "Method Not Allowed"), + (406, "Not Acceptable"), + (407, "Proxy Authentication Required"), + (408, "Request Timeout"), + (409, "Conflict"), + (410, "Gone"), + (411, "Length Required"), + (412, "Precondition Failed"), + (413, "Payload Too Large"), + (414, "URI Too Long"), + (415, "Unsupported Media Type"), + (416, "Range Not Satisfiable"), + (417, "Expectation Failed"), + (421, "Misdirected Request"), + (422, "Unprocessable Entity"), + (423, "Locked"), + (424, "Failed Dependency"), + (425, "Too Early"), + (426, "Upgrade Required"), + (428, "Precondition Required"), + (429, "Too Many Requests"), + (431, "Request Header Fields Too Large"), + (451, "Unavailable For Legal Reasons"), + // 5xx (server errors) + (500, "Internal Server Error"), + (501, "Not Implemented"), + (502, "Bad Gateway"), + (503, "Service Unavailable"), + (504, "Gateway Timeout"), + (505, "HTTP Version Not Supported"), + (506, "Variant Also Negotiates"), + (507, "Insufficient Storage"), + (508, "Loop Detected"), + (510, "Not Extended"), + (511, "Network Authentication Required"), + ]; + + for (code, reason) in cases { + run_case(code, reason).await; + } + } } } diff --git a/crates/bitwarden-auth/src/send_access/mod.rs b/crates/bitwarden-auth/src/send_access/mod.rs index eaabc7c25..171abf8e3 100644 --- a/crates/bitwarden-auth/src/send_access/mod.rs +++ b/crates/bitwarden-auth/src/send_access/mod.rs @@ -1,2 +1,24 @@ +//! The SendAccess module handles send access token requests and responses. +//! We use a custom extension OAuth2 grant type to request send access tokens +//! outside the context of a Bitwarden user. This will be used by the send portion of the +//! Bitwarden web app to allow users to access send access functionality without +//! needing to log in to a Bitwarden account. +//! Sends can be anonymous, password protected, or email protected. +//! If you request an access token for an anonymous send by id, no credentials are required. +//! If you request an access token for a password protected send, you must provide a correct +//! password hash. If you request an access token for an email protected send, you must provide the +//! email address and a one-time passcode (OTP) sent to that email address. +mod access_token_request; +mod access_token_response; mod client; + +pub mod api; + +pub use access_token_request::{ + SendAccessCredentials, SendAccessTokenRequest, SendEmailCredentials, SendEmailOtpCredentials, + SendPasswordCredentials, +}; +pub use access_token_response::{ + SendAccessTokenError, SendAccessTokenResponse, UnexpectedIdentityError, +}; pub use client::SendAccessClient;