Skip to content

Commit 96fc478

Browse files
authored
Allow headless application-password creation on Atomic sites (#22885)
* Allow headless application-password creation on Atomic sites Fixes #22884. `ApplicationPasswordsManager.getApplicationCredentials` returned `NotSupported` for any `site.isWPCom` site. The guard was correct for Simple sites but blocked Atomic sites, which are also `isWPCom`-flagged and do support REST application-password creation. Users on Atomic saw the "Authenticate using Application Password" card on My Site and had to authorize through a Chrome Custom Tab even though the app could mint the credential on their behalf. Relax the FluxC guard from `site.isWPCom` to `site.isWPComSimpleSite` and add a uniform validate-then-mint pipeline to `ApplicationPasswordViewModelSlice`: validate stored creds via wordpress-rs Basic auth against the direct host (new `ApplicationPasswordValidator`, using `WpApiClientProvider.getApplicationPasswordClient` from #22894); on Invalid, clear them via a new `SiteStore.deleteStoredApplicationPasswordCredentials` and fall through to mint via a new `SiteStore.createApplicationPassword` (FluxC Jetpack tunnel); on mint failure, the existing discovery + card path takes over. The XML-RPC-disabled card path is now gated on `!isUsingWpComRestApi` so it only fires for true self-hosted sites. Also provides the missing `@ApplicationPasswordsClientId` Dagger binding — without it any call into `ApplicationPasswordsStore` threw `NoSuchElementException`. The path was latent on these apps until the auto-mint above started routing My Site through it. * Single-flight buildCard to prevent double-mint race ApplicationPasswordViewModelSlice.buildCard fires from MySiteViewModel's onResume / refresh / onSitePicked. Without job tracking, two close-together calls race: both pass ApplicationPasswordsManager's `existingPassword == null` check and issue separate server-side mints. The 409 conflict handler then deletes-and-recreates the winner's password, so the losing racer destroys the credential it just helped create. Pre-PR the slice raced on validate-only, which had no server-side side effect. The new mint step in #22885 makes the race visible and harmful. Fix: track the in-flight Job and drop subsequent calls while it's active. The running call posts its result regardless, so the user-visible state still updates. Test gates `createApplicationPassword` on a `CompletableDeferred`, fires buildCard twice, and verifies a single mint invocation reaches the store.
1 parent 941ac5b commit 96fc478

17 files changed

Lines changed: 651 additions & 187 deletions

File tree

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
26.8
44
-----
55
* [**] Resolved an issue where the editor could become impossible to exit when it failed to load.
6+
* [*] Atomic sites can now create application passwords without leaving the app.
67

78
26.7
89
-----
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.wordpress.android.modules
2+
3+
import android.content.Context
4+
import dagger.Module
5+
import dagger.Provides
6+
import dagger.hilt.InstallIn
7+
import dagger.hilt.android.qualifiers.ApplicationContext
8+
import dagger.hilt.components.SingletonComponent
9+
import org.wordpress.android.R
10+
import org.wordpress.android.fluxc.module.ApplicationPasswordsClientId
11+
import org.wordpress.android.util.BuildConfigWrapper
12+
import org.wordpress.android.util.DeviceUtils
13+
import javax.inject.Singleton
14+
15+
@InstallIn(SingletonComponent::class)
16+
@Module
17+
object ApplicationPasswordsClientIdModule {
18+
@Provides
19+
@Singleton
20+
@ApplicationPasswordsClientId
21+
fun provideApplicationPasswordsClientId(
22+
@ApplicationContext context: Context,
23+
buildConfigWrapper: BuildConfigWrapper,
24+
): String {
25+
val deviceName = DeviceUtils.getInstance().getDeviceName(context)
26+
val resId = if (buildConfigWrapper.isJetpackApp) {
27+
R.string.application_password_app_name_jetpack
28+
} else {
29+
R.string.application_password_app_name_wordpress
30+
}
31+
return context.getString(resId, deviceName)
32+
}
33+
}

WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -49,49 +49,54 @@ class ApplicationPasswordLoginHelper @Inject constructor(
4949
) {
5050
private var processedAppPasswordData: String? = null
5151

52+
sealed class DiscoveryResult {
53+
data class Authorized(val authorizationUrl: String) : DiscoveryResult()
54+
data class Failed(val userFacingMessage: String) : DiscoveryResult()
55+
}
56+
5257
@Suppress("TooGenericExceptionCaught")
53-
suspend fun getAuthorizationUrlComplete(siteUrl: String): String =
58+
suspend fun getAuthorizationUrlComplete(siteUrl: String): DiscoveryResult =
5459
try {
5560
getAuthorizationUrlCompleteInternal(siteUrl)
5661
} catch (throwable: Throwable) {
57-
handleAuthenticationDiscoveryError(siteUrl, throwable)
62+
handleAuthenticationDiscoveryError(siteUrl, throwable.message ?: throwable::class.simpleName.orEmpty())
5863
}
5964

60-
private suspend fun getAuthorizationUrlCompleteInternal(siteUrl: String): String = withContext(bgDispatcher) {
61-
when (val urlDiscoveryResult = wpLoginClient.apiDiscovery(siteUrl)) {
62-
is ApiDiscoveryResult.Success -> {
63-
val authorizationUrl =
64-
discoverSuccessWrapper.getApplicationPasswordsAuthenticationUrl(urlDiscoveryResult)
65-
val apiRootUrl = discoverSuccessWrapper.getApiRootUrl(urlDiscoveryResult)
66-
if (apiRootUrl.isNotEmpty()) {
67-
// Store the ApiRootUrl for use it after the login
68-
apiRootUrlCache.put(UrlUtils.normalizeUrl(siteUrl), apiRootUrl)
65+
private suspend fun getAuthorizationUrlCompleteInternal(siteUrl: String): DiscoveryResult =
66+
withContext(bgDispatcher) {
67+
when (val urlDiscoveryResult = wpLoginClient.apiDiscovery(siteUrl)) {
68+
is ApiDiscoveryResult.Success -> {
69+
val authorizationUrl =
70+
discoverSuccessWrapper.getApplicationPasswordsAuthenticationUrl(urlDiscoveryResult)
71+
val apiRootUrl = discoverSuccessWrapper.getApiRootUrl(urlDiscoveryResult)
72+
if (apiRootUrl.isNotEmpty()) {
73+
// Store the ApiRootUrl for use it after the login
74+
apiRootUrlCache.put(UrlUtils.normalizeUrl(siteUrl), apiRootUrl)
75+
}
76+
val authorizationUrlComplete =
77+
uriLoginWrapper.appendParamsToRestAuthorizationUrl(authorizationUrl)
78+
appLogWrapper.d(
79+
AppLog.T.API,
80+
"A_P: Found authorization for $siteUrl URL: $authorizationUrlComplete " +
81+
"API_ROOT_URL $apiRootUrl")
82+
AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_SUCCESSFUL)
83+
DiscoveryResult.Authorized(authorizationUrlComplete)
6984
}
70-
val authorizationUrlComplete =
71-
uriLoginWrapper.appendParamsToRestAuthorizationUrl(authorizationUrl)
72-
appLogWrapper.d(
73-
AppLog.T.API,
74-
"A_P: Found authorization for $siteUrl URL: $authorizationUrlComplete " +
75-
"API_ROOT_URL $apiRootUrl")
76-
AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_SUCCESSFUL)
77-
authorizationUrlComplete
78-
}
7985

80-
is ApiDiscoveryResult.FailureFetchAndParseApiRoot ->
81-
handleAuthenticationDiscoveryError(siteUrl, Exception("FailureFetchAndParseApiRoot"))
82-
83-
is ApiDiscoveryResult.FailureFindApiRoot ->
84-
handleAuthenticationDiscoveryError(siteUrl, Exception("FailureFindApiRoot"))
85-
86-
is ApiDiscoveryResult.FailureParseSiteUrl ->
87-
handleAuthenticationDiscoveryError(siteUrl, urlDiscoveryResult.error)
86+
is ApiDiscoveryResult.FailureFetchAndParseApiRoot,
87+
is ApiDiscoveryResult.FailureFindApiRoot,
88+
is ApiDiscoveryResult.FailureParseSiteUrl ->
89+
handleAuthenticationDiscoveryError(
90+
siteUrl,
91+
urlDiscoveryResult.userFacingErrorMessage(siteUrl).orEmpty()
92+
)
93+
}
8894
}
89-
}
9095

91-
private fun handleAuthenticationDiscoveryError(siteUrl: String, throwable: Throwable): String {
92-
appLogWrapper.e(AppLog.T.API, "A_P: Error during API discovery for $siteUrl - ${throwable.message}")
96+
private fun handleAuthenticationDiscoveryError(siteUrl: String, message: String): DiscoveryResult {
97+
appLogWrapper.e(AppLog.T.API, "A_P: Error during API discovery for $siteUrl - $message")
9398
AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_FAILED)
94-
return ""
99+
return DiscoveryResult.Failed(message)
95100
}
96101

97102
sealed class StoreCredentialsResult {

WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordAutoAuthDialogViewModel.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,17 @@ class ApplicationPasswordAutoAuthDialogViewModel @Inject constructor(
149149
@Suppress("TooGenericExceptionCaught")
150150
private suspend fun fallbackToManualLogin(siteUrl: String) {
151151
try {
152-
val authUrl = applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)
153-
_navigationEvent.emit(NavigationEvent.FallbackToManualLogin(authUrl))
152+
when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) {
153+
is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized ->
154+
_navigationEvent.emit(NavigationEvent.FallbackToManualLogin(result.authorizationUrl))
155+
is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> {
156+
appLogWrapper.e(
157+
AppLog.T.API,
158+
"A_P: Discovery failed for: $siteUrl - ${result.userFacingMessage}"
159+
)
160+
_navigationEvent.emit(NavigationEvent.Error)
161+
}
162+
}
154163
} catch (e: Exception) {
155164
appLogWrapper.e(
156165
AppLog.T.API,

WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/ApplicationPasswordDialogViewModel.kt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ class ApplicationPasswordDialogViewModel @Inject constructor(
3838
}
3939

4040
try {
41-
val completeAuthUrl = applicationPasswordLoginHelper.getAuthorizationUrlComplete(authenticationUrl)
42-
43-
if (completeAuthUrl.isNotEmpty()) {
44-
_navigationEvent.emit(NavigationEvent.NavigateToLogin(completeAuthUrl))
45-
} else {
46-
appLogWrapper.e(AppLog.T.MAIN, "Failed to process authentication URL")
47-
_navigationEvent.emit(NavigationEvent.ShowError)
41+
when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(authenticationUrl)) {
42+
is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized ->
43+
_navigationEvent.emit(NavigationEvent.NavigateToLogin(result.authorizationUrl))
44+
is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> {
45+
appLogWrapper.e(
46+
AppLog.T.MAIN,
47+
"Failed to process authentication URL - ${result.userFacingMessage}"
48+
)
49+
_navigationEvent.emit(NavigationEvent.ShowError)
50+
}
4851
}
4952
} catch (e: Throwable) {
5053
appLogWrapper.e(AppLog.T.MAIN, "Error processing authentication URL - ${e.stackTraceToString()}")

WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,14 @@ class LoginSiteApplicationPasswordViewModel @Inject constructor(
3131
_errorMessage.value = null
3232
_loadingStateFlow.value = true
3333
discoveryJob = viewModelScope.launch {
34-
val discoveryUrl = applicationPasswordLoginHelper
35-
.getAuthorizationUrlComplete(siteUrl)
36-
_discoveryURL.send(discoveryUrl)
34+
when (val result = applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) {
35+
is ApplicationPasswordLoginHelper.DiscoveryResult.Authorized ->
36+
_discoveryURL.send(result.authorizationUrl)
37+
is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> {
38+
_errorMessage.value = result.userFacingMessage
39+
_discoveryURL.send("")
40+
}
41+
}
3742
_loadingStateFlow.value = false
3843
}
3944
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.wordpress.android.ui.mysite.cards.applicationpassword
2+
3+
import org.wordpress.android.fluxc.model.SiteModel
4+
import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider
5+
import org.wordpress.android.fluxc.utils.AppLogWrapper
6+
import org.wordpress.android.util.AppLog
7+
import rs.wordpress.api.kotlin.WpRequestResult
8+
import uniffi.wp_api.RequestExecutionErrorReason
9+
import javax.inject.Inject
10+
11+
/**
12+
* Validates that the SiteModel's application-password credentials still work against the site's
13+
* direct host. Uses [WpApiClientProvider.getApplicationPasswordClient] so the call exercises the
14+
* application password specifically — `getWpApiClient` would route WPCom-flagged sites through the
15+
* bearer-token path and would not catch a revoked password.
16+
*/
17+
class ApplicationPasswordValidator @Inject constructor(
18+
private val wpApiClientProvider: WpApiClientProvider,
19+
private val appLogWrapper: AppLogWrapper,
20+
) {
21+
suspend fun validate(site: SiteModel): Outcome {
22+
appLogWrapper.d(
23+
AppLog.T.MAIN,
24+
"A_P: Validating application password for ${site.url} as user='${site.apiRestUsernamePlain}'"
25+
)
26+
return try {
27+
val client = wpApiClientProvider.getApplicationPasswordClient(site)
28+
val response = client.request { it.users().retrieveMeWithViewContext() }
29+
appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation response: ${response::class.simpleName}")
30+
when (response) {
31+
is WpRequestResult.Success -> {
32+
val user = response.response.data
33+
appLogWrapper.d(
34+
AppLog.T.MAIN,
35+
"A_P: Validation Success returned user id=${user.id}, slug='${user.slug}', name='${user.name}'"
36+
)
37+
Outcome.Valid
38+
}
39+
is WpRequestResult.WpError -> Outcome.Invalid
40+
is WpRequestResult.UnknownError -> Outcome.Invalid
41+
is WpRequestResult.RequestExecutionFailed ->
42+
if (response.reason is RequestExecutionErrorReason.HttpTimeoutError) {
43+
Outcome.NetworkUnavailable
44+
} else {
45+
Outcome.Invalid
46+
}
47+
else -> Outcome.Invalid
48+
}
49+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
50+
appLogWrapper.e(
51+
AppLog.T.MAIN,
52+
"A_P: Validation exception for ${site.url}: ${e::class.simpleName}: ${e.message}"
53+
)
54+
Outcome.NetworkUnavailable
55+
}
56+
}
57+
58+
enum class Outcome { Valid, Invalid, NetworkUnavailable }
59+
}

0 commit comments

Comments
 (0)