diff --git a/.gitignore b/.gitignore index ac8eef3..47da23b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ tests/_support/_generated/ # OS .DS_Store Thumbs.db +/.claude/ diff --git a/AGENTS.md b/AGENTS.md index e173c7e..04c0858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,22 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php` | `[workos:password-reset]` — toggles between admin-of-other (`user="id-or-email"`) and self-service (no `user`) modes based on its attributes. | | `src/WorkOS/Auth/PasswordResetAdmin/Assets.php` | Registers `workos-admin-password-reset` JS/CSS handles + localizes `workosPasswordReset` (REST URL, `wp_rest` nonce, masked-email-aware UI strings). Auto-enqueues on `users.php` / `user-edit.php` / `profile.php`. | | `src/js/admin-password-reset/index.ts` | Delegated `.workos-pwreset-trigger` click handler — POSTs to the admin endpoint and surfaces a transient admin notice. Same handler powers every trigger surface (WP Users row, user-edit panel, shortcode, WorkOS Users admin page row). | +| **Auth — Change Email** ([`docs/change-email.md`](docs/change-email.md)) | | +| `src/WorkOS/Auth/ChangeEmail/Controller.php` | DI controller — wires REST endpoints, JS/CSS, row action, profile panel, shortcode, and the frontend confirm route. Honors the `change_email_enabled` setting + `workos_change_email_enabled` filter. | +| `src/WorkOS/Auth/ChangeEmail/RestApi.php` | Three endpoints: `POST /workos/v1/users/{id}/email-change` (initiate, `edit_user` cap, per-IP + per-user rate limits, enumeration-safe conflict response), `…/email-change/confirm` (token-gated, race re-check, transient-guarded WorkOS + WP commit), `…/email-change/cancel` (cancel-token *or* `edit_user` cap). Logs `email_change.*` events. | +| `src/WorkOS/Auth/ChangeEmail/PendingChange.php` | Stores the pending record as `_workos_pending_email_change` user_meta — only hashes (HMAC-SHA256 + `wp_salt('auth')`), never plaintext tokens. Single-use: cleared on confirm/cancel/expiry. | +| `src/WorkOS/Auth/ChangeEmail/TokenFactory.php` | `wp_generate_password()`-backed token generator + `hash_hmac()` storage + `hash_equals()` verify. | +| `src/WorkOS/Auth/ChangeEmail/ConflictResolver.php` | Enforces `change_email_conflict_policy`: `block` (default), `allow_orphan` (gated by `_workos_user_id`, posts, comments, last-login window — defaults to 90 days, filterable via `workos_change_email_orphan_max_inactive_days`), `merge_request` (rejects + fires `workos_change_email_merge_requested` for Issue 2). Fires `workos_change_email_conflict_detected` on any collision. | +| `src/WorkOS/Auth/ChangeEmail/Notifier.php` | Three transactional emails via `wp_mail()`: verification to the new address, "change requested" cancel-link to the old address (suppressed by `change_email_notify_old_address=false`), post-commit confirmation to the old address. | +| `src/WorkOS/Auth/ChangeEmail/RowActions.php` | "Change email" row action under the WorkOS column on `wp-admin/users.php` (priority 11, sits next to "Send password reset"). | +| `src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php` | "Change Email" panel + trigger button on the user-edit / profile screen (priority 21, after the password-reset panel). | +| `src/WorkOS/Auth/ChangeEmail/Shortcode.php` | `[workos:change-email]` — self-service form when no `user` attribute; admin-of-other form when `user="id-or-email"`. | +| `src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php` | Rewrite rule for the configurable confirm path (default `/workos/change-email/`, settable via `change_email_confirm_path`). `template_redirect` renders `templates/change-email/confirm-page.php`, which enqueues the JS bundle that POSTs to the confirm/cancel endpoint. | +| `src/WorkOS/Auth/ChangeEmail/Assets.php` | Registers `workos-admin-change-email` (admin row/panel/shortcode trigger) + `workos-change-email-confirm` (frontend confirm page) JS/CSS handles. | +| `src/js/admin-change-email/index.ts` | Delegated `.workos-change-email-trigger` click handler — prompts for the new email (standalone button) or reads from `.workos-change-email-input` (shortcode form), POSTs to the initiate endpoint. | +| `src/js/change-email-confirm/index.ts` | Frontend confirm-page logic: reads token + user_id from URL, picks confirm vs cancel based on `?action=cancel`, POSTs, renders success / error. | +| `src/WorkOS/Email/Mailer.php` | Small `wp_mail()` wrapper shared by the change-email Notifier — locates templates (with theme override at `wp-content/themes/{theme}/integration-workos/{template}.php`), sets HTML headers + a filterable from-address, and exposes `workos_email_body`, `workos_email_subject`, `workos_email_headers` filters. | +| `templates/change-email/*.php` | Email + confirm-page templates (verification, old-address notice, confirmation notice, confirm page). | | `src/WorkOS/Auth/Redirect.php` | Role-based login redirects | | `src/WorkOS/Auth/LogoutRedirect.php` | Role-based logout redirects | | **Auth — Custom AuthKit (React shell)** | | diff --git a/CHANGELOG.md b/CHANGELOG.md index bccd0cb..0147019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [1.0.6] - Unreleased +### Added + +- **Change email (WorkOS-verified, conflict-guarded)** (#22) — self-service and admin-triggered email-change flow. + - Self-service `[workos:change-email]` shortcode. + - Admin "Change email" row action under the **WorkOS column** on `wp-admin/users.php`; "Change Email" panel on the user-edit / profile screen. + - WP-side verification: hashed (HMAC-SHA256 + `wp_salt('auth')`) confirm + cancel tokens stored as `_workos_pending_email_change` user_meta, validated with `hash_equals`, single-use, expiry-bounded. (WorkOS's `email_verification` endpoints can't verify a *pending* address.) + - Three new REST endpoints: `POST /workos/v1/users/{id}/email-change` (initiate, capability-gated, per-IP + per-user rate-limited, enumeration-safe conflict response), `…/email-change/confirm` (race re-checked, transient-guarded WorkOS + WP commit, rollback on partial failure), `…/email-change/cancel` (cancel-token or `edit_user` cap). + - Configurable conflict policy: `block` (default), `allow_orphan` (gated by `_workos_user_id`, posts, comments, last-login window), `merge_request` (rejects today, fires `workos_change_email_merge_requested` for the future merge feature). + - `wp_mail()`-backed verification, old-address cancel-link notice (opt-out via `change_email_notify_old_address`), and post-commit confirmation. Templates under `templates/change-email/` and overridable from a theme. + - Frontend confirm route under a configurable path (default `/workos/change-email/`, settable via `change_email_confirm_path`). + - UserSync race guard: `_workos_email_change_in_progress_` transient short-circuits `handle_user_updated()` while the confirm handler is mid-commit. + - 8 new filters (`workos_change_email_enabled`, `…/conflict_policy`, `…/token_lifetime`, `…/can_initiate`, `…/notify_old_address`, `…/orphan_max_inactive_days`, plus `workos_email_subject|body|headers`) and 5 actions (`…/initiated`, `…/confirmed`, `…/cancelled`, `…/conflict_detected`, `…/merge_requested`). + - 7 new activity-log event types: `email_change.initiated|confirmed|cancelled|expired|conflict_blocked|commit_failed|admin_bypass`. + - 40 new WPUnit tests across 6 suites under `tests/wpunit/ChangeEmail*Test.php`. + - See [`docs/change-email.md`](docs/change-email.md). + ## [1.0.5] - 2026-05-18 ### Added diff --git a/README.md b/README.md index 811e410..1a4915b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. - **Sign-in methods**: email + password, magic code, social OAuth (Google, Microsoft, GitHub, Apple), passkey - **In-app flows**: self-serve sign-up with email verification, invitation acceptance, password reset (two-field new-password form with `wp.passwordStrength.meter` gating, optional auto-login on success, post-reset `redirect_url` validated same-host) - **Admin-triggered password reset** — send a WorkOS reset email on behalf of any linked user via `POST /wp-json/workos/v1/admin/users/{id}/password-reset` (gated by `edit_user($id)` so the same route covers self-service). Surfaced as a row action under the **WorkOS column** on `wp-admin/users.php`, a button on the user-edit screen, a per-row button on the WorkOS Users admin page, and a `[workos:password-reset]` shortcode. Successful sends are audited as `password_reset.admin_sent` in the activity log; emails point at the in-site React shell (`/workos/login/{slug}?token=…&redirect_to=…`) and the new password is mirrored to the linked WP user so `?fallback=1` / `wp_authenticate` / REST app passwords stay in sync. See [`docs/password-reset.md`](docs/password-reset.md). +- **Change email** — WorkOS-verified, conflict-guarded email-change flow. Self-service via `[workos:change-email]` shortcode; admin via a row action under the **WorkOS column** on `wp-admin/users.php` and a panel on the user-edit screen. WP-side hashed token sent to the new address; cancel-link notice sent to the old address; configurable conflict policy (`block`, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Confirm commits to WorkOS first (via `update_user`) and then mirrors into WP, with a 60-second in-progress transient that short-circuits the webhook fan-back. See [`docs/change-email.md`](docs/change-email.md). - **Skeleton placeholders** on every AuthKit surface (wp-login.php takeover, `/workos/login/{profile}`, shortcode) — pre-hydration markup from PHP plus a React `FlowSkeleton` during bootstrap, mirroring the real card heights so swap-in is a flicker not a jump. - **MFA** — TOTP, SMS, WebAuthn/passkey with full enrollment + challenge UI; profile-level `mfa.enforce` (`never` / `if_required` / `always`) and factor allowlist - **Profile routing rules** — ordered `redirect_to` glob / `referrer_host` / `user_role` matchers pick the right profile per request @@ -177,7 +178,11 @@ full developer guide on injecting React elements (SlotFill), enqueuing per-profile CSS/JS, and the available PHP filters. For password-reset integrations (admin-triggered, self-service, shortcode, redirect_url policy, what-not-to-do), see -[`docs/password-reset.md`](docs/password-reset.md). +[`docs/password-reset.md`](docs/password-reset.md). For the +WorkOS-verified change-email flow (self-service + admin row action + +configurable conflict policy + WP-side token verification + cancel +link to old address), see +[`docs/change-email.md`](docs/change-email.md). ### Custom paths diff --git a/docs/change-email.md b/docs/change-email.md new file mode 100644 index 0000000..e511b5c --- /dev/null +++ b/docs/change-email.md @@ -0,0 +1,211 @@ +# Change Email + +WorkOS-verified, conflict-guarded email-change flow for both self-service and admin-triggered scenarios. + +## Why this exists + +WordPress lets an admin overwrite a user's `user_email` directly in `wp-admin/users.php` — no proof of ownership, no warning to the old address, no enforcement against collisions. For a WorkOS-backed deployment that's a footgun: an attacker who briefly compromises an admin session can pivot to "I own this account" by repointing the address. + +This feature adds: + +- A self-service `[workos:change-email]` shortcode that prompts the user for a new address and starts the flow. +- An admin "Change email" row action + user-edit panel that mirrors the existing "Send password reset" surfaces. +- A WP-side hashed token + pending-state record so the new address must be confirmed (clicked on) before the change commits. +- An old-address notice with a one-click cancel link (so a session-hijack victim can stop a change in progress). +- A configurable conflict policy that prevents the change from silently overwriting another local WP user's email. +- A WorkOS sync race guard so the `user.updated` webhook fan-back can't re-trigger the very mutation we just made. + +Verification is owned WP-side because WorkOS's `email_verification` endpoints verify the *current* address on a WorkOS user, not a pending change. + +## Flow + +```mermaid +sequenceDiagram + participant User + participant WP as WordPress + participant Mail as Mail (wp_mail) + participant WorkOS + + User->>WP: POST /workos/v1/users/{id}/email-change + Note over WP: validate, rate-limit, conflict-check + WP->>WP: PendingChange::store(hash, expiry) + WP->>Mail: send_verification(new_email, confirm_url) + WP->>Mail: send_old_address_notice(old_email, cancel_url) + WP-->>User: 200 { masked_new_email, expires_at } + + User->>WP: GET /workos/change-email/?user_id=…&token=… + WP-->>User: confirm page (JS POSTs the token) + User->>WP: POST /workos/v1/users/{id}/email-change/confirm + Note over WP: race re-check, set in-progress transient + WP->>WorkOS: PUT /user_management/users/{wid} { email } + WorkOS-->>WP: 200 + WP->>WP: wp_update_user({ user_email }) + WP->>WP: clear pending meta + transient + WP->>Mail: send_confirmation_notice(old_email) + WP-->>User: 200 { redirect_url } +``` + +## Endpoints + +All under `/wp-json/workos/v1/`. Requests must include the WP REST nonce in `X-WP-Nonce`. + +### `POST users/{id}/email-change` + +Initiate a pending change. + +- **Auth:** `current_user_can('edit_user', $id)` (admins-of-other + self via WP's default cap mapping). +- **Body:** + ```json + { "new_email": "jane.new@example.com", "redirect_url": "/welcome" } + ``` +- **Response (enumeration-safe, identical shape on success and on blocked-by-conflict):** + ```json + { "ok": true, "masked_new_email": "j•••@e•••.com", "expires_at": 1717948800 } + ``` + +### `POST users/{id}/email-change/confirm` + +Consume the token shipped in the verification email. + +- **Auth:** the token itself. +- **Body:** `{ "token": "…" }` +- **Behavior:** re-runs the conflict resolver (a race can produce a new collision between initiate and confirm), sets a 60-second `_workos_email_change_in_progress_` transient, calls `update_user` on WorkOS, mirrors with `wp_update_user`, clears the pending meta and the transient. +- **Errors:** `400` on bad/tampered token, `410` on expired, `409` on confirm-time conflict, `502` on a WorkOS API failure (with automatic rollback if `wp_update_user` also fails). + +### `POST users/{id}/email-change/cancel` + +Cancel a pending change. + +- **Auth:** EITHER a valid cancel token (from the old-address notice link) OR `edit_user` on the target. +- **Body:** `{ "token": "…" }` for the token path; omit for the capability path. + +## Settings + +Stored under the active environment (`workos()->option(...)`); defaults are listed below. + +| Option | Default | Purpose | +|---|---|---| +| `change_email_enabled` | `true` | Master switch. Also filterable: `workos_change_email_enabled`. | +| `change_email_conflict_policy` | `'block'` | `block` \| `allow_orphan` \| `merge_request`. | +| `change_email_token_lifetime` | `3600` | Seconds. Clamped to `[300, 86400]`. | +| `change_email_rate_limit_user_count` | `3` | Initiate attempts per user. | +| `change_email_rate_limit_user_window` | `3600` | Window in seconds. | +| `change_email_rate_limit_ip_count` | `10` | Initiate attempts per IP. | +| `change_email_rate_limit_ip_window` | `3600` | Window in seconds. | +| `change_email_notify_old_address` | `true` | Send the "change requested" + "change confirmed" notices to the old address. | +| `change_email_require_reauth` | `true` | Reserved for the AuthKit step-up flow. | +| `change_email_admin_bypass_verification` | `false` | When true, an admin with `edit_users` can commit without email verification (audit-logged via `email_change.admin_bypass`). | +| `change_email_confirm_path` | `'workos/change-email'` | Rewrite path for the confirm route. Slash-trimmed; restricted to `[a-zA-Z0-9/_-]`. | + +## Conflict policies + +- **`block`** (default): a hard reject. The user-facing message is intentionally vague ("That email cannot be used for this account.") so the response can't be used to enumerate which addresses are taken. Logged as `email_change.conflict_blocked`. +- **`allow_orphan`**: permits the change when the conflicting WP user is unlinked from WorkOS (no `_workos_user_id`), has authored no posts, has authored no comments, and has been inactive for at least `workos_change_email_orphan_max_inactive_days` days (default 90, filterable). Audit-logged as a takeover. The conflicting account is not deleted — the email is simply reassigned. +- **`merge_request`**: rejects today (until Issue 2's merge flow ships), but fires `workos_change_email_merge_requested` so the future merge feature can observe. + +## Hooks + +### Filters + +- `workos_change_email_enabled` — master switch. +- `workos_change_email_conflict_policy` — request-time policy override (e.g. force `block` for HIPAA-tagged users). +- `workos_change_email_token_lifetime` — seconds, clamped to `[300, 86400]`. +- `workos_change_email_can_initiate` — `( bool $allowed, int $target_id, int $initiator_id )`. +- `workos_change_email_notify_old_address` — bool override for the opt-out gate. +- `workos_change_email_orphan_max_inactive_days` — inactivity threshold for `allow_orphan`. +- `workos_email_subject`, `workos_email_body`, `workos_email_headers` — shared email customization (used by all three change-email templates). + +### Actions + +- `workos_change_email_initiated` — `( int $user_id, string $new_email, int $initiated_by )`. +- `workos_change_email_confirmed` — `( int $user_id, string $old_email, string $new_email )`. +- `workos_change_email_cancelled` — `( int $user_id, string $reason )` where `$reason` is `'token'` or `'capability'`. +- `workos_change_email_conflict_detected` — `( int $target_user_id, string $new_email, int $conflicting_user_id, string $policy )`. +- `workos_change_email_merge_requested` — `( int $target_user_id, string $new_email, int $conflicting_user_id )`. + +## Activity log events + +- `email_change.initiated` +- `email_change.confirmed` +- `email_change.cancelled` +- `email_change.expired` +- `email_change.conflict_blocked` +- `email_change.commit_failed` +- `email_change.admin_bypass` (only when `change_email_admin_bypass_verification=true`) + +Each row records `{ user_id, user_email, workos_user_id, ip_address, metadata: { masked_new_email, masked_old_email, policy, initiator_id, self_service } }`. + +## Shortcode + +```html + +[workos:change-email] + + +[workos:change-email redirect_url="/welcome"] + + +[workos:change-email user="42"] +[workos:change-email user="jane@example.com"] + + +[workos:change-email label="Update my address"] +``` + +The shortcode silently renders nothing when: + +- No `user` attribute and the viewer is logged out. +- The target user is not linked to WorkOS (no `_workos_user_id` meta). +- The viewer lacks `edit_user` on the target. + +## Email templates + +Templates live in `templates/change-email/` and are loaded by `WorkOS\Email\Mailer`. A theme can override any of them by placing a file at `wp-content/themes/{theme}/integration-workos/change-email/{name}.php` — the loader checks `locate_template()` first. + +| Template | Recipient | Trigger | +|---|---|---| +| `verification-email.php` | **new** address | initiate | +| `old-address-notice.php` | **old** address | initiate (when `change_email_notify_old_address=true`) | +| `confirmation-notice.php` | **old** address | post-commit (when `change_email_notify_old_address=true`) | +| `confirm-page.php` | — | frontend confirm-route render | + +## Security checklist + +| Check | Implementation | +|---|---| +| Token hashing on storage | `hash_hmac('sha256', $token, wp_salt('auth'))` in `TokenFactory::hash()`. | +| Constant-time compare | `hash_equals()` in `TokenFactory::verify()`. | +| Single-use | Pending meta deleted on confirm/cancel/expiry; a second confirm finds no record. | +| Expiry enforced | `expires_at` checked before `hash_equals`; expired records are cleared as a side-effect. | +| Enumeration-safe initiate | Conflict-blocked responses share the same shape as success responses. | +| Rate limiting | Two `RateLimiter::attempt()` calls per initiate (per-IP, per-user). | +| Old-address notice | Default-on with a one-click cancel link. | +| CSRF / nonce | `X-WP-Nonce` required on every endpoint; capability checks on initiate and on the capability-mode cancel path. | +| Audit log | Every state transition is written to `{$wpdb->prefix}workos_activity_log`. | +| Webhook race | `_workos_email_change_in_progress_` transient short-circuits `UserSync::handle_user_updated()` for 60s. | +| HTML-escape new email | `esc_html()` in templates; `sanitize_email()` on REST input. | + +## Tests + +Six WPUnit suites under `tests/wpunit/`: + +``` +ChangeEmailTokenFactoryTest.php # entropy + hashing + constant-time verify +ChangeEmailPendingChangeTest.php # storage invariants + expiry + clear() +ChangeEmailConflictResolverTest.php # block / allow_orphan / merge_request matrix +ChangeEmailNotifierTest.php # recipient routing + opt-out gate +ChangeEmailRestApiTest.php # 13 tests covering initiate / confirm / cancel +ChangeEmailUserSyncRaceGuardTest.php # the transient short-circuit +``` + +Run all change-email tests: + +```bash +slic run wpunit --filter ChangeEmail +``` + +## Out of scope + +- **Account merge.** When the conflict policy is `merge_request` we fire the hook but reject — the actual merge flow is tracked separately (Issue 2). Until that ships, `merge_request` behaves like `block` plus a future-facing hook fire. +- **Bulk email changes.** Each request handles a single user. +- **Username changes.** Only `user_email` is updated. diff --git a/readme.txt b/readme.txt index 9fdb1d1..3baa660 100644 --- a/readme.txt +++ b/readme.txt @@ -177,6 +177,8 @@ WorkOS is provided by WorkOS, Inc. = 1.0.6 - Unreleased = +* New: WorkOS-verified change-email flow. Self-service `[workos:change-email]` shortcode + admin row action on `wp-admin/users.php` + panel on the user-edit screen. The new address must be confirmed via a hashed token emailed by the plugin (because WorkOS's `email_verification` endpoints can't verify a *pending* change); the old address simultaneously receives a one-click cancel link. Configurable conflict policy (`block` default, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Commits to WorkOS first (`update_user`) and then mirrors into WordPress, with a 60-second in-progress transient that short-circuits the webhook fan-back. Eight new filters, five new actions, seven new activity-log events, and 40 new WPUnit tests. See `docs/change-email.md`. (#22) + = 1.0.5 - 2026-05-18 = * New: WorkOS → Users admin page. Paginated, searchable React list of WorkOS users for the active environment, with a per-row "Open in WorkOS" deep-link straight to the user's Dashboard page. Lets admins triage WorkOS users (including re-enabling a suppressed email under the Dashboard's Emails tab) without bouncing through the Dashboard's own user picker. Requires `manage_options`. No bulk re-enable yet — WorkOS does not expose a public REST endpoint for the "Re-enable email" action. ([CONS-273](https://linear.app/nexcess/issue/CONS-273/re-enable-workos-emails-for-affected-portal-users)) diff --git a/src/WorkOS/Admin/UserProfile.php b/src/WorkOS/Admin/UserProfile.php index 7114b7e..44ddef4 100644 --- a/src/WorkOS/Admin/UserProfile.php +++ b/src/WorkOS/Admin/UserProfile.php @@ -422,15 +422,30 @@ private function get_cached_events( string $workos_user_id, string $org_id ): ?a $cursor = null; for ( $page = 0; $page < self::MAX_EVENT_PAGES; $page++ ) { + /* + * WorkOS Events API quirks (verified live 2026-05-26): + * + * - `range_start` requires millisecond precision + literal `Z`. + * Plain `…:ssZ`, `…:ss+00:00`, and `…:ss.000+00:00` all return + * "Invalid range. Start date is not a valid ISO 8601 date." — + * only `.000Z` is accepted. + * - Max date range is 30 days. Anything wider returns "Date + * ranges cannot be longer than 30 days." + * - `range_start` and `after` are mutually exclusive — sending + * both returns "Only one of range_start and after may be + * provided." Use `range_start` on the first page and switch to + * `after` (the cursor) on subsequent pages. + */ $params = [ 'organization_id' => $org_id, 'events' => self::USER_EVENT_TYPES, 'limit' => self::EVENTS_PER_PAGE, - 'range_start' => gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '-90 days' ) ), ]; if ( $cursor ) { $params['after'] = $cursor; + } else { + $params['range_start'] = gmdate( 'Y-m-d\TH:i:s.000\Z', strtotime( '-30 days' ) ); } $result = workos()->api()->list_events( $params ); diff --git a/src/WorkOS/Auth/ChangeEmail/Assets.php b/src/WorkOS/Auth/ChangeEmail/Assets.php new file mode 100644 index 0000000..ebcec58 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/Assets.php @@ -0,0 +1,199 @@ +plugin_url = workos()->getUrl(); + $this->plugin_path = workos()->getDir(); + $this->version = workos()->getVersion(); + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'init', [ $this, 'register_assets' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_admin' ] ); + } + + /** + * Register both bundles so callers can `wp_enqueue_script(handle)`. + * + * @return void + */ + public function register_assets(): void { + $this->register_admin_assets(); + $this->register_confirm_assets(); + } + + /** + * Register the admin bundle. + * + * @return void + */ + private function register_admin_assets(): void { + $asset_file = $this->plugin_path . 'build/admin-change-email.asset.php'; + $asset = file_exists( $asset_file ) + ? require $asset_file + : [ + 'dependencies' => [ 'wp-i18n' ], + 'version' => $this->version, + ]; + + wp_register_script( + self::ADMIN_SCRIPT_HANDLE, + $this->plugin_url . 'build/admin-change-email.js', + (array) ( $asset['dependencies'] ?? [] ), + (string) ( $asset['version'] ?? $this->version ), + true + ); + + wp_set_script_translations( self::ADMIN_SCRIPT_HANDLE, 'integration-workos' ); + + wp_localize_script( + self::ADMIN_SCRIPT_HANDLE, + 'workosChangeEmail', + [ + 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/users/' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'strings' => [ + 'modalTitle' => __( 'Change email address', 'integration-workos' ), + 'modalMessage' => __( 'A verification link will be sent to the new address. The change only commits once that link is clicked.', 'integration-workos' ), + 'modalInputLabel' => __( 'New email address', 'integration-workos' ), + 'modalPlaceholder' => __( 'name@example.com', 'integration-workos' ), + 'modalConfirm' => __( 'Send verification', 'integration-workos' ), + 'modalCancel' => __( 'Cancel', 'integration-workos' ), + 'sending' => __( 'Sending verification…', 'integration-workos' ), + /* translators: %s: masked email (e.g. "j•••@e•••.com"). */ + 'success' => __( 'Verification email sent to %s.', 'integration-workos' ), + 'errorGeneric' => __( 'Could not start the email change. Please try again.', 'integration-workos' ), + 'invalidEmail' => __( 'Please enter a valid email address.', 'integration-workos' ), + ], + ] + ); + + wp_register_style( + self::ADMIN_STYLE_HANDLE, + $this->plugin_url . 'build/admin-change-email.css', + [], + (string) ( $asset['version'] ?? $this->version ) + ); + } + + /** + * Register the frontend confirm-page bundle. + * + * @return void + */ + private function register_confirm_assets(): void { + $asset_file = $this->plugin_path . 'build/change-email-confirm.asset.php'; + $asset = file_exists( $asset_file ) + ? require $asset_file + : [ + 'dependencies' => [ 'wp-i18n' ], + 'version' => $this->version, + ]; + + wp_register_script( + self::CONFIRM_SCRIPT_HANDLE, + $this->plugin_url . 'build/change-email-confirm.js', + (array) ( $asset['dependencies'] ?? [] ), + (string) ( $asset['version'] ?? $this->version ), + true + ); + + wp_set_script_translations( self::CONFIRM_SCRIPT_HANDLE, 'integration-workos' ); + + wp_localize_script( + self::CONFIRM_SCRIPT_HANDLE, + 'workosChangeEmailConfirm', + [ + 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/users/' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'strings' => [ + 'confirming' => __( 'Confirming…', 'integration-workos' ), + 'cancelling' => __( 'Cancelling…', 'integration-workos' ), + 'success' => __( 'Your email address has been updated.', 'integration-workos' ), + 'cancelled' => __( 'The email change has been cancelled.', 'integration-workos' ), + 'errorGeneric' => __( 'This confirmation link is no longer valid. Please request a new email change.', 'integration-workos' ), + 'continue' => __( 'Continue', 'integration-workos' ), + ], + ] + ); + + wp_register_style( + self::CONFIRM_STYLE_HANDLE, + $this->plugin_url . 'build/change-email-confirm.css', + [], + (string) ( $asset['version'] ?? $this->version ) + ); + } + + /** + * Enqueue the admin bundle on relevant admin screens. + * + * @param string $hook_suffix Admin screen hook suffix. + * + * @return void + */ + public function maybe_enqueue_admin( string $hook_suffix ): void { + $relevant = [ 'users.php', 'user-edit.php', 'profile.php' ]; + if ( ! in_array( $hook_suffix, $relevant, true ) ) { + return; + } + + wp_enqueue_script( self::ADMIN_SCRIPT_HANDLE ); + wp_enqueue_style( self::ADMIN_STYLE_HANDLE ); + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/ConflictResolver.php b/src/WorkOS/Auth/ChangeEmail/ConflictResolver.php new file mode 100644 index 0000000..1a927c4 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/ConflictResolver.php @@ -0,0 +1,219 @@ +ID === (int) $target->ID ) { + // Changing to the address you already have. No conflict; the + // REST handler treats this as a no-op and returns success + // without writing a pending record. + return null; + } + + $policy = $this->resolve_policy(); + + /** + * Fired before the policy is enforced so other features (notably + * the future merge flow in Issue 2) can observe the collision. + * + * @param int $target_user_id User whose email is changing. + * @param string $new_email Requested new address. + * @param int $conflicting_user_id Existing WP user that owns the address. + * @param string $policy Resolved policy slug. + */ + do_action( + 'workos_change_email_conflict_detected', + (int) $target->ID, + $new_email, + (int) $existing->ID, + $policy + ); + + switch ( $policy ) { + case self::POLICY_ALLOW_ORPHAN: + if ( $this->is_orphan( $existing ) ) { + return null; + } + return $this->reject( $new_email ); + + case self::POLICY_MERGE_REQUEST: + /** + * Fired when the conflict policy is `merge_request`. The + * future merge feature (Issue 2) subscribes here. Until + * that lands the request still rejects, mirroring `block`. + * + * @param int $target_user_id User whose email is changing. + * @param string $new_email Requested new address. + * @param int $conflicting_user_id Existing WP user that owns the address. + */ + do_action( + 'workos_change_email_merge_requested', + (int) $target->ID, + $new_email, + (int) $existing->ID + ); + return $this->reject( $new_email ); + + case self::POLICY_BLOCK: + default: + return $this->reject( $new_email ); + } + } + + /** + * Resolve the active conflict policy, honoring the request-time + * filter so a security plugin can force `block` regardless of stored + * settings. + * + * @return string + */ + public function resolve_policy(): string { + $option = (string) workos()->option( 'change_email_conflict_policy', self::POLICY_BLOCK ); + + /** + * Filter the change-email conflict policy at request time. + * + * @param string $policy One of block | allow_orphan | merge_request. + */ + $policy = (string) apply_filters( 'workos_change_email_conflict_policy', $option ); + + $valid = [ self::POLICY_BLOCK, self::POLICY_ALLOW_ORPHAN, self::POLICY_MERGE_REQUEST ]; + return in_array( $policy, $valid, true ) ? $policy : self::POLICY_BLOCK; + } + + /** + * Detect whether a WP user looks abandoned and can be reclaimed. + * + * Conservative on purpose: any signal of activity (posts, comments, + * a WorkOS link, a recent login record) disqualifies the user. The + * inactivity threshold defaults to 90 days; tune via the + * `workos_change_email_orphan_max_inactive_days` filter. + * + * @param WP_User $user Candidate user to check. + * + * @return bool + */ + public function is_orphan( WP_User $user ): bool { + // Linked to a WorkOS profile? Not orphaned. + if ( '' !== (string) get_user_meta( $user->ID, '_workos_user_id', true ) ) { + return false; + } + + // Authored content disqualifies — we don't want to silently + // rebind an address whose user is producing. + $post_count = count_user_posts( $user->ID, 'any', true ); + if ( $post_count > 0 ) { + return false; + } + + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $comment_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} WHERE user_id = %d", + $user->ID + ) + ); + if ( $comment_count > 0 ) { + return false; + } + + $days = (int) apply_filters( + 'workos_change_email_orphan_max_inactive_days', + self::DEFAULT_ORPHAN_MAX_INACTIVE_DAYS + ); + $days = max( 1, $days ); + + // WorkOS UserSync records last login as user meta; if it exists + // and is recent the user is not orphaned. + $last_login = (int) get_user_meta( $user->ID, '_workos_last_login_at', true ); + if ( $last_login > 0 && ( time() - $last_login ) < ( $days * DAY_IN_SECONDS ) ) { + return false; + } + + // Fall back to the user_registered date as a coarse activity + // proxy — accounts registered within the window are not yet + // candidates for takeover. + $registered = strtotime( (string) $user->user_registered . ' UTC' ); + if ( $registered && ( time() - $registered ) < ( $days * DAY_IN_SECONDS ) ) { + return false; + } + + return true; + } + + /** + * Build the consistent "conflict" error. + * + * The message is intentionally vague so that an attacker can't + * enumerate which addresses are taken. + * + * @param string $new_email Address that would have been written. + * + * @return WP_Error + */ + private function reject( string $new_email ): WP_Error { + return new WP_Error( + 'workos_change_email_conflict', + __( + 'That email cannot be used for this account.', + 'integration-workos' + ), + [ + 'status' => 409, + 'new_email' => $new_email, + ] + ); + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/Controller.php b/src/WorkOS/Auth/ChangeEmail/Controller.php new file mode 100644 index 0000000..5111279 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/Controller.php @@ -0,0 +1,77 @@ +container->singleton( TokenFactory::class ); + $this->container->singleton( PendingChange::class ); + $this->container->singleton( ConflictResolver::class ); + $this->container->singleton( Mailer::class ); + $this->container->singleton( Notifier::class ); + $this->container->singleton( RestApi::class ); + $this->container->singleton( Assets::class ); + $this->container->singleton( RowActions::class ); + $this->container->singleton( UserProfilePanel::class ); + $this->container->singleton( Shortcode::class ); + $this->container->singleton( FrontendConfirmRoute::class ); + + $this->container->get( RestApi::class )->register(); + $this->container->get( Assets::class )->register(); + $this->container->get( RowActions::class )->register(); + $this->container->get( UserProfilePanel::class )->register(); + $this->container->get( Shortcode::class )->register(); + $this->container->get( FrontendConfirmRoute::class )->register(); + } + + /** + * Unregister. + * + * @return void + */ + protected function doUnregister(): void { + } + + /** + * Master feature switch — applies the `change_email_enabled` option + * filtered through `workos_change_email_enabled`. + * + * @return bool + */ + public function isActive(): bool { + $enabled = (bool) workos()->option( 'change_email_enabled', true ); + + /** + * Filter whether the change-email feature is active. + * + * @param bool $enabled Whether the change-email feature is active. + */ + return (bool) apply_filters( 'workos_change_email_enabled', $enabled ); + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php b/src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php new file mode 100644 index 0000000..8cbb790 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/FrontendConfirmRoute.php @@ -0,0 +1,125 @@ +option( + * 'change_email_confirm_path', 'workos/change-email' )`. Changing it + * requires a rewrite-rules flush, handled here by storing the active + * value in a signature option and re-flushing when it changes. + */ +class FrontendConfirmRoute { + + public const QUERY_VAR = 'workos_change_email_confirm'; + public const SIGNATURE_OPTION = 'workos_change_email_confirm_path_signature'; + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'init', [ $this, 'register_rewrite' ] ); + add_filter( 'query_vars', [ $this, 'register_query_var' ] ); + add_action( 'template_redirect', [ $this, 'maybe_render' ] ); + } + + /** + * Register the rewrite rule for the configured confirm path. + * + * Signature-keyed: if the path option changes between requests we + * flush exactly once, then store the new signature so subsequent + * `init` ticks no-op. + * + * @return void + */ + public function register_rewrite(): void { + $path = $this->path(); + $regex = '^' . preg_quote( $path, '#' ) . '/?$'; + + add_rewrite_rule( + $regex, + 'index.php?' . self::QUERY_VAR . '=1', + 'top' + ); + + $stored = (string) get_option( self::SIGNATURE_OPTION, '' ); + if ( $stored !== $path ) { + update_option( self::SIGNATURE_OPTION, $path, false ); + // Soft flush — the rule is already added above, the flush + // just persists the rewrite cache. + flush_rewrite_rules( false ); + } + } + + /** + * Add our query var so WP exposes it via `get_query_var()`. + * + * @param array $vars Existing query vars. + * + * @return array + */ + public function register_query_var( array $vars ): array { + $vars[] = self::QUERY_VAR; + return $vars; + } + + /** + * If the current request matches the confirm route, render the page. + * + * @return void + */ + public function maybe_render(): void { + if ( '1' !== (string) get_query_var( self::QUERY_VAR ) ) { + return; + } + + // Pull token + user_id from the URL — they were appended by + // build_confirm_url() / build_cancel_url() in RestApi. + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; + $user_id = isset( $_GET['user_id'] ) ? absint( wp_unslash( $_GET['user_id'] ) ) : 0; + // phpcs:enable + + $site_name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); + + // Enqueue the page-side JS that will POST to the REST endpoint. + wp_enqueue_script( 'workos-change-email-confirm' ); + wp_enqueue_style( 'workos-change-email-confirm' ); + + $template = workos()->getDir() . 'templates/change-email/confirm-page.php'; + if ( file_exists( $template ) ) { + include $template; + exit; + } + } + + /** + * Resolve the active confirm path (trimmed, slash-free). + * + * @return string + */ + private function path(): string { + $path = (string) workos()->option( 'change_email_confirm_path', 'workos/change-email' ); + $path = trim( (string) preg_replace( '#[^a-zA-Z0-9/_-]#', '', $path ), '/' ); + return '' !== $path ? $path : 'workos/change-email'; + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/Notifier.php b/src/WorkOS/Auth/ChangeEmail/Notifier.php new file mode 100644 index 0000000..5860ffe --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/Notifier.php @@ -0,0 +1,203 @@ +mailer = $mailer; + } + + /** + * Send the verification email to the new address. + * + * @param WP_User $user Target WP user (used for context only). + * @param string $new_email New address (recipient). + * @param string $confirm_url URL the user clicks to confirm. + * @param int $expires_at Unix timestamp at which the link stops working. + * + * @return bool + */ + public function send_verification( WP_User $user, string $new_email, string $confirm_url, int $expires_at ): bool { + $site_name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); + + /* translators: %s: site name. */ + $subject = sprintf( __( 'Confirm your new email address on %s', 'integration-workos' ), $site_name ); + + return $this->mailer->send( + $new_email, + $subject, + 'change-email/verification-email', + [ + 'user' => $user, + 'new_email' => $new_email, + 'confirm_url' => $confirm_url, + 'expires_at' => $expires_at, + 'site_name' => $site_name, + 'site_url' => home_url( '/' ), + ] + ); + } + + /** + * Send the "change requested" notice to the old address. + * + * Suppressed when `change_email_notify_old_address` is false. + * + * @param WP_User $user Target WP user (old address comes from $user->user_email). + * @param string $new_email Requested new address (masked in the body). + * @param string $cancel_url URL that consumes the cancel token. + * @param int $expires_at Unix timestamp at which the change request expires. + * + * @return bool + */ + public function send_old_address_notice( WP_User $user, string $new_email, string $cancel_url, int $expires_at ): bool { + if ( ! $this->should_notify_old_address() ) { + return false; + } + + $old_email = (string) $user->user_email; + if ( '' === $old_email ) { + return false; + } + + $site_name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); + + /* translators: %s: site name. */ + $subject = sprintf( __( 'Email change requested for your %s account', 'integration-workos' ), $site_name ); + + return $this->mailer->send( + $old_email, + $subject, + 'change-email/old-address-notice', + [ + 'user' => $user, + 'old_email' => $old_email, + 'new_email' => $new_email, + 'masked_new_email' => $this->mask_email( $new_email ), + 'cancel_url' => $cancel_url, + 'expires_at' => $expires_at, + 'site_name' => $site_name, + 'site_url' => home_url( '/' ), + ] + ); + } + + /** + * Send the post-commit confirmation to the (now former) old address. + * + * @param WP_User $user Target WP user (now holds the new email). + * @param string $old_email Previous address (recipient). + * @param string $new_email New address (masked in the body). + * + * @return bool + */ + public function send_confirmation_notice( WP_User $user, string $old_email, string $new_email ): bool { + if ( ! $this->should_notify_old_address() ) { + return false; + } + + if ( '' === $old_email ) { + return false; + } + + $site_name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); + + /* translators: %s: site name. */ + $subject = sprintf( __( 'Your email address on %s was changed', 'integration-workos' ), $site_name ); + + return $this->mailer->send( + $old_email, + $subject, + 'change-email/confirmation-notice', + [ + 'user' => $user, + 'old_email' => $old_email, + 'new_email' => $new_email, + 'masked_new_email' => $this->mask_email( $new_email ), + 'site_name' => $site_name, + 'site_url' => home_url( '/' ), + ] + ); + } + + /** + * Whether the old address should receive notices, gated by the + * `change_email_notify_old_address` setting. + * + * @return bool + */ + private function should_notify_old_address(): bool { + $enabled = (bool) workos()->option( 'change_email_notify_old_address', true ); + + /** + * Filter whether change-email notices fan out to the old address. + * + * @param bool $enabled + */ + return (bool) apply_filters( 'workos_change_email_notify_old_address', $enabled ); + } + + /** + * Mask an email for inclusion in user-visible bodies. + * + * Mirrors the masking used by PasswordResetAdmin so notices look + * the same to end users (j•••@e•••.com). + * + * @param string $email Address. + * + * @return string Masked form. + */ + private function mask_email( string $email ): string { + $at = strpos( $email, '@' ); + if ( false === $at || $at < 1 ) { + return '•••'; + } + + $local = substr( $email, 0, $at ); + $domain = substr( $email, $at + 1 ); + + $local_mask = ( $local[0] ?? '' ) . str_repeat( '•', max( 1, strlen( $local ) - 1 ) ); + $dot = strrpos( $domain, '.' ); + $domain_mask = false === $dot + ? ( $domain[0] ?? '' ) . str_repeat( '•', max( 1, strlen( $domain ) - 1 ) ) + : ( $domain[0] ?? '' ) . str_repeat( '•', max( 1, $dot - 1 ) ) . substr( $domain, $dot ); + + return $local_mask . '@' . $domain_mask; + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/PendingChange.php b/src/WorkOS/Auth/ChangeEmail/PendingChange.php new file mode 100644 index 0000000..08bffd1 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/PendingChange.php @@ -0,0 +1,154 @@ +tokens = $tokens; + } + + /** + * Persist a new pending change. + * + * @param int $user_id Target WP user ID. + * @param string $new_email Lowercased, sanitized new email. + * @param string $confirm_token Plaintext confirm token (hashed before storage). + * @param string $cancel_token Plaintext cancel token (hashed before storage). + * @param int $expires_at Unix timestamp at which the confirm token expires. + * @param int $initiated_by WP user ID of the initiator (0 for system). + * + * @return void + */ + public function store( + int $user_id, + string $new_email, + string $confirm_token, + string $cancel_token, + int $expires_at, + int $initiated_by + ): void { + update_user_meta( + $user_id, + self::META_KEY, + [ + 'new_email' => $new_email, + 'token_hash' => $this->tokens->hash( $confirm_token ), + 'cancel_token_hash' => $this->tokens->hash( $cancel_token ), + 'expires_at' => $expires_at, + 'initiated_by' => $initiated_by, + 'initiated_at' => time(), + ] + ); + } + + /** + * Load the stored pending change for a user, or null if none exists. + * + * @param int $user_id WP user ID. + * + * @return array{new_email:string,token_hash:string,cancel_token_hash:string,expires_at:int,initiated_by:int,initiated_at:int}|null + */ + public function get( int $user_id ): ?array { + $stored = get_user_meta( $user_id, self::META_KEY, true ); + if ( ! is_array( $stored ) ) { + return null; + } + + // Defensive: only return well-formed records. Anything missing a + // required field is treated as "no pending change" rather than + // silently authenticating a half-filled meta row. + foreach ( [ 'new_email', 'token_hash', 'cancel_token_hash', 'expires_at' ] as $required ) { + if ( ! isset( $stored[ $required ] ) ) { + return null; + } + } + + return [ + 'new_email' => (string) $stored['new_email'], + 'token_hash' => (string) $stored['token_hash'], + 'cancel_token_hash' => (string) $stored['cancel_token_hash'], + 'expires_at' => (int) $stored['expires_at'], + 'initiated_by' => (int) ( $stored['initiated_by'] ?? 0 ), + 'initiated_at' => (int) ( $stored['initiated_at'] ?? 0 ), + ]; + } + + /** + * Check whether a stored record is past its expiry. + * + * @param array $record Record as returned by {@see get()}. + * + * @return bool + */ + public function expired( array $record ): bool { + return (int) ( $record['expires_at'] ?? 0 ) <= time(); + } + + /** + * Verify a candidate confirm token against the stored record. + * + * @param array $record Record as returned by {@see get()}. + * @param string $candidate Plaintext token. + * + * @return bool + */ + public function verify_confirm( array $record, string $candidate ): bool { + return $this->tokens->verify( $candidate, (string) ( $record['token_hash'] ?? '' ) ); + } + + /** + * Verify a candidate cancel token against the stored record. + * + * @param array $record Record as returned by {@see get()}. + * @param string $candidate Plaintext token. + * + * @return bool + */ + public function verify_cancel( array $record, string $candidate ): bool { + return $this->tokens->verify( $candidate, (string) ( $record['cancel_token_hash'] ?? '' ) ); + } + + /** + * Clear the pending change for a user. + * + * @param int $user_id WP user ID. + * + * @return void + */ + public function clear( int $user_id ): void { + delete_user_meta( $user_id, self::META_KEY ); + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/RestApi.php b/src/WorkOS/Auth/ChangeEmail/RestApi.php new file mode 100644 index 0000000..b97f8be --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -0,0 +1,733 @@ +api()->update_user()` to push the change. + * 4. Mirrors into WordPress with `wp_update_user()`. + * 5. Clears the transient and the pending meta. + * + * The transient TTL is 60 seconds — long enough to outlast the WorkOS + * round-trip + the webhook's typical fan-in window, short enough that + * an orphaned transient can't wedge sync indefinitely. + */ +class RestApi { + + public const NAMESPACE = 'workos/v1'; + public const TRANSIENT_PREFIX = '_workos_email_change_in_progress_'; + public const TRANSIENT_TTL = 60; + private const RATE_LIMIT_DEFAULT_USER = 3; + private const RATE_LIMIT_DEFAULT_IP = 10; + private const RATE_LIMIT_DEFAULT_WIN = 3600; + + /** + * Rate limiter. + * + * @var RateLimiter + */ + private RateLimiter $rate_limiter; + + /** + * Token factory. + * + * @var TokenFactory + */ + private TokenFactory $tokens; + + /** + * Pending-change storage. + * + * @var PendingChange + */ + private PendingChange $pending; + + /** + * Conflict resolver. + * + * @var ConflictResolver + */ + private ConflictResolver $conflicts; + + /** + * Notifier. + * + * @var Notifier + */ + private Notifier $notifier; + + /** + * Constructor. + * + * Redirect-URL validation is done inline in {@see validate_redirect()} + * rather than via the PasswordResetAdmin `RedirectValidator` because + * that helper requires a `Profile` (which doesn't apply to the + * change-email flow). The same same-host policy is enforced. + * + * @param RateLimiter $rate_limiter Rate limiter. + * @param TokenFactory $tokens Token factory. + * @param PendingChange $pending Pending-change storage. + * @param ConflictResolver $conflicts Conflict resolver. + * @param Notifier $notifier Email notifier. + */ + public function __construct( + RateLimiter $rate_limiter, + TokenFactory $tokens, + PendingChange $pending, + ConflictResolver $conflicts, + Notifier $notifier + ) { + $this->rate_limiter = $rate_limiter; + $this->tokens = $tokens; + $this->pending = $pending; + $this->conflicts = $conflicts; + $this->notifier = $notifier; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + + /** + * Register REST routes. + * + * @return void + */ + public function register_routes(): void { + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/email-change', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'initiate' ], + 'permission_callback' => [ $this, 'check_permission_initiate' ], + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + 'new_email' => [ 'sanitize_callback' => 'sanitize_email' ], + 'redirect_url' => [ 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/email-change/confirm', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'confirm' ], + // Token presence is the authentication — public route by design, + // the token-hash check inside is the real gatekeeper. + 'permission_callback' => '__return_true', + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + 'token' => [ 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/email-change/cancel', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'cancel' ], + // Permission may be granted via edit_user OR a valid cancel + // token; the handler resolves both paths. + 'permission_callback' => '__return_true', + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + 'token' => [ 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] + ); + } + + /** + * Permission callback for initiate — `edit_user` on the target. + * + * @param WP_REST_Request $request REST request. + * + * @return true|WP_Error + */ + public function check_permission_initiate( WP_REST_Request $request ) { + $target_id = absint( $request['id'] ?? 0 ); + if ( $target_id <= 0 ) { + return new WP_Error( + 'workos_invalid_user', + __( 'Invalid user ID.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + if ( ! current_user_can( 'edit_user', $target_id ) ) { + return new WP_Error( + 'workos_forbidden', + __( 'You do not have permission to change this user’s email.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + /** + * Filter whether the current request can initiate a change for + * a given target. + * + * @param bool $allowed Whether the request is allowed. + * @param int $target_id Target user ID. + * @param int $initiator_id Current user ID (0 for unauthenticated). + */ + $allowed = (bool) apply_filters( + 'workos_change_email_can_initiate', + true, + $target_id, + get_current_user_id() + ); + if ( ! $allowed ) { + return new WP_Error( + 'workos_forbidden', + __( 'You do not have permission to change this user’s email.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + return true; + } + + /** + * Initiate a pending email change. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function initiate( WP_REST_Request $request ) { + $target_id = absint( $request['id'] ); + $user = get_userdata( $target_id ); + if ( ! $user instanceof WP_User ) { + return new WP_Error( + 'workos_user_not_found', + __( 'User not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + $new_email = sanitize_email( (string) $request->get_param( 'new_email' ) ); + if ( '' === $new_email || ! is_email( $new_email ) ) { + return new WP_Error( + 'workos_invalid_email', + __( 'A valid email address is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + $new_email = strtolower( $new_email ); + + $ip = $this->rate_limiter->client_ip(); + $user_count = (int) workos()->option( 'change_email_rate_limit_user_count', self::RATE_LIMIT_DEFAULT_USER ); + $user_window = (int) workos()->option( 'change_email_rate_limit_user_window', self::RATE_LIMIT_DEFAULT_WIN ); + $ip_count = (int) workos()->option( 'change_email_rate_limit_ip_count', self::RATE_LIMIT_DEFAULT_IP ); + $ip_window = (int) workos()->option( 'change_email_rate_limit_ip_window', self::RATE_LIMIT_DEFAULT_WIN ); + + $rate_ok = $this->rate_limiter->attempt( 'change_email_init_ip', $ip, $ip_count, $ip_window ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + $rate_ok = $this->rate_limiter->attempt( 'change_email_init_user', (string) $user->ID, $user_count, $user_window ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + // Treat "change to the address I already have" as a benign no-op + // rather than an error, but also don't ship a verification email + // — there's nothing to verify. + if ( strcasecmp( $new_email, (string) $user->user_email ) === 0 ) { + return new WP_REST_Response( + [ + 'ok' => true, + 'masked_new_email' => $this->mask_email( $new_email ), + 'no_op' => true, + ], + 200 + ); + } + + $conflict = $this->conflicts->check( $new_email, $user ); + if ( is_wp_error( $conflict ) ) { + EventLogger::log( + 'email_change.conflict_blocked', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( $new_email ), + 'policy' => $this->conflicts->resolve_policy(), + 'initiator_id' => get_current_user_id(), + ], + ] + ); + + // Enumeration-safe: same shape as success. The conflict is + // surfaced in the activity log, not in the response. + return new WP_REST_Response( + [ + 'ok' => true, + 'masked_new_email' => $this->mask_email( $new_email ), + ], + 200 + ); + } + + $lifetime = $this->token_lifetime(); + $expires = time() + $lifetime; + + $confirm_token = $this->tokens->generate(); + $cancel_token = $this->tokens->generate(); + + $this->pending->store( + (int) $user->ID, + $new_email, + $confirm_token, + $cancel_token, + $expires, + get_current_user_id() + ); + + $redirect_url = (string) $request->get_param( 'redirect_url' ); + $confirm_url = $this->build_confirm_url( (int) $user->ID, $confirm_token, $redirect_url ); + $cancel_url = $this->build_cancel_url( (int) $user->ID, $cancel_token ); + + $this->notifier->send_verification( $user, $new_email, $confirm_url, $expires ); + $this->notifier->send_old_address_notice( $user, $new_email, $cancel_url, $expires ); + + EventLogger::log( + 'email_change.initiated', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( $new_email ), + 'initiator_id' => get_current_user_id(), + 'self_service' => get_current_user_id() === (int) $user->ID, + 'expires_at' => $expires, + ], + ] + ); + + /** + * Fires after a pending email change is stored and notifications sent. + * + * @param int $user_id Target WP user ID. + * @param string $new_email Requested new address. + * @param int $initiated_by Current user ID (0 for system). + */ + do_action( 'workos_change_email_initiated', (int) $user->ID, $new_email, get_current_user_id() ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'masked_new_email' => $this->mask_email( $new_email ), + 'expires_at' => $expires, + ], + 200 + ); + } + + /** + * Confirm a pending email change. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function confirm( WP_REST_Request $request ) { + $target_id = absint( $request['id'] ); + $token = (string) $request->get_param( 'token' ); + if ( $target_id <= 0 || '' === $token ) { + return new WP_Error( + 'workos_invalid_request', + __( 'Invalid confirmation request.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $user = get_userdata( $target_id ); + if ( ! $user instanceof WP_User ) { + return new WP_Error( + 'workos_invalid_token', + __( 'This confirmation link is no longer valid.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $record = $this->pending->get( (int) $user->ID ); + if ( null === $record ) { + return new WP_Error( + 'workos_invalid_token', + __( 'This confirmation link is no longer valid.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + if ( $this->pending->expired( $record ) ) { + $this->pending->clear( (int) $user->ID ); + EventLogger::log( + 'email_change.expired', + [ + 'user_id' => $user->ID, + 'metadata' => [ 'masked_new_email' => $this->mask_email( $record['new_email'] ) ], + ] + ); + return new WP_Error( + 'workos_token_expired', + __( 'This confirmation link has expired. Please start the change again.', 'integration-workos' ), + [ 'status' => 410 ] + ); + } + + if ( ! $this->pending->verify_confirm( $record, $token ) ) { + return new WP_Error( + 'workos_invalid_token', + __( 'This confirmation link is no longer valid.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $new_email = (string) $record['new_email']; + + // Race re-check: another local user may have started using + // $new_email between initiate and confirm. + $conflict = $this->conflicts->check( $new_email, $user ); + if ( is_wp_error( $conflict ) ) { + $this->pending->clear( (int) $user->ID ); + EventLogger::log( + 'email_change.conflict_blocked', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( $new_email ), + 'phase' => 'confirm', + 'policy' => $this->conflicts->resolve_policy(), + ], + ] + ); + return $conflict; + } + + $old_email = (string) $user->user_email; + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + + // Set the in-progress transient BEFORE we touch WorkOS so the + // user.updated webhook fan-back is a no-op while we own the + // transition. {@see UserSync::handle_user_updated()}. + set_transient( self::TRANSIENT_PREFIX . (int) $user->ID, 1, self::TRANSIENT_TTL ); + + if ( '' !== $workos_user_id ) { + $workos_response = workos()->api()->update_user( $workos_user_id, [ 'email' => $new_email ] ); + if ( is_wp_error( $workos_response ) ) { + delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); + EventLogger::log( + 'email_change.commit_failed', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( $new_email ), + 'reason' => $workos_response->get_error_message(), + ], + ] + ); + return new WP_Error( + 'workos_commit_failed', + __( 'Could not update the email at WorkOS. Please try again.', 'integration-workos' ), + [ 'status' => 502 ] + ); + } + } + + $wp_update = wp_update_user( + [ + 'ID' => (int) $user->ID, + 'user_email' => $new_email, + ] + ); + + if ( is_wp_error( $wp_update ) ) { + // Rollback WorkOS so the two stores don't drift. + if ( '' !== $workos_user_id && '' !== $old_email ) { + workos()->api()->update_user( $workos_user_id, [ 'email' => $old_email ] ); + } + delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); + EventLogger::log( + 'email_change.commit_failed', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( $new_email ), + 'reason' => $wp_update->get_error_message(), + 'rolled_back' => true, + ], + ] + ); + return new WP_Error( + 'workos_commit_failed', + __( 'Could not update the email locally. Please try again.', 'integration-workos' ), + [ 'status' => 500 ] + ); + } + + $this->pending->clear( (int) $user->ID ); + delete_transient( self::TRANSIENT_PREFIX . (int) $user->ID ); + + // Refresh user object now that the email is committed. + $user = get_userdata( (int) $user->ID ); + if ( $user instanceof WP_User ) { + $this->notifier->send_confirmation_notice( $user, $old_email, $new_email ); + } + + EventLogger::log( + 'email_change.confirmed', + [ + 'user_id' => $target_id, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( $new_email ), + 'masked_old_email' => $this->mask_email( $old_email ), + ], + ] + ); + + /** + * Fires after an email change is committed to WorkOS + WP. + * + * @param int $user_id Target WP user ID. + * @param string $old_email Previous email. + * @param string $new_email New email. + */ + do_action( 'workos_change_email_confirmed', $target_id, $old_email, $new_email ); + + $redirect_url = $this->validate_redirect( (string) $request->get_param( 'redirect_url' ) ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'redirect_url' => $redirect_url, + ], + 200 + ); + } + + /** + * Cancel a pending email change. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function cancel( WP_REST_Request $request ) { + $target_id = absint( $request['id'] ); + $user = get_userdata( $target_id ); + if ( ! $user instanceof WP_User ) { + return new WP_Error( + 'workos_user_not_found', + __( 'User not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + $record = $this->pending->get( (int) $user->ID ); + if ( null === $record ) { + // Nothing to cancel — treat as success so a double-click on + // the cancel link doesn't look like an error. + return new WP_REST_Response( [ 'ok' => true ], 200 ); + } + + $token = (string) $request->get_param( 'token' ); + $by_token = '' !== $token && $this->pending->verify_cancel( $record, $token ); + $by_capability = current_user_can( 'edit_user', $user->ID ); + + if ( ! $by_token && ! $by_capability ) { + return new WP_Error( + 'workos_forbidden', + __( 'You do not have permission to cancel this email change.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + $this->pending->clear( (int) $user->ID ); + + EventLogger::log( + 'email_change.cancelled', + [ + 'user_id' => $user->ID, + 'metadata' => [ + 'masked_new_email' => $this->mask_email( (string) $record['new_email'] ), + 'reason' => $by_token ? 'token' : 'capability', + ], + ] + ); + + /** + * Fires after a pending change is cancelled. + * + * @param int $user_id Target WP user ID. + * @param string $reason 'token' or 'capability'. + */ + do_action( 'workos_change_email_cancelled', (int) $user->ID, $by_token ? 'token' : 'capability' ); + + return new WP_REST_Response( [ 'ok' => true ], 200 ); + } + + /** + * Resolve the configured token lifetime (clamped to [300, 86400]). + * + * @return int + */ + private function token_lifetime(): int { + $lifetime = (int) workos()->option( 'change_email_token_lifetime', 3600 ); + + /** + * Filter the change-email token lifetime in seconds. + * + * @param int $lifetime Lifetime in seconds. + */ + $lifetime = (int) apply_filters( 'workos_change_email_token_lifetime', $lifetime ); + + // Defensive clamp so a misconfiguration can't issue infinite or + // 1-second tokens. + return max( 300, min( 86400, $lifetime ) ); + } + + /** + * Build the frontend confirm URL for an emailed link. + * + * @param int $user_id Target user ID. + * @param string $token Plaintext confirm token. + * @param string $redirect_url Optional same-host redirect after success. + * + * @return string + */ + private function build_confirm_url( int $user_id, string $token, string $redirect_url ): string { + $path = (string) workos()->option( 'change_email_confirm_path', 'workos/change-email' ); + $path = trim( $path, '/' ); + + $base = home_url( '/' . $path . '/' ); + + $args = [ + 'user_id' => $user_id, + 'token' => $token, + ]; + if ( '' !== $redirect_url ) { + $args['redirect_to'] = $redirect_url; + } + + return add_query_arg( $args, $base ); + } + + /** + * Build the cancel URL emailed to the old address. + * + * The cancel URL points at the same frontend confirm route with + * `action=cancel`, which the page-side JS picks up to POST to the + * cancel endpoint instead of confirm. + * + * @param int $user_id Target user ID. + * @param string $token Plaintext cancel token. + * + * @return string + */ + private function build_cancel_url( int $user_id, string $token ): string { + $path = (string) workos()->option( 'change_email_confirm_path', 'workos/change-email' ); + $path = trim( $path, '/' ); + + $base = home_url( '/' . $path . '/' ); + + return add_query_arg( + [ + 'user_id' => $user_id, + 'token' => $token, + 'action' => 'cancel', + ], + $base + ); + } + + /** + * Validate a redirect URL against site host, falling back to home. + * + * @param string $url Raw URL. + * + * @return string + */ + private function validate_redirect( string $url ): string { + if ( '' === $url ) { + return home_url( '/' ); + } + + $candidate = wp_validate_redirect( $url, '' ); + if ( '' === $candidate ) { + return home_url( '/' ); + } + + $url_host = strtolower( (string) wp_parse_url( $candidate, PHP_URL_HOST ) ); + $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); + + return $url_host === $site_host ? $candidate : home_url( '/' ); + } + + /** + * Mask an email for activity-log + response surfacing. + * + * @param string $email Address. + * + * @return string + */ + private function mask_email( string $email ): string { + $at = strpos( $email, '@' ); + if ( false === $at || $at < 1 ) { + return '•••'; + } + + $local = substr( $email, 0, $at ); + $domain = substr( $email, $at + 1 ); + + $local_mask = ( $local[0] ?? '' ) . str_repeat( '•', max( 1, strlen( $local ) - 1 ) ); + $dot = strrpos( $domain, '.' ); + $domain_mask = false === $dot + ? ( $domain[0] ?? '' ) . str_repeat( '•', max( 1, strlen( $domain ) - 1 ) ) + : ( $domain[0] ?? '' ) . str_repeat( '•', max( 1, $dot - 1 ) ) . substr( $domain, $dot ); + + return $local_mask . '@' . $domain_mask; + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/RowActions.php b/src/WorkOS/Auth/ChangeEmail/RowActions.php new file mode 100644 index 0000000..6d07ce8 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/RowActions.php @@ -0,0 +1,59 @@ + $actions Existing action HTML keyed by slug. + * @param int $user_id WordPress user ID. + * @param string $workos_id Linked WorkOS user ID. + * + * @return array + */ + public function add_action( array $actions, int $user_id, string $workos_id ): array { + if ( '' === $workos_id ) { + return $actions; + } + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + return $actions; + } + + $actions['workos_change_email'] = sprintf( + '%s', + (int) $user_id, + esc_html__( 'Change email', 'integration-workos' ) + ); + + return $actions; + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/Shortcode.php b/src/WorkOS/Auth/ChangeEmail/Shortcode.php new file mode 100644 index 0000000..3cbd2f2 --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/Shortcode.php @@ -0,0 +1,136 @@ +"` — admin-of-other mode. Visible only to viewers + * with `edit_user` on the target. + * - no `user` attribute — self-service: the form pre-targets the + * logged-in user. + * + * In both cases submit fires `POST /workos/v1/users/{id}/email-change`, + * which validates the redirect_url, enforces capability, and + * rate-limits. + * + * Attributes: + * - user="42" or user="jane@example.com" — target user (omit for self-service) + * - redirect_url="/welcome" — same-host URL after the change is confirmed + * - label="Update my email" — button label + */ +class Shortcode { + + public const TAG = 'workos:change-email'; + + /** + * Register the shortcode. + * + * @return void + */ + public function register(): void { + add_shortcode( self::TAG, [ $this, 'render' ] ); + } + + /** + * Shortcode callback. + * + * @param array $atts Shortcode attributes. + * + * @return string + */ + public function render( $atts ): string { + $atts = shortcode_atts( + [ + 'user' => '', + 'redirect_url' => '', + 'label' => '', + ], + is_array( $atts ) ? $atts : [], + self::TAG + ); + + $target = $this->resolve_target( (string) $atts['user'] ); + if ( 0 === $target ) { + return ''; + } + + if ( ! current_user_can( 'edit_user', $target ) ) { + return ''; + } + + if ( '' === (string) get_user_meta( $target, '_workos_user_id', true ) ) { + return ''; + } + + wp_enqueue_script( Assets::ADMIN_SCRIPT_HANDLE ); + wp_enqueue_style( Assets::ADMIN_STYLE_HANDLE ); + + $label = (string) $atts['label']; + if ( '' === $label ) { + $label = get_current_user_id() === $target + ? __( 'Change my email', 'integration-workos' ) + : __( 'Change email', 'integration-workos' ); + } + + ob_start(); + ?> +
+

+ + +

+

+ +

+
+
+ ID : 0; + } + + return 0; + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/TokenFactory.php b/src/WorkOS/Auth/ChangeEmail/TokenFactory.php new file mode 100644 index 0000000..15c03ff --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/TokenFactory.php @@ -0,0 +1,68 @@ +hash( $candidate ) ); + } +} diff --git a/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php b/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php new file mode 100644 index 0000000..be3180e --- /dev/null +++ b/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php @@ -0,0 +1,67 @@ +ID ) ) { + return; + } + + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return; + } + + printf( '

%s

', esc_html__( 'Change Email', 'integration-workos' ) ); + + printf( + '

%s

', + esc_html__( + 'Send a verification email to a new address. The change only commits after the new address is confirmed.', + 'integration-workos' + ) + ); + + printf( + '

', + (int) $user->ID, + esc_html__( 'Change email…', 'integration-workos' ) + ); + } +} diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Assets.php b/src/WorkOS/Auth/PasswordResetAdmin/Assets.php index 11be2c9..71ca485 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/Assets.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/Assets.php @@ -95,7 +95,10 @@ public function register_assets(): void { 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/admin/users/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'strings' => [ - 'confirm' => __( "Send a password reset email to this user?\n\nThey will receive a link from WorkOS to set a new password.", 'integration-workos' ), + 'modalTitle' => __( 'Send password reset?', 'integration-workos' ), + 'modalMessage' => __( 'The user will receive a link from WorkOS to set a new password.', 'integration-workos' ), + 'modalConfirm' => __( 'Send reset email', 'integration-workos' ), + 'modalCancel' => __( 'Cancel', 'integration-workos' ), 'sending' => __( 'Sending…', 'integration-workos' ), /* translators: %s: masked email address (e.g. "j•••@e•••.com"). */ 'success' => __( 'Password reset email sent to %s.', 'integration-workos' ), diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index 3586ccd..d3174b8 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -15,6 +15,7 @@ use WorkOS\Auth\AuthKit\Controller as AuthKitController; use WorkOS\Auth\Controller as AuthController; use WorkOS\Auth\PasswordResetAdmin\Controller as PasswordResetAdminController; +use WorkOS\Auth\ChangeEmail\Controller as ChangeEmailController; use WorkOS\REST\Controller as RESTController; use WorkOS\Webhook\Controller as WebhookController; use WorkOS\Sync\Controller as SyncController; @@ -40,6 +41,7 @@ protected function doRegister(): void { $this->container->register( UsersAdminController::class ); $this->container->register( AuthController::class ); $this->container->register( PasswordResetAdminController::class ); + $this->container->register( ChangeEmailController::class ); $this->container->register( RESTController::class ); $this->container->register( WebhookController::class ); $this->container->register( SyncController::class ); diff --git a/src/WorkOS/Email/Mailer.php b/src/WorkOS/Email/Mailer.php new file mode 100644 index 0000000..986a077 --- /dev/null +++ b/src/WorkOS/Email/Mailer.php @@ -0,0 +1,165 @@ +getDir()}/templates/ and ships + * them out via `wp_mail()` with HTML headers + a configurable from + * address. + * + * Plugin-side transactional emails (today: the change-email flow) + * deliberately do NOT use the WorkOS-hosted email infrastructure + * because the verification step is owned by WordPress — see the + * commentary in {@see \WorkOS\Auth\ChangeEmail\Controller} for the + * design choice. + * + * Output is filterable at every seam so a site can theme the email or + * route it through a different transport (e.g. Postmark/SendGrid via + * `wp_mail` overrides). + */ +class Mailer { + + /** + * Send a templated HTML email. + * + * @param string $to Recipient address. + * @param string $subject Email subject line. + * @param string $template Template basename under templates/ (no `.php`). + * @param array $context Template context vars. + * + * @return bool True when wp_mail accepted the message. + */ + public function send( string $to, string $subject, string $template, array $context ): bool { + $body = $this->render( $template, $context ); + if ( '' === $body ) { + return false; + } + + $headers = $this->build_headers( $template, $context ); + + /** + * Filter the rendered email body before sending. + * + * @param string $body Rendered HTML body. + * @param string $template Template basename. + * @param array $context Template context. + */ + $body = (string) apply_filters( 'workos_email_body', $body, $template, $context ); + + /** + * Filter the email subject line before sending. + * + * @param string $subject Subject line. + * @param string $template Template basename. + * @param array $context Template context. + */ + $subject = (string) apply_filters( 'workos_email_subject', $subject, $template, $context ); + + return (bool) wp_mail( $to, $subject, $body, $headers ); + } + + /** + * Render a template to a string. + * + * Templates are PHP files. The `$context` array is extracted into + * the local scope before inclusion — keys that collide with + * WordPress globals are skipped via `EXTR_SKIP`. + * + * @param string $template Template basename (e.g. `change-email/verification-email`). + * @param array $context Template context. + * + * @return string + */ + public function render( string $template, array $context ): string { + $path = $this->locate( $template ); + if ( '' === $path ) { + return ''; + } + + ob_start(); + // phpcs:ignore WordPress.PHP.DontExtract.extract_extract -- Template helper; keys collisioning with globals are skipped via EXTR_SKIP. + extract( $context, EXTR_SKIP ); + include $path; + return (string) ob_get_clean(); + } + + /** + * Resolve a template basename to an absolute filesystem path. + * + * Allows themes / mu-plugins to override a template by placing a + * file at `wp-content/themes/{theme}/integration-workos/{template}.php`, + * mirroring the standard WP template-override pattern. + * + * @param string $template Basename (without `.php`). + * + * @return string Absolute path, or '' when no candidate exists. + */ + private function locate( string $template ): string { + $relative = ltrim( $template, '/' ) . '.php'; + + $theme_override = locate_template( 'integration-workos/' . $relative ); + if ( '' !== $theme_override && file_exists( $theme_override ) ) { + return $theme_override; + } + + $plugin_path = workos()->getDir() . 'templates/' . $relative; + if ( file_exists( $plugin_path ) ) { + return $plugin_path; + } + + return ''; + } + + /** + * Build the headers array (Content-Type + From). + * + * @param string $template Template basename. + * @param array $context Template context. + * + * @return array + */ + private function build_headers( string $template, array $context ): array { + $site_name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); + $from = sprintf( '%s <%s>', $site_name, $this->from_address() ); + + $headers = [ + 'Content-Type: text/html; charset=UTF-8', + 'From: ' . $from, + ]; + + /** + * Filter the headers used for plugin-sent emails. + * + * @param array $headers Header lines. + * @param string $template Template basename. + * @param array $context Template context. + */ + return (array) apply_filters( 'workos_email_headers', $headers, $template, $context ); + } + + /** + * Pick a reasonable from-address. + * + * Filterable via the WP-standard `wp_mail_from` so existing email + * routing plugins continue to work without a WorkOS-specific config. + * + * @return string + */ + private function from_address(): string { + $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); + $default = (string) get_option( 'admin_email', 'wordpress@' . ( '' !== $host ? $host : 'localhost' ) ); + + // `wp_mail_from` is a WP core filter — we honor it so existing + // transport plugins continue to work. PHPCS doesn't recognize core + // filter names as "allowed unprefixed" because they aren't. + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + return (string) apply_filters( 'wp_mail_from', $default ); + } +} diff --git a/src/WorkOS/Options/Production.php b/src/WorkOS/Options/Production.php index f24b0fc..dba0f63 100644 --- a/src/WorkOS/Options/Production.php +++ b/src/WorkOS/Options/Production.php @@ -41,6 +41,18 @@ protected function defaults(): array { 'redirect_first_login_only' => true, 'logout_redirect_urls' => [], 'audit_logging_enabled' => false, + // Change-email feature (see src/WorkOS/Auth/ChangeEmail). + 'change_email_enabled' => true, + 'change_email_conflict_policy' => 'block', + 'change_email_token_lifetime' => 3600, + 'change_email_rate_limit_user_count' => 3, + 'change_email_rate_limit_user_window' => 3600, + 'change_email_rate_limit_ip_count' => 10, + 'change_email_rate_limit_ip_window' => 3600, + 'change_email_notify_old_address' => true, + 'change_email_require_reauth' => true, + 'change_email_admin_bypass_verification' => false, + 'change_email_confirm_path' => 'workos/change-email', ]; } } diff --git a/src/WorkOS/Options/Staging.php b/src/WorkOS/Options/Staging.php index 2353a3b..55af2aa 100644 --- a/src/WorkOS/Options/Staging.php +++ b/src/WorkOS/Options/Staging.php @@ -41,6 +41,18 @@ protected function defaults(): array { 'redirect_first_login_only' => true, 'logout_redirect_urls' => [], 'audit_logging_enabled' => false, + // Change-email feature (see src/WorkOS/Auth/ChangeEmail). + 'change_email_enabled' => true, + 'change_email_conflict_policy' => 'block', + 'change_email_token_lifetime' => 3600, + 'change_email_rate_limit_user_count' => 3, + 'change_email_rate_limit_user_window' => 3600, + 'change_email_rate_limit_ip_count' => 10, + 'change_email_rate_limit_ip_window' => 3600, + 'change_email_notify_old_address' => true, + 'change_email_require_reauth' => true, + 'change_email_admin_bypass_verification' => false, + 'change_email_confirm_path' => 'workos/change-email', ]; } } diff --git a/src/WorkOS/Sync/UserSync.php b/src/WorkOS/Sync/UserSync.php index 2c66908..7fd7894 100644 --- a/src/WorkOS/Sync/UserSync.php +++ b/src/WorkOS/Sync/UserSync.php @@ -344,6 +344,17 @@ public function handle_user_updated( array $event ): void { return; } + // Race guard for the WP-side email-change flow: while our confirm + // handler is mid-commit it writes to WorkOS first and then mirrors + // the change into WP. The webhook fan-back from that WorkOS write + // would otherwise race the local wp_update_user() and could + // re-trigger the very mutation that already happened. The transient + // is set in ChangeEmail\RestApi::confirm() and cleared once the + // local write succeeds (or on rollback). + if ( get_transient( \WorkOS\Auth\ChangeEmail\RestApi::TRANSIENT_PREFIX . $wp_user_id ) ) { + return; + } + // Check if profile actually changed. $current_hash = get_user_meta( $wp_user_id, '_workos_profile_hash', true ); $new_hash = self::hash_profile( $workos_user ); diff --git a/src/js/admin-change-email/index.ts b/src/js/admin-change-email/index.ts new file mode 100644 index 0000000..59d3f50 --- /dev/null +++ b/src/js/admin-change-email/index.ts @@ -0,0 +1,250 @@ +/** + * Click handler for the "Change email" admin trigger and the + * `[workos:change-email]` self-service shortcode. + * + * Two entry shapes share one delegated handler: + * + * - Standalone trigger button (admin row action / profile panel) — the + * handler opens a WP-styled modal asking for the new email address. + * - Form trigger inside a `.workos-change-email-form` — the handler + * pulls the new address from the form's `.workos-change-email-input`. + */ + +import { __, sprintf } from '@wordpress/i18n'; +import { promptModal } from '../shared/modal'; +import './styles.css'; + +/** + * Runtime config injected via `wp_localize_script()` on `window.workosChangeEmail`. + * + * Mirrors the shape produced by {@link \WorkOS\Auth\ChangeEmail\Assets::register_admin_assets()}. + */ +interface ChangeEmailConfig { + /** Base REST URL — endpoint is `${restUrl}{id}/email-change`. */ + restUrl: string; + /** WP REST nonce (`wp_rest`). */ + nonce: string; + /** Pre-translated UI strings. */ + strings: { + modalTitle: string; + modalMessage: string; + modalInputLabel: string; + modalPlaceholder: string; + modalConfirm: string; + modalCancel: string; + sending: string; + success: string; + errorGeneric: string; + invalidEmail: string; + }; +} + +interface SuccessResponse { + ok: true; + masked_new_email?: string; + expires_at?: number; + no_op?: boolean; +} + +interface ErrorResponse { + code?: string; + message?: string; +} + +declare global { + interface Window { + workosChangeEmail?: ChangeEmailConfig; + } +} + +function getConfig(): ChangeEmailConfig | null { + return window.workosChangeEmail ?? null; +} + +/** Loose RFC-style email check — server is authoritative. */ +function isLikelyEmail( value: string ): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( value ); +} + +/** + * Render a transient WP admin notice. Mirrors PasswordReset's notice + * surface so the two flows feel identical in admin. + */ +function showNotice( message: string, kind: 'success' | 'error' ): void { + const existing = document.getElementById( 'workos-change-email-notice' ); + if ( existing ) { + existing.remove(); + } + + const notice = document.createElement( 'div' ); + notice.id = 'workos-change-email-notice'; + notice.className = `notice notice-${ kind } is-dismissible workos-change-email-notice`; + notice.setAttribute( 'role', 'status' ); + + const p = document.createElement( 'p' ); + p.textContent = message; + notice.appendChild( p ); + + const target = + document.querySelector( '.wrap > h1, .wrap > h2' )?.parentElement || + document.body; + target.insertBefore( notice, target.firstChild ); + + setTimeout( () => { + notice.remove(); + }, 7000 ); +} + +/** + * Update the inline status div inside a shortcode form (if present). + */ +function showInlineStatus( + form: HTMLElement | null, + message: string, + kind: 'success' | 'error' +): void { + if ( ! form ) { + return; + } + const status = form.querySelector< HTMLElement >( + '.workos-change-email-status' + ); + if ( ! status ) { + return; + } + status.textContent = message; + status.classList.remove( 'is-success', 'is-error' ); + status.classList.add( `is-${ kind }` ); +} + +async function sendChange( trigger: HTMLElement ): Promise< void > { + const config = getConfig(); + if ( ! config ) { + return; + } + + // Form mode vs standalone-button mode. + const form = trigger.closest< HTMLElement >( '.workos-change-email-form' ); + const userId = Number( + ( form ?? trigger ).getAttribute( 'data-user-id' ) || '0' + ); + if ( ! userId ) { + return; + } + + const redirectUrl = + ( form ?? trigger ).getAttribute( 'data-redirect-url' ) || ''; + + let newEmail = ''; + if ( form ) { + const input = form.querySelector< HTMLInputElement >( + '.workos-change-email-input' + ); + newEmail = input?.value.trim() || ''; + + if ( '' === newEmail ) { + return; + } + + if ( ! isLikelyEmail( newEmail ) ) { + showInlineStatus( form, config.strings.invalidEmail, 'error' ); + return; + } + } else { + // Standalone-button mode: open a WP-styled modal. The modal + // runs the email-shape check internally and refuses to close + // on invalid input; a cancel resolves to null. + const result = await promptModal( { + title: config.strings.modalTitle, + message: config.strings.modalMessage, + inputLabel: config.strings.modalInputLabel, + inputType: 'email', + placeholder: config.strings.modalPlaceholder, + confirmLabel: config.strings.modalConfirm, + cancelLabel: config.strings.modalCancel, + validate: ( value: string ) => + isLikelyEmail( value ) ? null : config.strings.invalidEmail, + } ); + if ( null === result || '' === result ) { + return; + } + newEmail = result; + } + + const original = trigger.innerHTML; + trigger.setAttribute( 'disabled', 'disabled' ); + trigger.classList.add( 'is-busy' ); + trigger.textContent = config.strings.sending; + + if ( form ) { + showInlineStatus( form, config.strings.sending, 'success' ); + } + + try { + const url = `${ config.restUrl }${ userId }/email-change`; + const response = await fetch( url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': config.nonce, + }, + body: JSON.stringify( { + new_email: newEmail, + redirect_url: redirectUrl, + } ), + } ); + + const data = ( await response.json() ) as + | SuccessResponse + | ErrorResponse; + + if ( ! response.ok ) { + const err = data as ErrorResponse; + const msg = err.message || config.strings.errorGeneric; + if ( form ) { + showInlineStatus( form, msg, 'error' ); + } else { + showNotice( msg, 'error' ); + } + return; + } + + const ok = data as SuccessResponse; + const masked = ok.masked_new_email || __( 'the new address', 'integration-workos' ); + const msg = sprintf( config.strings.success, masked ); + if ( form ) { + showInlineStatus( form, msg, 'success' ); + } else { + showNotice( msg, 'success' ); + } + } catch ( _ ) { + const msg = config.strings.errorGeneric; + if ( form ) { + showInlineStatus( form, msg, 'error' ); + } else { + showNotice( msg, 'error' ); + } + } finally { + trigger.removeAttribute( 'disabled' ); + trigger.classList.remove( 'is-busy' ); + trigger.innerHTML = original; + } +} + +document.addEventListener( 'click', ( event ) => { + const target = event.target as HTMLElement | null; + if ( ! target ) { + return; + } + + const trigger = target.closest< HTMLElement >( + '.workos-change-email-trigger' + ); + if ( ! trigger ) { + return; + } + + event.preventDefault(); + void sendChange( trigger ); +} ); diff --git a/src/js/admin-change-email/styles.css b/src/js/admin-change-email/styles.css new file mode 100644 index 0000000..61a2a22 --- /dev/null +++ b/src/js/admin-change-email/styles.css @@ -0,0 +1,22 @@ +.workos-change-email-trigger.is-busy { + cursor: progress; + opacity: 0.7; +} + +.workos-change-email-notice { + margin: 10px 0 15px; +} + +.workos-change-email-status { + margin-top: 8px; + min-height: 1.5em; + font-size: 13px; +} + +.workos-change-email-status.is-error { + color: #b32d2e; +} + +.workos-change-email-status.is-success { + color: #08660d; +} diff --git a/src/js/admin-password-reset/index.ts b/src/js/admin-password-reset/index.ts index 5551391..db81225 100644 --- a/src/js/admin-password-reset/index.ts +++ b/src/js/admin-password-reset/index.ts @@ -10,6 +10,7 @@ */ import { __, sprintf } from '@wordpress/i18n'; +import { confirmModal } from '../shared/modal'; import './styles.css'; /** @@ -24,7 +25,10 @@ interface PasswordResetConfig { nonce: string; /** Pre-translated UI strings (admin locale, server-side translated). */ strings: { - confirm: string; + modalTitle: string; + modalMessage: string; + modalConfirm: string; + modalCancel: string; sending: string; success: string; errorGeneric: string; @@ -125,7 +129,13 @@ async function sendReset( button: HTMLElement ): Promise< void > { const redirectUrl = button.getAttribute( 'data-redirect-url' ) || ''; const profile = button.getAttribute( 'data-profile' ) || ''; - if ( ! window.confirm( config.strings.confirm ) ) { + const confirmed = await confirmModal( { + title: config.strings.modalTitle, + message: config.strings.modalMessage, + confirmLabel: config.strings.modalConfirm, + cancelLabel: config.strings.modalCancel, + } ); + if ( ! confirmed ) { return; } diff --git a/src/js/change-email-confirm/index.ts b/src/js/change-email-confirm/index.ts new file mode 100644 index 0000000..f553ffe --- /dev/null +++ b/src/js/change-email-confirm/index.ts @@ -0,0 +1,154 @@ +/** + * Frontend confirm page logic. + * + * The PHP template (`templates/change-email/confirm-page.php`) prints + * the token + user_id into a `data-*` host element and renders a + * placeholder status. This script reads those values, decides whether + * the user is confirming or cancelling (the cancel link in the + * old-address notice appends `?action=cancel`), POSTs to the right REST + * endpoint, and rewrites the host's contents with the outcome. + * + * We do the mutation in JS — rather than handling the link as a GET on + * the server — so an email-prefetch scanner that hits the link can't + * accidentally consume the token. (Browsers + reasonable mail clients + * never POST on prefetch.) + */ + +import { __ } from '@wordpress/i18n'; +import './styles.css'; + +interface ConfirmConfig { + restUrl: string; + nonce: string; + strings: { + confirming: string; + cancelling: string; + success: string; + cancelled: string; + errorGeneric: string; + continue: string; + }; +} + +interface SuccessResponse { + ok: true; + redirect_url?: string; +} + +interface ErrorResponse { + code?: string; + message?: string; +} + +declare global { + interface Window { + workosChangeEmailConfirm?: ConfirmConfig; + } +} + +function getConfig(): ConfirmConfig | null { + return window.workosChangeEmailConfirm ?? null; +} + +function setStatus( + host: HTMLElement, + message: string, + kind: 'pending' | 'success' | 'error', + redirectUrl?: string +): void { + const klass = + kind === 'success' ? 'is-success' : kind === 'error' ? 'is-error' : ''; + host.className = klass; + + host.innerHTML = ''; + const p = document.createElement( 'p' ); + p.textContent = message; + host.appendChild( p ); + + if ( kind === 'success' && redirectUrl ) { + const a = document.createElement( 'a' ); + a.href = redirectUrl; + a.className = 'button'; + a.textContent = getConfig()?.strings.continue || + __( 'Continue', 'integration-workos' ); + host.appendChild( a ); + } +} + +function getActionFromUrl(): 'cancel' | 'confirm' { + const params = new URLSearchParams( window.location.search ); + return params.get( 'action' ) === 'cancel' ? 'cancel' : 'confirm'; +} + +async function run(): Promise< void > { + const host = document.getElementById( 'workos-change-email-confirm-status' ); + if ( ! host ) { + return; + } + + const config = getConfig(); + if ( ! config ) { + setStatus( host, __( 'Confirmation is unavailable.', 'integration-workos' ), 'error' ); + return; + } + + const token = host.getAttribute( 'data-token' ) || ''; + const userId = Number( host.getAttribute( 'data-user-id' ) || '0' ); + + if ( '' === token || ! userId ) { + setStatus( host, config.strings.errorGeneric, 'error' ); + return; + } + + const action = getActionFromUrl(); + const path = action === 'cancel' ? 'email-change/cancel' : 'email-change/confirm'; + + setStatus( + host, + action === 'cancel' ? config.strings.cancelling : config.strings.confirming, + 'pending' + ); + + try { + const url = `${ config.restUrl }${ userId }/${ path }`; + const response = await fetch( url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': config.nonce, + }, + body: JSON.stringify( { token } ), + } ); + + const data = ( await response.json() ) as + | SuccessResponse + | ErrorResponse; + + if ( ! response.ok ) { + const err = data as ErrorResponse; + setStatus( + host, + err.message || config.strings.errorGeneric, + 'error' + ); + return; + } + + const ok = data as SuccessResponse; + setStatus( + host, + action === 'cancel' ? config.strings.cancelled : config.strings.success, + 'success', + action === 'cancel' ? undefined : ok.redirect_url + ); + } catch ( _ ) { + setStatus( host, config.strings.errorGeneric, 'error' ); + } +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', () => void run() ); +} else { + void run(); +} diff --git a/src/js/change-email-confirm/styles.css b/src/js/change-email-confirm/styles.css new file mode 100644 index 0000000..6495ee6 --- /dev/null +++ b/src/js/change-email-confirm/styles.css @@ -0,0 +1,24 @@ +.workos-change-email-confirm { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; + color: #1d2327; +} + +.workos-change-email-confirm .is-error { + color: #b32d2e; +} + +.workos-change-email-confirm .is-success { + color: #08660d; +} + +.workos-change-email-confirm a.button { + display: inline-block; + background: #2271b1; + color: #fff; + padding: 10px 16px; + border-radius: 4px; + text-decoration: none; + font-weight: 600; + margin-top: 12px; +} diff --git a/src/js/shared/modal.css b/src/js/shared/modal.css new file mode 100644 index 0000000..7cef373 --- /dev/null +++ b/src/js/shared/modal.css @@ -0,0 +1,103 @@ +/* + * Styles for the shared WP-admin-styled modal helper. See modal.ts. + * + * Targets the native element. The ::backdrop pseudo provides + * the dimmed overlay for free in modern browsers. + */ + +.workos-modal { + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 20px 24px 16px; + max-width: 480px; + width: calc(100vw - 32px); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); + color: #1d2327; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 14px; + line-height: 1.5; +} + +.workos-modal::backdrop { + background: rgba(0, 0, 0, 0.45); +} + +.workos-modal__title { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px; + color: #1d2327; +} + +.workos-modal__message { + margin: 0 0 16px; + color: #50575e; +} + +.workos-modal__label { + display: block; + margin: 0 0 4px; + font-weight: 600; + color: #1d2327; +} + +.workos-modal__input { + display: block; + width: 100%; + box-sizing: border-box; + margin: 0 0 8px; +} + +.workos-modal__error { + margin: 0 0 12px; + min-height: 1.2em; + color: #b32d2e; + font-size: 13px; +} + +.workos-modal__error:empty { + display: none; +} + +.workos-modal__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #f0f0f1; +} + +/* + * Match the WordPress button-link-delete variant for destructive + * confirms (e.g. cancel-flow type interactions). + */ +.workos-modal__confirm.button-link-delete { + background: #b32d2e; + color: #fff; + border-color: #b32d2e; +} + +.workos-modal__confirm.button-link-delete:hover, +.workos-modal__confirm.button-link-delete:focus { + background: #a02525; + border-color: #a02525; + color: #fff; +} + +@media (prefers-reduced-motion: no-preference) { + .workos-modal[open] { + animation: workos-modal-fade-in 0.12s ease-out; + } + + @keyframes workos-modal-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} diff --git a/src/js/shared/modal.ts b/src/js/shared/modal.ts new file mode 100644 index 0000000..b4d4866 --- /dev/null +++ b/src/js/shared/modal.ts @@ -0,0 +1,215 @@ +/** + * Lightweight WP-admin-styled modal helper. + * + * Two entry points: + * + * confirmModal({ title, message, confirmLabel, cancelLabel }) + * → Promise resolves true on confirm, false on cancel + * + * promptModal({ title, message, inputLabel, ... }) + * → Promise resolves the trimmed input on confirm, + * null on cancel + * + * Implementation uses the native HTML `` element which gives us + * focus trapping, backdrop click handling, and Esc-to-close for free + * across all evergreen browsers. The visual styling matches WP admin + * (notice paddings, button classes) so it sits naturally inside + * wp-admin/users.php and friends. Imported `./modal.css` ships the + * styling alongside whichever bundle pulls this module in. + */ + +import './modal.css'; + +export interface ConfirmOptions { + title: string; + message?: string; + confirmLabel: string; + cancelLabel: string; + /** Visual variant for the confirm button. Defaults to 'primary'. */ + variant?: 'primary' | 'danger'; +} + +export interface PromptOptions extends ConfirmOptions { + /** Visible label for the input field. */ + inputLabel: string; + inputType?: 'email' | 'text'; + placeholder?: string; + initialValue?: string; + /** + * Optional sync validator. Return an error string to block submit, or + * null/empty to allow it. The error renders inline beneath the input. + */ + validate?: ( value: string ) => string | null; +} + +/** + * Build the dialog DOM and return a Promise that resolves with the user's + * choice. Internal — `confirmModal` and `promptModal` are the public API. + */ +function openDialog( opts: ConfirmOptions, prompt: PromptOptions | null ): Promise< boolean | string | null > { + return new Promise( ( resolve ) => { + // Remember which element was focused before we open so we can + // restore focus on close (recommended pattern for accessibility). + const previouslyFocused = document.activeElement as HTMLElement | null; + + const dialog = document.createElement( 'dialog' ); + dialog.className = 'workos-modal'; + dialog.setAttribute( 'aria-labelledby', 'workos-modal-title' ); + + const titleEl = document.createElement( 'h2' ); + titleEl.id = 'workos-modal-title'; + titleEl.className = 'workos-modal__title'; + titleEl.textContent = opts.title; + dialog.appendChild( titleEl ); + + if ( opts.message ) { + const p = document.createElement( 'p' ); + p.className = 'workos-modal__message'; + p.textContent = opts.message; + dialog.appendChild( p ); + } + + let input: HTMLInputElement | null = null; + let errorEl: HTMLParagraphElement | null = null; + if ( prompt ) { + const label = document.createElement( 'label' ); + label.className = 'workos-modal__label'; + label.textContent = prompt.inputLabel; + const inputId = 'workos-modal-input-' + Math.random().toString( 36 ).slice( 2, 9 ); + label.setAttribute( 'for', inputId ); + dialog.appendChild( label ); + + input = document.createElement( 'input' ); + input.id = inputId; + input.type = prompt.inputType || 'text'; + input.className = 'workos-modal__input regular-text'; + input.value = prompt.initialValue || ''; + if ( prompt.placeholder ) { + input.placeholder = prompt.placeholder; + } + input.autocomplete = 'off'; + dialog.appendChild( input ); + + errorEl = document.createElement( 'p' ); + errorEl.className = 'workos-modal__error'; + errorEl.setAttribute( 'aria-live', 'polite' ); + dialog.appendChild( errorEl ); + } + + const actions = document.createElement( 'div' ); + actions.className = 'workos-modal__actions'; + + const cancelBtn = document.createElement( 'button' ); + cancelBtn.type = 'button'; + cancelBtn.className = 'button workos-modal__cancel'; + cancelBtn.textContent = opts.cancelLabel; + actions.appendChild( cancelBtn ); + + const confirmBtn = document.createElement( 'button' ); + confirmBtn.type = 'button'; + const variantClass = opts.variant === 'danger' ? 'button-link-delete' : 'button-primary'; + confirmBtn.className = `button ${ variantClass } workos-modal__confirm`; + confirmBtn.textContent = opts.confirmLabel; + actions.appendChild( confirmBtn ); + + dialog.appendChild( actions ); + document.body.appendChild( dialog ); + + const cleanup = (): void => { + try { + if ( dialog.open ) { + dialog.close(); + } + } catch ( _ ) { + // dialog may already be detached on rapid open/close cycles + } + dialog.remove(); + if ( previouslyFocused && typeof previouslyFocused.focus === 'function' ) { + previouslyFocused.focus(); + } + }; + + const handleCancel = (): void => { + cleanup(); + resolve( prompt ? null : false ); + }; + + const handleConfirm = (): void => { + if ( prompt && input ) { + const value = input.value.trim(); + const error = prompt.validate ? prompt.validate( value ) : null; + if ( error ) { + if ( errorEl ) { + errorEl.textContent = error; + } + input.focus(); + input.select(); + return; + } + cleanup(); + resolve( value ); + return; + } + cleanup(); + resolve( true ); + }; + + cancelBtn.addEventListener( 'click', handleCancel ); + confirmBtn.addEventListener( 'click', handleConfirm ); + + // Native fires `cancel` on Esc; route it through our + // cancel handler so the promise resolves consistently. + dialog.addEventListener( 'cancel', ( event ) => { + event.preventDefault(); + handleCancel(); + } ); + + // Backdrop click — treat clicking the dialog itself (i.e. the + // pseudo-backdrop area, since clicks land on the when + // they're outside the inner content rect) as a cancel. + dialog.addEventListener( 'click', ( event ) => { + if ( event.target === dialog ) { + handleCancel(); + } + } ); + + if ( input ) { + // Enter submits, with validation; Esc cancels (handled by ). + input.addEventListener( 'keydown', ( event ) => { + if ( event.key === 'Enter' ) { + event.preventDefault(); + handleConfirm(); + } + } ); + } + + // Open as a true modal so the rest of the page becomes inert. + try { + dialog.showModal(); + } catch ( _ ) { + // `showModal` throws if the element is already open or not + // connected. Fall back to a non-modal show. + dialog.show(); + } + + if ( input ) { + input.focus(); + } else { + confirmBtn.focus(); + } + } ); +} + +/** + * Show a confirm dialog. Resolves true on confirm, false on cancel. + */ +export function confirmModal( opts: ConfirmOptions ): Promise< boolean > { + return openDialog( opts, null ) as Promise< boolean >; +} + +/** + * Show a prompt dialog. Resolves the trimmed value on confirm, null on cancel. + */ +export function promptModal( opts: PromptOptions ): Promise< string | null > { + return openDialog( opts, opts ) as Promise< string | null >; +} diff --git a/templates/change-email/confirm-page.php b/templates/change-email/confirm-page.php new file mode 100644 index 0000000..63e2ad2 --- /dev/null +++ b/templates/change-email/confirm-page.php @@ -0,0 +1,43 @@ + +
+
+

+ +

+ +
+

+ +

+
+ + +
+
+display_name : ''; +?> + + + + + + <?php echo esc_html( $site_name ); ?> + + +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+
+ %s right away.', 'integration-workos' ), + [ 'a' => [ 'href' => true ] ] + ), + esc_url( $site_url ), + esc_html( $site_url ) + ); + ?> +

+ +
+

+ +

+ + diff --git a/templates/change-email/old-address-notice.php b/templates/change-email/old-address-notice.php new file mode 100644 index 0000000..b9d3390 --- /dev/null +++ b/templates/change-email/old-address-notice.php @@ -0,0 +1,90 @@ +display_name : ''; +?> + + + + + + <?php echo esc_html( $site_name ); ?> + + +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+
+ +

+ + + +

+ +

+ +

+ +

+
+ +

+ +
+

+ +

+ + diff --git a/templates/change-email/verification-email.php b/templates/change-email/verification-email.php new file mode 100644 index 0000000..5965903 --- /dev/null +++ b/templates/change-email/verification-email.php @@ -0,0 +1,88 @@ +display_name : ''; +?> + + + + + + <?php echo esc_html( $site_name ); ?> + + +

+ +

+ +

+ +

+ +

+ +

+ +

+ +

+ + + +

+ +

+ +

+ +

+
+ +

+ +
+ +

+ +

+ +

+ +

+ + diff --git a/tests/wpunit/ChangeEmailConflictResolverTest.php b/tests/wpunit/ChangeEmailConflictResolverTest.php new file mode 100644 index 0000000..f11bf32 --- /dev/null +++ b/tests/wpunit/ChangeEmailConflictResolverTest.php @@ -0,0 +1,201 @@ + 'block' ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $suffix = uniqid( 'crv_', true ); + $this->target_id = wp_insert_user( + [ + 'user_login' => 'tg_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'target-' . $suffix . '@example.test', + 'role' => 'subscriber', + ] + ); + $this->conflicting_id = wp_insert_user( + [ + 'user_login' => 'cn_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'taken-' . $suffix . '@example.test', + 'role' => 'subscriber', + ] + ); + + $this->assertIsInt( $this->target_id ); + $this->assertIsInt( $this->conflicting_id ); + } + + public function tearDown(): void { + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + parent::tearDown(); + } + + /** + * No existing user with the new address → no conflict. + */ + public function test_allows_unique_email(): void { + $resolver = new ConflictResolver(); + + $result = $resolver->check( 'fresh-' . uniqid() . '@example.test', get_userdata( $this->target_id ) ); + + $this->assertNull( $result ); + } + + /** + * Default `block` policy rejects a colliding address. + */ + public function test_block_policy_rejects_collision(): void { + $resolver = new ConflictResolver(); + + $existing = get_userdata( $this->conflicting_id ); + $result = $resolver->check( $existing->user_email, get_userdata( $this->target_id ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'workos_change_email_conflict', $result->get_error_code() ); + } + + /** + * Changing to your own current email is a no-op, not a conflict. + */ + public function test_same_user_no_op_is_allowed(): void { + $resolver = new ConflictResolver(); + $user = get_userdata( $this->target_id ); + + $result = $resolver->check( $user->user_email, $user ); + + $this->assertNull( $result ); + } + + /** + * `allow_orphan` lets a takeover proceed when the conflicting user + * is unlinked, has no content, and was registered long ago. + */ + public function test_allow_orphan_permits_takeover_of_orphan(): void { + update_option( 'workos_production', [ 'change_email_conflict_policy' => 'allow_orphan' ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + // Backdate the conflicting user's registration well beyond the 90-day window. + global $wpdb; + $wpdb->update( + $wpdb->users, + [ 'user_registered' => gmdate( 'Y-m-d H:i:s', time() - ( 365 * DAY_IN_SECONDS ) ) ], + [ 'ID' => $this->conflicting_id ] + ); + clean_user_cache( $this->conflicting_id ); + + $resolver = new ConflictResolver(); + $existing = get_userdata( $this->conflicting_id ); + $result = $resolver->check( $existing->user_email, get_userdata( $this->target_id ) ); + + $this->assertNull( $result, 'Orphan takeover should be allowed under allow_orphan.' ); + } + + /** + * `allow_orphan` still rejects when the conflicting user is linked + * to a WorkOS profile — those are never orphans. + */ + public function test_allow_orphan_rejects_when_user_is_linked(): void { + update_option( 'workos_production', [ 'change_email_conflict_policy' => 'allow_orphan' ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + update_user_meta( $this->conflicting_id, '_workos_user_id', 'user_existing_01' ); + + $resolver = new ConflictResolver(); + $existing = get_userdata( $this->conflicting_id ); + $result = $resolver->check( $existing->user_email, get_userdata( $this->target_id ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * `merge_request` rejects today (until Issue 2 ships) AND fires the + * dedicated merge-request action so a future plugin can observe. + */ + public function test_merge_request_policy_fires_action(): void { + update_option( 'workos_production', [ 'change_email_conflict_policy' => 'merge_request' ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $fired = 0; + $cb = static function () use ( &$fired ) { + ++$fired; + }; + add_action( 'workos_change_email_merge_requested', $cb ); + + $resolver = new ConflictResolver(); + $existing = get_userdata( $this->conflicting_id ); + $result = $resolver->check( $existing->user_email, get_userdata( $this->target_id ) ); + + remove_action( 'workos_change_email_merge_requested', $cb ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 1, $fired ); + } + + /** + * The conflict_detected action fires under every policy. + */ + public function test_conflict_detected_action_fires(): void { + $fired = 0; + $cb = static function () use ( &$fired ) { + ++$fired; + }; + add_action( 'workos_change_email_conflict_detected', $cb ); + + $resolver = new ConflictResolver(); + $existing = get_userdata( $this->conflicting_id ); + $resolver->check( $existing->user_email, get_userdata( $this->target_id ) ); + + remove_action( 'workos_change_email_conflict_detected', $cb ); + + $this->assertSame( 1, $fired ); + } + + /** + * The conflict-policy filter overrides the stored option for the + * current request — used by tightening security plugins. + */ + public function test_policy_filter_overrides_option(): void { + update_option( 'workos_production', [ 'change_email_conflict_policy' => 'allow_orphan' ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $cb = static function () { + return 'block'; + }; + add_filter( 'workos_change_email_conflict_policy', $cb ); + + $resolver = new ConflictResolver(); + $this->assertSame( 'block', $resolver->resolve_policy() ); + + remove_filter( 'workos_change_email_conflict_policy', $cb ); + } +} diff --git a/tests/wpunit/ChangeEmailNotifierTest.php b/tests/wpunit/ChangeEmailNotifierTest.php new file mode 100644 index 0000000..7c669d5 --- /dev/null +++ b/tests/wpunit/ChangeEmailNotifierTest.php @@ -0,0 +1,144 @@ + + */ + private array $mail_captured = []; + + public function setUp(): void { + parent::setUp(); + + \WorkOS\Config::set_active_environment( 'production' ); + update_option( + 'workos_production', + [ + 'change_email_notify_old_address' => true, + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $suffix = uniqid( 'nf_', true ); + $this->user_id = wp_insert_user( + [ + 'user_login' => 'nf_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'old-' . $suffix . '@example.test', + 'role' => 'subscriber', + ] + ); + $this->assertIsInt( $this->user_id ); + + add_filter( 'wp_mail', [ $this, 'capture_mail' ], 10, 1 ); + add_filter( 'pre_wp_mail', '__return_true' ); + $this->mail_captured = []; + } + + public function tearDown(): void { + remove_filter( 'wp_mail', [ $this, 'capture_mail' ], 10 ); + remove_filter( 'pre_wp_mail', '__return_true' ); + + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + public function capture_mail( array $args ): array { + $this->mail_captured[] = $args; + return $args; + } + + private function notifier(): Notifier { + return new Notifier( new Mailer() ); + } + + public function test_verification_goes_to_new_address_with_confirm_url(): void { + $notifier = $this->notifier(); + $user = get_userdata( $this->user_id ); + $new_email = 'new-' . uniqid() . '@example.test'; + $confirm_url = home_url( '/workos/change-email/?token=abc123&user_id=' . $this->user_id ); + + $sent = $notifier->send_verification( $user, $new_email, $confirm_url, time() + 3600 ); + + $this->assertTrue( $sent ); + $this->assertNotEmpty( $this->mail_captured ); + + $mail = $this->mail_captured[0]; + $to = is_array( $mail['to'] ) ? $mail['to'][0] : (string) $mail['to']; + $this->assertSame( $new_email, $to ); + $this->assertStringContainsString( 'abc123', (string) $mail['message'] ); + } + + public function test_old_address_notice_suppressed_when_option_off(): void { + update_option( + 'workos_production', + [ 'change_email_notify_old_address' => false ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $notifier = $this->notifier(); + $sent = $notifier->send_old_address_notice( + get_userdata( $this->user_id ), + 'new@example.test', + home_url( '/workos/change-email/?action=cancel' ), + time() + 3600 + ); + + $this->assertFalse( $sent ); + $this->assertEmpty( $this->mail_captured ); + } + + public function test_old_address_notice_goes_to_current_email(): void { + $user = get_userdata( $this->user_id ); + $notifier = $this->notifier(); + + $sent = $notifier->send_old_address_notice( + $user, + 'new@example.test', + home_url( '/workos/change-email/?action=cancel' ), + time() + 3600 + ); + + $this->assertTrue( $sent ); + + $mail = $this->mail_captured[0]; + $to = is_array( $mail['to'] ) ? $mail['to'][0] : (string) $mail['to']; + $this->assertSame( $user->user_email, $to ); + $this->assertStringContainsString( 'cancel', (string) $mail['message'] ); + } + + public function test_confirmation_notice_goes_to_previous_address(): void { + $user = get_userdata( $this->user_id ); + $notifier = $this->notifier(); + + $sent = $notifier->send_confirmation_notice( $user, 'old@example.test', 'new@example.test' ); + + $this->assertTrue( $sent ); + + $mail = $this->mail_captured[0]; + $to = is_array( $mail['to'] ) ? $mail['to'][0] : (string) $mail['to']; + $this->assertSame( 'old@example.test', $to ); + } +} diff --git a/tests/wpunit/ChangeEmailPendingChangeTest.php b/tests/wpunit/ChangeEmailPendingChangeTest.php new file mode 100644 index 0000000..17a64ba --- /dev/null +++ b/tests/wpunit/ChangeEmailPendingChangeTest.php @@ -0,0 +1,164 @@ +user_id = wp_insert_user( + [ + 'user_login' => 'pc_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'user-' . uniqid() . '@example.test', + 'role' => 'subscriber', + ] + ); + + $this->assertIsInt( $this->user_id ); + } + + public function tearDown(): void { + if ( $this->user_id ) { + delete_user_meta( $this->user_id, PendingChange::META_KEY ); + } + parent::tearDown(); + } + + /** + * Stored meta must contain hashes, never plaintext tokens. + */ + public function test_store_persists_hashes_only(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $confirm_token = $factory->generate(); + $cancel_token = $factory->generate(); + + $pending->store( + $this->user_id, + 'new@example.test', + $confirm_token, + $cancel_token, + time() + 600, + 0 + ); + + $raw = get_user_meta( $this->user_id, PendingChange::META_KEY, true ); + + $this->assertIsArray( $raw ); + $this->assertSame( 'new@example.test', $raw['new_email'] ); + $this->assertNotSame( $confirm_token, $raw['token_hash'] ); + $this->assertNotSame( $cancel_token, $raw['cancel_token_hash'] ); + $this->assertSame( $factory->hash( $confirm_token ), $raw['token_hash'] ); + $this->assertSame( $factory->hash( $cancel_token ), $raw['cancel_token_hash'] ); + } + + /** + * `get()` returns null when no row exists or the row is malformed. + */ + public function test_get_returns_null_when_absent_or_malformed(): void { + $pending = new PendingChange( new TokenFactory() ); + + $this->assertNull( $pending->get( $this->user_id ) ); + + update_user_meta( $this->user_id, PendingChange::META_KEY, [ 'new_email' => 'partial@example.test' ] ); + $this->assertNull( $pending->get( $this->user_id ) ); + } + + /** + * `expired()` flips to true once the wall clock passes `expires_at`. + */ + public function test_expired_after_window(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + + $pending->store( + $this->user_id, + 'new@example.test', + $factory->generate(), + $factory->generate(), + time() - 1, + 0 + ); + + $record = $pending->get( $this->user_id ); + $this->assertIsArray( $record ); + $this->assertTrue( $pending->expired( $record ) ); + } + + /** + * Confirm-token verification: matching plaintext passes, tampered fails. + */ + public function test_verify_confirm_paths(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $confirm = $factory->generate(); + $cancel = $factory->generate(); + + $pending->store( $this->user_id, 'new@example.test', $confirm, $cancel, time() + 600, 0 ); + + $record = $pending->get( $this->user_id ); + $this->assertTrue( $pending->verify_confirm( $record, $confirm ) ); + $this->assertFalse( $pending->verify_confirm( $record, $cancel ) ); + } + + /** + * Cancel-token verification is a separate channel. + */ + public function test_verify_cancel_paths(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $confirm = $factory->generate(); + $cancel = $factory->generate(); + + $pending->store( $this->user_id, 'new@example.test', $confirm, $cancel, time() + 600, 0 ); + + $record = $pending->get( $this->user_id ); + $this->assertTrue( $pending->verify_cancel( $record, $cancel ) ); + $this->assertFalse( $pending->verify_cancel( $record, $confirm ) ); + } + + /** + * `clear()` removes the meta row. + */ + public function test_clear_removes_meta(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + + $pending->store( + $this->user_id, + 'new@example.test', + $factory->generate(), + $factory->generate(), + time() + 600, + 0 + ); + + $pending->clear( $this->user_id ); + + $this->assertNull( $pending->get( $this->user_id ) ); + $this->assertSame( '', get_user_meta( $this->user_id, PendingChange::META_KEY, true ) ); + } +} diff --git a/tests/wpunit/ChangeEmailRestApiTest.php b/tests/wpunit/ChangeEmailRestApiTest.php new file mode 100644 index 0000000..831445c --- /dev/null +++ b/tests/wpunit/ChangeEmailRestApiTest.php @@ -0,0 +1,512 @@ + + */ + private array $http_captured = []; + + /** + * Captured wp_mail invocations. + * + * @var array + */ + private array $mail_captured = []; + + private int $linked_user_id = 0; + private int $admin_user_id = 0; + private int $unlinked_user_id = 0; + private int $other_user_id = 0; + private string $linked_email = ''; + private string $other_email = ''; + + public function setUp(): void { + parent::setUp(); + + \WorkOS\Config::set_active_environment( 'production' ); + update_option( + 'workos_production', + [ + 'api_key' => 'sk_test_fake', + 'client_id' => 'client_fake', + 'environment_id' => 'environment_test', + 'enable_activity_log' => true, + 'change_email_enabled' => true, + 'change_email_conflict_policy' => 'block', + 'change_email_token_lifetime' => 3600, + 'change_email_rate_limit_user_count' => 3, + 'change_email_rate_limit_user_window' => 3600, + 'change_email_rate_limit_ip_count' => 10, + 'change_email_rate_limit_ip_window' => 3600, + 'change_email_notify_old_address' => true, + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $this->reset_rate_limit_buckets(); + + $tokens = new TokenFactory(); + $pending = new PendingChange( $tokens ); + $mailer = new Mailer(); + $notifier = new Notifier( $mailer ); + + $rest = new RestApi( + new RateLimiter(), + $tokens, + $pending, + new ConflictResolver(), + $notifier + ); + + add_action( 'rest_api_init', [ $rest, 'register_routes' ] ); + $server = rest_get_server(); + do_action( 'rest_api_init', $server ); + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + add_filter( 'wp_mail', [ $this, 'capture_mail' ], 10, 1 ); + // Stop wp_mail from actually trying to send (mailcatcher / sendmail). + add_filter( 'pre_wp_mail', '__return_true' ); + + $suffix = uniqid( 'ce_', true ); + $this->linked_email = 'linked-' . $suffix . '@example.test'; + $this->other_email = 'other-' . $suffix . '@example.test'; + $this->linked_user_id = $this->create_user( $this->linked_email, 'subscriber' ); + $this->unlinked_user_id = $this->create_user( 'unlinked-' . $suffix . '@example.test', 'subscriber' ); + $this->admin_user_id = $this->create_user( 'admin-' . $suffix . '@example.test', 'administrator' ); + $this->other_user_id = $this->create_user( $this->other_email, 'subscriber' ); + + update_user_meta( $this->linked_user_id, '_workos_user_id', 'user_linked_01' ); + + $this->http_captured = []; + $this->mail_captured = []; + } + + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + remove_filter( 'wp_mail', [ $this, 'capture_mail' ], 10 ); + remove_filter( 'pre_wp_mail', '__return_true' ); + + global $wpdb; + $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}workos_activity_log" ); + + wp_set_current_user( 0 ); + + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + private function create_user( string $email, string $role ): int { + $user_id = wp_insert_user( + [ + 'user_login' => 'ce_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => $email, + 'role' => $role, + ] + ); + $this->assertIsInt( $user_id ); + return $user_id; + } + + /** + * Capture outbound HTTP and return a 200 OK so the endpoint code + * doesn't error out talking to WorkOS. + */ + public function intercept_http( $preempt, array $args, string $url ): array { + $this->http_captured[] = [ + 'url' => $url, + 'method' => $args['method'] ?? 'GET', + 'body' => (string) ( $args['body'] ?? '' ), + ]; + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => '{}', + ]; + } + + /** + * Capture wp_mail() invocations. + * + * @param array $args Mail args. + * + * @return array + */ + public function capture_mail( array $args ): array { + $this->mail_captured[] = $args; + return $args; + } + + private function dispatch( string $method, string $path, array $body = [], ?string $nonce = null ): WP_REST_Response { + $request = new WP_REST_Request( $method, $path ); + $request->set_header( 'Content-Type', 'application/json' ); + if ( null !== $nonce ) { + $request->set_header( 'X-WP-Nonce', $nonce ); + } + $request->set_body( wp_json_encode( $body ) ); + return rest_get_server()->dispatch( $request ); + } + + // ----------------------------------------------------------------- initiate + + public function test_initiate_writes_pending_meta_and_sends_verification(): void { + wp_set_current_user( $this->admin_user_id ); + + $new_email = 'brand-new-' . uniqid() . '@example.test'; + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => $new_email ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['ok'] ?? false ); + $this->assertStringContainsString( '•', $data['masked_new_email'] ?? '' ); + + $stored = get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ); + $this->assertIsArray( $stored ); + $this->assertSame( strtolower( $new_email ), $stored['new_email'] ); + $this->assertNotEmpty( $stored['token_hash'] ); + + // Verification email landed on the new address. + $found = false; + foreach ( $this->mail_captured as $mail ) { + $to = is_array( $mail['to'] ) ? implode( ',', $mail['to'] ) : (string) $mail['to']; + if ( str_contains( strtolower( $to ), strtolower( $new_email ) ) ) { + $found = true; + break; + } + } + $this->assertTrue( $found, 'Verification email must be delivered to the new address.' ); + } + + public function test_initiate_rejects_invalid_email(): void { + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => 'not-an-email' ] + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertNull( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ?: null ); + } + + public function test_initiate_returns_403_when_no_edit_user_cap(): void { + wp_set_current_user( $this->unlinked_user_id ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => 'free-' . uniqid() . '@example.test' ] + ); + + $this->assertSame( 403, $response->get_status() ); + } + + public function test_initiate_conflict_block_is_enumeration_safe(): void { + wp_set_current_user( $this->admin_user_id ); + + // Target the address owned by another local user. + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => $this->other_email ] + ); + + // Enumeration-safe: response shape is the same as success. + $this->assertSame( 200, $response->get_status() ); + + // But no pending meta was written. + $stored = get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ); + $this->assertTrue( '' === $stored || empty( $stored ) ); + + // And the activity log records the block. + global $wpdb; + $row = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}workos_activity_log WHERE event_type = %s", + 'email_change.conflict_blocked' + ) + ); + $this->assertSame( '1', (string) $row ); + } + + public function test_initiate_same_email_is_noop(): void { + wp_set_current_user( $this->linked_user_id ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => $this->linked_email ] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['no_op'] ?? false ); + + // No pending meta, no email. + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + $this->assertEmpty( $this->mail_captured ); + } + + public function test_initiate_user_rate_limit(): void { + wp_set_current_user( $this->admin_user_id ); + + $last = null; + for ( $i = 0; $i < 4; $i++ ) { + $last = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => 'free-' . uniqid() . '@example.test' ] + ); + } + + $this->assertNotNull( $last ); + $this->assertSame( 429, $last->get_status() ); + } + + // ----------------------------------------------------------------- confirm + + public function test_confirm_commits_change_and_clears_meta(): void { + wp_set_current_user( $this->admin_user_id ); + + $new_email = 'fresh-' . uniqid() . '@example.test'; + + // Patch the token factory so we know the plaintext to confirm with. + // We do this by storing a known token directly. + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $token = $factory->generate(); + $cancel = $factory->generate(); + $pending->store( + $this->linked_user_id, + strtolower( $new_email ), + $token, + $cancel, + time() + 600, + $this->admin_user_id + ); + + $this->http_captured = []; + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/confirm', + [ 'token' => $token ] + ); + + $this->assertSame( 200, $response->get_status() ); + + // WP email updated. + $updated = get_userdata( $this->linked_user_id ); + $this->assertSame( strtolower( $new_email ), strtolower( $updated->user_email ) ); + + // Pending meta cleared. + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + + // WorkOS API was called with the new email. + $found_workos_call = false; + foreach ( $this->http_captured as $call ) { + if ( str_contains( $call['url'], '/user_management/users/user_linked_01' ) ) { + $decoded = json_decode( $call['body'], true ); + if ( is_array( $decoded ) && ( $decoded['email'] ?? '' ) === strtolower( $new_email ) ) { + $found_workos_call = true; + break; + } + } + } + $this->assertTrue( $found_workos_call, 'WorkOS update_user must be called with the new email.' ); + } + + public function test_confirm_rejects_expired_token(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $token = $factory->generate(); + $pending->store( + $this->linked_user_id, + 'never@example.test', + $token, + $factory->generate(), + time() - 10, + $this->admin_user_id + ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/confirm', + [ 'token' => $token ] + ); + + $this->assertSame( 410, $response->get_status() ); + + // Expired meta is cleared as a side-effect. + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + } + + public function test_confirm_rejects_tampered_token(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $token = $factory->generate(); + $pending->store( + $this->linked_user_id, + 'never@example.test', + $token, + $factory->generate(), + time() + 600, + $this->admin_user_id + ); + + $tampered = $token; + $tampered[0] = 'A' === $tampered[0] ? 'B' : 'A'; + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/confirm', + [ 'token' => $tampered ] + ); + + $this->assertSame( 400, $response->get_status() ); + // Pending meta remains — single-use is only on valid confirms. + $this->assertNotEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + } + + public function test_confirm_race_check_rejects_new_collision(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $token = $factory->generate(); + $pending->store( + $this->linked_user_id, + $this->other_email, + $token, + $factory->generate(), + time() + 600, + $this->admin_user_id + ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/confirm', + [ 'token' => $token ] + ); + + $this->assertSame( 409, $response->get_status() ); + // And the pending meta is cleared so the user can start over. + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + } + + // ----------------------------------------------------------------- cancel + + public function test_cancel_via_token_clears_pending(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $cancel = $factory->generate(); + $pending->store( + $this->linked_user_id, + 'free-' . uniqid() . '@example.test', + $factory->generate(), + $cancel, + time() + 600, + $this->admin_user_id + ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/cancel', + [ 'token' => $cancel ] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + } + + public function test_cancel_via_capability_clears_pending(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $pending->store( + $this->linked_user_id, + 'free-' . uniqid() . '@example.test', + $factory->generate(), + $factory->generate(), + time() + 600, + $this->admin_user_id + ); + + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/cancel', + [] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + } + + public function test_cancel_without_token_or_cap_is_403(): void { + $factory = new TokenFactory(); + $pending = new PendingChange( $factory ); + $pending->store( + $this->linked_user_id, + 'free-' . uniqid() . '@example.test', + $factory->generate(), + $factory->generate(), + time() + 600, + $this->admin_user_id + ); + + wp_set_current_user( $this->unlinked_user_id ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change/cancel', + [] + ); + + $this->assertSame( 403, $response->get_status() ); + } + + // ----------------------------------------------------------------- helpers + + private function reset_rate_limit_buckets(): void { + global $wpdb; + $wpdb->query( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_workos_rl_%' OR option_name LIKE '_transient_timeout_workos_rl_%'" + ); + + if ( wp_using_ext_object_cache() ) { + wp_cache_flush(); + } + } +} diff --git a/tests/wpunit/ChangeEmailTokenFactoryTest.php b/tests/wpunit/ChangeEmailTokenFactoryTest.php new file mode 100644 index 0000000..2b16ded --- /dev/null +++ b/tests/wpunit/ChangeEmailTokenFactoryTest.php @@ -0,0 +1,97 @@ +generate(); + $b = $factory->generate(); + + $this->assertNotSame( $a, $b ); + $this->assertNotSame( '', $a ); + } + + /** + * Tokens must be long enough to resist brute force. + */ + public function test_token_is_long(): void { + $factory = new TokenFactory(); + $token = $factory->generate(); + + $this->assertGreaterThanOrEqual( 32, strlen( $token ) ); + } + + /** + * Hashing the same input twice yields the same hash (deterministic + * with site salt). + */ + public function test_hash_is_deterministic(): void { + $factory = new TokenFactory(); + + $this->assertSame( $factory->hash( 'abc' ), $factory->hash( 'abc' ) ); + } + + /** + * Different inputs produce different hashes. + */ + public function test_hash_diverges_on_different_input(): void { + $factory = new TokenFactory(); + + $this->assertNotSame( $factory->hash( 'abc' ), $factory->hash( 'abd' ) ); + } + + /** + * `verify()` returns true only for the exact plaintext that produced + * the hash. + */ + public function test_verify_accepts_matching_token(): void { + $factory = new TokenFactory(); + $token = $factory->generate(); + $hash = $factory->hash( $token ); + + $this->assertTrue( $factory->verify( $token, $hash ) ); + } + + /** + * A single-byte tamper invalidates the token. Guards against bugs + * that downgrade the constant-time comparison to substring matching. + */ + public function test_verify_rejects_tampered_token(): void { + $factory = new TokenFactory(); + $token = $factory->generate(); + $hash = $factory->hash( $token ); + + $tampered = $token; + $tampered[0] = 'A' === $tampered[0] ? 'B' : 'A'; + + $this->assertFalse( $factory->verify( $tampered, $hash ) ); + } + + /** + * Empty strings never validate. + */ + public function test_verify_rejects_empty_token(): void { + $factory = new TokenFactory(); + + $this->assertFalse( $factory->verify( '', 'doesnt-matter' ) ); + $this->assertFalse( $factory->verify( 'token', '' ) ); + } +} diff --git a/tests/wpunit/ChangeEmailUserSyncRaceGuardTest.php b/tests/wpunit/ChangeEmailUserSyncRaceGuardTest.php new file mode 100644 index 0000000..60bb8c9 --- /dev/null +++ b/tests/wpunit/ChangeEmailUserSyncRaceGuardTest.php @@ -0,0 +1,87 @@ +user_id = wp_insert_user( + [ + 'user_login' => 'rg_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'rg-' . $suffix . '@example.test', + 'role' => 'subscriber', + ] + ); + $this->assertIsInt( $this->user_id ); + + update_user_meta( $this->user_id, '_workos_user_id', $this->workos_id ); + update_user_meta( $this->user_id, '_workos_profile_hash', 'stale-hash' ); + } + + public function tearDown(): void { + delete_transient( RestApi::TRANSIENT_PREFIX . $this->user_id ); + parent::tearDown(); + } + + public function test_handle_user_updated_skips_while_in_progress(): void { + set_transient( RestApi::TRANSIENT_PREFIX . $this->user_id, 1, 30 ); + + $sync = new UserSync(); + $sync->handle_user_updated( + [ + 'data' => [ + 'id' => $this->workos_id, + 'email' => 'changed-' . uniqid() . '@example.test', + 'first_name' => 'Changed', + 'last_name' => 'Person', + ], + ] + ); + + // The hash would have been rewritten if the handler had run; + // it should still be the stale placeholder. + $this->assertSame( 'stale-hash', get_user_meta( $this->user_id, '_workos_profile_hash', true ) ); + } + + public function test_handle_user_updated_runs_when_transient_absent(): void { + delete_transient( RestApi::TRANSIENT_PREFIX . $this->user_id ); + + $sync = new UserSync(); + $sync->handle_user_updated( + [ + 'data' => [ + 'id' => $this->workos_id, + 'email' => 'changed-' . uniqid() . '@example.test', + 'first_name' => 'Changed', + 'last_name' => 'Person', + ], + ] + ); + + // Without the transient set the handler must run, refreshing the + // stored profile hash with whatever the new payload produced. + $this->assertNotSame( 'stale-hash', get_user_meta( $this->user_id, '_workos_profile_hash', true ) ); + } +} diff --git a/webpack.config.js b/webpack.config.js index a9a2d54..f3925ac 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -16,5 +16,7 @@ module.exports = { 'admin-profiles': path.resolve( __dirname, 'src/js/admin-profiles/index.tsx' ), 'admin-users': path.resolve( __dirname, 'src/js/admin-users/index.tsx' ), 'admin-password-reset': path.resolve( __dirname, 'src/js/admin-password-reset/index.ts' ), + 'admin-change-email': path.resolve( __dirname, 'src/js/admin-change-email/index.ts' ), + 'change-email-confirm': path.resolve( __dirname, 'src/js/change-email-confirm/index.ts' ), }, };