Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement wp-login.php based cookie authentication #327

Open
wants to merge 28 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
de6b9a0
Implement wp-login.php based cookie authentication
crazytonyli Sep 27, 2024
97c9bbf
Add a few integration tests for cookie authentication
crazytonyli Sep 30, 2024
b3e0d05
Implement authentication methods in dedicated types
crazytonyli Oct 6, 2024
5d977f2
Save fetched nonce
crazytonyli Oct 14, 2024
1366c95
Merge branch 'trunk' into wp-login-cookie-authentication
crazytonyli Oct 15, 2024
f8e73e6
Add TestCredentials.admin_account_password
crazytonyli Oct 15, 2024
2721dfd
Do not mutate request in Authenticator
crazytonyli Oct 15, 2024
aeb2ae5
Add unit tests for derived url functions
crazytonyli Oct 15, 2024
a0613fd
Format code
crazytonyli Oct 15, 2024
083a7d3
Add a constrant for "application/x-www-form-urlencoded"
crazytonyli Oct 15, 2024
bf61854
Derive Default on InnerRequestBuilder
crazytonyli Oct 15, 2024
2cba32e
Refactor authenticator to extra authentication logic
crazytonyli Oct 15, 2024
52c8a5c
Remove AuthenticationError
crazytonyli Oct 15, 2024
06ef60d
Add an unit test to verify nonce is reused across multiple requests
crazytonyli Oct 22, 2024
527c176
Prevent repeatedly fetching nonce when sending concurrent requests
crazytonyli Oct 22, 2024
53f6f66
Replace empty default value with assertions
crazytonyli Oct 22, 2024
1d0c91c
Clone instead of re-creating ApiBaseUrl
crazytonyli Oct 22, 2024
9ca673b
Run cookie authentication tests in parallel
crazytonyli Oct 22, 2024
776ba65
Add an API to document RequestExecutor must support cookie-jar
crazytonyli Oct 22, 2024
3112f32
Update function parameters
crazytonyli Oct 22, 2024
3fcdda3
Rename get_reset_nonce to fetch_rest_nonce
crazytonyli Oct 22, 2024
0afe44d
Merge branch 'trunk' into wp-login-cookie-authentication
crazytonyli Oct 23, 2024
c30f3fb
Add more test cases to wp-login and rest-nonce unit tests
crazytonyli Oct 23, 2024
a5884e7
Simplify deriving wp-login url implementation
crazytonyli Oct 23, 2024
86caf42
Simplify Authenticator trait design
crazytonyli Oct 24, 2024
2bf1ec5
Fix a compiling issue
crazytonyli Oct 24, 2024
40404ff
Pass previous authentication header instead of the request object
crazytonyli Oct 24, 2024
012a36a
Add unit tests for CookieAuthenticator
crazytonyli Oct 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ rstest = "0.21"
rstest_reuse = "0.7.0"
serde = "1.0"
serde_json = "1.0"
serde_urlencoded = "0.7"
serial_test = "3.1"
strum = "0.26"
strum_macros = "0.26"
Expand Down
1 change: 1 addition & 0 deletions wp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ paste = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = [ "derive" ] }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
thiserror = { workspace = true }
uniffi = { workspace = true }
uuid = { workspace = true, features = [ "v4" ] }
Expand Down
8 changes: 8 additions & 0 deletions wp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,17 @@ impl WpContext {
}
}

/// WordPress site user account which is used to login from wp-login.php.
#[derive(Debug, Clone, uniffi::Record)]
pub struct WpLoginCredentials {
pub username: String,
pub password: String,
}

#[derive(Debug, Clone, uniffi::Enum)]
pub enum WpAuthentication {
AuthorizationHeader { token: String },
UserAccount { login: WpLoginCredentials },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've discussed this with @jkmassel as well, and I think this authentication method should take a nonce token and a cookie string and attach them to every request.

In my opinion, we should handle the re-authentication as its own thing instead of the current built-in mechanism.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this authentication method should take a nonce token and a cookie string and attach them to every request.

If we want to implement "cookie authentication" this way, we don't actually have to do anything to support it in this library. The client can just make their RequestExector implementation including cookies and nonce in their requests.

My intention in this PR is making clients do as minimal work as possible. They only need to supply username and password, we'll take care of the nitty gritty of cookie authentication.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can give all of the necessary pieces to the clients/wrappers, but the decisions are explicit.

I am slowly understanding that a lot of my concerns are related to how weird this type of authentication is. Still, I want to find a way to make everything more explicit, rather than being buried.

Also, I think you mentioned on Slack that you needed to retain some state related to the nonce token, wouldn't making this change help with that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can give all of the necessary pieces to the clients/wrappers, but the decisions are explicit.

I guess it depends on what "the necessary pieces" are. In this PR, those are the account username and password: we'll do the cookies and nonce things internally. In your previous comment, you mentioned your preference is clients supplying cookies and nonces (which means clients need to make necessary HTTP requests to obtain those things).

Also, I think you mentioned on Slack that you needed to retain some state related to the nonce token, wouldn't making this change help with that?

I stopped looking after your answer about self-referential types. 🙈

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your previous comment, you mentioned your preference is clients supplying cookies and nonces (which means clients need to make necessary HTTP requests to obtain those things).

Although that's my preference, I also think that we should provide helpers to make the necessary HTTP requests. In my opinion, the best setup is we have individual easy to use pieces and the clients glue them up with a few lines of code. I am not 100% sure that this is possible or will work out as good as I have it in my mind, but I think it's worth a try.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, we should handle the re-authentication as its own thing instead of the current built-in mechanism.

I have made some changes regarding this suggestion. Can you please have another look at this PR? If you think that's an acceptable solution, I can further refine some details and address your other comments.

Authentication (including application-password) is now in their dedicated "Authenticator" types. They are now decoupled from the "HTTP request building" code.

None,
}

Expand Down
97 changes: 94 additions & 3 deletions wp_api/src/login/login_client.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use std::str;
use std::sync::Arc;

use crate::request::endpoint::WpEndpointUrl;
use url::Url;

use crate::request::endpoint::{ApiBaseUrl, WpEndpointUrl};
use crate::request::{
RequestExecutor, RequestMethod, WpNetworkHeaderMap, WpNetworkRequest, WpNetworkResponse,
RequestExecutor, RequestMethod, WpNetworkHeaderMap, WpNetworkRequest, WpNetworkRequestBody,
WpNetworkResponse,
};
use crate::ParsedUrl;
use crate::{ParsedUrl, WpLoginCredentials};

use super::url_discovery::{
self, FetchApiDetailsError, FetchApiRootUrlError, StateInitial, UrlDiscoveryAttemptError,
Expand Down Expand Up @@ -156,4 +159,92 @@ impl WpLoginClient {
.await
.map_err(FetchApiDetailsError::from)
}

pub(crate) async fn insert_rest_nonce(
&self,
request: &WpNetworkRequest,
api_base_url: &ApiBaseUrl,
login: &WpLoginCredentials,
) -> Option<WpNetworkRequest> {
// Only attempt login if the request is to the WordPress site.
if Url::parse(api_base_url.as_str()).ok()?.host_str()
!= Url::parse(request.url.0.as_str()).ok()?.host_str()
{
return None;
}

let nonce = self.get_rest_nonce(api_base_url, login).await?;

let mut request = request.clone();
let mut headers = request.header_map.as_header_map();
headers.insert(
http::header::HeaderName::from_bytes("X-WP-Nonce".as_bytes())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we don't use HeaderName::from_str here? It internally calls from_bytes and I don't think there is any extra value in us using from_bytes directly.

.expect("This conversion should never fail"),
nonce.try_into().expect("This conversion should never fail"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonce is a value we get back from a request, right? So, why can the conversation not fail?

);
request.header_map = WpNetworkHeaderMap::new(headers).into();

Some(request)
}

async fn get_rest_nonce(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation seems to be a multi-step process. It'd be good to separate the steps into their own functions and if necessary have a function that combines them. That'll make it easier to re-use parts of it and more importantly, easier to understand.

&self,
api_base_url: &ApiBaseUrl,
login: &WpLoginCredentials,
) -> Option<String> {
let rest_nonce_url = api_base_url.derived_rest_nonce_url();
let rest_nonce_url_clone = rest_nonce_url.clone();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of cloning, you can create the redirect_to &str here.

let nonce_request = WpNetworkRequest {
method: RequestMethod::GET,
url: rest_nonce_url.into(),
header_map: WpNetworkHeaderMap::new(http::HeaderMap::new()).into(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
header_map: WpNetworkHeaderMap::new(http::HeaderMap::new()).into(),
header_map: WpNetworkHeaderMap::default().into(),

body: None,
};

let nonce_from_request = |request: WpNetworkRequest| async move {
self.request_executor
.execute(request.into())
.await
.ok()
.and_then(|response| {
// A 200 OK response from the `rest_nonce_url` (a.k.a `wp-admin/admin-ajax.php`)
// should be the nonce value. However, just in case the site is configured to
// return a 200 OK response with other content (for example redirection to
// other webpage), here we check the body length here for a light validation of
// the nonce value.
if response.status_code == 200 {
let body = response.body_as_string();
if body.len() < 50 {
return Some(body);
}
}
None
})
};

if let Some(nonce) = nonce_from_request(nonce_request).await {
return Some(nonce);
}

let mut headers = http::HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
"application/x-www-form-urlencoded".parse().unwrap(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a constant similar to CONTENT_TYPE_JSON.

);
let body = serde_urlencoded::to_string([
["log", login.username.as_str()],
["pwd", login.password.as_str()],
["rememberme", "true"],
["redirect_to", rest_nonce_url_clone.to_string().as_str()],
])
.unwrap();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use unwrap() in non-test code in wp_api crate. Could you replace it with .expect?

let login_request = WpNetworkRequest {
method: RequestMethod::POST,
url: api_base_url.derived_wp_login_url().into(),
header_map: WpNetworkHeaderMap::new(headers).into(),
body: Some(WpNetworkRequestBody::new(body.into_bytes()).into()),
};

nonce_from_request(login_request).await
}
}
5 changes: 3 additions & 2 deletions wp_api/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ impl InnerRequestBuilder {
);
match self.authentication {
WpAuthentication::None => (),
WpAuthentication::UserAccount { ref login } => (),
WpAuthentication::AuthorizationHeader { ref token } => {
let hv = HeaderValue::from_str(&format!("Basic {}", token));
let hv = hv.expect("It shouldn't be possible to build WpAuthentication::AuthorizationHeader with an invalid token");
Expand Down Expand Up @@ -101,7 +102,7 @@ pub struct WpNetworkRequestBody {
}

impl WpNetworkRequestBody {
fn new(body: Vec<u8>) -> Self {
pub fn new(body: Vec<u8>) -> Self {
Self { inner: body }
}
}
Expand All @@ -114,7 +115,7 @@ impl WpNetworkRequestBody {
}

// Has custom `Debug` trait implementation
#[derive(uniffi::Object)]
#[derive(Clone, uniffi::Object)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the PR is close to being merged, let's re-visit this. If possible, I'd love to avoid deriving Clone as that's not a cheap operation and I want to prevent us from doing so by not allowing it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to avoid deriving Clone as that's not a cheap operation

Correct me if I'm wrong. The "expensive" parts of WpNetworkRequest are Arc. Does that mean Clone is just clone references rather than actual HTTP headers and body?

I can remove the Clone here and create a function to clone the references. I'm not sure if that's an ideal way to implement that.

diff --git a/wp_api/src/authenticator.rs b/wp_api/src/authenticator.rs
index 283d915..046e728 100644
--- a/wp_api/src/authenticator.rs
+++ b/wp_api/src/authenticator.rs
@@ -268,7 +268,7 @@ impl RequestExecutor for AuthenticatedRequestExecutor {
         &self,
         request: Arc<WpNetworkRequest>,
     ) -> Result<WpNetworkResponse, RequestExecutionError> {
-        let mut original = (*request).clone();
+        let mut original = clone_request(&request);
 
         // Authenticate the initial request.
         match self.authenticator.authenticate(&original).await {
@@ -286,7 +286,7 @@ impl RequestExecutor for AuthenticatedRequestExecutor {
         // Retry if the request fails due to authentication failure
         if let Ok(response) = &initial_response {
             if self.authenticator.should_reauthenticate(response) {
-                let mut original = (*request).clone();
+                let mut original = clone_request(&request);
                 if let Ok(headers) = self
                     .authenticator
                     .re_authenticate(&original, response)
@@ -302,3 +302,12 @@ impl RequestExecutor for AuthenticatedRequestExecutor {
         initial_response
     }
 }
+
+fn clone_request(request: &Arc<WpNetworkRequest>) -> WpNetworkRequest {
+    WpNetworkRequest {
+        method: request.method.clone(),
+        url: request.url.clone(),
+        header_map: request.header_map.clone(),
+        body: request.body.clone(),
+    }
+}
diff --git a/wp_api/src/request.rs b/wp_api/src/request.rs
index f1fef34..d658722 100644
--- a/wp_api/src/request.rs
+++ b/wp_api/src/request.rs
@@ -85,7 +85,7 @@ pub trait RequestExecutor: Send + Sync + Debug {
     ) -> Result<WpNetworkResponse, RequestExecutionError>;
 }
 
-#[derive(Clone, uniffi::Object)]
+#[derive(uniffi::Object)]
 pub struct WpNetworkRequestBody {
     inner: Vec<u8>,
 }
@@ -104,7 +104,7 @@ impl WpNetworkRequestBody {
 }
 
 // Has custom `Debug` trait implementation
-#[derive(Clone, uniffi::Object)]
+#[derive(uniffi::Object)]
 pub struct WpNetworkRequest {
     pub(crate) method: RequestMethod,
     pub(crate) url: WpEndpointUrl,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that's true in the current iteration - we made body & header_map an Arc. It shouldn't be a problem to derive Clone here 🤔

pub struct WpNetworkRequest {
pub(crate) method: RequestMethod,
pub(crate) url: WpEndpointUrl,
Expand Down
35 changes: 34 additions & 1 deletion wp_api/src/request/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@ impl ApiBaseUrl {
site_base_url.try_into()
}

pub(crate) fn derived_wp_login_url(&self) -> Url {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I review this implementation, could you add some unit tests to help make the expectations clear?

let mut url = self.url.clone();

if let Some(segments) = url.path_segments() {
if segments.last() == Some("") {
url.path_segments_mut()
.expect("ApiBaseUrl is a full HTTP URL")
.pop();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines were one of the reasons I asked about the unit tests. I've ran the unit tests by commenting out this implementation and they were still successful. Could you add a test case that is handled by this implementation?

I can't remember if/when the segment could be an empty string, so even after adding a unit test case, a comment might be useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't remember if/when the segment could be an empty string

It'd be empty if there is trailing /, like https://example.com/f/

Could you add a test case that is handled by this implementation?

So, I kinda cheated here. I didn't add new unit tests to cover this code block because they would fail. The cause is not this new code though. It's because ApiBaseUrl can't parse some URLs with trailing slash. I have fixed the issue in #353. Do you mind having a look at that PR first?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added more test cases in c30f3fb


url.path_segments_mut()
.expect("ApiBaseUrl is a full HTTP URL")
.pop()
.push("wp-login.php");

url
}

pub(crate) fn derived_rest_nonce_url(&self) -> Url {
let mut url = self.derived_wp_login_url();

url.path_segments_mut()
.expect("login url is a full HTTP URL")
.pop()
.push("wp-admin")
.push("admin-ajax.php");

url.query_pairs_mut().append_pair("action", "rest-nonce");

url
}

fn by_appending(&self, segment: &str) -> Url {
self.url
.clone()
Expand All @@ -106,7 +139,7 @@ impl ApiBaseUrl {
.expect("ApiBaseUrl is already parsed, so this can't result in an error")
}

fn as_str(&self) -> &str {
pub fn as_str(&self) -> &str {
self.url.as_str()
}
}
Expand Down
2 changes: 1 addition & 1 deletion wp_api_integration_tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
futures = { workspace = true }
http = { workspace = true }
reqwest = { workspace = true, features = [ "json" ] }
reqwest = { workspace = true, features = [ "json", "cookies" ] }
serde = { workspace = true, features = [ "derive" ] }
serde_json = { workspace = true }
tokio = { workspace = true, features = [ "full" ] }
Expand Down
9 changes: 9 additions & 0 deletions wp_api_integration_tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ impl Default for AsyncWpNetworking {
}

impl AsyncWpNetworking {
pub fn with_cookie_store() -> Self {
Self {
client: reqwest::ClientBuilder::new()
.cookie_store(true)
.build()
.unwrap(),
}
}

pub async fn async_request(
&self,
wp_request: Arc<WpNetworkRequest>,
Expand Down
18 changes: 17 additions & 1 deletion wp_derive_request_builder/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,23 @@ fn generate_async_request_executor(config: &Config, parsed_enum: &ParsedEnum) ->
quote! {
pub async #fn_signature -> Result<#output_type, #static_wp_api_error_type> {
#request_from_request_builder
self.request_executor.execute(std::sync::Arc::new(request)).await?.parse()

let cloned_request = request.clone();
let result = self.request_executor.execute(request.into()).await;

if let Ok(response) = &result {
if response.status_code == 401 {
if let crate::WpAuthentication::UserAccount { ref login } = self.request_builder.inner.authentication {
let client = crate::login::WpLoginClient::new(self.request_executor.clone());
let api_base_url = self.request_builder.endpoint.api_base_url.clone();
if let Some(request) = client.insert_rest_nonce(&cloned_request, &api_base_url, login).await {
return self.request_executor.execute(request.into()).await?.parse();
}
}
}
}

result?.parse()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this logic belongs here. We want the generated code logic to remain simple and more importantly it shouldn't contain any "magic". Having said that, it's tricky to move this to somewhere else. We probably want a more structured retry and/or re-authenticate strategy that clients can opt into or out of.

Also worth noting that the current design choice leads to some inefficiencies, but it's better to look into them once we are done with moving pieces around.

Finally, if I am not misunderstanding how this code works, due to the request_executor being cloned, the original executor's authentication won't be updated - meaning every request will have to be re-authenticated and retried. This might not happen in iOS, due to how URLSession works, but I believe it'll happen for other clients.

Copy link
Contributor Author

@crazytonyli crazytonyli Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want the generated code logic to remain simple and more importantly it shouldn't contain any "magic".

I tried to do that, by having minimal code in here. Maybe it's because I'm new to Rust, but writing and debugging code in this proc macro is not a super straightforward experience.

These statements are the minimal code to call the insert_rest_nonce function. But we can move them all out into a static function if that's easier to read.

We probably want a more structured retry and/or re-authenticate strategy that clients can opt into or out of.

Interesting. I don't see this new code as retry or re-authenticate though. It's part of the cookie authentication, where we have to get the nonce value from the site, and pass it to REST API calls. That is not something clients can opt-out of, if they want to use an actual account username and password to authenticate REST API calls.

due to the request_executor being cloned, the original executor's authentication won't be updated

The request_executor is Arc<dyn RequestExecutor>. I'm not sure what cloning it would do. Does it creat another copy of the actual trait RequestExecutor instance or just create a new pointer to the same trait RequestExecutor instance? Can trait RequestExecutor even be cloned?

BTW, the Arc<dyn RequestExecturo> passed to WpApiClient is cloned multiple times at the moment:

    impl WpApiClient {
        pub fn new(
            site_url: Arc<ParsedUrl>,
            authentication: WpAuthentication,
            request_executor: Arc<dyn RequestExecutor>,
        ) -> Self {
            let api_base_url: Arc<ApiBaseUrl> = Arc::new(site_url.inner.clone().into());
            Self {
                application_passwords: ApplicationPasswordsRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
                plugins: PluginsRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
                post_types: PostTypesRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
                posts: PostsRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
                site_settings: SiteSettingsRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
                users: UsersRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
                wp_site_health_tests: WpSiteHealthTestsRequestExecutor::new(
                        api_base_url.clone(),
                        authentication.clone(),
                        request_executor.clone(),
                    )
                    .into(),
            }
        }
    }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request_executor is Arc. I'm not sure what cloning it would do. Does it creat another copy of the actual trait RequestExecutor instance or just create a new pointer to the same trait RequestExecutor instance? Can trait RequestExecutor even be cloned?

Sorry, this is something I saw at the last moment and it got mixed up in my mind with cloning the client 🤦 It's totally fine to clone the RequestExecutor.

Having said that, I think the original point is maybe still accurate? I am doubting myself, because I still don't think I fully understand how this type of authentication is supposed to work. I guess maybe it works for all clients if the request executor does cookie retention?

The original requests never contain the nonce header, so wouldn't all of them fail at first?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am doubting myself, because I still don't think I fully understand how this type of authentication is supposed to work.

Regarding how it works, I included a brief summary in this PR. The official doc have more details. But I can re-cap it here in the context of this library.

First of all, users have to actually sign in their site. In this PR, that's implemented using a POST wp-login.php request. That's pretty much the same as submitting the login form from a web browser. If the login HTTP request is successful, the response would include necessary authentication related cookies for the site.

In this PR, I didn't handle cookies, because I defer that to RequestExecutor. Basically RequestExecutor needs to have the capability of storing cookies in HTTP responses and using stored cookies for HTTP requests.

However, that's not enough for REST API. It also needs a "WP REST Nonce" thing. This nonce is generated and stored in server to verify incoming REST requests. We can get this nonce using /wp-admin/admin-ajax.php?action=rest-nonce. Once we get the nonce, we can put it in HTTP request headers or URL query.

And those two things are what REST API cookies authentication needs.

The original requests never contain the nonce header, so wouldn't all of them fail at first?

Yes, it would. As I noted in my PR description, we should store the nonce somewhere and re-use it. But that's not implemented in this PR.

}
}
})
Expand Down