Skip to content

Hardening for the Atomic application-password validator and cleanup flow#22893

Draft
jkmassel wants to merge 4 commits into
jkmassel/issue-22884from
jkmassel/issue-22884-validator-hardening
Draft

Hardening for the Atomic application-password validator and cleanup flow#22893
jkmassel wants to merge 4 commits into
jkmassel/issue-22884from
jkmassel/issue-22884-validator-hardening

Conversation

@jkmassel
Copy link
Copy Markdown
Contributor

Four corrections to the validator + cleanup flow introduced in #22885. Found during on-device testing of that PR.

Summary

  1. Tighten validator error classification — only return Invalid when there's positive evidence the credential was rejected.
  2. Treat 401/403 WpError responses as auth failures regardless of error-code name.
  3. Clear the SiteModel's credential columns in deleteStoredApplicationPasswordCredentials, not just the encrypted-prefs cache.
  4. Adapt to the wordpress-rs UInt status-code API (post-rebase mechanical change).

Root Cause

#22885 introduced an asymmetric pipeline: any Outcome.Invalid from ApplicationPasswordValidator triggers siteStore.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 Invalid

The original when mapped WpRequestResult.WpError, UnknownError, and the else branch all to Outcome.Invalid. That meant a transient 5xx, a DeviceIsOfflineError, a parse error, or any unrecognized variant would all wipe working credentials. The generic catch (Exception) also swallowed CancellationException.

2. WpError auth detection used WpErrorCode names instead of status codes

The first fix tightened the classification to recognize a fixed allowlist of WpErrorCode variants (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 into WpErrorCode.CustomError("incorrect_password") via its untagged-string fallback. None of the name-based matchers caught CustomError, so the validator silently returned Outcome.NetworkUnavailable and the My Site card stayed hidden when it should have triggered a re-mint. Reproduced on-device:

A_P: Validating application password for ... as user='jkmassel'
A_P: Validation response: WpError
A_P: Validation network error for ...

Status code is the reliable signal — any parseable WpError with a 401/403 status is, by definition, an auth rejection regardless of which WpErrorCode it carries.

3. deleteStoredApplicationPasswordCredentials only cleared the encrypted-prefs cache

The method cleared only ApplicationPasswordsStore (encrypted SharedPreferences) and left the SiteModel's apiRest* DB columns untouched. Combined with the validate→wipe→re-mint loop, if validation returned Invalid and the subsequent mint failed (network down, server unreachable, etc.), the next buildCard reloaded the site from DB, decryptAPIRestCredentials resurrected the stale plain fields, siteHasBadCredentials reported false, and validation ran again with the same revoked password. The attemptXmlRpcRediscovery path also reads the plain fields directly and 401s when they're stale.

Fix

Validator

  • Invert the default: anything unrecognized → Outcome.NetworkUnavailable (non-destructive). Only return Outcome.Invalid when there's positive evidence of an auth rejection — auth-specific WpErrorCode (mirrors the canonical PostRsErrorUtils.isAuthError pattern), auth-specific RequestExecutionErrorReason, or a WpError with 401/403 status.
  • Re-throw CancellationException before the generic Exception catch.

Credential cleanup

  • deleteStoredApplicationPasswordCredentials now 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 as removeApplicationPassword. Emits OnSiteChanged so observers see the credential state change. wpApiRestUrl is intentionally preserved — unlike full sign-out, the discovered REST URL is reusable across credential rotations.

Wordpress-rs UInt migration

  • The wordpress-rs bump on trunk (fc05d3bb3d9) changed statusCode from UShort to UInt. One-line signature change in isAuthStatusCode + four toUShort()toUInt() test fixture updates. No behavior change.

Tests

ApplicationPasswordValidatorTest (new, 21 tests):

  • SuccessValid.
  • WpError for each auth-related WpErrorCodeInvalid.
  • WpError with any code + status 401/403 → Invalid (covers the incorrect_password case).
  • WpError with non-auth code + non-auth status → NetworkUnavailable.
  • WpError with non-auth code + 500 → NetworkUnavailable.
  • RequestExecutionFailed for each auth-related reason → Invalid.
  • RequestExecutionFailed for each non-auth reason (timeout, offline, DNS, generic transport) → NetworkUnavailable.
  • UnknownError, InvalidHttpStatusCode, ResponseParsingErrorNetworkUnavailable.
  • Generic ExceptionNetworkUnavailable.
  • CancellationException is rethrown.

Test plan

  • Atomic site with valid creds — pull-to-refresh. Validate succeeds → no card.
  • Atomic site with revoked creds — pull-to-refresh. Validator returns Invalid → wipe → re-mint succeeds → no card.
  • Atomic site with revoked creds + airplane mode — pull-to-refresh. Validator returns NetworkUnavailable → no card, no destructive action; creds preserved in DB.
  • Self-hosted regression: still works.
  • :WordPress:testJetpackDebugUnitTest --tests "org.wordpress.android.ui.mysite.cards.applicationpassword.*" all pass.

Related

@dangermattic
Copy link
Copy Markdown
Collaborator

1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

@jkmassel jkmassel force-pushed the jkmassel/issue-22884 branch from 912dd2e to a123523 Compare May 25, 2026 17:56
@jkmassel jkmassel force-pushed the jkmassel/issue-22884-validator-hardening branch from c65575d to d233f27 Compare May 25, 2026 17:56
@wpmobilebot
Copy link
Copy Markdown
Contributor

wpmobilebot commented May 25, 2026

App Icon📲 You can test the changes from this Pull Request in Jetpack Android by scanning the QR code below to install the corresponding build.

App NameJetpack Android
Build TypeDebug
Versionpr22893-e570420
Build Number1488
Application IDcom.jetpack.android.prealpha
Commite570420
Installation URL7h36vbjg2q658
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot
Copy link
Copy Markdown
Contributor

wpmobilebot commented May 25, 2026

App Icon📲 You can test the changes from this Pull Request in WordPress Android by scanning the QR code below to install the corresponding build.

App NameWordPress Android
Build TypeDebug
Versionpr22893-e570420
Build Number1488
Application IDorg.wordpress.android.prealpha
Commite570420
Installation URL2o0iue89alr48
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 25, 2026

Codecov Report

❌ Patch coverage is 59.37500% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.35%. Comparing base (cc93500) to head (e570420).

Files with missing lines Patch % Lines
...ava/org/wordpress/android/fluxc/store/SiteStore.kt 0.00% 13 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jkmassel jkmassel force-pushed the jkmassel/issue-22884 branch from dc9ff28 to cc93500 Compare May 25, 2026 19:10
jkmassel added 4 commits May 25, 2026 13:11
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.
@jkmassel jkmassel force-pushed the jkmassel/issue-22884-validator-hardening branch from d233f27 to e570420 Compare May 25, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants