From 44285bb8793bc33ea9fab606ad1d8bdc2a191640 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:24:12 -0600 Subject: [PATCH] Add initial OAuth 2 support --- wp_api/src/login.rs | 198 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 188 insertions(+), 10 deletions(-) diff --git a/wp_api/src/login.rs b/wp_api/src/login.rs index 56dcba55..410d9b97 100644 --- a/wp_api/src/login.rs +++ b/wp_api/src/login.rs @@ -11,6 +11,7 @@ use crate::ParsedUrl; use crate::WpUuid; const KEY_APPLICATION_PASSWORDS: &str = "application-passwords"; +const KEY_OAUTH_2: &str = "oauth2"; mod login_client; mod url_discovery; @@ -57,27 +58,147 @@ pub struct WpApiDetails { pub gmt_offset: i64, pub timezone_string: String, pub namespaces: Vec, - pub authentication: HashMap, + pub authentication: HashMap, pub site_icon_url: Option, } #[uniffi::export] impl WpApiDetails { pub fn find_application_passwords_authentication_url(&self) -> Option { - self.authentication - .get(KEY_APPLICATION_PASSWORDS) - .map(|auth_scheme| auth_scheme.endpoints.authorization.clone()) + match self.authentication.get(KEY_APPLICATION_PASSWORDS) { + Some(AuthenticationProtocol::ApplicationPassword(scheme)) => { + Some(scheme.endpoints.authorization.clone()) + } + _ => None, + } + } + + pub fn find_oauth_server_details(&self) -> Option { + match self.authentication.get(KEY_OAUTH_2) { + Some(AuthenticationProtocol::OAuth2(scheme)) => Some(scheme.clone().into()), + _ => None, + } + } + + pub fn registered_authentication_methods(&self) -> Vec { + let mut methods: Vec = vec![]; + + for (name, protocol) in &self.authentication { + methods.push(protocol.clone().into()) + } + + methods + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum AuthenticationProtocol { + OAuth2(OAuth2Scheme), + ApplicationPassword(ApplicationPasswordScheme), + Other(UnknownAuthenticationData), +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum WpAuthenticationProtocol { + OAuth2(WpApiOAuth2ServerDetails), + ApplicationPassword(String), + Other(UnknownAuthenticationData), +} + +impl From for WpAuthenticationProtocol { + fn from(protocol: AuthenticationProtocol) -> Self { + match protocol { + AuthenticationProtocol::OAuth2(scheme) => { + WpAuthenticationProtocol::OAuth2(scheme.clone().into()) + } + AuthenticationProtocol::ApplicationPassword(scheme) => { + WpAuthenticationProtocol::ApplicationPassword( + scheme.endpoints.authorization.clone(), + ) + } + AuthenticationProtocol::Other(scheme) => { + WpAuthenticationProtocol::Other(scheme.clone()) + } + } } } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] -pub struct WpRestApiAuthenticationScheme { - pub endpoints: WpRestApiAuthenticationEndpoint, +#[derive(Debug, Serialize, Deserialize, Clone, uniffi::Enum)] +#[serde(untagged)] +pub enum UnknownAuthenticationData { + Bool(bool), + Int(i64), + String(String), + Float(f64), + Object(HashMap), + Dictionary(HashMap), + List(Vec), +} + +/// An internal JSON representation of the WP Core `application-passwords` authentication method. +/// +#[derive(Debug, Serialize, Deserialize, Clone, uniffi::Record)] +pub struct ApplicationPasswordScheme { + endpoints: ApplicationPasswordEndpoints, } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] -pub struct WpRestApiAuthenticationEndpoint { - pub authorization: String, +/// An internal JSON representation of the WP Core `application-passwords` authentication method's endpoints. +#[derive(Debug, Serialize, Deserialize, Clone, uniffi::Record)] +pub struct ApplicationPasswordEndpoints { + authorization: String, +} + +/// An internal JSON representation of an `oauth2` authentication method as provided by https://wordpress.org/plugins/oauth2-provider/ +/// Provides a fallback for servers that use an `endpoints` key to match the `application-passwords` method. +/// +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum OAuth2Scheme { + WithoutEndpointKey(OAuth2SchemeWithoutEndpoint), + WithEndpointKey(OAuth2SchemeWithEndpoint), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OAuth2SchemeWithEndpoint { + endpoints: OAuth2Endpoints, +} + +/// An internal JSON representation of the `oauth2` authentication method's endpoints. +/// +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OAuth2Endpoints { + authorization: String, + token: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OAuth2SchemeWithoutEndpoint { + authorize: String, + token: String, +} + +/// A derived representation of `OAuth2Scheme` for clients that normalizes the fields +/// +#[derive(Debug, Serialize, Deserialize, Clone, uniffi::Record)] +pub struct WpApiOAuth2ServerDetails { + pub authorization_url: String, + pub token_url: String, +} + +impl From for WpApiOAuth2ServerDetails { + fn from(scheme: OAuth2Scheme) -> Self { + match scheme { + OAuth2Scheme::WithoutEndpointKey(subscheme) => WpApiOAuth2ServerDetails { + authorization_url: subscheme.authorize.clone(), + token_url: subscheme.token.clone(), + }, + OAuth2Scheme::WithEndpointKey(subscheme) => WpApiOAuth2ServerDetails { + authorization_url: subscheme.endpoints.authorization.clone(), + token_url: subscheme.endpoints.token.clone(), + }, + } + } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, uniffi::Record)] @@ -200,4 +321,61 @@ mod tests { ); assert_eq!(auth_url, ParsedUrl::parse(expected_url.as_str()).unwrap()); } + + #[derive(Debug, Serialize, Deserialize)] + struct AuthenticationTest { + authentication: HashMap, + } + + #[rstest] + #[case(r#"{ "authentication": { } }"#)] + // #[case(r#"{ "authentication": [ ] }"#)] // TODO + fn test_empty_authentication_can_be_parsed(#[case] input_json: &str) { + let test_object: AuthenticationTest = serde_json::from_str(input_json).unwrap(); + assert!(test_object.authentication.is_empty()) + } + + #[rstest] + fn test_authentication_with_valid_application_passwords() { + let input_json = r#" + { "authentication": { "application-passwords": { "endpoints": { "authorization": "http:\/\/localhost\/wp-admin\/authorize-application.php" } } } }"#; + let test_object: AuthenticationTest = serde_json::from_str(input_json).unwrap(); + assert!(matches!( + test_object + .authentication + .get(KEY_APPLICATION_PASSWORDS) + .unwrap(), + AuthenticationProtocol::ApplicationPassword(_) + )); + } + + #[rstest] + #[case(r#"{ "authentication": { "application-passwords": { } } }"#)] + #[case(r#"{ "authentication": { "application-passwords": [ ] } }"#)] + #[case(r#"{ "authentication": { "application-passwords": { "disabled": true } } }"#)] + #[case(r#"{ "authentication": { "application-passwords": { "florps": 42 } } }"#)] + #[case(r#"{ "authentication": { "application-passwords": { "florps": -42 } } }"#)] + #[case(r#"{ "authentication": { "application-passwords": { "florps": 0.5234 } } }"#)] + fn test_authentication_with_invalid_application_passwords_is_other(#[case] input_json: &str) { + let test_object: AuthenticationTest = serde_json::from_str(input_json).unwrap(); + assert!(matches!( + test_object + .authentication + .get(KEY_APPLICATION_PASSWORDS) + .unwrap(), + AuthenticationProtocol::Other(_) + )) + } + + #[rstest] + #[case(r#"{ "authentication": { "oauth2": { "authorize": "http:\/\/localhost\/oauth\/authorize", "token": "http:\/\/localhost\/oauth\/token", "me": "http:\/\/localhost\/oauth\/me", "version": "2.0", "software": "WP OAuth Server" } } }"#)] + #[case(r#"{ "authentication": { "oauth2": { "endpoints": { "authorization": "https:\/\/public-api.wordpress.com\/oauth2\/authorize", "token": "https:\/\/public-api.wordpress.com\/oauth2\/token" } } } }"#)] + fn test_authentication_with_valid_oauth2(#[case] input_json: &str) { + let test_object: AuthenticationTest = serde_json::from_str(input_json).unwrap(); + println!("{:?}", test_object); + assert!(matches!( + test_object.authentication.get(KEY_OAUTH_2).unwrap(), + AuthenticationProtocol::OAuth2(_) + )); + } }