Skip to content

Commit 64697b7

Browse files
adalpariclaude
andauthored
CMM-1256: Fix 401 errors for self-hosted sites with application passwords (#22603)
* Fix 401 errors for self-hosted sites with application passwords ReactNativeStore used cookie-nonce auth for all non-WPCom sites, which fails for sites with application passwords since those credentials can't log in via wp-login.php. This adds Basic auth support using the site's application password credentials directly, bypassing the nonce flow for those sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Clean up suppress annotations and remove unused imports Add LongMethod and ReturnCount suppress annotations to executeWPAPIRequest and remove unused eq and Unknown imports from tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add inline comments to ReactNativeStore request flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add null safety and documentation for app password auth Add defensive null checks for application password credentials to prevent potential NPE from race conditions. Expand inline documentation explaining the nonce auth bypass rationale. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix PluginWPApiRestClientTest matcher count for new headers param Add missing any() matcher for the headers parameter added to syncGetRequest and syncPostRequest in WPAPIGsonRequestBuilder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Re-add missing nonce unknown test case Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9c70c06 commit 64697b7

File tree

5 files changed

+197
-27
lines changed

5 files changed

+197
-27
lines changed

libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequestBuilder.kt

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ class WPAPIGsonRequestBuilder @Inject constructor() {
1919
clazz: Class<T>,
2020
enableCaching: Boolean = false,
2121
cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME,
22-
nonce: String? = null
22+
nonce: String? = null,
23+
headers: Map<String, String> = emptyMap()
2324
) = suspendCancellableCoroutine<WPAPIResponse<T>> { cont ->
24-
callMethod(Method.GET, url, params, body, clazz, cont, enableCaching, cacheTimeToLive, nonce, restClient)
25+
callMethod(Method.GET, url, params, body, clazz, cont, enableCaching, cacheTimeToLive, nonce, restClient,
26+
headers)
2527
}
2628
suspend fun <T> syncGetRequest(
2729
restClient: BaseWPAPIRestClient,
@@ -31,19 +33,22 @@ class WPAPIGsonRequestBuilder @Inject constructor() {
3133
type: Type,
3234
enableCaching: Boolean = false,
3335
cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME,
34-
nonce: String? = null
36+
nonce: String? = null,
37+
headers: Map<String, String> = emptyMap()
3538
) = suspendCancellableCoroutine<WPAPIResponse<T>> { cont ->
36-
callMethod(Method.GET, url, params, body, type, cont, enableCaching, cacheTimeToLive, nonce, restClient)
39+
callMethod(Method.GET, url, params, body, type, cont, enableCaching, cacheTimeToLive, nonce, restClient,
40+
headers)
3741
}
3842

3943
suspend fun <T> syncPostRequest(
4044
restClient: BaseWPAPIRestClient,
4145
url: String,
4246
body: Map<String, Any> = emptyMap(),
4347
clazz: Class<T>,
44-
nonce: String? = null
48+
nonce: String? = null,
49+
headers: Map<String, String> = emptyMap()
4550
) = suspendCancellableCoroutine<WPAPIResponse<T>> { cont ->
46-
callMethod(Method.POST, url, null, body, clazz, cont, false, 0, nonce, restClient)
51+
callMethod(Method.POST, url, null, body, clazz, cont, false, 0, nonce, restClient, headers)
4752
}
4853

4954
suspend fun <T> syncPutRequest(
@@ -77,7 +82,8 @@ class WPAPIGsonRequestBuilder @Inject constructor() {
7782
enableCaching: Boolean,
7883
cacheTimeToLive: Int,
7984
nonce: String?,
80-
restClient: BaseWPAPIRestClient
85+
restClient: BaseWPAPIRestClient,
86+
headers: Map<String, String> = emptyMap()
8187
) {
8288
val request = WPAPIGsonRequest(method, url, params, body, clazz, { response ->
8389
cont.resume(Success(response))
@@ -97,6 +103,8 @@ class WPAPIGsonRequestBuilder @Inject constructor() {
97103
request.addHeader("x-wp-nonce", nonce)
98104
}
99105

106+
headers.forEach { (key, value) -> request.addHeader(key, value) }
107+
100108
restClient.add(request)
101109
}
102110

@@ -111,7 +119,8 @@ class WPAPIGsonRequestBuilder @Inject constructor() {
111119
enableCaching: Boolean,
112120
cacheTimeToLive: Int,
113121
nonce: String?,
114-
restClient: BaseWPAPIRestClient
122+
restClient: BaseWPAPIRestClient,
123+
headers: Map<String, String> = emptyMap()
115124
) {
116125
val request = WPAPIGsonRequest<T>(method, url, params, body, type, { response ->
117126
cont.resume(Success(response))
@@ -131,6 +140,8 @@ class WPAPIGsonRequestBuilder @Inject constructor() {
131140
request.addHeader("x-wp-nonce", nonce)
132141
}
133142

143+
headers.forEach { (key, value) -> request.addHeader(key, value) }
144+
134145
restClient.add(request)
135146
}
136147
}

libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClient.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ class ReactNativeWPAPIRestClient @Inject constructor(
2828
successHandler: (data: JsonElement?) -> ReactNativeFetchResponse,
2929
errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse,
3030
nonce: String? = null,
31-
enableCaching: Boolean = true
31+
enableCaching: Boolean = true,
32+
headers: Map<String, String> = emptyMap()
3233
): ReactNativeFetchResponse {
3334
val response =
3435
wpApiGsonRequestBuilder.syncGetRequest(
@@ -38,7 +39,8 @@ class ReactNativeWPAPIRestClient @Inject constructor(
3839
emptyMap(),
3940
JsonElement::class.java,
4041
enableCaching,
41-
nonce = nonce)
42+
nonce = nonce,
43+
headers = headers)
4244
return when (response) {
4345
is Success -> successHandler(response.data)
4446
is Error -> errorHandler(response.error)
@@ -51,14 +53,16 @@ class ReactNativeWPAPIRestClient @Inject constructor(
5153
successHandler: (data: JsonElement?) -> ReactNativeFetchResponse,
5254
errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse,
5355
nonce: String? = null,
56+
headers: Map<String, String> = emptyMap()
5457
): ReactNativeFetchResponse {
5558
val response =
5659
wpApiGsonRequestBuilder.syncPostRequest(
5760
this,
5861
url,
5962
body,
6063
JsonElement::class.java,
61-
nonce = nonce)
64+
nonce = nonce,
65+
headers = headers)
6266
return when (response) {
6367
is Success -> successHandler(response.data)
6468
is Error -> errorHandler(response.error)

libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReactNativeStore.kt

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import org.wordpress.android.fluxc.network.discovery.DiscoveryWPAPIRestClient
1010
import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Available
1111
import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.FailedRequest
1212
import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Unknown
13+
import okhttp3.Credentials
1314
import org.wordpress.android.fluxc.network.rest.wpapi.NonceRestClient
1415
import org.wordpress.android.fluxc.network.rest.wpapi.reactnative.ReactNativeWPAPIRestClient
1516
import org.wordpress.android.fluxc.network.rest.wpcom.reactnative.ReactNativeWPComRestClient
@@ -159,7 +160,7 @@ class ReactNativeStore @VisibleForTesting constructor(
159160
return Error(error)
160161
}
161162

162-
@Suppress("ComplexMethod", "NestedBlockDepth", "LongParameterList")
163+
@Suppress("ComplexMethod", "NestedBlockDepth", "LongParameterList", "LongMethod", "ReturnCount")
163164
private suspend fun executeWPAPIRequest(
164165
site: SiteModel,
165166
path: String,
@@ -181,6 +182,16 @@ class ReactNativeStore @VisibleForTesting constructor(
181182
}
182183
val fullRestUrl = slashJoin(wpApiRestUrl, path)
183184

185+
// Use Basic auth for sites with application passwords instead of nonce auth.
186+
// Application passwords authenticate via REST API Basic auth and cannot
187+
// authenticate via wp-login.php (which is required for nonce-based auth).
188+
if (site.hasApplicationPassword()) {
189+
return executeWithApplicationPassword(
190+
site, path, method, params, body, enableCaching,
191+
fullRestUrl, usingSavedRestUrl
192+
)
193+
}
194+
184195
var nonce = nonceRestClient.getNonce(site)
185196
val usingSavedNonce = nonce is Available
186197
val failedRecently = true == (nonce as? FailedRequest)?.timeOfResponse?.let {
@@ -208,8 +219,10 @@ class ReactNativeStore @VisibleForTesting constructor(
208219
val nonceIsUpdated = newNonce != null && newNonce != previousNonce
209220
if (nonceIsUpdated) {
210221
return when (method) {
211-
RequestMethod.GET -> executeGet(fullRestUrl, params, newNonce, enableCaching)
212-
RequestMethod.POST -> executePost(fullRestUrl, body, newNonce)
222+
RequestMethod.GET ->
223+
executeGet(fullRestUrl, params, newNonce, enableCaching)
224+
RequestMethod.POST ->
225+
executePost(fullRestUrl, body, newNonce)
213226
}
214227
}
215228
}
@@ -227,14 +240,63 @@ class ReactNativeStore @VisibleForTesting constructor(
227240
// so the rest url will be retrieved using discovery
228241
executeWPAPIRequest(site, path, method, params, body, enableCaching)
229242
} else {
230-
// Already used discovery to fetch the rest base url and still got 'not found', so
231-
// just return the error response
243+
// Already used discovery to fetch the rest base url and still got
244+
// 'not found', so just return the error response
232245
response
233246
}
234-
235-
// For all other failures just return the error response
236247
}
237248

249+
// For all other failures just return the error response
250+
else -> response
251+
}
252+
}
253+
}
254+
255+
@Suppress("LongParameterList")
256+
private suspend fun executeWithApplicationPassword(
257+
site: SiteModel,
258+
path: String,
259+
method: RequestMethod,
260+
params: Map<String, String>,
261+
body: Map<String, Any>,
262+
enableCaching: Boolean,
263+
fullRestUrl: String,
264+
usingSavedRestUrl: Boolean
265+
): ReactNativeFetchResponse {
266+
val username = site.apiRestUsernamePlain
267+
val password = site.apiRestPasswordPlain
268+
if (username == null || password == null) {
269+
val error = BaseNetworkError(GenericErrorType.UNKNOWN).apply {
270+
message = "Application password credentials missing"
271+
}
272+
return Error(error)
273+
}
274+
val authHeaderValue = Credentials.basic(username, password)
275+
val headers = mapOf(AUTHORIZATION_HEADER to authHeaderValue)
276+
277+
val response = when (method) {
278+
RequestMethod.GET -> executeGet(
279+
fullRestUrl, params, nonce = null, enableCaching, headers
280+
)
281+
RequestMethod.POST -> executePost(
282+
fullRestUrl, body, nonce = null, headers
283+
)
284+
}
285+
return when (response) {
286+
is Success -> response
287+
is Error -> when (response.statusCode()) {
288+
HttpURLConnection.HTTP_NOT_FOUND -> {
289+
site.wpApiRestUrl = null
290+
persistSiteSafely(site)
291+
292+
if (usingSavedRestUrl) {
293+
executeWPAPIRequest(
294+
site, path, method, params, body, enableCaching
295+
)
296+
} else {
297+
response
298+
}
299+
}
238300
else -> response
239301
}
240302
}
@@ -244,16 +306,24 @@ class ReactNativeStore @VisibleForTesting constructor(
244306
fullRestApiUrl: String,
245307
params: Map<String, String>,
246308
nonce: String?,
247-
enableCaching: Boolean
309+
enableCaching: Boolean,
310+
headers: Map<String, String> = emptyMap()
248311
): ReactNativeFetchResponse =
249-
wpAPIRestClient.getRequest(fullRestApiUrl, params, ::Success, ::Error, nonce, enableCaching)
312+
wpAPIRestClient.getRequest(
313+
fullRestApiUrl, params, ::Success, ::Error,
314+
nonce, enableCaching, headers
315+
)
250316

251317
private suspend fun executePost(
252318
fullRestApiUrl: String,
253319
body: Map<String, Any>,
254-
nonce: String?
320+
nonce: String?,
321+
headers: Map<String, String> = emptyMap()
255322
): ReactNativeFetchResponse =
256-
wpAPIRestClient.postRequest(fullRestApiUrl, body, ::Success, ::Error, nonce)
323+
wpAPIRestClient.postRequest(
324+
fullRestApiUrl, body, ::Success, ::Error,
325+
nonce, headers
326+
)
257327

258328
private fun parseUrlAndParamsForWPCom(
259329
pathWithParams: String,
@@ -294,6 +364,7 @@ class ReactNativeStore @VisibleForTesting constructor(
294364
private fun getNonce(site: SiteModel) = nonceRestClient.getNonce(site)
295365

296366
companion object {
367+
private const val AUTHORIZATION_HEADER = "Authorization"
297368
private const val FIVE_MIN_MILLIS: Long = 5 * 60 * 1000
298369

299370
/**

libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPApiRestClientTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ class PluginWPApiRestClientTest {
186186
eq(type),
187187
eq(cachingEnabled),
188188
any(),
189+
any(),
189190
any()
190191
)
191192
).thenReturn(response)
@@ -203,6 +204,7 @@ class PluginWPApiRestClientTest {
203204
urlCaptor.capture(),
204205
bodyCaptor.capture(),
205206
eq(PluginResponseModel::class.java),
207+
any(),
206208
any()
207209
)
208210
).thenReturn(response)

libs/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWPAPITest.kt

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.android.volley.VolleyError
66
import org.junit.Before
77
import org.junit.Test
88
import org.junit.runner.RunWith
9+
import okhttp3.Credentials
910
import org.mockito.kotlin.any
1011
import org.mockito.kotlin.inOrder
1112
import org.mockito.kotlin.mock
@@ -45,7 +46,6 @@ class ReactNativeStoreWPAPITest {
4546
private val wpApiRestClient = mock<ReactNativeWPAPIRestClient>()
4647
private val discoveryWPAPIRestClient = mock<DiscoveryWPAPIRestClient>()
4748
private val nonceRestClient = mock<NonceRestClient>()
48-
4949
private lateinit var store: ReactNativeStore
5050
private lateinit var site: SiteModel
5151

@@ -495,11 +495,93 @@ class ReactNativeStoreWPAPITest {
495495
assertEquals(UNKNOWN, errorType)
496496
}
497497

498-
private suspend fun ReactNativeWPAPIRestClient.getRequest(url: String, nonce: String? = null) =
499-
getRequest(url, paramMap, ReactNativeFetchResponse::Success, ReactNativeFetchResponse::Error, nonce)
498+
//
499+
// Application password tests
500+
//
501+
502+
@Test
503+
fun `site with application password uses Basic auth for GET request`() = test {
504+
site.apiRestUsernamePlain = "user"
505+
site.apiRestPasswordPlain = "pass"
506+
val appPasswordAuthHeader = Credentials.basic("user", "pass")
507+
508+
val fetchUrl = "${site.wpApiRestUrl}/$restPath"
509+
val successResponse = mock<Success>()
510+
val expectedHeaders = mapOf("Authorization" to appPasswordAuthHeader)
511+
whenever(
512+
wpApiRestClient.getRequest(
513+
fetchUrl, paramMap, ::Success, ::Error,
514+
null, true, expectedHeaders
515+
)
516+
).thenReturn(successResponse)
517+
518+
val actualResponse = store.executeGetRequest(site, restPathWithParams)
519+
assertEquals(successResponse, actualResponse)
520+
verify(nonceRestClient, never()).getNonce(any<SiteModel>())
521+
verify(nonceRestClient, never()).requestNonce(any())
522+
}
523+
524+
@Test
525+
fun `site with application password uses Basic auth for POST request`() = test {
526+
site.apiRestUsernamePlain = "user"
527+
site.apiRestPasswordPlain = "pass"
528+
val appPasswordAuthHeader = Credentials.basic("user", "pass")
529+
530+
val postUrl = "${site.wpApiRestUrl}/$restPath"
531+
val successResponse = mock<Success>()
532+
val expectedHeaders = mapOf("Authorization" to appPasswordAuthHeader)
533+
whenever(
534+
wpApiRestClient.postRequest(
535+
postUrl, bodyMap, ::Success, ::Error,
536+
null, expectedHeaders
537+
)
538+
).thenReturn(successResponse)
539+
540+
val actualResponse = store.executePostRequest(site, restPathWithParams, bodyMap)
541+
assertEquals(successResponse, actualResponse)
542+
verify(nonceRestClient, never()).getNonce(any<SiteModel>())
543+
verify(nonceRestClient, never()).requestNonce(any())
544+
}
545+
546+
@Test
547+
fun `site with application password does not retry nonce on 401`() = test {
548+
site.apiRestUsernamePlain = "user"
549+
site.apiRestPasswordPlain = "pass"
550+
val appPasswordAuthHeader = Credentials.basic("user", "pass")
551+
552+
val fetchUrl = "${site.wpApiRestUrl}/$restPath"
553+
val unauthorizedResponse = errorResponse(StatusCode.UNAUTHORIZED_401)
554+
val expectedHeaders = mapOf("Authorization" to appPasswordAuthHeader)
555+
whenever(
556+
wpApiRestClient.getRequest(
557+
fetchUrl, paramMap, ::Success, ::Error,
558+
null, true, expectedHeaders
559+
)
560+
).thenReturn(unauthorizedResponse)
561+
562+
val actualResponse = store.executeGetRequest(site, restPathWithParams)
563+
assertEquals(unauthorizedResponse, actualResponse)
564+
verify(nonceRestClient, never()).getNonce(any<SiteModel>())
565+
verify(nonceRestClient, never()).requestNonce(any())
566+
}
500567

501-
private suspend fun ReactNativeWPAPIRestClient.postRequest(url: String, nonce: String? = null) =
502-
postRequest(url, bodyMap, ReactNativeFetchResponse::Success, ReactNativeFetchResponse::Error, nonce)
568+
private suspend fun ReactNativeWPAPIRestClient.getRequest(
569+
url: String,
570+
nonce: String? = null,
571+
headers: Map<String, String> = emptyMap()
572+
) = getRequest(
573+
url, paramMap, ReactNativeFetchResponse::Success,
574+
ReactNativeFetchResponse::Error, nonce, true, headers
575+
)
576+
577+
private suspend fun ReactNativeWPAPIRestClient.postRequest(
578+
url: String,
579+
nonce: String? = null,
580+
headers: Map<String, String> = emptyMap()
581+
) = postRequest(
582+
url, bodyMap, ReactNativeFetchResponse::Success,
583+
ReactNativeFetchResponse::Error, nonce, headers
584+
)
503585

504586
private fun errorResponse(statusCode: Int): ReactNativeFetchResponse = Error(mock()).apply {
505587
error.volleyError = VolleyError(NetworkResponse(statusCode, null, false, 0L, null))

0 commit comments

Comments
 (0)