Skip to content

feat(editor): in-editor reauth — Milestone A (password path)#178

Open
dknauss wants to merge 21 commits into
mainfrom
feat/gutenberg-reauth-increment-3
Open

feat(editor): in-editor reauth — Milestone A (password path)#178
dknauss wants to merge 21 commits into
mainfrom
feat/gutenberg-reauth-increment-3

Conversation

@dknauss

@dknauss dknauss commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • What is this? The tracking home for Milestone A of the in-editor (Gutenberg) reauthentication modal. This branch carries a working password-grant modal floor (25c2e72) that was built, manually demoed in Playground, then parked so 4.6.0 could ship only the safe Increment 1 (link-out) + Increment 2 (server grant plumbing). This PR revives it as a tracked, reviewable work item and adds the milestone plan.
  • What it does: on a sudo_required REST rejection inside the block editor, an apiFetch middleware opens a single-flight wp.components.Modal (build-free), grants the sudo session via AJAX (session-only, user's own wp_rest/wp_sudo_challenge nonces — C3), then transparently re-dispatches the original request so the action completes without leaving the editor. A stale grant nonce is refreshed first. 2FA / cancel / no-config fall back to the Increment-1 link-out to the full challenge page.
  • Scope decision (this PR = Milestone A only): password path, finished and hardened. Non-2FA users get the modal; 2FA and passkey users link out (identical to today's 4.6.0 — no regression, no 2FA bypass: the server mints the session and returns 2fa_pending for 2FA accounts, which the modal bounces to the full challenge). In-modal 2FA is Milestone B. Rationale + full task/gate breakdown: .planning/gutenberg-editor-reauth-milestone-plan.md; technical detail + C1–C4 + security-config in the phase-2 plan.

Validation

  • composer test — not yet run for this milestone. This is a WIP draft, not merge-ready. The modal has no automated verification yet: editor-reauth.spec.ts EDITOR-06 (modal grant) and the superseded snackbar tests are test.fixme; only code-level review passed.
  • Milestone A exit gates (must all pass before un-drafting): rebase onto current main (branch is 9 behind); de-fixme + finish EDITOR-06; 2FA-bypass invariant test (a 2FA account's password-only modal call must never grant); stale-nonce recovery test; password-manager/autofill matrix; single-site and WP_MULTISITE=1 E2E green; degradation + headless-challenge_url (C4) regression; mandatory security-scoped review of the real diff with the C1–C4 checklist; docs/current-metrics.md synced.
  • Runtime/E2E/wp-env/password-manager verification runs in a browser-capable session (not the headless build sandbox).

Checklist

  • Linked issue / rationale — implements the "Stabilize … in-editor reauth" roadmap track; scope recorded in the milestone plan (this PR adds it).
  • Updated docs / tests — planning docs added; tests still test.fixme (Milestone A work).
  • No sensitive details disclosed publicly.

⚠️ Do not merge as-is. Draft tracking home; the modal code predates the Milestone-A gates above. First task is a rebase onto main.

🤖 Generated with Claude Code


Generated by Claude Code

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

🎮 Try in WordPress Playground

Open this PR in Playground →

Preview is pinned to WordPress 7.0.

Uses the checked-in blueprint.json from this PR, with the plugin install pinned to commit 94b992e414ce010fd9fd555e74e34c7723739df4 via a GitHub archive .zip (resource: url, fetched through the WordPress Playground CORS proxy).

Logs in as admin / password; use password again for the WP Sudo reauthentication challenge.

✅ What you can test

Feature Notes
Plugin activation & settings page
Gate fires on dangerous actions (plugin activate/delete, user delete, etc.)
Challenge / reauthentication page
Password verification & session cookie
Admin bar countdown timer
Request stash & replay after auth
Rate limiting / 5-attempt lockout ✅ within session
Session expiry by time ✅ wait out the configured duration (1–15 min)
Two Factor plugin (TOTP) ✅ installed automatically via blueprint
unfiltered_html removed from Editor role

❌ What won't work

Feature Why
WP-CLI / Cron entry point policies No CLI in browser
REST / XML-RPC entry point policies Network disabled in Playground
Two Factor email / magic-link providers PHP outbound network is off
Multisite behaviour Single-site only
State after refreshing Playground Full reset on page reload

Transients and user meta persist across normal WP navigation within
a session, but are wiped if you reload the Playground page itself.
Use the integration test suite to verify
transient TTL, real bcrypt, and multisite isolation.

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.79%. Comparing base (41795cb) to head (94b992e).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##               main     #178   +/-   ##
=========================================
  Coverage     90.79%   90.79%           
  Complexity     1524     1524           
=========================================
  Files            20       20           
  Lines          5801     5801           
=========================================
  Hits           5267     5267           
  Misses          534      534           
Flag Coverage Δ
unit 90.79% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
includes/class-plugin.php 97.47% <100.00%> (ø)
🚀 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.

dknauss commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

▶ START HERE — executing Milestone A (browser-capable session)

This PR is a parked WIP tracking home, not merge-ready. Milestone A needs a browser-capable session (wp-env / Docker / real browsers + password managers) — the headless build sandbox can't run those. Launch with /Users/danknauss/bin/claude-playwright (or the browser handoff), then work this branch.

Read first (in order):

  1. .planning/gutenberg-editor-reauth-milestone-plan.md — scope, A/B split, what's built vs. unverified, per-milestone gates.
  2. .planning/gutenberg-editor-reauth-phase2-plan.md — the how: Tasks 1–7, C1–C4, and Part 7 (security-config). Hand the C1–C4 checklist to the reviewer explicitly — the generic pre-commit reviewer won't know them otherwise.

Current state (facts):

  • Modal floor built on 25c2e72: apiFetch-detect → single-flight wp.components.Modal → AJAX session-only grant → transparent re-dispatch (C3); refreshNonceAction for stale grant nonce; 2fa_pending/cancel/no-config fall back to the Increment-1 link-out.
  • Branch is 9 commits behind main (merges clean). E2E is unverified: editor-reauth.spec.ts EDITOR-06 (modal grant) + the superseded snackbar tests are test.fixme; only EDITOR-04 is active.

First tasks:

  1. Rebase onto main (reconcile admin/js/wp-sudo-editor-reauth.js + includes/class-plugin.php against the shipped Increment-1/2 code).
  2. De-fixme and finish EDITOR-06 against live wp-env; reconcile/retire EDITOR-01/02/03/05.
  3. 2FA-bypass invariant test (the security gate): a 2FA-enabled account's password-only modal call must never return a granted session — assert at the AJAX layer and E2E (2FA acct → modal password → no grant → link-out).
  4. Stale-nonce recovery test (aged/overnight-tab, not just nonce-valid).
  5. Password-manager/autofill matrix (1Password / Bitwarden / Keychain / built-ins) against the createElement field; apply cheap markup fixes.
  6. REST boundaries: single-flight + concurrent-queue (Q3); batched-envelope detect-and-surface (Q2, no silent no-op).
  7. Degradation + headless challenge_url-free regression (C4).

Exit gates (all required before un-drafting): rebased; EDITOR-06 + bypass-invariant + stale-nonce + degradation green on single-site AND WP_MULTISITE=1; password-manager matrix recorded; C1–C4 verified; composer analyse (PHPStan+Psalm) + integration suite pass; pre-commit reviewer flag on every code commit; security-scoped review of the real diff; docs/current-metrics.md synced; release notes state the 2FA-links-out fallback.

Scope: password path only. In-modal 2FA (server-rendered provider partial; WebAuthn stays link-out) is Milestone B — a separate follow-up on top of this.


Generated by Claude Code

dknauss commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Execution checklist added.planning/gutenberg-editor-reauth-execution-checklist.md (commit 0979474). Ordered RED→GREEN steps to work top-to-bottom: rebase → EDITOR-06 modal floor → 2FA-bypass invariant → stale-nonce → REST boundaries → degradation/C4 → password-manager matrix → full single-site + WP_MULTISITE=1 gate sweep → un-draft. Milestone B sequence (from the reviewed 2FA-partial brief) follows at the bottom. This is the step-by-step companion to the "▶ START HERE" note above.


Generated by Claude Code

dknauss and others added 6 commits July 13, 2026 16:48
…loor)

WIP floor — runtime/E2E verification deferred to a wp-env/browser session
(the modal-render tests are test.fixme; code-level review passed).

On sudo_required the apiFetch middleware now opens a single-flight in-editor
modal instead of only linking out. The password step mirrors the proven
wp-sudo-challenge.js fetch flow (FormData authAction + wp_sudo_challenge
nonce, session-only, credentials:same-origin); on {authenticated} it closes
and transparently re-dispatches the original request via wp.apiFetch so it
carries the user's own wp_rest nonce and re-passes the now-active session
(C3). 2fa_pending / cancel / no-config fall back to the Increment 1 link-out
snackbar. A stale grant nonce is refreshed via refreshNonceAction first.

- admin/js/wp-sudo-editor-reauth.js: modal + single-flight + re-dispatch.
- Plugin::enqueue_editor_reauth(): +wp-element, +wp-components deps.
- editor-reauth.spec.ts: snackbar-primary EDITOR-01/02/03/05 -> test.fixme
  (superseded by the modal; reconcile in wp-env); EDITOR-04 stays active;
  new EDITOR-06 documents the modal-grant target. E2E 69 -> 70.

C1 holds (grant nonce is CSRF-only; server still enforces check_ajax_referer
+ password + Sudo_Session). No stash_key, no content-save gating, no build
step. Password never stored/sanitized/logged client-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a WordPress Playground demo for the in-development block-editor
(Gutenberg) reauthentication modal. The blueprint boots WP + PHP 8.2,
installs the plugin from this branch's GitHub archive, and writes a
demo-only mu-plugin that suppresses the login auto-grant and clears the
session on each editor page load, so the reauth modal reliably triggers
on the first gated editor action (e.g. a Block Directory install).

Also add a readme.md Playground badge, an "In-editor reauthentication
(Gutenberg — in development)" section, and a screenshot of the modal
over the block editor.

Browser-verified end-to-end in Playground; no production code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the scope/sequencing decision after finding a working password-grant
modal floor already built on this branch. Splits the transparent-modal work
into Milestone A (password path — finish + harden this floor) and Milestone B
(2FA in the modal), with per-milestone gates including a hard 2FA-bypass
invariant test. Corrects the phase-2 plan's stale "not started" status to point
at the new milestone plan.

Docs-only; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TCJoA72EDAmLviMKDB5zx
Scopes the hard piece of Milestone B (in-modal TOTP/email-OTP/backup-code
reauth): extract the full-page 2FA render block into a shared renderer, expose
it behind a login+nonce+2fa_pending-gated admin-ajax partial endpoint, inject
verbatim, and POST the provider's own fields to the unchanged handle_ajax_2fa.
WebAuthn/push/unknown/hook-only providers link out (default-deny).

Design-reviewed; critique incorporated. Notably: the email provider's
authentication_page() sends the OTP mail on render, so the partial fetch is
state-changing for email — flagged as needing a shared server-side resend
throttle, retracting the "render surface only" framing. Also handles the
hook-based 2FA extension path (no Two_Factor_Core provider -> link out),
pending-expiry mid-2FA, generic field serialization, and multisite transient
locality.

Verified against the live WordPress/two-factor source (2026-07-14):
Two_Factor_Email::authentication_page() calls generate_and_email_token(), which
calls wp_mail() — the OTP email is sent on render, so a partial re-fetch is a
mail-sending side effect. Source:
https://github.com/WordPress/two-factor/blob/master/providers/class-two-factor-email.php

Docs-only; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TCJoA72EDAmLviMKDB5zx
Docs-only; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TCJoA72EDAmLviMKDB5zx
Ordered RED->GREEN TDD steps (rebase -> EDITOR-06 floor -> 2FA-bypass invariant
-> stale-nonce -> REST boundaries -> degradation/C4 -> password-manager matrix
-> full single-site+multisite gate sweep -> un-draft), then the Milestone B
sequence from the reviewed 2FA-partial brief. Gate commands and the per-step
gates included so a browser-capable session can execute straight through.

Docs-only; no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TCJoA72EDAmLviMKDB5zx
@dknauss dknauss force-pushed the feat/gutenberg-reauth-increment-3 branch from 0979474 to 964d730 Compare July 14, 2026 04:17
Rebase onto origin/main was a clean replay (0 behind / 6 ahead); branch
editor-reauth code byte-identical to the reviewed floor. Gates green
(test:unit 1026, lint, PHPStan+Psalm, verify:metrics); force-pushed to
PR #178 (head 964d730). Note the stale-classmap gotcha for the next session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dknauss added a commit that referenced this pull request Jul 14, 2026
Rebase onto origin/main was a clean replay (0 behind / 6 ahead); branch
editor-reauth code byte-identical to the reviewed floor. Gates green
(test:unit 1026, lint, PHPStan+Psalm, verify:metrics); force-pushed to
PR #178 (head 964d730). Note the stale-classmap gotcha for the next session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fore E2E

A bare 'a WordPress answers on :8889' is not sufficient — that port can be
an unrelated project's env (observed: a Pixel wp-env via SSH tunnel with no
WP Sudo). With nothing gating, EDITOR-04 passes vacuously and masks a dead
target. Guard: assert a WP Sudo plugin entry is status:active and a gated
apiFetch is rejected (sudo_required) before trusting any spec result. Also
note wp-env start here brought up MySQL but not the WordPress containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dknauss

dknauss commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

⚠ Step 1 env guard — verify WP Sudo's own wp-env before running E2E

Learned the hard way during Step 0 handoff prep: "a WordPress answers on localhost:8889" does not mean it's WP Sudo's env. On this machine that port was an unrelated Pixel environment reached through an SSH tunnel — WP Sudo was not installed on it at all.

Consequences if you don't check:

  • EDITOR-04 passes vacuously. With nothing gating requests, "a normal error does not surface the modal" is trivially true — a green EDITOR-04 can mask a completely dead target.
  • EDITOR-06 fails for the wrong reason (no plugin → no modal), which looks like a code bug but isn't.

Do this before trusting any E2E result:

  1. Authenticated GET /wp-json/wp/v2/plugins → confirm a WP Sudo entry with status: active.
  2. Sanity-check a gated apiFetch (e.g. PUT /wp/v2/plugins/hello {status:'active'}) is rejected with a sudo_required-class code — not resolved. If it resolves, gating isn't active → wrong/dead env.

Also: on this session wp-env start (even on free ports) brought up MySQL only — the WordPress containers were never created (exit 0), likely the deprecated .wp-env.json testsPort/env options + the custom WP 7.0 core zip. Don't assume exit-0 == provisioned; verify WordPress is actually reachable and that it's the Sudo env.

Guard is now recorded at the top of Step 1 in .planning/gutenberg-editor-reauth-execution-checklist.md (commit 041c7db).

Context: Step 0 (rebase) is complete — rebased clean onto main, gates green, force-pushed. Step 1+ need the browser-capable session (claude-playwright) with WP Sudo's own wp-env.


Generated by Claude Code

@dknauss

dknauss commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

✅ Ready E2E env for Step 1 — WordPress Studio (localhost:8881)

The Step 1 env guard is now satisfied with a provisioned, verified environment — no wp-env needed. Provisioned via WordPress Studio and confirmed working end-to-end.

Environment

Piece State
Site Studio "Sudo Test"http://localhost:8881 — Online, WP 7.0.1, PHP 8.5
Plugin wp-content/plugins/wp-sudo symlinked to the repo (live mount → tracks branch feat/gutenberg-reauth-increment-3), activated, v4.6.0
Served build Confirmed this branch's modal JS (wp-sudo-editor-reauth.js?ver=4.6.0, components.Modal present)
Admin admin / password with all four governance caps (manage_wp_sudo, view_wp_sudo_activity, export_wp_sudo_activity, revoke_wp_sudo_sessions)
Gating proven With wp_sudo_* cookies stripped (as global-setup does), a gated apiFetch opens the modal — "Confirm your identity", password field, Confirm button. All three EDITOR-06 selectors verified live.

Run Step 1 against it (the harness defaults to wp-env on :8889, so target Studio explicitly and skip the wp-env-only cap step — caps are already set):

WP_BASE_URL=http://localhost:8881 WP_SUDO_SKIP_WP_ENV_CAP_SETUP=1 npm run test:e2e:local

global-setup already logs in as admin/password, strips wp_sudo_* cookies, and works against this site (verified by mirroring its exact flow).

Studio CLI cheatsheet (bundled at /Applications/Studio.app/Contents/Resources/bin/studio-cli.sh):

  • … status --path ~/Studio/sudo-test — site URL / online state
  • … start --path ~/Studio/sudo-test — start if offline
  • … wp --path ~/Studio/sudo-test <wp-cli …> — WP-CLI passthrough (note: plugin toggles are themselves sudo-gated on the CLI surface)

Baseline note: Hello Dolly is currently active; EDITOR-06's PUT /wp/v2/plugins/hello {status:'active'} still fires the gate on the request (proven), so it passes regardless of current state.

Remaining Step 1 work (browser session): un-fixme EDITOR-06 (selectors already confirmed) → green with the command above; reconcile EDITOR-01/02/03/05 (retire or repoint to link-out; keep EDITOR-04); update docs/current-metrics.md E2E count in the same commit; reviewer flag on the code commit.


Generated by Claude Code

…Milestone A, Step 1)

Activate the in-editor grant-modal E2E floor and correct the middleware to the
reviewed C4/Q2 decisions that the initial floor (25c2e72) had not yet met.

Production (admin/js/wp-sudo-editor-reauth.js):
- C4: handleSudoRequired() now opens the grant modal only when the response
  carried a validated same-origin challenge_url. A null/unsafe URL (headless,
  app-password, or a javascript: URL rejected by isSafeChallengeUrl) surfaces a
  plain notice with no reauth affordance — no modal.
- Q2: a batched /batch/v1 sudo_required is detect-and-surface ONLY. It links out
  via the snackbar and never re-dispatches the envelope (which could repeat
  successful sibling mutations) and never opens the modal.
- Robustness: a terminal .catch on the submit chain restores the busy state on a
  rejected transport/body-read, so a failed grant can never lock the modal
  (onRequestClose/Cancel are disabled while busy).

Tests (tests/e2e/specs/editor-reauth.spec.ts) — 7 active, no test.fixme:
- EDITOR-06 modal password grant re-dispatches the original request.
- EDITOR-01 modal cancel falls back to the link-out snackbar.
- EDITOR-02 batched sudo_required detect-and-surface (no modal, no replay).
- EDITOR-03/05 no safe challenge_url -> plain message, no modal (C4).
- EDITOR-07 a rejected submission restores the modal and replays nothing.
- EDITOR-04 unchanged (normal error != modal).
Verified 7/7 green against a live WP Sudo env (WordPress Studio, WP 7.0.1).

Also: refresh languages/wp-sudo.pot (new + previously-unextracted modal
strings), update the editor docblock in includes/class-plugin.php, and sync the
E2E count in docs/current-metrics.md (71).

Q3 concurrent single-flight and the /batch/v1 deeper handling remain Step 4;
PR #178 stays draft until Step 8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dknauss added a commit that referenced this pull request Jul 14, 2026
…Milestone A, Step 1)

Activate the in-editor grant-modal E2E floor and correct the middleware to the
reviewed C4/Q2 decisions that the initial floor (25c2e72) had not yet met.

Production (admin/js/wp-sudo-editor-reauth.js):
- C4: handleSudoRequired() now opens the grant modal only when the response
  carried a validated same-origin challenge_url. A null/unsafe URL (headless,
  app-password, or a javascript: URL rejected by isSafeChallengeUrl) surfaces a
  plain notice with no reauth affordance — no modal.
- Q2: a batched /batch/v1 sudo_required is detect-and-surface ONLY. It links out
  via the snackbar and never re-dispatches the envelope (which could repeat
  successful sibling mutations) and never opens the modal.
- Robustness: a terminal .catch on the submit chain restores the busy state on a
  rejected transport/body-read, so a failed grant can never lock the modal
  (onRequestClose/Cancel are disabled while busy).

Tests (tests/e2e/specs/editor-reauth.spec.ts) — 7 active, no test.fixme:
- EDITOR-06 modal password grant re-dispatches the original request.
- EDITOR-01 modal cancel falls back to the link-out snackbar.
- EDITOR-02 batched sudo_required detect-and-surface (no modal, no replay).
- EDITOR-03/05 no safe challenge_url -> plain message, no modal (C4).
- EDITOR-07 a rejected submission restores the modal and replays nothing.
- EDITOR-04 unchanged (normal error != modal).
Verified 7/7 green against a live WP Sudo env (WordPress Studio, WP 7.0.1).

Also: refresh languages/wp-sudo.pot (new + previously-unextracted modal
strings), update the editor docblock in includes/class-plugin.php, and sync the
E2E count in docs/current-metrics.md (71).

Q3 concurrent single-flight and the /batch/v1 deeper handling remain Step 4;
PR #178 stays draft until Step 8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dknauss and others added 9 commits July 14, 2026 09:36
…carryovers

Step 1 (in-editor reauth modal floor) landed in ce1c67e, codex-approved.
Record that Q2 batch detect-and-surface (Step 4) and C4 no-safe-URL client
degradation (Step 5) landed early in Step 1, and carry the two deferred
PR-level items to Step 8: Q3 concurrent single-flight and the b86de6b
commit-message source-URL amendment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… client (Milestone A, Step 2)

The security gate: a 2FA user's password-only step must return 2fa_pending and
NEVER mint a sudo session, so the in-editor password modal is safe for 2FA
accounts (they link out to the full challenge). The invariant already held in
production (attempt_activation() only calls activate() on the non-2FA path);
this makes it an enforced, regression-guarded guarantee. Tests only — no
production change.

- Unit (SudoSessionTest): a 2FA user's correct password yields 2fa_pending and
  update_user_meta() is never called — activate() writes the expiry/token meta,
  so its absence proves no session/token was minted.
- Integration (TwoFactorTest, real Two Factor plugin): attempt_activation() and
  handle_ajax_auth() both yield 2fa_pending (never authenticated) with
  is_active() false. Verified against a Studio-seeded WP 7.0.1 core + MariaDB
  (full suite 215 tests, 0 failures).
- E2E (editor-reauth.spec.ts EDITOR-08): a 2fa_pending grant response makes the
  modal link out instead of granting; the original request stays rejected.
  Verified 8/8 on WordPress Studio.

Sync docs/current-metrics.md (unit 1027/3104, integration 210 methods, E2E 72,
line counts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2FA-bypass invariant locked in across session/AJAX/client layers (1c42898,
codex-approved). Records the Studio-seeded-core method used to run the full
integration suite locally despite the throttled wordpress.org download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Step 3)

The in-editor reauth modal must survive an overnight-open editor tab whose
localized grant nonce has aged past the ~24h nonce life. This recovery already
holds in production and is now regression-guarded at both layers; tests only,
no production change.

Server (tests/Integration/StaleNonceRecoveryTest.php, 3 methods):
- STALE-01: handle_ajax_refresh_nonce() re-mints a nonce that validates for
  Challenge::NONCE_ACTION WITHOUT requiring the stale grant nonce (login-gated
  only) — the property that makes recovery possible.
- STALE-02: the refresh endpoint is login-gated (logged-out is rejected).
- STALE-03: end-to-end — a stale nonce is rejected by handle_ajax_auth (no
  session minted), then the freshly-refreshed nonce is accepted and grants.
  Asserts the recovery AND the failure mode it recovers from.

Client (tests/e2e/specs/editor-reauth.spec.ts, EDITOR-09):
- Poison the localized grant nonce; submit()'s refreshNonce() re-mints it before
  postPassword(), so the grant still succeeds and re-dispatches. Also asserts the
  localized nonce was replaced, so a still-valid original cannot explain the pass.

C1 preserved: recovery re-mints the same single wp_sudo_challenge CSRF action;
no second nonce action is introduced.

Gates: unit 1027, lint 22/22, analyse (PHPStan + Psalm) 0 errors, verify:metrics
in sync, integration 218/218, E2E editor-reauth 9/9 (Studio, WP 7.0.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tep 4, Q3)

The in-editor apiFetch middleware shows one grant modal for concurrent
sudo_required rejections (single-flight), but on a grant EVERY waiting caller
re-dispatched its own request via wp.apiFetch(options) — replaying background /
secondary gated requests the user never actioned at that moment. For an
intent-confirmation plugin that is a surprise mutation: the session legitimately
authorizes it, but the user never confirmed it.

Make re-dispatch owner-scoped:
- requestGrant() captures isOwner = !pendingGrant synchronously per-caller (the
  caller that opens the modal owns the grant; single-threaded JS makes the
  check-and-set race-free) and returns { granted, isOwner } so ownership rides
  each caller's own continuation, not the shared promise's resolution value.
- handleSudoRequired() re-dispatches only for result.granted && result.isOwner.
  A granted non-owner returns undefined with no surface() — the session is now
  active (nothing to link out) and the request was not user-actioned, so it is
  left rejected with its ORIGINAL error and self-heals on a natural retry. The
  not-granted path (2FA / cancel) still surfaces the link-out to every caller.
- Header, pendingGrant, and requestGrant() docstrings updated to the owner-scoped
  contract (they previously promised "all re-dispatch").

Design: Pre-Implementation Design Review endorsed owner-only over replay-all —
under-replay fails safe and self-heals; over-replay fires an unconfirmed mutation.

Tests (tests/e2e/specs/editor-reauth.spec.ts):
- EDITOR-10: two concurrent gated requests share one modal; on grant only the
  owner re-dispatches and resolves, the non-owner stays rejected:sudo_required.
  Verified RED on the old replay-all code (non-owner resolved), GREEN after.
- EDITOR-11: cancelling a shared modal links out and rejects every concurrent
  caller (guards the shared not-granted fallback).

Clears the Q3 (codex #4) Step 8 un-draft blocker.

Gates: unit 1027, lint 22/22, analyse (PHPStan) 0 errors, verify:metrics in sync
(E2E 73→75), editor-reauth E2E 11/11 (Studio, WP 7.0.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-editor reauth UI must never echo the action-specific rule label (Q4): a
generic prompt cannot be socially engineered around a specific action and does
not disclose what the user was doing. This already holds in production; Step 5
makes it a regression-guarded guarantee at both halves. Tests only.

- Server boundary (tests/Integration/RestGatingTest.php): a cookie-auth
  sudo_required error's human MESSAGE carries the label ("Delete plugin", so the
  label is available server-side), but the structured data the editor consumes
  (rule_id = machine id plugin.delete, challenge_url) never embeds it. Proves the
  editor's generic copy is a deliberate suppression, not an accident, and guards
  against a future leak of the label into client-consumed error data.
- Client (tests/e2e/specs/editor-reauth.spec.ts EDITOR-12): the grant modal body
  is generic ("reauthentication") and never contains the plugin.activate label
  tokens ("activate"/"plugin") nor the server message's keyboard-shortcut hint.
  Complements EDITOR-01 (snackbar surface).

Headless (C4) needed no new server test: intercept_rest()'s app-password/bearer
branch never returns sudo_required and never carries challenge_url (it returns
sudo_blocked/sudo_disabled or passes through) — already asserted with
assertArrayNotHasKey('challenge_url') by the existing RestGatingTest app-password
tests. Cited rather than duplicated.

Gates: unit 1027, lint 22/22, analyse (PHPStan) 0 errors, verify:metrics in sync
(integration 214 methods, E2E 76), integration 219/219, editor-reauth E2E 12/12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…matrix (Step 6)

Step 6's automatable half. New docs/password-manager-compatibility.md records a
factual, source-cited markup audit of both reauth surfaces (full-page challenge and
the in-editor createElement modal): both already expose the standards-minimal
password-autofill set — real <form>, type=password, autocomplete="current-password",
associated <label>, submit. The full-page TOTP one-time-code token is provided by
the Two Factor plugin's own rendering; the modal has no 2FA step yet (Milestone B).

The one gap is no autocomplete="username" hint field — documented as a candidate
cheap fix but deliberately NOT applied: it changes a security surface for an
unverified per-manager payoff, and the repo's rules forbid recording manager
behavior without a concrete version + repro. The per-manager × surface × autofill
matrix is scaffolded with every cell 'pending' — it requires manual verification
with each manager installed (incl. the 1Password-with-2FA repro) and cannot be
produced here.

Docs only (findings note + research-todo progress + checklist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 4's owner-scoping edit to admin/js/wp-sudo-editor-reauth.js shifted the
source lines of already-extracted strings (e.g. :208→:215, :281→:288). No strings
were added or removed — regenerating languages/wp-sudo.pot updates only the `#:`
line-reference comments so `composer verify:i18n` is back in sync. Should have
ridden with the Step 4 commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Steps 5 (Q4) and 6 (autofill markup audit) recorded; Step 7 gate results logged —
integration 219/0 single-site AND multisite, PHPStan+Psalm/lint/metrics/i18n green,
security-scoped C1-C4 review clean (two LOW residuals, no code change). Full/multisite
E2E deferred to CI per maintainer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dknauss dknauss force-pushed the feat/gutenberg-reauth-increment-3 branch from 753ce24 to 289b53a Compare July 14, 2026 20:04
@dknauss dknauss marked this pull request as ready for review July 14, 2026 20:05
@dknauss dknauss changed the title feat(editor): in-editor reauth — Milestone A (password path) [WIP] feat(editor): in-editor reauth — Milestone A (password path) Jul 14, 2026
@dknauss

dknauss commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Milestone A un-drafted — ready for review

The in-editor reauth password path is complete. This turn landed Steps 3–7 of the execution checklist:

  • Step 3 — stale-nonce recovery: handle_ajax_refresh_nonce() re-mints the single wp_sudo_challenge nonce (login-gated, no stale-nonce requirement); regression-guarded server-side (StaleNonceRecoveryTest) and client-side (EDITOR-09).
  • Step 4 — Q3 owner-scoped re-dispatch: on a grant, only the request that opened the modal re-dispatches; concurrent non-owner rejections are left rejected (no surprise mutation). Design-reviewed (owner-only over replay-all) + EDITOR-10/11. Clears the Q3 un-draft blocker.
  • Step 5 — Q4 label suppression: the reauth UI never echoes the rule label (RestGatingTest server boundary + EDITOR-12); headless challenge_url-free already covered.
  • Step 6 — autofill markup audit: docs/password-manager-compatibility.md (both surfaces expose the standards-minimal password-autofill set).
  • Step 7 gates: integration 219/0 single-site AND multisite, PHPStan+Psalm 0 errors, lint 22/22, verify:metrics + verify:i18n in sync, editor-reauth.spec.ts 12/12 on a live env. A security-scoped C1–C4 review of the branch was clean (2 LOW residuals, no code change).

The b86de6b commit message was amended to cite the verified WordPress/two-factor source (class-two-factor-email.php) per the repo's third-party-verification rule (history rewrite + force-push).

Remaining pre-merge (not blockers to review, but before merge)

  1. Full/multisite E2E sweepnpm run test:e2e:local (all specs) + :multisite in CI/wp-env. This branch touches only the editor-reauth spec (green) + its middleware JS, so nothing else can regress; the sweep is a release-gate formality.
  2. Password-manager matrix — the manual per-manager autofill verification (incl. the 1Password-with-2FA repro) in docs/password-manager-compatibility.md is pending (needs a human with those managers).

2FA accounts link out to the full challenge page (password-only in-editor for now); the 2FA-bypass invariant is enforced and regression-guarded at all three layers.

b86de6b reworded (two-factor source URL cited, re-verified against live source)
and force-pushed; PR #178 marked ready for review with the remaining pre-merge
items (CI E2E sweep, manual password-manager matrix) noted in a PR comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 289b53a20b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +275 to +277
if ( res.ok && '2fa_pending' === res.code ) {
// 2FA is not handled in-modal yet (Task 4) — link out.
props.resolve( false );

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid double-prompting 2FA users before link-out

For accounts with 2FA enabled, this branch runs only after the user has already entered a correct password in the modal, then falls back to the full challenge. The full challenge page still starts on the password form and keeps the 2FA step hidden until its own password AJAX returns 2fa_pending (includes/class-challenge.php:371, revealed by admin/js/wp-sudo-challenge.js only after another password submit), so these users must enter their password twice before reaching 2FA. That regresses the previous link-out path for any 2FA account; either skip the modal for known-2FA users or have the challenge page consume the existing pending state directly.

Useful? React with 👍 / 👎.

dknauss and others added 2 commits July 14, 2026 14:38
…ocs-lint)

The docs-lint gate (.github/workflows/docs-lint.yml) forbids YYYY-MM-DD fixed-date
references in user-facing docs. This file cited the research todo by its dated
filename (.planning/todos/pending/2026-07-05-...md), and the 2026-07-05 tripped the
gate. Reference the todo generically (the item under .planning/todos/pending/)
instead; the todo itself is under .planning/, which the gate excludes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@dknauss dknauss left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Final review: no blocking findings from my side.

What changed since the last review:

  • Fixed the docs-lint blocker on docs/password-manager-compatibility.md in commit 94b992e.
  • Added a manual password-manager verification protocol plus a focused 1Password + Two Factor repro target, without recording unverified manager behavior as fact.

Validation:

  • Local composer test:unit: pass, 1027 tests / 3104 assertions.
  • Local composer lint: pass.
  • Local composer analyse: pass.
  • PR CI on head 94b992e: all checks pass, including all four wp-env E2E shards and challenge-replay-multisite.

Remaining-items take:

  • I would not dispatch extra release-confidence or Nginx/MariaDB multisite E2E solely for this PR. The relevant PR wp-env E2E and multisite integration coverage are green; reserve the heavier workflow for release-candidate confidence.
  • The password-manager matrix is now scaffolded for human-local verification. I did not inspect or automate vault contents; those observations should be filled from a real local browser profile.
  • Q3 owner=request assumption is not a merge blocker. Worst case remains UX or request semantics, not auth bypass. A good follow-up would encode a per-request owner/queue so only the initiating request is retried after grant.

Approved from my side.

@dknauss

dknauss commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Final review: no blocking findings from my side.

What changed since the last review:

  • Fixed the docs-lint blocker on docs/password-manager-compatibility.md in commit 94b992e.
  • Added a manual password-manager verification protocol plus a focused 1Password + Two Factor repro target, without recording unverified manager behavior as fact.

Validation:

  • Local composer test:unit: pass, 1027 tests / 3104 assertions.
  • Local composer lint: pass.
  • Local composer analyse: pass.
  • PR CI on head 94b992e: all checks pass, including all four wp-env E2E shards and challenge-replay-multisite.

Remaining-items take:

  • I would not dispatch extra release-confidence or Nginx/MariaDB multisite E2E solely for this PR. The relevant PR wp-env E2E and multisite integration coverage are green; reserve the heavier workflow for release-candidate confidence.
  • The password-manager matrix is now scaffolded for human-local verification. I did not inspect or automate vault contents; those observations should be filled from a real local browser profile.
  • Q3 owner=request assumption is not a merge blocker. Worst case remains UX or request semantics, not auth bypass. A good follow-up would encode a per-request owner/queue so only the initiating request is retried after grant.

Approved from my side.

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.

2 participants