Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
1 change: 1 addition & 0 deletions 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 @@ -62,6 +62,7 @@ semver = "1.0"
serde = "1.0"
serde_json = "1.0"
serde_repr = "0.1"
serde_urlencoded = "0.7"
serial_test = "3.2"
strum = "0.27"
strum_macros = "0.27"
Expand Down
8 changes: 4 additions & 4 deletions Package.resolved

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

14 changes: 14 additions & 0 deletions native/swift/Sources/wordpress-api/WordPressLoginClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public final class WordPressLoginClient: @unchecked Sendable {

private let requestExecutor: SafeRequestExecutor
private let client: UniffiWpLoginClient
private let middleware: MiddlewarePipeline

public convenience init(
urlSession: URLSession,
Expand All @@ -22,6 +23,7 @@ public final class WordPressLoginClient: @unchecked Sendable {
middleware: MiddlewarePipeline = .default
) {
self.requestExecutor = requestExecutor
self.middleware = middleware
self.client = UniffiWpLoginClient(requestExecutor: requestExecutor, middlewarePipeline: middleware)
}

Expand Down Expand Up @@ -60,6 +62,18 @@ public final class WordPressLoginClient: @unchecked Sendable {
public func credentials(from callbackUrl: URL) throws -> WpApiApplicationPasswordDetails {
try extractLoginDetailsFromUrl(url: callbackUrl.absoluteString)
}

public func authenticateTemporarily(username: String, password: String, details: AutoDiscoveryAttemptSuccess) async throws -> WordPressAPI {
let nonceRetrieval = WpRestNonceRetrieval(details: details, requestExecutor: requestExecutor)
let nonce = try await nonceRetrieval.getNonce(username: username, password: password)
return WordPressAPI(
apiUrlResolver: WpOrgSiteApiUrlResolver(apiRootUrl: details.apiRootUrl),
authenticationProvider: .staticWithAuth(auth: .nonce(nonce: nonce)),
executor: requestExecutor,
middlewarePipeline: middleware,
appNotifier: nil
)
}
}

extension AutoDiscoveryAttemptSuccess {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import Testing

@Suite
struct NonceAuthenticationTests {

@Test
func success() async throws {
let credentials = TestCredentials.instance()
let client = WordPressLoginClient(urlSession: .init(configuration: .ephemeral))
let details = try await client.details(ofSite: credentials.siteUrl)
let api = try await client.authenticateTemporarily(
username: credentials.adminUsername,
password: credentials.adminAccountPassword,
details: details
)
let loggedIn = try await api.users.retrieveMeWithEditContext().data.username
#expect(loggedIn == credentials.adminUsername)
}

@Test
func signInWithADifferentUser() async throws {
let credentials = TestCredentials.instance()
let client = WordPressLoginClient(urlSession: .init(configuration: .ephemeral))
let details = try await client.details(ofSite: credentials.siteUrl)

// Given the URLSession is already signed in with the admin account.
let api = try await client.authenticateTemporarily(
username: credentials.adminUsername,
password: credentials.adminAccountPassword,
details: details
)
let loggedIn = try await api.users.retrieveMeWithEditContext().data.username
#expect(loggedIn == credentials.adminUsername)

// When sign in with another account, an error should be returned.
await #expect(throws: NonceRetrievalError.AlreadyLoggedIn(username: credentials.adminUsername)) {
let _ = try await client.authenticateTemporarily(
username: credentials.authorUsername,
password: credentials.authorPassword,
details: details
)
}
}

}
2 changes: 2 additions & 0 deletions native/swift/Tests/integration-tests/TestCredentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct TestCredentials: Decodable {
var adminUsername: String
var adminPassword: String
var adminPasswordUuid: String
var adminAccountPassword: String
var subscriberUsername: String
var subscriberPassword: String
var subscriberPasswordUuid: String
Expand All @@ -24,6 +25,7 @@ struct TestCredentials: Decodable {
case adminUsername = "admin_username"
case adminPassword = "admin_password"
case adminPasswordUuid = "admin_password_uuid"
case adminAccountPassword = "admin_account_password"
case subscriberUsername = "subscriber_username"
case subscriberPassword = "subscriber_password"
case subscriberPasswordUuid = "subscriber_password_uuid"
Expand Down
4 changes: 3 additions & 1 deletion scripts/setup-test-site.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ echo "--- :wordpress: Setting up WordPress"
wp core version --extra
wp --info

ADMIN_ACCOUNT_PASSWORD="strongpassword"
## Install WordPress
wp core install \
--url=localhost \
--title=my-test-site \
[email protected] \
[email protected] \
--admin_password=strongpassword \
--admin_password="$ADMIN_ACCOUNT_PASSWORD" \
--skip-email

## Ensure URLs work as expected
Expand Down Expand Up @@ -279,6 +280,7 @@ create_test_credentials () {
admin_username="$ADMIN_USERNAME" \
admin_password="$ADMIN_PASSWORD" \
admin_password_uuid="$ADMIN_PASSWORD_UUID" \
admin_account_password="$ADMIN_ACCOUNT_PASSWORD" \
subscriber_username="$SUBSCRIBER_USERNAME" \
subscriber_password="$SUBSCRIBER_PASSWORD" \
subscriber_password_uuid="$SUBSCRIBER_PASSWORD_UUID" \
Expand Down
1 change: 1 addition & 0 deletions wp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ scraper = { workspace = true }
serde = { workspace = true, features = [ "derive", "rc" ] }
serde_json = { workspace = true, features = [ "raw_value" ] }
serde_repr = { workspace = true }
serde_urlencoded = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }
Expand Down
37 changes: 25 additions & 12 deletions wp_api/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
use http::HeaderValue;
use http::{HeaderMap, HeaderValue};
use std::fmt::Debug;
use std::sync::{Arc, RwLock};

#[derive(Debug, Clone, uniffi::Enum)]
pub enum WpAuthentication {
AuthorizationHeader { token: String },
Bearer { token: String },
// Cookies+nonce authentication.
// The "cookies" part is implicitly handled by the HTTP client.
// Since nonce is refreshed often, when using this authentication method,
// the caller should not keep using the same nonce for a long time.
Nonce { nonce: String },
Copy link
Contributor

Choose a reason for hiding this comment

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

WDYT about using username/password here and automatically getting the nonce prior to each request?

We should still document that this is bad for performance, but as long as the username/password don't change the client won't become invalid unexpectedly which seems slightly better?

I don't feel strongly about this either way.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

using username/password here and automatically getting the nonce prior to each request?

That's how the original PR #327 implements it. It got pretty complicated because we need to refresh the nonce internally, and automatically retry the original API request after refreshing.

Considering the use case right now is using username and password to make one request, rather than all requests on the site, I thought it's better to keep it simple.

Copy link
Contributor

@jkmassel jkmassel Oct 31, 2025

Choose a reason for hiding this comment

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

I was thinking we'd avoid the complexity by just assuming that the nonce is never valid and refreshing it prior to each request. That makes it simple but more expensive to use.

Still works great for our "we only plan to ever make one request" purpose, but ensures that if the client somehow persists for a while it doesn't "go bad" – it'll always work.

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 implemented it in a different way in 0154c20. The implementation takes advantage of the existing auth mechanism, using the DynamicAuthenticationProvider trait. See the new test_cookies_nonce function.

The implementation should just work if the nonce has expired. It should refresh the nonce and retry the request.

To summarize: We can now use the account username and password to create a WpApiClient (backed by CookiesNonceAuthenticationProvider), which uses cookies+nonce authentication method. The API client instance is not affected by the nonce expiration time, which means it can be kept around for a long time, like your regular WpApiClient with an application password. It stores the nonce internally and refreshes it when it's detected to have expired.

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks cool – nothing else required to use it from Swift? Android should be ok

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 don't think there is anything extra needed for Swift. The WordPressAPI already has public APIs that can accept CookiesNonceAuthenticationProvider. We can probably add a convenience initialiser to WordPressAPI to make the API more ergonomic.

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 there is anything extra needed for Swift. The WordPressAPI already has public APIs that can accept CookiesNonceAuthenticationProvider. We can probably add a convenience initialiser to WordPressAPI to make the API more ergonomic.

I'll approve the PR to unblock, feel free to add this now or later :)

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 plan to use the authenticateTemporarily added in this PR. So, I'll merge the PR as it is. When we need to frequently use the account username & password to call the REST API from the apps, I'll see what kind of helpers are needed.

None,
}

Expand All @@ -25,15 +30,21 @@ impl WpAuthentication {
}
}

pub fn header_value(&self) -> Option<HeaderValue> {
pub fn insert_header(&self, headers: &mut http::HeaderMap) {
match self {
Self::None => None,
Self::None => {}
Self::AuthorizationHeader { token } => {
Some(HeaderValue::from_str(&format!("Basic {token}"))
.expect("It shouldn't be possible to build WpAuthentication::AuthorizationHeader with an invalid token"))
let value = HeaderValue::from_str(&format!("Basic {token}"))
.expect("It shouldn't be possible to build WpAuthentication::AuthorizationHeader with an invalid token");
headers.insert(http::header::AUTHORIZATION, value);
}
Self::Bearer { token } => {
Some(HeaderValue::from_str(&format!("Bearer {token}")).expect("It shouldn't be possible to build WpAuthentication::Bearer with an invalid token"))
let value = HeaderValue::from_str(&format!("Bearer {token}")).expect("It shouldn't be possible to build WpAuthentication::Bearer with an invalid token");
headers.insert(http::header::AUTHORIZATION, value);
}
Self::Nonce { nonce } => {
let value = HeaderValue::from_str(nonce).expect("It shouldn't be possible to build WpAuthentication::Nonce with an invalid nonce");
headers.insert("X-WP-Nonce", value);
}
}
}
Expand Down Expand Up @@ -62,11 +73,11 @@ impl ModifiableAuthenticationProvider {
}

impl ModifiableAuthenticationProvider {
pub fn header_value(&self) -> Option<HeaderValue> {
pub fn insert_header(&self, headers: &mut http::HeaderMap) {
self.auth
.read()
.expect("If the lock is poisoned, there isn't much we can do")
.header_value()
.insert_header(headers)
}
}

Expand Down Expand Up @@ -136,13 +147,15 @@ impl WpAuthenticationProvider {
}

impl WpAuthenticationProvider {
pub fn auth_header_value(&self) -> Option<HeaderValue> {
pub fn insert_header(&self, headers: &mut HeaderMap) {
match self {
WpAuthenticationProvider::StaticAuthenticationProvider { auth } => auth.header_value(),
WpAuthenticationProvider::StaticAuthenticationProvider { auth } => {
auth.insert_header(headers)
}
WpAuthenticationProvider::DynamicAuthenticationProvider { inner } => {
inner.auth().header_value()
inner.auth().insert_header(headers)
}
WpAuthenticationProvider::Modifiable { inner } => inner.header_value(),
WpAuthenticationProvider::Modifiable { inner } => inner.insert_header(headers),
}
}

Expand Down
10 changes: 10 additions & 0 deletions wp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,16 @@ pub trait WpAppNotifier: Send + Sync + std::fmt::Debug {
async fn requested_with_invalid_authentication(&self, request_url: String);
}

#[derive(Debug)]
pub struct EmptyAppNotifier;

#[async_trait::async_trait]
impl WpAppNotifier for EmptyAppNotifier {
async fn requested_with_invalid_authentication(&self, _request_url: String) {
// no-op
}
}

#[macro_export]
macro_rules! generate {
($type_name:ident) => {
Expand Down
1 change: 1 addition & 0 deletions wp_api/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use wp_serde_helper::{
const KEY_APPLICATION_PASSWORDS: &str = "application-passwords";

pub mod login_client;
pub mod nonce;
pub mod url_discovery;

#[derive(Debug, uniffi::Record)]
Expand Down
Loading