Hardening for the Atomic application-password validator and cleanup flow#22893
Draft
jkmassel wants to merge 4 commits into
Draft
Hardening for the Atomic application-password validator and cleanup flow#22893jkmassel wants to merge 4 commits into
jkmassel wants to merge 4 commits into
Conversation
Collaborator
Generated by 🚫 Danger |
912dd2e to
a123523
Compare
c65575d to
d233f27
Compare
8 tasks
Contributor
|
|
Contributor
|
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## jkmassel/issue-22884 #22893 +/- ##
========================================================
+ Coverage 37.33% 37.35% +0.02%
========================================================
Files 2321 2321
Lines 124644 124662 +18
Branches 16938 16944 +6
========================================================
+ Hits 46538 46570 +32
+ Misses 74342 74328 -14
Partials 3764 3764 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
dc9ff28 to
cc93500
Compare
The previous classification was destructive on ambiguous outcomes: any WpRequestResult that wasn't Success, the timeout variant of RequestExecutionFailed, or — via `else` — any other variant fell to Outcome.Invalid, which the slice acts on by wiping the stored credentials and attempting a re-mint. That means a transient 5xx, a DeviceIsOfflineError, a parse error, or an unrecognized variant would all destroy working credentials. Invert the default. Outcome.Invalid is now returned only when there's positive evidence the credential was rejected — WpErrorCode.Unauthorized / Forbidden / ApplicationPasswordNotFound / NoAuthenticatedAppPassword, or RequestExecutionErrorReason.HttpAuthenticationRejectedError / HttpAuthenticationRequiredError / HttpForbiddenError. This mirrors the canonical PostRsErrorUtils.isAuthError pattern. Everything else maps to Outcome.NetworkUnavailable, which hides the card and retries next foreground. Also re-throw CancellationException before the generic Exception catch, so coroutine cancellation isn't silently converted to NetworkUnavailable. Adds 18 unit tests covering every relevant WpRequestResult variant and both exception paths — the class previously had none.
…ials Before this commit, the method cleared only the encrypted SharedPreferences cache (`ApplicationPasswordsStore`) and left the SiteModel's `apiRest*` columns untouched. Combined with the slice's "validate → wipe → re-mint" loop, that meant: if validation returns Invalid and the subsequent mint fails (network down, server unreachable, etc.), the next buildCard reloads the site from DB, `decryptAPIRestCredentials` resurrects the stale plain fields, `siteHasBadCredentials` reports false, and validation runs again with the same revoked password — looping until either the mint eventually succeeds or the user re-authenticates via the banner. The XML-RPC rediscovery path (`ApplicationPasswordViewModelSlice.attemptXmlRpcRediscovery`) also reads `site.apiRestUsernamePlain` / `Password` directly and 401s when they're stale. Fix: also clear the SiteModel's plain + encrypted credential columns, using the same fetch-fresh-from-DB / mutate / save pattern as `removeApplicationPassword`, and emit `OnSiteChanged` so observers see the credential state change. `wpApiRestUrl` is deliberately preserved — it's reusable across credential rotations and clearing it would force a re-discovery on every revoke. Both clears happen: the encrypted prefs (so `ApplicationPasswordsManager` doesn't short-circuit the next mint by returning `Existing`) and the DB columns (so the validator and other readers see the cleared state).
The previous classifier matched a fixed allowlist of WpErrorCode
variants — Unauthorized, Forbidden, ApplicationPasswordNotFound,
NoAuthenticatedAppPassword — to decide whether a WpError represented
an auth rejection. That missed the most common case in practice:
WordPress returns `{"code": "incorrect_password", ...}` for a revoked
application password (Basic auth rejection), and wordpress-rs parses
that into `WpErrorCode.CustomError("incorrect_password")` via its
untagged-string fallback. None of our name-based matchers caught
CustomError, so the validator returned Outcome.NetworkUnavailable
and the slice silently hid the My Site card instead of triggering
the wipe + re-mint recovery flow.
Reproduced on an Atomic site with a server-side-revoked application
password:
A_P: Validating application password for ... as user='jkmassel'
A_P: Validation response: WpError
A_P: Validation network error for ...
(The "list" screen on the same site surfaces the same 401 as
"The provided password is an invalid application password.")
Fix: also classify by status code. Any parseable WpError with a 401
or 403 status is, by definition, an auth rejection. The error-code
name remains a useful fast path for unusual cases (a Forbidden code
without a 401/403 status, for example), but is no longer the only
signal. This sidesteps the long tail of plugin-defined and untagged
error codes WordPress can emit on auth failure.
Adds 3 status-code-based tests and adjusts an existing fixture so the
non-auth-code path isn't accidentally exercised with an auth status.
The wordpress-rs bump that landed on trunk (fc05d3b) changes WpError.statusCode / UnknownError.statusCode / InvalidHttpStatusCode.statusCode from `UShort` to `UInt`. Update the validator's `isAuthStatusCode` parameter and the test fixtures accordingly. No behavior change.
d233f27 to
e570420
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Four corrections to the validator + cleanup flow introduced in #22885. Found during on-device testing of that PR.
Summary
Invalidwhen there's positive evidence the credential was rejected.WpErrorresponses as auth failures regardless of error-code name.deleteStoredApplicationPasswordCredentials, not just the encrypted-prefs cache.Root Cause
#22885 introduced an asymmetric pipeline: any
Outcome.InvalidfromApplicationPasswordValidatortriggerssiteStore.deleteStoredApplicationPasswordCredentials(...)+ a re-mint. The original implementation had three bugs that combined to either destroy working credentials or strand the user with an unrecoverable revoked password:1. Validator misclassified recoverable errors as
InvalidThe original
whenmappedWpRequestResult.WpError,UnknownError, and theelsebranch all toOutcome.Invalid. That meant a transient 5xx, aDeviceIsOfflineError, a parse error, or any unrecognized variant would all wipe working credentials. The genericcatch (Exception)also swallowedCancellationException.2. WpError auth detection used WpErrorCode names instead of status codes
The first fix tightened the classification to recognize a fixed allowlist of
WpErrorCodevariants (Unauthorized,Forbidden,ApplicationPasswordNotFound,NoAuthenticatedAppPassword). That missed the most common case in practice: WordPress returns{"code": "incorrect_password", ...}for a revoked application password (Basic auth rejection), and wordpress-rs parses that intoWpErrorCode.CustomError("incorrect_password")via its untagged-string fallback. None of the name-based matchers caughtCustomError, so the validator silently returnedOutcome.NetworkUnavailableand the My Site card stayed hidden when it should have triggered a re-mint. Reproduced on-device:Status code is the reliable signal — any parseable
WpErrorwith a 401/403 status is, by definition, an auth rejection regardless of whichWpErrorCodeit carries.3.
deleteStoredApplicationPasswordCredentialsonly cleared the encrypted-prefs cacheThe method cleared only
ApplicationPasswordsStore(encrypted SharedPreferences) and left the SiteModel'sapiRest*DB columns untouched. Combined with the validate→wipe→re-mint loop, if validation returnedInvalidand the subsequent mint failed (network down, server unreachable, etc.), the nextbuildCardreloaded the site from DB,decryptAPIRestCredentialsresurrected the stale plain fields,siteHasBadCredentialsreported false, and validation ran again with the same revoked password. TheattemptXmlRpcRediscoverypath also reads the plain fields directly and 401s when they're stale.Fix
Validator
Outcome.NetworkUnavailable(non-destructive). Only returnOutcome.Invalidwhen there's positive evidence of an auth rejection — auth-specificWpErrorCode(mirrors the canonicalPostRsErrorUtils.isAuthErrorpattern), auth-specificRequestExecutionErrorReason, or aWpErrorwith 401/403 status.CancellationExceptionbefore the genericExceptioncatch.Credential cleanup
deleteStoredApplicationPasswordCredentialsnow clears the SiteModel's plain and encrypted credential columns in addition to the encrypted-prefs cache, using the same fetch-fresh-from-DB / mutate / save pattern asremoveApplicationPassword. EmitsOnSiteChangedso observers see the credential state change.wpApiRestUrlis intentionally preserved — unlike full sign-out, the discovered REST URL is reusable across credential rotations.Wordpress-rs UInt migration
fc05d3bb3d9) changedstatusCodefromUShorttoUInt. One-line signature change inisAuthStatusCode+ fourtoUShort()→toUInt()test fixture updates. No behavior change.Tests
ApplicationPasswordValidatorTest(new, 21 tests):Success→Valid.WpErrorfor each auth-relatedWpErrorCode→Invalid.WpErrorwith any code + status 401/403 →Invalid(covers theincorrect_passwordcase).WpErrorwith non-auth code + non-auth status →NetworkUnavailable.WpErrorwith non-auth code + 500 →NetworkUnavailable.RequestExecutionFailedfor each auth-related reason →Invalid.RequestExecutionFailedfor each non-auth reason (timeout, offline, DNS, generic transport) →NetworkUnavailable.UnknownError,InvalidHttpStatusCode,ResponseParsingError→NetworkUnavailable.Exception→NetworkUnavailable.CancellationExceptionis rethrown.Test plan
Invalid→ wipe → re-mint succeeds → no card.NetworkUnavailable→ no card, no destructive action; creds preserved in DB.:WordPress:testJetpackDebugUnitTest --tests "org.wordpress.android.ui.mysite.cards.applicationpassword.*"all pass.Related