From 7d5ddcb98e046c535d2929f415abd9926c688b53 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 16:48:11 -0400 Subject: [PATCH 01/12] feat(authkit): admin-triggered password reset with redirect_url (1.0.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A privileged WP user can now send a WorkOS password-reset email on behalf of a linked user. Three trigger surfaces — `wp-admin/users.php` row action, user-edit screen panel, and the new `[workos:password-reset]` shortcode (admin-of-other and self-service modes) — all post to `POST /wp-json/workos/v1/admin/users/{id}/password-reset`. The endpoint is gated by `edit_user($id)` (which WP grants on one's own ID, so the same route covers self-service), rate-limited per IP and per target, and writes a `password_reset.admin_sent` event to the activity log. A `redirect_url` parameter threads through every layer: validated same-host against `home_url()`, baked into the URL handed to WorkOS for the email, and returned to the React shell so the user lands on the chosen page after a successful reset. The same parameter is also accepted on the existing public `/auth/password/reset/{start,confirm}` endpoints. Fixes CONS-287 (reset password redirected users to Kadence Central). WorkOS reset emails now point at the in-site React reset page (`/workos/login/{slug}?token=…&redirect_to=…`) instead of `wp-login.php`. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly via `LoginTakeover`, so any reset emails already in users' inboxes keep working. Linear: https://linear.app/nexcess/issue/CONS-287/reset-password-redirects-users-to-kadence-central --- CHANGELOG.md | 30 ++ integration-workos.php | 2 +- readme.txt | 11 +- src/WorkOS/Auth/AuthKit/FrontendRoute.php | 31 ++ src/WorkOS/Auth/PasswordResetAdmin/Assets.php | 131 +++++++ .../Auth/PasswordResetAdmin/Controller.php | 47 +++ .../PasswordResetAdmin/RedirectValidator.php | 117 +++++++ .../Auth/PasswordResetAdmin/RestApi.php | 326 ++++++++++++++++++ .../Auth/PasswordResetAdmin/RowActions.php | 60 ++++ .../Auth/PasswordResetAdmin/Shortcode.php | 125 +++++++ .../PasswordResetAdmin/UserProfilePanel.php | 71 ++++ src/WorkOS/Controller.php | 2 + src/WorkOS/Plugin.php | 2 +- src/WorkOS/REST/Auth/Password.php | 57 ++- src/js/admin-password-reset/index.ts | 156 +++++++++ src/js/admin-password-reset/styles.css | 8 + src/js/authkit/flows.tsx | 48 ++- webpack.config.js | 1 + 18 files changed, 1195 insertions(+), 30 deletions(-) create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/Assets.php create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/Controller.php create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/RestApi.php create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/RowActions.php create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php create mode 100644 src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php create mode 100644 src/js/admin-password-reset/index.ts create mode 100644 src/js/admin-password-reset/styles.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 583f00f..7abdfe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [1.0.5] - 2026-05-18 + +### Added + +- **Admin-triggered WorkOS password reset** (#20, CONS-287) — A + privileged WP user can now send a WorkOS password-reset email on + behalf of any linked user via `POST /wp-json/workos/v1/admin/users/ + {id}/password-reset`. The endpoint is gated by `edit_user($id)` + (so the same route also covers self-service from the shortcode), + rate-limited per-IP and per-target, and writes a + `password_reset.admin_sent` event to the activity log. Triggered + from three surfaces: a `Send WorkOS password reset` inline row + action on `wp-admin/users.php`, a `Password Reset` panel on the + user-edit screen, and a `[workos:password-reset]` shortcode that + toggles between admin-of-other (`user="…"`) and self-service + modes based on its attributes. Companion `redirect_url` parameter + threads through every layer: the value is validated same-host + against `home_url()`, baked into the URL handed to WorkOS for the + email, and passed back to the React shell so the user lands on + the chosen page after a successful reset. Fixes the long-standing + CONS-287 ("reset password redirects users to Kadence Central") + where the post-reset URL was unconfigurable. +- **In-site React reset page** — reset emails now point at + `/workos/login/{profile}?token=…&redirect_to=…` instead of + `wp-login.php`. The existing AuthKit `ResetConfirm` step picks the + token off the URL and navigates the user to the validated redirect + on success. The old `wp-login.php?workos_action=reset-password` + URL still resolves cleanly via `LoginTakeover`, so any reset + emails already in users' inboxes continue to work. + ## [1.0.4] - 2026-05-14 ### Fixed diff --git a/integration-workos.php b/integration-workos.php index d5a19f2..5c6e9b1 100644 --- a/integration-workos.php +++ b/integration-workos.php @@ -3,7 +3,7 @@ * Plugin Name: Integration with WorkOS * Plugin URI: https://github.com/bordoni/integration-workos * Description: Enterprise identity management for WordPress powered by WorkOS. SSO, directory sync, MFA, and user management. - * Version: 1.0.4 + * Version: 1.0.5 * Author: Gustavo Bordoni * Author URI: https://github.com/bordoni * License: GPL-2.0-or-later diff --git a/readme.txt b/readme.txt index 75a3bdc..08bf80c 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: sso, identity, workos, authentication, directory-sync Requires at least: 6.2 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.0.4 +Stable tag: 1.0.5 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -175,6 +175,12 @@ WorkOS is provided by WorkOS, Inc. == Changelog == += 1.0.5 - 2026-05-18 = + +* New: Admin-triggered WorkOS password reset. A user with `edit_user` capability on a linked target (which includes self-service, since WP grants `edit_user` on one's own ID) can send a WorkOS reset email via three surfaces — a row action on `wp-admin/users.php`, a "Password Reset" panel on the user-edit screen, and the new `[workos:password-reset]` shortcode. The shortcode supports both admin-of-other (`user="…"`) and self-service (no `user` attr) modes. (#20) +* New: `redirect_url` parameter on the admin REST endpoint and on the existing public reset endpoints. The value is validated against `home_url()` host, threaded through the WorkOS-hosted email link, and used by the AuthKit React shell to send the user to the chosen page after a successful reset. Fixes the CONS-287 regression where the post-reset URL was unconfigurable. +* New: WorkOS reset emails now point at the in-site React reset page (`/workos/login/{slug}?token=…&redirect_to=…`) instead of `wp-login.php`. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly so any reset emails already in users' inboxes keep working. + = 1.0.4 - 2026-05-14 = * Fix: `wp-login.php?loggedout=true` is now claimed by the AuthKit takeover instead of rendering native wp-login. The "you have been logged out" screen advertised the wp-login username/password field, which legacy customers misread as a still-working classic sign-in. The URL now 302s to `/login/?loggedout=true` (or the configured custom path) so the React form handles it. `?fallback=1`, `?workos=0`, and `action=logout|lostpassword|rp|...` bypasses are unchanged. (#18) @@ -245,6 +251,9 @@ Base platform: == Upgrade Notice == += 1.0.5 = +Adds admin-triggered WorkOS password resets (Users list row action, user-edit panel, and `[workos:password-reset]` shortcode) and a `redirect_url` parameter that lands users on the chosen page after they finish resetting. WorkOS reset emails now point at the in-site React reset page instead of `wp-login.php`. + = 1.0.4 = Fixes the "you have been logged out" screen leaking the native wp-login form, password-reset emails arriving with HTML-encoded `&` in the link, an infinite redirect loop caused by cached redirect responses, and a Login Profile editor bug where unchecking an auth method or MFA factor did not persist on save. diff --git a/src/WorkOS/Auth/AuthKit/FrontendRoute.php b/src/WorkOS/Auth/AuthKit/FrontendRoute.php index a0577ea..c072c30 100644 --- a/src/WorkOS/Auth/AuthKit/FrontendRoute.php +++ b/src/WorkOS/Auth/AuthKit/FrontendRoute.php @@ -69,6 +69,37 @@ public function register(): void { add_action( 'workos_login_profile_deleted', [ $this, 'invalidate_signature' ] ); } + /** + * Build the canonical frontend URL for a given profile. + * + * Always returns the /workos/login/{slug} canonical URL — custom paths + * are an inbound alias only, so callers that need a stable outbound URL + * (emails, redirects, admin links) get the canonical one. Extra query + * args (e.g. `redirect_to`, `token`) are appended verbatim; they should + * already be validated by the caller. + * + * @param Profile $profile Active profile. + * @param array $args Optional query args to append. + * + * @return string Absolute URL. + */ + public static function url_for_profile( Profile $profile, array $args = [] ): string { + $base = home_url( '/workos/login/' . $profile->get_slug() . '/' ); + if ( empty( $args ) ) { + return $base; + } + + $filtered = []; + foreach ( $args as $key => $value ) { + if ( null === $value || '' === $value ) { + continue; + } + $filtered[ $key ] = (string) $value; + } + + return $filtered ? add_query_arg( $filtered, $base ) : $base; + } + /** * Register the /workos/login/{profile} rewrite rule. * diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Assets.php b/src/WorkOS/Auth/PasswordResetAdmin/Assets.php new file mode 100644 index 0000000..11be2c9 --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/Assets.php @@ -0,0 +1,131 @@ +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 the JS + CSS handles so callers can enqueue them by name. + * + * @return void + */ + public function register_assets(): void { + $asset_file = $this->plugin_path . 'build/admin-password-reset.asset.php'; + $asset = file_exists( $asset_file ) + ? require $asset_file + : [ + 'dependencies' => [ 'wp-i18n' ], + 'version' => $this->version, + ]; + + wp_register_script( + self::SCRIPT_HANDLE, + $this->plugin_url . 'build/admin-password-reset.js', + (array) ( $asset['dependencies'] ?? [] ), + (string) ( $asset['version'] ?? $this->version ), + true + ); + + wp_set_script_translations( self::SCRIPT_HANDLE, 'integration-workos' ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'workosPasswordReset', + [ + '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' ), + 'sending' => __( 'Sending…', 'integration-workos' ), + /* translators: %s: masked email address (e.g. "j•••@e•••.com"). */ + 'success' => __( 'Password reset email sent to %s.', 'integration-workos' ), + 'errorGeneric' => __( 'Could not send the reset email. Please try again.', 'integration-workos' ), + ], + ] + ); + + wp_register_style( + self::STYLE_HANDLE, + $this->plugin_url . 'build/admin-password-reset.css', + [], + (string) ( $asset['version'] ?? $this->version ) + ); + } + + /** + * Enqueue on admin screens where our triggers live. + * + * @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::SCRIPT_HANDLE ); + wp_enqueue_style( self::STYLE_HANDLE ); + } +} diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Controller.php b/src/WorkOS/Auth/PasswordResetAdmin/Controller.php new file mode 100644 index 0000000..376d1e4 --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/Controller.php @@ -0,0 +1,47 @@ +container->singleton( RedirectValidator::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->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(); + } + + /** + * Unregister. + * + * @return void + */ + protected function doUnregister(): void { + } +} diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php b/src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php new file mode 100644 index 0000000..28d3077 --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php @@ -0,0 +1,117 @@ +normalize( (string) $url ); + + if ( '' !== $candidate ) { + $validated = wp_validate_redirect( $candidate, '' ); + if ( '' !== $validated && $this->same_host( $validated ) ) { + return $validated; + } + } + + return $this->fallback( $profile ); + } + + /** + * Resolve the fallback redirect when no valid URL was supplied. + * + * @param Profile $profile Active profile. + * + * @return string Absolute URL. + */ + public function fallback( Profile $profile ): string { + $preferred = $profile->get_post_login_redirect(); + if ( '' !== $preferred ) { + $candidate = $this->normalize( $preferred ); + if ( '' !== $candidate ) { + $validated = wp_validate_redirect( $candidate, '' ); + if ( '' !== $validated && $this->same_host( $validated ) ) { + return $validated; + } + } + } + + return home_url( '/' ); + } + + /** + * Normalize an input URL to an absolute http(s) form. + * + * Relative paths are resolved against `home_url()`. Schemes other than + * http/https are rejected outright. + * + * @param string $url Raw URL. + * + * @return string Absolute URL with an http/https scheme, or empty on reject. + */ + private function normalize( string $url ): string { + $url = trim( $url ); + if ( '' === $url ) { + return ''; + } + + // Relative path → resolve against home_url so wp_validate_redirect + // can compare the host. Anything starting with `//` is protocol-relative + // and should be treated as cross-origin; reject. + if ( '/' === $url[0] && ( ! isset( $url[1] ) || '/' !== $url[1] ) ) { + return home_url( $url ); + } + + $parts = wp_parse_url( $url ); + if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return ''; + } + + $scheme = strtolower( $parts['scheme'] ); + if ( 'http' !== $scheme && 'https' !== $scheme ) { + return ''; + } + + return $url; + } + + /** + * Check that a URL's host matches the WP site host. + * + * @param string $url Absolute URL. + * + * @return bool + */ + private function same_host( string $url ): bool { + $url_host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); + $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); + + return '' !== $url_host && $url_host === $site_host; + } +} diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php new file mode 100644 index 0000000..8883138 --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php @@ -0,0 +1,326 @@ +profiles = $profiles; + $this->rate_limiter = $rate_limiter; + $this->redirect_validator = $redirect_validator; + } + + /** + * 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, + '/admin/users/(?P\d+)/password-reset', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'send_reset' ], + 'permission_callback' => [ $this, 'check_permission' ], + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + 'redirect_url' => [ 'sanitize_callback' => 'sanitize_text_field' ], + 'profile' => [ 'sanitize_callback' => 'sanitize_title' ], + ], + ] + ); + } + + /** + * Permission callback — `edit_user` on the target user. + * + * Granular by design: this is the WP capability that maps to "can edit + * the user with this ID", which is true for the user themselves (so + * self-service via the shortcode works) and true for admins/editors + * with the broader `edit_users` cap acting on others. + * + * @param WP_REST_Request $request REST request. + * + * @return true|WP_Error + */ + public function check_permission( 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 send a password reset for this user.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + return true; + } + + /** + * Endpoint callback — send a WorkOS reset email for the target user. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function send_reset( 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 ] + ); + } + + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return new WP_Error( + 'workos_user_not_linked', + __( 'This user is not linked to a WorkOS account.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + $email = (string) $user->user_email; + if ( '' === $email || ! is_email( $email ) ) { + return new WP_Error( + 'workos_user_missing_email', + __( 'This user has no usable email address.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + $profile = $this->resolve_profile( (string) $request->get_param( 'profile' ) ); + if ( ! $profile instanceof Profile ) { + return $profile; + } + + if ( ! $profile->is_password_reset_flow_enabled() ) { + return new WP_Error( + 'workos_reset_disabled', + __( 'Password reset is not enabled for this login profile.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limiter->attempt( + 'pw_reset_admin_ip', + $ip, + self::RATE_LIMIT_IP_ATTEMPTS, + self::RATE_LIMIT_WINDOW + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + $rate_ok = $this->rate_limiter->attempt( + 'pw_reset_admin_user', + (string) $user->ID, + self::RATE_LIMIT_USER_ATTEMPTS, + self::RATE_LIMIT_WINDOW + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $redirect_url = $this->redirect_validator->validate( + (string) $request->get_param( 'redirect_url' ), + $profile + ); + + $reset_url = $this->build_reset_url( $profile, $redirect_url ); + + $response = workos()->api()->send_password_reset( $email, $reset_url ); + if ( is_wp_error( $response ) ) { + return $response; + } + + EventLogger::log( + 'password_reset.admin_sent', + [ + 'user_id' => $user->ID, + 'user_email' => $email, + 'workos_user_id' => $workos_user_id, + 'metadata' => [ + 'profile' => $profile->get_slug(), + 'redirect_url' => $redirect_url, + 'initiator_id' => get_current_user_id(), + 'self_service' => get_current_user_id() === $user->ID, + ], + ] + ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'email_hint' => $this->mask_email( $email ), + 'profile' => $profile->get_slug(), + 'redirect_url' => $redirect_url, + ], + 200 + ); + } + + /** + * Resolve the login profile to use, defaulting to the canonical default. + * + * @param string $slug Optional profile slug from the request. + * + * @return Profile|WP_Error + */ + private function resolve_profile( string $slug ) { + if ( '' !== $slug ) { + $profile = $this->profiles->find_by_slug( $slug ); + if ( $profile ) { + return $profile; + } + return new WP_Error( + 'workos_profile_not_found', + __( 'Login profile not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + $default = $this->profiles->find_by_slug( Profile::DEFAULT_SLUG ); + return $default ? $default : Profile::defaults(); + } + + /** + * Build the URL emailed to the user. + * + * Mirrors REST\Auth\Password::build_password_reset_url() so admin- and + * user-initiated emails carry the same URL shape. We re-use the URL + * builder by going through the same FrontendRoute helper. + * + * @param Profile $profile Active profile. + * @param string $redirect_url Validated redirect URL or empty. + * + * @return string + */ + private function build_reset_url( Profile $profile, string $redirect_url ): string { + $base = html_entity_decode( + \WorkOS\Auth\AuthKit\FrontendRoute::url_for_profile( $profile ), + ENT_QUOTES | ENT_HTML5 + ); + + $args = []; + if ( '' !== $redirect_url ) { + $args['redirect_to'] = $redirect_url; + } + + return $args ? add_query_arg( $args, $base ) : $base; + } + + /** + * Mask an email for display in admin notices. + * + * Preserves the first character of the local part and the TLD; the + * rest is replaced with `•`. Example: jdoe@example.com → j•••@e•••.com. + * + * @param string $email 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/PasswordResetAdmin/RowActions.php b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php new file mode 100644 index 0000000..920fd2e --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php @@ -0,0 +1,60 @@ +ID ) ) { + return $actions; + } + + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return $actions; + } + + $actions['workos_password_reset'] = sprintf( + '%s', + (int) $user->ID, + esc_html__( 'Send WorkOS password reset', 'integration-workos' ) + ); + + return $actions; + } +} diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php b/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php new file mode 100644 index 0000000..d6db52d --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php @@ -0,0 +1,125 @@ +"` — admin-of-other mode. Visible only to viewers + * with `edit_user` on the target. Useful on internal dashboards. + * + * - no `user` attribute — self-service mode. Visible to any logged-in + * user; pre-targets their own account. + * + * In both cases the click fires `POST /workos/v1/admin/users/{id}/password-reset`, + * 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 reset (optional) + * - label="Reset password" — button label (optional) + */ +class Shortcode { + + public const TAG = 'workos:password-reset'; + + /** + * 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' => '', + 'profile' => '', + ], + 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 ''; + } + + // Confirm the user is actually linked to WorkOS — otherwise this + // button can't do anything useful, so silently render nothing. + if ( '' === (string) get_user_meta( $target, '_workos_user_id', true ) ) { + return ''; + } + + wp_enqueue_script( 'workos-admin-password-reset' ); + wp_enqueue_style( 'workos-admin-password-reset' ); + + $label = (string) $atts['label']; + if ( '' === $label ) { + $label = get_current_user_id() === $target + ? __( 'Reset my password', 'integration-workos' ) + : __( 'Send password reset email', 'integration-workos' ); + } + + return sprintf( + '', + (int) $target, + esc_attr( (string) $atts['redirect_url'] ), + esc_attr( (string) $atts['profile'] ), + esc_html( $label ) + ); + } + + /** + * Resolve the shortcode's `user` attribute to a WP user ID. + * + * Accepts a numeric ID, an email address, or an empty string (in which + * case the currently logged-in user is used). + * + * @param string $raw Raw attribute value. + * + * @return int User ID, or 0 if unresolved. + */ + private function resolve_target( string $raw ): int { + $raw = trim( $raw ); + + if ( '' === $raw ) { + return is_user_logged_in() ? get_current_user_id() : 0; + } + + if ( ctype_digit( $raw ) ) { + return (int) $raw; + } + + if ( is_email( $raw ) ) { + $user = get_user_by( 'email', $raw ); + return $user ? (int) $user->ID : 0; + } + + return 0; + } +} diff --git a/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php b/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php new file mode 100644 index 0000000..6f876df --- /dev/null +++ b/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php @@ -0,0 +1,71 @@ +ID ) ) { + return; + } + + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return; + } + + printf( + '

%s

', + esc_html__( 'Password Reset', 'integration-workos' ) + ); + + printf( + '

%s

', + esc_html__( + 'Send a password-reset email from WorkOS. The user receives a link to set a new password in-site.', + 'integration-workos' + ) + ); + + printf( + '

', + (int) $user->ID, + esc_html__( 'Send password reset email', 'integration-workos' ) + ); + } +} diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index b622524..0620217 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -13,6 +13,7 @@ use WorkOS\Admin\LoginProfiles\Controller as LoginProfilesAdminController; use WorkOS\Auth\AuthKit\Controller as AuthKitController; use WorkOS\Auth\Controller as AuthController; +use WorkOS\Auth\PasswordResetAdmin\Controller as PasswordResetAdminController; use WorkOS\REST\Controller as RESTController; use WorkOS\Webhook\Controller as WebhookController; use WorkOS\Sync\Controller as SyncController; @@ -36,6 +37,7 @@ protected function doRegister(): void { $this->container->register( AuthKitController::class ); $this->container->register( LoginProfilesAdminController::class ); $this->container->register( AuthController::class ); + $this->container->register( PasswordResetAdminController::class ); $this->container->register( RESTController::class ); $this->container->register( WebhookController::class ); $this->container->register( SyncController::class ); diff --git a/src/WorkOS/Plugin.php b/src/WorkOS/Plugin.php index 2fd15d4..08bcbdb 100644 --- a/src/WorkOS/Plugin.php +++ b/src/WorkOS/Plugin.php @@ -57,7 +57,7 @@ class Plugin { * * @var string */ - private string $version = '1.0.4'; + private string $version = '1.0.5'; /** * Container instance. diff --git a/src/WorkOS/REST/Auth/Password.php b/src/WorkOS/REST/Auth/Password.php index 0da4ee5..69a4c87 100644 --- a/src/WorkOS/REST/Auth/Password.php +++ b/src/WorkOS/REST/Auth/Password.php @@ -7,7 +7,9 @@ namespace WorkOS\REST\Auth; +use WorkOS\Auth\AuthKit\FrontendRoute; use WorkOS\Auth\AuthKit\Profile; +use WorkOS\Auth\PasswordResetAdmin\RedirectValidator; use WP_Error; use WP_REST_Request; use WP_REST_Response; @@ -225,6 +227,11 @@ public function reset_start( WP_REST_Request $request ) { return $rate_ok; } + $redirect_url = ( new RedirectValidator() )->validate( + (string) $request->get_param( 'redirect_url' ), + $profile + ); + // Fire the WorkOS send call but *always* return 200 to prevent email // enumeration (an attacker cannot tell whether an account exists). // Also normalize response time: WorkOS answers valid + unknown @@ -235,7 +242,7 @@ public function reset_start( WP_REST_Request $request ) { workos()->api()->send_password_reset( $email, - $this->build_password_reset_url( $profile ), + $this->build_password_reset_url( $profile, $redirect_url ), $this->get_radar_token( $request ) ); @@ -307,8 +314,16 @@ public function reset_confirm( WP_REST_Request $request ) { return $workos_response; } + $redirect_url = ( new RedirectValidator() )->validate( + (string) $request->get_param( 'redirect_url' ), + $profile + ); + return new WP_REST_Response( - [ 'ok' => true ], + [ + 'ok' => true, + 'redirect_url' => $redirect_url, + ], 200 ); } @@ -344,25 +359,33 @@ private function resolve_workos_user_id( \WP_User $wp_user, string $email ): str /** * Build the URL emailed to users for completing a password reset. * - * @param Profile $profile Active profile. + * Points at the AuthKit React shell at /workos/login/{slug}, which + * already mounts directly into the `reset_confirm` step when `token` + * is present in the URL (FrontendRoute::maybe_render). WorkOS appends + * its generated reset token as `&token=…` before sending the email. + * + * @param Profile $profile Active profile. + * @param string $redirect_url Optional same-host URL to send the user to + * after they finish resetting. * * @return string */ - private function build_password_reset_url( Profile $profile ): string { - // `wp_login_url()` runs through the `login_url`/`home_url` filters, - // which a host-site filter escapes (`&` → `&` or `&`). - // Decode before `add_query_arg()`, not after: `add_query_arg()` - // reads the `#` in `&` as a fragment delimiter and shears the - // rest of the query off the URL. WorkOS emails the URL verbatim. - $login_url = html_entity_decode( wp_login_url(), ENT_QUOTES | ENT_HTML5 ); - - return add_query_arg( - [ - 'workos_action' => 'reset-password', - 'profile' => $profile->get_slug(), - ], - $login_url + private function build_password_reset_url( Profile $profile, string $redirect_url = '' ): string { + // `home_url()` runs through filters that may HTML-encode `&`. Decode + // before `add_query_arg()` so `&` doesn't shear the query off + // (see prior fix: commits 750dfa5 / f040ac4). WorkOS emails the URL + // verbatim, so a clean URL in == a clean URL out. + $base = html_entity_decode( + FrontendRoute::url_for_profile( $profile ), + ENT_QUOTES | ENT_HTML5 ); + + $args = []; + if ( '' !== $redirect_url ) { + $args['redirect_to'] = $redirect_url; + } + + return $args ? add_query_arg( $args, $base ) : $base; } /** diff --git a/src/js/admin-password-reset/index.ts b/src/js/admin-password-reset/index.ts new file mode 100644 index 0000000..62f4016 --- /dev/null +++ b/src/js/admin-password-reset/index.ts @@ -0,0 +1,156 @@ +/** + * Click handler for the "Send WorkOS password reset" trigger. + * + * Listens for clicks on any `.workos-pwreset-trigger` element on the + * page — present on the WP users.php row action, the user-edit screen + * panel, the WorkOS Users admin page, and the `[workos:password-reset]` + * shortcode. POSTs to `POST /workos/v1/admin/users/{id}/password-reset` + * with the per-element `data-user-id`, `data-redirect-url`, and + * `data-profile` attributes. + */ + +import { __, sprintf } from '@wordpress/i18n'; +import './styles.css'; + +interface PasswordResetConfig { + restUrl: string; + nonce: string; + strings: { + confirm: string; + sending: string; + success: string; + errorGeneric: string; + }; +} + +interface SuccessResponse { + ok: true; + email_hint: string; +} + +interface ErrorResponse { + code?: string; + message?: string; +} + +declare global { + interface Window { + workosPasswordReset?: PasswordResetConfig; + } +} + +function getConfig(): PasswordResetConfig | null { + return window.workosPasswordReset ?? null; +} + +function showNotice( message: string, kind: 'success' | 'error' ): void { + const existing = document.getElementById( + 'workos-pwreset-notice' + ) as HTMLDivElement | null; + if ( existing ) { + existing.remove(); + } + + const notice = document.createElement( 'div' ); + notice.id = 'workos-pwreset-notice'; + notice.className = `notice notice-${ kind } is-dismissible workos-pwreset-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 ); +} + +async function sendReset( button: HTMLElement ): Promise< void > { + const config = getConfig(); + if ( ! config ) { + return; + } + + const userId = Number( button.getAttribute( 'data-user-id' ) || '0' ); + if ( ! userId ) { + return; + } + + const redirectUrl = button.getAttribute( 'data-redirect-url' ) || ''; + const profile = button.getAttribute( 'data-profile' ) || ''; + + if ( ! window.confirm( config.strings.confirm ) ) { + return; + } + + const original = button.innerHTML; + button.setAttribute( 'disabled', 'disabled' ); + button.classList.add( 'is-busy' ); + button.textContent = config.strings.sending; + + try { + const url = `${ config.restUrl }${ userId }/password-reset`; + const response = await fetch( url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': config.nonce, + }, + body: JSON.stringify( { + redirect_url: redirectUrl, + profile, + } ), + } ); + + const data = ( await response.json() ) as + | SuccessResponse + | ErrorResponse; + + if ( ! response.ok ) { + const err = data as ErrorResponse; + showNotice( + err.message || config.strings.errorGeneric, + 'error' + ); + return; + } + + const ok = data as SuccessResponse; + showNotice( + sprintf( + config.strings.success, + ok.email_hint || __( 'the user', 'integration-workos' ) + ), + 'success' + ); + } catch ( _ ) { + showNotice( config.strings.errorGeneric, 'error' ); + } finally { + button.removeAttribute( 'disabled' ); + button.classList.remove( 'is-busy' ); + button.innerHTML = original; + } +} + +document.addEventListener( 'click', ( event ) => { + const target = event.target as HTMLElement | null; + if ( ! target ) { + return; + } + + const trigger = target.closest< HTMLElement >( + '.workos-pwreset-trigger' + ); + if ( ! trigger ) { + return; + } + + event.preventDefault(); + void sendReset( trigger ); +} ); diff --git a/src/js/admin-password-reset/styles.css b/src/js/admin-password-reset/styles.css new file mode 100644 index 0000000..3179d24 --- /dev/null +++ b/src/js/admin-password-reset/styles.css @@ -0,0 +1,8 @@ +.workos-pwreset-trigger.is-busy { + cursor: progress; + opacity: 0.7; +} + +.workos-pwreset-notice { + margin: 10px 0 15px; +} diff --git a/src/js/authkit/flows.tsx b/src/js/authkit/flows.tsx index 6f39ef4..ea3d5a5 100644 --- a/src/js/authkit/flows.tsx +++ b/src/js/authkit/flows.tsx @@ -803,6 +803,7 @@ export function ResetRequest( { client, profile, onSent, onBack }: ResetRequestP setError( '' ); const { ok, data } = await client.json( '/password/reset/start', { email, + redirect_url: profile.redirectTo, } ); setLoading( false ); if ( ! ok ) { @@ -879,25 +880,37 @@ interface ResetConfirmProps { onDone: () => void; } +interface ResetConfirmResponse { + ok: boolean; + redirect_url?: string; +} + export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmProps ) { const [ password, setPassword ] = useState( '' ); const [ loading, setLoading ] = useState( false ); const [ error, setError ] = useState( '' ); const [ success, setSuccess ] = useState( false ); + const [ redirectUrl, setRedirectUrl ] = useState( '' ); const submit = async ( event: FormEvent ) => { event.preventDefault(); setLoading( true ); setError( '' ); - const { ok, data } = await client.json( '/password/reset/confirm', { - token, - new_password: password, - } ); + const { ok, data } = await client.json< ResetConfirmResponse >( + '/password/reset/confirm', + { + token, + new_password: password, + redirect_url: profile.redirectTo, + } + ); setLoading( false ); if ( ! ok ) { setError( errorMessage( data ) ); return; } + const payload = data as ResetConfirmResponse; + setRedirectUrl( payload?.redirect_url ?? '' ); setSuccess( true ); }; @@ -906,6 +919,14 @@ export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmPr profile.branding.heading || __( 'Sign in', 'integration-workos' ); if ( success ) { + const handleContinue = (): void => { + if ( redirectUrl ) { + window.location.assign( redirectUrl ); + return; + } + onDone(); + }; + return ( - { __( - 'You can now sign in with your new password.', - 'integration-workos' - ) } + { redirectUrl + ? __( + 'You can now sign in with your new password. Continuing…', + 'integration-workos' + ) + : __( + 'You can now sign in with your new password.', + 'integration-workos' + ) } } > - Date: Mon, 18 May 2026 19:55:00 -0400 Subject: [PATCH 02/12] docs: add docblocks to password-reset TS/TSX additions Documents the interfaces and functions added in the previous commit: - admin-password-reset/index.ts: PasswordResetConfig, SuccessResponse, ErrorResponse interfaces; getConfig, showNotice, sendReset, and the delegated click listener now each carry a JSDoc. - authkit/flows.tsx: new ResetConfirmResponse interface and the modified ResetConfirm component now describe how redirect_url threads through to the success-card 'Continue' action. --- src/js/admin-password-reset/index.ts | 49 ++++++++++++++++++++++++++++ src/js/authkit/flows.tsx | 18 ++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/js/admin-password-reset/index.ts b/src/js/admin-password-reset/index.ts index 62f4016..5551391 100644 --- a/src/js/admin-password-reset/index.ts +++ b/src/js/admin-password-reset/index.ts @@ -12,9 +12,17 @@ import { __, sprintf } from '@wordpress/i18n'; import './styles.css'; +/** + * Runtime config injected via `wp_localize_script()` on `window.workosPasswordReset`. + * + * Mirrors the shape produced by {@link \WorkOS\Auth\PasswordResetAdmin\Assets::register_assets()}. + */ interface PasswordResetConfig { + /** Base REST URL — admin endpoint is `${restUrl}{id}/password-reset`. */ restUrl: string; + /** WP REST nonce (`wp_rest`). */ nonce: string; + /** Pre-translated UI strings (admin locale, server-side translated). */ strings: { confirm: string; sending: string; @@ -23,11 +31,18 @@ interface PasswordResetConfig { }; } +/** + * Successful response payload from `POST /workos/v1/admin/users/{id}/password-reset`. + */ interface SuccessResponse { ok: true; + /** Masked email like `j•••@e•••.com` — never the full address. */ email_hint: string; } +/** + * Error response payload (`code`/`message` shape returned by `WP_Error::__construct`). + */ interface ErrorResponse { code?: string; message?: string; @@ -39,10 +54,25 @@ declare global { } } +/** + * Read the localized config from the global namespace. + * + * Returns null when the script was loaded without its localize-script + * companion (e.g. on a page outside the registered admin screens). + */ function getConfig(): PasswordResetConfig | null { return window.workosPasswordReset ?? null; } +/** + * Render a transient WP admin notice at the top of the current screen. + * + * Replaces any previously rendered notice with the same id so rapid + * clicks don't stack multiple banners. Auto-dismisses after 7 seconds. + * + * @param message Pre-translated message to display. + * @param kind Visual variant — maps to `.notice-success` / `.notice-error`. + */ function showNotice( message: string, kind: 'success' | 'error' ): void { const existing = document.getElementById( 'workos-pwreset-notice' @@ -70,6 +100,17 @@ function showNotice( message: string, kind: 'success' | 'error' ): void { }, 7000 ); } +/** + * Send a password-reset request for the user identified by a trigger element. + * + * Reads `data-user-id`, `data-redirect-url`, and `data-profile` off the + * button, prompts the operator to confirm, then POSTs to the admin REST + * endpoint with the WP REST nonce. The button is put in a busy state + * while the request is in-flight and restored in the `finally` block so + * a failed call leaves the UI usable. + * + * @param button Trigger element clicked by the operator. + */ async function sendReset( button: HTMLElement ): Promise< void > { const config = getConfig(); if ( ! config ) { @@ -138,6 +179,14 @@ async function sendReset( button: HTMLElement ): Promise< void > { } } +/** + * Document-level click listener — delegates to {@link sendReset} when the + * click originates inside a `.workos-pwreset-trigger` element. + * + * Delegation (vs per-element binding) lets surfaces like the users.php + * row action and the shortcode share one bound handler regardless of + * when the trigger element is rendered into the DOM. + */ document.addEventListener( 'click', ( event ) => { const target = event.target as HTMLElement | null; if ( ! target ) { diff --git a/src/js/authkit/flows.tsx b/src/js/authkit/flows.tsx index ea3d5a5..6fcfde7 100644 --- a/src/js/authkit/flows.tsx +++ b/src/js/authkit/flows.tsx @@ -880,11 +880,29 @@ interface ResetConfirmProps { onDone: () => void; } +/** + * Successful body shape for `POST /password/reset/confirm`. + * + * The server validates and echoes `redirect_url` so the React shell can + * navigate the user to a post-reset destination without re-running the + * same-host validation client-side. An absent or empty value means + * "fall back to the existing onDone behavior" (return to sign-in). + */ interface ResetConfirmResponse { ok: boolean; redirect_url?: string; } +/** + * Reset-confirm step — user submits a new password with the token that + * came in via the email link (`?token=…` on `/workos/login/{profile}`). + * + * On success we read the server-validated `redirect_url` from the + * response payload; the success card's "Continue" button uses that URL + * if present, falling back to the parent's `onDone` (which returns the + * user to the method picker so they can sign in with their new + * password). + */ export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmProps ) { const [ password, setPassword ] = useState( '' ); const [ loading, setLoading ] = useState( false ); From c0764c50de13c44c7b274d454bf74587c0328b9b Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 20:03:10 -0400 Subject: [PATCH 03/12] feat(authkit): require password confirmation + strength check on reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset-confirm step now asks for the new password twice and scores it in real time with WordPress's `wp.passwordStrength.meter` (zxcvbn-backed). The submit button stays disabled until both fields match and the score reaches Strong (zxcvbn ≥ 3). When zxcvbn is still streaming in we report "Checking strength…" rather than gating on a transient. `Renderer::enqueue()` now adds `password-strength-meter` to the AuthKit bundle's dependency list, which transitively pulls in zxcvbn-async so the meter is available everywhere AuthKit renders (wp-login.php takeover, /workos/login/{profile}, the shortcode). Site name and common words are passed as the zxcvbn disallowed list so they lose strength points. The user's email is intentionally NOT in the list — the reset flow only knows the opaque WorkOS token, not the recipient. --- CHANGELOG.md | 11 ++ readme.txt | 1 + src/WorkOS/Auth/AuthKit/Renderer.php | 10 +- src/js/authkit/flows.tsx | 154 ++++++++++++++++++++++++++- src/js/authkit/styles.css | 37 +++++++ 5 files changed, 211 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7abdfe8..9f0dc5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,17 @@ on success. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly via `LoginTakeover`, so any reset emails already in users' inboxes continue to work. +- **Password strength + confirmation on reset** — the + `ResetConfirm` step now requires the user to enter the new + password twice and runs it through WordPress's + `wp.passwordStrength.meter` (zxcvbn-backed) in real time. The + submit button stays disabled until the two fields match and the + score reaches `Strong` (zxcvbn ≥ 3). Site name and common words + are passed as the zxcvbn disallowed list so they lose strength + points. The `password-strength-meter` script is wired in as a + dependency of the AuthKit bundle by `Renderer::enqueue()`; when + zxcvbn is still loading the meter reports "Checking strength…" + rather than gating on a transient. ## [1.0.4] - 2026-05-14 diff --git a/readme.txt b/readme.txt index 08bf80c..25f187b 100644 --- a/readme.txt +++ b/readme.txt @@ -180,6 +180,7 @@ WorkOS is provided by WorkOS, Inc. * New: Admin-triggered WorkOS password reset. A user with `edit_user` capability on a linked target (which includes self-service, since WP grants `edit_user` on one's own ID) can send a WorkOS reset email via three surfaces — a row action on `wp-admin/users.php`, a "Password Reset" panel on the user-edit screen, and the new `[workos:password-reset]` shortcode. The shortcode supports both admin-of-other (`user="…"`) and self-service (no `user` attr) modes. (#20) * New: `redirect_url` parameter on the admin REST endpoint and on the existing public reset endpoints. The value is validated against `home_url()` host, threaded through the WorkOS-hosted email link, and used by the AuthKit React shell to send the user to the chosen page after a successful reset. Fixes the CONS-287 regression where the post-reset URL was unconfigurable. * New: WorkOS reset emails now point at the in-site React reset page (`/workos/login/{slug}?token=…&redirect_to=…`) instead of `wp-login.php`. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly so any reset emails already in users' inboxes keep working. +* New: Password strength + confirmation on the reset-confirm step. Users must enter the new password twice; the value is scored in real time via WordPress's `wp.passwordStrength.meter` (zxcvbn) and the submit button stays disabled until the fields match and the score reaches Strong. Site name and common words are passed as the zxcvbn disallowed list. = 1.0.4 - 2026-05-14 = diff --git a/src/WorkOS/Auth/AuthKit/Renderer.php b/src/WorkOS/Auth/AuthKit/Renderer.php index 3c3c4f4..a2c7e27 100644 --- a/src/WorkOS/Auth/AuthKit/Renderer.php +++ b/src/WorkOS/Auth/AuthKit/Renderer.php @@ -72,10 +72,18 @@ public function enqueue( ?Profile $profile = null ): void { 'version' => WORKOS_VERSION, ]; + // WP's `password-strength-meter` registers `window.wp.passwordStrength` + // (zxcvbn-async backed). Stapled onto the bundle so the reset-confirm + // flow can score the new password in real time and reject anything + // weak before we ask WorkOS to accept it. + $dependencies = (array) ( $asset['dependencies'] ?? [ 'wp-element' ] ); + $dependencies[] = 'password-strength-meter'; + $dependencies = array_values( array_unique( $dependencies ) ); + wp_enqueue_script( self::SCRIPT_HANDLE, $this->assets_url . 'authkit.js', - $asset['dependencies'] ?? [ 'wp-element' ], + $dependencies, $asset['version'] ?? WORKOS_VERSION, true ); diff --git a/src/js/authkit/flows.tsx b/src/js/authkit/flows.tsx index 6fcfde7..5fc45ab 100644 --- a/src/js/authkit/flows.tsx +++ b/src/js/authkit/flows.tsx @@ -903,15 +903,141 @@ interface ResetConfirmResponse { * user to the method picker so they can sign in with their new * password). */ +/** + * Score buckets returned by `window.wp.passwordStrength.meter`. + * + * The function exists once `password-strength-meter` is enqueued (we + * declare it as a hard dependency of the AuthKit bundle in + * {@see \WorkOS\Auth\AuthKit\Renderer::enqueue()}). zxcvbn loads + * asynchronously, so the meter returns `LOADING` until the library + * lands — treat that bucket as "still measuring" rather than "weak" + * so an early submit isn't blocked by a transient. + */ +const STRENGTH_LOADING = -1; +const STRENGTH_MISMATCH = 5; +/** Minimum zxcvbn score (0-4) we require before allowing submit. */ +const STRENGTH_MIN_REQUIRED = 3; + +interface PasswordStrengthGlobal { + meter: ( password1: string, disallowedList: string[], password2: string ) => number; +} + +/** + * Read the WP password-strength helper off the `wp` global. + * + * Returns null when the bundle was loaded without `password-strength-meter` + * — surfaces can then degrade to "match-only" gating instead of crashing. + */ +function getPasswordStrength(): PasswordStrengthGlobal | null { + const wp = ( window as unknown as { wp?: { passwordStrength?: PasswordStrengthGlobal } } ).wp; + return wp?.passwordStrength ?? null; +} + +/** + * Translate a `wp.passwordStrength.meter` score into a UI label. + * + * Mirrors the buckets WP uses in its own profile editor (Very weak → + * Weak → Medium → Strong) so users see familiar terminology. + */ +function strengthLabel( score: number ): string { + if ( score === STRENGTH_MISMATCH ) { + return __( 'Passwords do not match', 'integration-workos' ); + } + if ( score === STRENGTH_LOADING ) { + return __( 'Checking strength…', 'integration-workos' ); + } + switch ( score ) { + case 0: + return __( 'Very weak', 'integration-workos' ); + case 1: + return __( 'Weak', 'integration-workos' ); + case 2: + return __( 'Medium', 'integration-workos' ); + case 3: + return __( 'Strong', 'integration-workos' ); + case 4: + return __( 'Very strong', 'integration-workos' ); + default: + return ''; + } +} + +/** + * CSS-ready strength bucket name for styling the meter dot/bar. + */ +function strengthVariant( score: number ): string { + if ( score === STRENGTH_MISMATCH ) { + return 'mismatch'; + } + if ( score === STRENGTH_LOADING ) { + return 'loading'; + } + if ( score <= 1 ) { + return 'weak'; + } + if ( score === 2 ) { + return 'medium'; + } + return 'strong'; +} + export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmProps ) { const [ password, setPassword ] = useState( '' ); + const [ confirmPassword, setConfirmPassword ] = useState( '' ); const [ loading, setLoading ] = useState( false ); const [ error, setError ] = useState( '' ); const [ success, setSuccess ] = useState( false ); const [ redirectUrl, setRedirectUrl ] = useState( '' ); + // The disallowed list seeds zxcvbn's dictionary so common + // site-specific guesses (the site name) lose strength points. + // We deliberately don't include the user's email here — the reset + // flow only knows the opaque WorkOS token, not the recipient. + const disallowedList = [ profile.siteName, 'wordpress', 'admin' ].filter( + ( value ) => value && value.length > 0 + ); + + const strength = getPasswordStrength(); + const score = ( () => { + if ( ! password ) { + return STRENGTH_LOADING; + } + if ( ! strength ) { + // Meter wasn't loaded — fall back to match-only gating. + return confirmPassword && password !== confirmPassword + ? STRENGTH_MISMATCH + : STRENGTH_MIN_REQUIRED; + } + return strength.meter( password, disallowedList, confirmPassword ); + } )(); + + const matches = password.length > 0 && password === confirmPassword; + const strongEnough = + score !== STRENGTH_MISMATCH && + score !== STRENGTH_LOADING && + score >= STRENGTH_MIN_REQUIRED; + const canSubmit = matches && strongEnough && ! loading; + const submit = async ( event: FormEvent ) => { event.preventDefault(); + if ( ! canSubmit ) { + if ( ! matches ) { + setError( + __( + 'The two passwords don’t match. Re-enter them and try again.', + 'integration-workos' + ) + ); + } else if ( ! strongEnough ) { + setError( + __( + 'Please choose a stronger password before continuing.', + 'integration-workos' + ) + ); + } + return; + } setLoading( true ); setError( '' ); const { ok, data } = await client.json< ResetConfirmResponse >( @@ -1010,8 +1136,34 @@ export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmPr required={ true } /> + + + + { password.length > 0 && ( +

+ + { __( 'Password strength:', 'integration-workos' ) } + { ' ' } + { strengthLabel( score ) } +

+ ) } - Date: Mon, 18 May 2026 21:34:17 -0400 Subject: [PATCH 04/12] feat(authkit): auto-sign-in after password reset (per-profile toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the new `auto_login_after_reset` toggle is on (default), a successful reset_confirm immediately re-authenticates the user with the new password, runs the result through `LoginCompleter` (so MFA, organization selection, and the entitlement gate behave the same as a normal sign-in), sets the WP auth cookie, and sends the user to the validated post-reset redirect URL. With the toggle off the prior behaviour is preserved — the user lands on the "Password reset / Continue to sign in" card. The MFA path is handed back to the React App's existing `mfa` step via the new `onMfa` callback on `ResetConfirm`, so the experience is identical to the rest of the auth surfaces. The toggle ships on the Login Profile editor under "Flows" and is greyed out when the password-reset flow itself is disabled. --- CHANGELOG.md | 12 +++ readme.txt | 1 + src/WorkOS/Auth/AuthKit/Profile.php | 131 ++++++++++++++++------------ src/WorkOS/REST/Auth/Password.php | 85 ++++++++++++++++-- src/js/admin-profiles/index.tsx | 22 +++++ src/js/authkit/App.tsx | 8 ++ src/js/authkit/flows.tsx | 64 +++++++++++++- src/js/authkit/index.tsx | 1 + src/js/authkit/types.ts | 1 + 9 files changed, 263 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0dc5c..9297288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,18 @@ dependency of the AuthKit bundle by `Renderer::enqueue()`; when zxcvbn is still loading the meter reports "Checking strength…" rather than gating on a transient. +- **Auto-login after password reset** — new per-profile toggle + `auto_login_after_reset` (default: on). When enabled, a + successful reset_confirm authenticates the user with the new + password, runs it through the same `LoginCompleter` the rest of + the auth surfaces use (so MFA, organization selection, and the + entitlement gate all behave the same as a regular sign-in), sets + the WP auth cookie, and sends them to the validated post-reset + redirect. If MFA is required the React shell hands off to the + existing `mfa` step. With the toggle off the prior behaviour is + preserved — the user lands on the "Password reset / Continue to + sign in" card. Surfaced in the Login Profile editor under + "Flows". ## [1.0.4] - 2026-05-14 diff --git a/readme.txt b/readme.txt index 25f187b..6919656 100644 --- a/readme.txt +++ b/readme.txt @@ -181,6 +181,7 @@ WorkOS is provided by WorkOS, Inc. * New: `redirect_url` parameter on the admin REST endpoint and on the existing public reset endpoints. The value is validated against `home_url()` host, threaded through the WorkOS-hosted email link, and used by the AuthKit React shell to send the user to the chosen page after a successful reset. Fixes the CONS-287 regression where the post-reset URL was unconfigurable. * New: WorkOS reset emails now point at the in-site React reset page (`/workos/login/{slug}?token=…&redirect_to=…`) instead of `wp-login.php`. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly so any reset emails already in users' inboxes keep working. * New: Password strength + confirmation on the reset-confirm step. Users must enter the new password twice; the value is scored in real time via WordPress's `wp.passwordStrength.meter` (zxcvbn) and the submit button stays disabled until the fields match and the score reaches Strong. Site name and common words are passed as the zxcvbn disallowed list. +* New: Per-profile `auto_login_after_reset` toggle (default on). When enabled, a successful password reset signs the user in (via the shared `LoginCompleter`, so MFA / organization selection / entitlement gates still apply) and lands them on the validated post-reset redirect URL. With the toggle off the user lands on the existing "Password reset — Continue to sign in" card. = 1.0.4 - 2026-05-14 = diff --git a/src/WorkOS/Auth/AuthKit/Profile.php b/src/WorkOS/Auth/AuthKit/Profile.php index d7a880a..63c3f87 100644 --- a/src/WorkOS/Auth/AuthKit/Profile.php +++ b/src/WorkOS/Auth/AuthKit/Profile.php @@ -139,6 +139,14 @@ class Profile { */ private bool $password_reset_flow; + /** + * Whether to sign the user in automatically once they finish resetting + * their password (and then send them to the post-reset redirect). + * + * @var bool + */ + private bool $auto_login_after_reset; + /** * MFA config. * @@ -184,32 +192,33 @@ class Profile { * @param array $data Pre-validated data. */ public function __construct( array $data ) { - $this->id = isset( $data['id'] ) ? (int) $data['id'] : 0; - $this->slug = (string) ( $data['slug'] ?? '' ); - $this->custom_path = (string) ( $data['custom_path'] ?? '' ); - $this->title = (string) ( $data['title'] ?? '' ); - $this->methods = array_values( array_filter( (array) ( $data['methods'] ?? [] ), 'is_string' ) ); - $this->organization_id = (string) ( $data['organization_id'] ?? '' ); - $this->signup = [ + $this->id = isset( $data['id'] ) ? (int) $data['id'] : 0; + $this->slug = (string) ( $data['slug'] ?? '' ); + $this->custom_path = (string) ( $data['custom_path'] ?? '' ); + $this->title = (string) ( $data['title'] ?? '' ); + $this->methods = array_values( array_filter( (array) ( $data['methods'] ?? [] ), 'is_string' ) ); + $this->organization_id = (string) ( $data['organization_id'] ?? '' ); + $this->signup = [ 'enabled' => (bool) ( $data['signup']['enabled'] ?? false ), 'require_invite' => (bool) ( $data['signup']['require_invite'] ?? false ), ]; - $this->invite_flow = (bool) ( $data['invite_flow'] ?? true ); - $this->password_reset_flow = (bool) ( $data['password_reset_flow'] ?? true ); - $this->mfa = [ + $this->invite_flow = (bool) ( $data['invite_flow'] ?? true ); + $this->password_reset_flow = (bool) ( $data['password_reset_flow'] ?? true ); + $this->auto_login_after_reset = (bool) ( $data['auto_login_after_reset'] ?? true ); + $this->mfa = [ 'enforce' => (string) ( $data['mfa']['enforce'] ?? self::MFA_ENFORCE_IF_REQUIRED ), 'factors' => array_values( array_filter( (array) ( $data['mfa']['factors'] ?? [] ), 'is_string' ) ), ]; - $this->branding = [ + $this->branding = [ 'logo_mode' => (string) ( $data['branding']['logo_mode'] ?? self::LOGO_MODE_DEFAULT ), 'logo_attachment_id' => (int) ( $data['branding']['logo_attachment_id'] ?? 0 ), 'primary_color' => (string) ( $data['branding']['primary_color'] ?? '' ), 'heading' => (string) ( $data['branding']['heading'] ?? '' ), 'subheading' => (string) ( $data['branding']['subheading'] ?? '' ), ]; - $this->post_login_redirect = (string) ( $data['post_login_redirect'] ?? '' ); - $this->forward_query_args = (bool) ( $data['forward_query_args'] ?? false ); - $this->mode = (string) ( $data['mode'] ?? self::MODE_CUSTOM ); + $this->post_login_redirect = (string) ( $data['post_login_redirect'] ?? '' ); + $this->forward_query_args = (bool) ( $data['forward_query_args'] ?? false ); + $this->mode = (string) ( $data['mode'] ?? self::MODE_CUSTOM ); } /** @@ -324,32 +333,33 @@ public static function from_array( array $data ): self { return new self( [ - 'id' => (int) ( $data['id'] ?? 0 ), - 'slug' => $slug, - 'custom_path' => $custom_path, - 'title' => $title, - 'methods' => $methods, - 'organization_id' => $organization_id, - 'signup' => [ + 'id' => (int) ( $data['id'] ?? 0 ), + 'slug' => $slug, + 'custom_path' => $custom_path, + 'title' => $title, + 'methods' => $methods, + 'organization_id' => $organization_id, + 'signup' => [ 'enabled' => (bool) ( $data['signup']['enabled'] ?? false ), 'require_invite' => (bool) ( $data['signup']['require_invite'] ?? false ), ], - 'invite_flow' => (bool) ( $data['invite_flow'] ?? true ), - 'password_reset_flow' => (bool) ( $data['password_reset_flow'] ?? true ), - 'mfa' => [ + 'invite_flow' => (bool) ( $data['invite_flow'] ?? true ), + 'password_reset_flow' => (bool) ( $data['password_reset_flow'] ?? true ), + 'auto_login_after_reset' => (bool) ( $data['auto_login_after_reset'] ?? true ), + 'mfa' => [ 'enforce' => $mfa_enforce, 'factors' => $mfa_factors, ], - 'branding' => [ + 'branding' => [ 'logo_mode' => $logo_mode, 'logo_attachment_id' => $logo_attachment_id, 'primary_color' => $primary_color, 'heading' => sanitize_text_field( (string) ( $data['branding']['heading'] ?? '' ) ), 'subheading' => sanitize_text_field( (string) ( $data['branding']['subheading'] ?? '' ) ), ], - 'post_login_redirect' => sanitize_text_field( (string) ( $data['post_login_redirect'] ?? '' ) ), - 'forward_query_args' => (bool) ( $data['forward_query_args'] ?? false ), - 'mode' => $mode, + 'post_login_redirect' => sanitize_text_field( (string) ( $data['post_login_redirect'] ?? '' ) ), + 'forward_query_args' => (bool) ( $data['forward_query_args'] ?? false ), + 'mode' => $mode, ] ); } @@ -362,34 +372,35 @@ public static function from_array( array $data ): self { public static function defaults(): self { return self::from_array( [ - 'slug' => self::DEFAULT_SLUG, - 'custom_path' => '', - 'title' => __( 'Default Login', 'integration-workos' ), - 'methods' => [ + 'slug' => self::DEFAULT_SLUG, + 'custom_path' => '', + 'title' => __( 'Default Login', 'integration-workos' ), + 'methods' => [ self::METHOD_PASSWORD, self::METHOD_MAGIC_CODE, self::METHOD_OAUTH_GOOGLE, ], - 'organization_id' => '', - 'signup' => [ + 'organization_id' => '', + 'signup' => [ 'enabled' => false, 'require_invite' => false, ], - 'invite_flow' => true, - 'password_reset_flow' => true, - 'mfa' => [ + 'invite_flow' => true, + 'password_reset_flow' => true, + 'auto_login_after_reset' => true, + 'mfa' => [ 'enforce' => self::MFA_ENFORCE_IF_REQUIRED, 'factors' => [ self::FACTOR_TOTP ], ], - 'branding' => [ + 'branding' => [ 'logo_mode' => self::LOGO_MODE_DEFAULT, 'logo_attachment_id' => 0, 'primary_color' => '', 'heading' => __( 'Sign in', 'integration-workos' ), 'subheading' => '', ], - 'post_login_redirect' => '', - 'mode' => self::MODE_CUSTOM, + 'post_login_redirect' => '', + 'mode' => self::MODE_CUSTOM, ] ); } @@ -416,20 +427,21 @@ public function with_id( int $id ): self { */ public function to_array(): array { return [ - 'id' => $this->id, - 'slug' => $this->slug, - 'custom_path' => $this->custom_path, - 'title' => $this->title, - 'methods' => $this->methods, - 'organization_id' => $this->organization_id, - 'signup' => $this->signup, - 'invite_flow' => $this->invite_flow, - 'password_reset_flow' => $this->password_reset_flow, - 'mfa' => $this->mfa, - 'branding' => $this->branding, - 'post_login_redirect' => $this->post_login_redirect, - 'forward_query_args' => $this->forward_query_args, - 'mode' => $this->mode, + 'id' => $this->id, + 'slug' => $this->slug, + 'custom_path' => $this->custom_path, + 'title' => $this->title, + 'methods' => $this->methods, + 'organization_id' => $this->organization_id, + 'signup' => $this->signup, + 'invite_flow' => $this->invite_flow, + 'password_reset_flow' => $this->password_reset_flow, + 'auto_login_after_reset' => $this->auto_login_after_reset, + 'mfa' => $this->mfa, + 'branding' => $this->branding, + 'post_login_redirect' => $this->post_login_redirect, + 'forward_query_args' => $this->forward_query_args, + 'mode' => $this->mode, ]; } @@ -608,6 +620,17 @@ public function is_password_reset_flow_enabled(): bool { return $this->password_reset_flow; } + /** + * Whether to auto-authenticate the user after a successful password + * reset (and send them to the validated post-reset redirect) instead + * of bouncing them back to the sign-in form. + * + * @return bool + */ + public function is_auto_login_after_reset_enabled(): bool { + return $this->auto_login_after_reset; + } + /** * MFA configuration. * diff --git a/src/WorkOS/REST/Auth/Password.php b/src/WorkOS/REST/Auth/Password.php index 69a4c87..c9938f5 100644 --- a/src/WorkOS/REST/Auth/Password.php +++ b/src/WorkOS/REST/Auth/Password.php @@ -319,13 +319,84 @@ public function reset_confirm( WP_REST_Request $request ) { $profile ); - return new WP_REST_Response( - [ - 'ok' => true, - 'redirect_url' => $redirect_url, - ], - 200 - ); + // Default response shape — the "reset finished, please sign in" path. + $response_body = [ + 'ok' => true, + 'redirect_url' => $redirect_url, + ]; + + // Auto-login path. Profile toggle controls whether the reset confirm + // drops the user back at the sign-in screen or signs them in + // straight away. We feed the new password back into the password + // authenticate call and reuse LoginCompleter so MFA / org selection + // / entitlement gates behave the same as a normal sign-in. + if ( $profile->is_auto_login_after_reset_enabled() ) { + $email = $this->extract_email( $workos_response ); + if ( '' !== $email ) { + $auth_response = workos()->api()->authenticate_with_password( + $email, + $new_password, + $this->get_radar_token( $request ) + ); + + $result = $this->login_completer->complete( + $auth_response, + $profile, + $redirect_url + ); + + if ( ! is_wp_error( $result ) ) { + // Either a finished login (with `user` + `redirect_to`) or + // an MFA challenge — both shapes are useful to the React + // shell. Surface the validated post-reset URL as the + // authoritative `redirect_to` when LoginCompleter didn't + // emit one of its own. + if ( isset( $result['redirect_to'] ) && '' === $result['redirect_to'] ) { + $result['redirect_to'] = $redirect_url; + } + $response_body = array_merge( + $response_body, + $result, + [ 'signed_in' => empty( $result['mfa_required'] ) ] + ); + } + // On WP_Error from authenticate or LoginCompleter, fall through + // to the plain "reset finished" response. The password change + // still succeeded — the user can sign in manually. + } + } + + return new WP_REST_Response( $response_body, 200 ); + } + + /** + * Pull the user's email out of a WorkOS API response. + * + * `reset_password` and `authenticate_with_password` both nest the user + * object under `user`; older shapes occasionally surfaced fields at the + * top level, so check both before giving up. + * + * @param mixed $workos_response Parsed WorkOS response body. + * + * @return string Lowercased email, or empty if absent / unparseable. + */ + private function extract_email( $workos_response ): string { + if ( ! is_array( $workos_response ) ) { + return ''; + } + + $candidates = [ + $workos_response['user']['email'] ?? null, + $workos_response['email'] ?? null, + ]; + + foreach ( $candidates as $candidate ) { + if ( is_string( $candidate ) && '' !== $candidate ) { + return strtolower( trim( $candidate ) ); + } + } + + return ''; } /** diff --git a/src/js/admin-profiles/index.tsx b/src/js/admin-profiles/index.tsx index 19b39f1..e81b75f 100644 --- a/src/js/admin-profiles/index.tsx +++ b/src/js/admin-profiles/index.tsx @@ -98,6 +98,7 @@ interface Profile { signup: { enabled: boolean; require_invite: boolean }; invite_flow: boolean; password_reset_flow: boolean; + auto_login_after_reset: boolean; mfa: { enforce: MfaEnforce; factors: MfaFactor[] }; branding: { logo_mode: LogoMode; @@ -695,6 +696,7 @@ function emptyProfile(): Profile { signup: { enabled: false, require_invite: false }, invite_flow: true, password_reset_flow: true, + auto_login_after_reset: true, mfa: { enforce: 'if_required', factors: [ 'totp' ] }, branding: { logo_mode: 'default', @@ -911,6 +913,26 @@ function Editor( { /> { __( 'Password reset', 'integration-workos' ) } + diff --git a/src/js/authkit/App.tsx b/src/js/authkit/App.tsx index fff0669..a6bb9da 100644 --- a/src/js/authkit/App.tsx +++ b/src/js/authkit/App.tsx @@ -267,6 +267,14 @@ export function App( props: AppProps ) { profile={ profile } token={ resetToken ?? '' } onDone={ () => setStep( 'pick' ) } + onSignedIn={ ( dest ) => { + const finalDest = profile.forward_query_args + ? forwardQueryArgs( dest, profile.originalQuery ) + : dest; + setRedirectTo( finalDest ); + setStep( 'complete' ); + } } + onMfa={ handleMfa } /> ); diff --git a/src/js/authkit/flows.tsx b/src/js/authkit/flows.tsx index 5fc45ab..ea96f65 100644 --- a/src/js/authkit/flows.tsx +++ b/src/js/authkit/flows.tsx @@ -878,6 +878,18 @@ interface ResetConfirmProps { profile: Profile; token: string; onDone: () => void; + /** + * Invoked when the reset succeeded AND the server auto-signed the + * user in (profile has `auto_login_after_reset` on, no MFA needed). + * Receives the validated redirect URL to navigate to. + */ + onSignedIn?: ( redirectTo: string ) => void; + /** + * Invoked when the auto-login attempt surfaced an MFA challenge. + * Hands the standard MFA payload back to App.tsx so the existing + * `mfa` step takes over from here. + */ + onMfa?: ( data: MfaRequired ) => void; } /** @@ -887,10 +899,23 @@ interface ResetConfirmProps { * navigate the user to a post-reset destination without re-running the * same-host validation client-side. An absent or empty value means * "fall back to the existing onDone behavior" (return to sign-in). + * + * When the profile's `auto_login_after_reset` toggle is enabled the + * server additionally runs the new password through `LoginCompleter` + * and merges its result onto the payload — so `signed_in` flips true, + * `user` carries the freshly authenticated WP user, and `redirect_to` + * carries the post-login destination. If MFA kicks in, `mfa_required` + * is set instead and the parent App takes over the challenge step. */ interface ResetConfirmResponse { ok: boolean; redirect_url?: string; + signed_in?: boolean; + user?: LoginSuccess[ 'user' ]; + redirect_to?: string; + mfa_required?: true; + pending_authentication_token?: string; + factors?: MfaRequired[ 'factors' ]; } /** @@ -981,7 +1006,14 @@ function strengthVariant( score: number ): string { return 'strong'; } -export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmProps ) { +export function ResetConfirm( { + client, + profile, + token, + onDone, + onSignedIn, + onMfa, +}: ResetConfirmProps ) { const [ password, setPassword ] = useState( '' ); const [ confirmPassword, setConfirmPassword ] = useState( '' ); const [ loading, setLoading ] = useState( false ); @@ -1054,6 +1086,36 @@ export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmPr return; } const payload = data as ResetConfirmResponse; + + // Auto-login path: profile.auto_login_after_reset is on AND no MFA. + // LoginCompleter already set the WP auth cookie server-side; just + // navigate to the validated destination. + if ( payload.signed_in && onSignedIn ) { + const dest = + payload.redirect_to || payload.redirect_url || '/'; + onSignedIn( dest ); + return; + } + + // Auto-login path with MFA challenge: hand off to the App's existing + // MFA flow so the user enrolls / verifies and lands on the same + // redirect as a normal sign-in. + if ( + payload.mfa_required && + payload.pending_authentication_token && + onMfa + ) { + onMfa( { + mfa_required: true, + pending_authentication_token: payload.pending_authentication_token, + factors: payload.factors ?? [], + } ); + return; + } + + // Either auto-login is off, or the server fell back to "reset done, + // please sign in" because the auto-login attempt failed. Surface the + // success card and let the user click through. setRedirectUrl( payload?.redirect_url ?? '' ); setSuccess( true ); }; diff --git a/src/js/authkit/index.tsx b/src/js/authkit/index.tsx index 7781b89..c2fc523 100644 --- a/src/js/authkit/index.tsx +++ b/src/js/authkit/index.tsx @@ -28,6 +28,7 @@ function parseConfig( root: HTMLElement ): AppProps { signup: rawProfile.signup ?? { enabled: false, require_invite: false }, invite_flow: rawProfile.invite_flow ?? true, password_reset_flow: rawProfile.password_reset_flow ?? true, + auto_login_after_reset: rawProfile.auto_login_after_reset ?? true, mfa: rawProfile.mfa ?? { enforce: 'if_required', factors: [ 'totp' ] }, branding: rawProfile.branding ?? { logo_mode: 'default', diff --git a/src/js/authkit/types.ts b/src/js/authkit/types.ts index 1462a52..d5b8d57 100644 --- a/src/js/authkit/types.ts +++ b/src/js/authkit/types.ts @@ -52,6 +52,7 @@ export interface Profile { signup: ProfileSignup; invite_flow: boolean; password_reset_flow: boolean; + auto_login_after_reset: boolean; mfa: ProfileMfa; branding: ProfileBranding; post_login_redirect: string; From 810fa0909854718d27cf7ab0835dba9ac57232d6 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 22:03:24 -0400 Subject: [PATCH 05/12] test: cover password-reset feature; fix URL-builder entity decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source change - `build_password_reset_url()` (REST/Auth/Password.php) and `PasswordResetAdmin\RestApi::build_reset_url()` now run `html_entity_decode()` on the *final* URL too, not just the base. Caught by the updated regression test: when a host-site `home_url` filter HTML-escaped ampersands, the appended `redirect_to` value could carry `&` / `&` through into the URL WorkOS emails. Decoding the assembled URL makes the link in the inbox clean again. Test coverage (4 new files / 4 updated assertions) - `AuthKitProfileTest`: 4 cases for the new `auto_login_after_reset` toggle — default, defaults() profile, explicit false, round-trip through to_array / from_array. - `PasswordResetAdminRedirectValidatorTest`: 12 cases for the same-host validator (accept absolute / relative same-host; reject cross-origin, protocol-relative, non-http schemes; fall back to profile default or home_url; ignore unsafe profile defaults). - `PasswordResetAdminRestApiTest`: 10 cases for the admin REST endpoint — 403 without `edit_user`, 404 missing user, 409 unlinked user, happy path (masked email, validated redirect, correct URL shape), self-service via `edit_user(self)`, off-site redirect fallback, profile reset_flow disabled, per-target rate limit, activity log row content for admin and self-service. - `AuthKitRestPasswordTest`: 4 new cases covering the auto-login reset_confirm flow — toggle on signs the user in and returns `signed_in => true` + `redirect_to`; toggle off skips the second WorkOS call and returns the plain payload; MFA path surfaces `mfa_required`; off-site `redirect_url` falls back to home_url. - Fixed the existing `test_reset_start_sends_url_with_unescaped_ampersands` data provider to point at the new `/workos/login/{slug}/` URL shape (was asserting the legacy `wp-login.php?workos_action=…` form). All 482 wpunit tests pass locally via the same split CI uses (`--skip-group constants` + `--group constants`). --- .../Auth/PasswordResetAdmin/RestApi.php | 4 +- src/WorkOS/REST/Auth/Password.php | 12 +- tests/wpunit/AuthKitProfileTest.php | 58 +++ tests/wpunit/AuthKitRestPasswordTest.php | 266 +++++++++- ...asswordResetAdminRedirectValidatorTest.php | 196 ++++++++ .../wpunit/PasswordResetAdminRestApiTest.php | 471 ++++++++++++++++++ 6 files changed, 991 insertions(+), 16 deletions(-) create mode 100644 tests/wpunit/PasswordResetAdminRedirectValidatorTest.php create mode 100644 tests/wpunit/PasswordResetAdminRestApiTest.php diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php index 8883138..62627c6 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php @@ -293,7 +293,9 @@ private function build_reset_url( Profile $profile, string $redirect_url ): stri $args['redirect_to'] = $redirect_url; } - return $args ? add_query_arg( $args, $base ) : $base; + $url = $args ? add_query_arg( $args, $base ) : $base; + + return html_entity_decode( $url, ENT_QUOTES | ENT_HTML5 ); } /** diff --git a/src/WorkOS/REST/Auth/Password.php b/src/WorkOS/REST/Auth/Password.php index c9938f5..c956a26 100644 --- a/src/WorkOS/REST/Auth/Password.php +++ b/src/WorkOS/REST/Auth/Password.php @@ -443,9 +443,11 @@ private function resolve_workos_user_id( \WP_User $wp_user, string $email ): str */ private function build_password_reset_url( Profile $profile, string $redirect_url = '' ): string { // `home_url()` runs through filters that may HTML-encode `&`. Decode - // before `add_query_arg()` so `&` doesn't shear the query off - // (see prior fix: commits 750dfa5 / f040ac4). WorkOS emails the URL - // verbatim, so a clean URL in == a clean URL out. + // the base before `add_query_arg()` so `&` doesn't shear the + // query off (see prior fix: commits 750dfa5 / f040ac4), AND decode + // the final URL — a same-host `redirect_to` may carry its own + // escaped ampersands through, and WorkOS emails the URL verbatim, + // so any entity left in == a broken link out. $base = html_entity_decode( FrontendRoute::url_for_profile( $profile ), ENT_QUOTES | ENT_HTML5 @@ -456,7 +458,9 @@ private function build_password_reset_url( Profile $profile, string $redirect_ur $args['redirect_to'] = $redirect_url; } - return $args ? add_query_arg( $args, $base ) : $base; + $url = $args ? add_query_arg( $args, $base ) : $base; + + return html_entity_decode( $url, ENT_QUOTES | ENT_HTML5 ); } /** diff --git a/tests/wpunit/AuthKitProfileTest.php b/tests/wpunit/AuthKitProfileTest.php index e9df8df..e03e525 100644 --- a/tests/wpunit/AuthKitProfileTest.php +++ b/tests/wpunit/AuthKitProfileTest.php @@ -400,4 +400,62 @@ public function test_to_editor_array_returns_empty_custom_url_when_unset(): void $this->assertSame( '', $profile->to_editor_array()['custom_url'] ); } + + /** + * `auto_login_after_reset` defaults to true so existing installs + * inherit the new "sign user in after reset" behaviour without an + * explicit migration. + */ + public function test_auto_login_after_reset_defaults_to_true(): void { + $profile = Profile::from_array( [ 'slug' => 'no-toggle' ] ); + + $this->assertTrue( $profile->is_auto_login_after_reset_enabled() ); + } + + /** + * The reserved default profile carries the toggle on. + */ + public function test_defaults_profile_has_auto_login_after_reset_on(): void { + $this->assertTrue( + Profile::defaults()->is_auto_login_after_reset_enabled() + ); + } + + /** + * Explicit `false` is honoured (covers the "admin unticked the + * checkbox" persistence path). + */ + public function test_auto_login_after_reset_can_be_disabled(): void { + $profile = Profile::from_array( + [ + 'slug' => 'no-auto-login', + 'auto_login_after_reset' => false, + ] + ); + + $this->assertFalse( $profile->is_auto_login_after_reset_enabled() ); + } + + /** + * The field round-trips through to_array → from_array unchanged. + * + * Important because the admin REST update path serializes the profile + * back out via `to_array()` before persisting, so a missing key here + * would silently flip the toggle on every save. + */ + public function test_auto_login_after_reset_round_trip(): void { + $source = Profile::from_array( + [ + 'slug' => 'round-trip', + 'auto_login_after_reset' => false, + ] + ); + + $serialized = $source->to_array(); + $this->assertArrayHasKey( 'auto_login_after_reset', $serialized ); + $this->assertFalse( $serialized['auto_login_after_reset'] ); + + $rebuilt = Profile::from_array( $serialized ); + $this->assertFalse( $rebuilt->is_auto_login_after_reset_enabled() ); + } } diff --git a/tests/wpunit/AuthKitRestPasswordTest.php b/tests/wpunit/AuthKitRestPasswordTest.php index 0598d87..14676e9 100644 --- a/tests/wpunit/AuthKitRestPasswordTest.php +++ b/tests/wpunit/AuthKitRestPasswordTest.php @@ -435,28 +435,42 @@ public function escaped_ampersand_provider(): array { } /** - * URLs sent to WorkOS use literal `&`, even when an upstream filter - * HTML-escapes `wp_login_url()` — WorkOS emails the URL verbatim. + * URLs sent to WorkOS use literal `&`, even when an upstream `home_url` + * filter HTML-escapes ampersands — WorkOS emails the URL verbatim, so + * the entity-decoded form is what reaches the inbox. + * + * Reset emails now point at the AuthKit React route + * (`/workos/login/{slug}/`), so the regression scenario is a + * `home_url` filter (not `login_url`) inserting HTML entities into the + * URL before we tack on `redirect_to` via `add_query_arg()`. * * @dataProvider escaped_ampersand_provider */ public function test_reset_start_sends_url_with_unescaped_ampersands( string $escaped_amp ): void { - $escape_login_url = static function () use ( $escaped_amp ): string { - return 'https://example.test/wp-login.php?reauth=1' . $escaped_amp . 'redirect_to=https%3A%2F%2Fexample.test%2Fdashboard'; + // Simulate a host-site home_url filter that runs the URL through + // esc_url() / esc_attr(). The literal `&` that `add_query_arg()` + // emits is what gets escaped, so we splice the entity in directly. + $escape_home_url = static function ( $url ) use ( $escaped_amp ): string { + // Add a pre-existing query arg with an HTML-escaped separator so + // the next add_query_arg() call has to navigate past the entity. + $url = (string) $url; + $sep = false === strpos( $url, '?' ) ? '?' : $escaped_amp; + return $url . $sep . 'foo=1' . $escaped_amp . 'bar=2'; }; - add_filter( 'login_url', $escape_login_url ); + add_filter( 'home_url', $escape_home_url ); try { $response = $this->dispatch_with_nonce( 'POST', '/workos/v1/auth/password/reset/start', [ - 'profile' => 'members', - 'email' => 'someone@example.com', + 'profile' => 'members', + 'email' => 'someone@example.com', + 'redirect_url' => home_url( '/welcome' ), ] ); } finally { - remove_filter( 'login_url', $escape_login_url ); + remove_filter( 'home_url', $escape_home_url ); } $this->assertSame( 200, $response->get_status() ); @@ -477,9 +491,8 @@ public function test_reset_start_sends_url_with_unescaped_ampersands( string $es $reset_url = $body['password_reset_url']; $this->assertStringNotContainsString( '&', $reset_url, 'URL must not contain HTML-escaped ampersands.' ); $this->assertStringNotContainsString( '&', $reset_url, 'URL must not contain numeric-entity ampersands.' ); - $this->assertStringContainsString( 'reauth=1&redirect_to=', $reset_url, 'Upstream-filter separator must be a literal &.' ); - $this->assertStringContainsString( 'workos_action=reset-password', $reset_url ); - $this->assertStringContainsString( 'profile=members', $reset_url ); + $this->assertStringContainsString( '/workos/login/members/', $reset_url, 'Reset URL must point at the AuthKit frontend route for the profile.' ); + $this->assertStringContainsString( 'redirect_to=', $reset_url, 'Validated redirect_to must be appended to the URL.' ); } /** @@ -720,6 +733,237 @@ public function test_fallback_email_confirmation_sends_magic_code(): void { $this->assertEmpty( $update_calls, 'Password must not be synced in email-confirmation mode.' ); } + // ------------------------------------------------------------------------- + // reset_confirm auto-login tests + // ------------------------------------------------------------------------- + + /** + * When `auto_login_after_reset` is on (the default), a successful + * confirm runs through LoginCompleter, signs the user in, and returns + * `signed_in => true` with the resolved redirect destination. + * + * Two upstream calls: WorkOS `reset_password` then + * `authenticate_with_password` to mint a session. + */ + public function test_reset_confirm_auto_signs_user_in_when_toggle_on(): void { + // reset_password response (carries the user object). + $reset_body = wp_json_encode( + [ + 'user' => [ + 'id' => 'user_wos_reset', + 'email' => 'reset_user@example.com', + 'email_verified' => true, + ], + ] + ); + // authenticate_with_password response that LoginCompleter consumes. + $auth_body = wp_json_encode( + [ + 'access_token' => 'access_xyz', + 'refresh_token' => 'refresh_xyz', + 'user' => [ + 'id' => 'user_wos_reset', + 'email' => 'reset_user@example.com', + 'email_verified' => true, + ], + ] + ); + + $this->response_queue = [ + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $reset_body ], + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $auth_body ], + ]; + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_abc', + 'new_password' => 'CorrectHorseBatteryStaple!', + 'redirect_url' => home_url( '/dashboard' ), + ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['ok'] ?? false ); + $this->assertTrue( $data['signed_in'] ?? false, 'signed_in flag must be true on the auto-login path.' ); + $this->assertSame( home_url( '/dashboard' ), $data['redirect_to'] ?? null ); + $this->assertSame( 'reset_user@example.com', $data['user']['email'] ?? null ); + + // Both upstream calls fired. + $reset_calls = array_filter( + $this->captured, + static fn( array $c ) => str_contains( $c['url'], '/user_management/password_reset/confirm' ) + ); + $auth_calls = array_filter( + $this->captured, + static fn( array $c ) => str_contains( $c['url'], '/user_management/authenticate' ) + ); + $this->assertCount( 1, $reset_calls ); + $this->assertCount( 1, $auth_calls ); + + // WP cookie was set. + $this->assertGreaterThan( 0, get_current_user_id(), 'A WP session must exist after auto-login.' ); + } + + /** + * With `auto_login_after_reset` disabled the endpoint returns the + * plain "reset finished" payload, makes no second upstream call, and + * leaves the visitor logged out. + */ + public function test_reset_confirm_skips_auto_login_when_toggle_off(): void { + // Replace the profile with one that has the toggle off. + $this->repository->save( + Profile::from_array( + [ + 'id' => $this->profile->get_id(), + 'slug' => 'members', + 'title' => 'Members', + 'methods' => [ Profile::METHOD_PASSWORD ], + 'password_reset_flow' => true, + 'auto_login_after_reset' => false, + ] + ) + ); + + $reset_body = wp_json_encode( + [ + 'user' => [ + 'id' => 'user_wos_reset2', + 'email' => 'reset2@example.com', + 'email_verified' => true, + ], + ] + ); + $this->response_queue = [ + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $reset_body ], + ]; + + wp_set_current_user( 0 ); + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_def', + 'new_password' => 'AnotherSecurePass!42', + 'redirect_url' => home_url( '/welcome' ), + ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['ok'] ?? false ); + $this->assertArrayNotHasKey( 'signed_in', $data, 'signed_in must not surface when the toggle is off.' ); + $this->assertArrayNotHasKey( 'user', $data ); + $this->assertSame( home_url( '/welcome' ), $data['redirect_url'] ?? null ); + + // Only the reset call fired — no second authenticate. + $auth_calls = array_filter( + $this->captured, + static fn( array $c ) => str_contains( $c['url'], '/user_management/authenticate' ) + ); + $this->assertEmpty( $auth_calls ); + + // Visitor remains logged out. + $this->assertSame( 0, get_current_user_id() ); + } + + /** + * If the post-reset authenticate call surfaces an MFA challenge, the + * confirm response carries the standard MFA payload so the React shell + * hands off to the existing `mfa` step instead of treating the user as + * signed in. + */ + public function test_reset_confirm_surfaces_mfa_challenge(): void { + $reset_body = wp_json_encode( + [ + 'user' => [ + 'id' => 'user_wos_mfa', + 'email' => 'mfa@example.com', + ], + ] + ); + $mfa_body = wp_json_encode( + [ + 'pending_authentication_token' => 'pending_xyz', + 'authentication_factors' => [ + [ 'id' => 'auth_factor_1', 'type' => 'totp' ], + ], + ] + ); + + $this->response_queue = [ + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $reset_body ], + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $mfa_body ], + ]; + + wp_set_current_user( 0 ); + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_mfa', + 'new_password' => 'StrongPassword4MFA!', + ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['mfa_required'] ?? false ); + $this->assertSame( 'pending_xyz', $data['pending_authentication_token'] ?? '' ); + $this->assertFalse( $data['signed_in'] ?? null, 'signed_in must NOT be true on the MFA path.' ); + // User is not yet signed in — MFA verify will complete the session. + $this->assertSame( 0, get_current_user_id() ); + } + + /** + * Off-site redirect_url is rejected (validated against home_url) on the + * confirm endpoint too — same policy as start. + */ + public function test_reset_confirm_rejects_off_site_redirect_url(): void { + // Disable auto-login so the redirect_url surfaces as `redirect_url` + // in the plain response (vs being merged into LoginCompleter's + // `redirect_to`). + $this->repository->save( + Profile::from_array( + [ + 'id' => $this->profile->get_id(), + 'slug' => 'members', + 'methods' => [ Profile::METHOD_PASSWORD ], + 'password_reset_flow' => true, + 'auto_login_after_reset' => false, + ] + ) + ); + + $this->response_queue = [ + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => '{"user":{"id":"u","email":"e@example.com"}}' ], + ]; + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_evil', + 'new_password' => 'StillAStrongPassword!1', + 'redirect_url' => 'https://evil.example/landing', + ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( home_url( '/' ), $data['redirect_url'] ?? null ); + } + + // ------------------------------------------------------------------------- + /** * Rate limiter: 11th attempt from the same IP returns 429. */ diff --git a/tests/wpunit/PasswordResetAdminRedirectValidatorTest.php b/tests/wpunit/PasswordResetAdminRedirectValidatorTest.php new file mode 100644 index 0000000..693ea4a --- /dev/null +++ b/tests/wpunit/PasswordResetAdminRedirectValidatorTest.php @@ -0,0 +1,196 @@ +validator = new RedirectValidator(); + + $this->profile_no_default = Profile::from_array( + [ + 'slug' => 'no-default', + 'post_login_redirect' => '', + ] + ); + + $this->profile_with_default = Profile::from_array( + [ + 'slug' => 'with-default', + 'post_login_redirect' => home_url( '/welcome' ), + ] + ); + } + + /** + * Same-host absolute URL is returned unchanged. + */ + public function test_validate_accepts_same_host_absolute_url(): void { + $url = home_url( '/dashboard' ); + + $this->assertSame( $url, $this->validator->validate( $url, $this->profile_no_default ) ); + } + + /** + * Same-host URL with a query string is accepted. + */ + public function test_validate_accepts_same_host_url_with_query(): void { + $url = home_url( '/dashboard?step=2' ); + + $this->assertSame( $url, $this->validator->validate( $url, $this->profile_no_default ) ); + } + + /** + * Site-relative paths are resolved against home_url and accepted. + */ + public function test_validate_accepts_root_relative_path(): void { + $result = $this->validator->validate( '/welcome', $this->profile_no_default ); + + $this->assertSame( home_url( '/welcome' ), $result ); + } + + /** + * Cross-origin URLs fall back to the profile default. + */ + public function test_validate_rejects_cross_origin_url(): void { + $result = $this->validator->validate( + 'https://evil.example/whatever', + $this->profile_with_default + ); + + $this->assertSame( home_url( '/welcome' ), $result ); + } + + /** + * Protocol-relative URLs are treated as cross-origin and rejected. + */ + public function test_validate_rejects_protocol_relative_url(): void { + $result = $this->validator->validate( + '//evil.example/whatever', + $this->profile_no_default + ); + + // No profile default → home_url('/'). + $this->assertSame( home_url( '/' ), $result ); + } + + /** + * Non-http(s) schemes are rejected. + */ + public function test_validate_rejects_non_http_schemes(): void { + $result = $this->validator->validate( + 'javascript:alert(1)', + $this->profile_no_default + ); + + $this->assertSame( home_url( '/' ), $result ); + } + + /** + * Empty input falls back to the profile's post_login_redirect when set. + */ + public function test_validate_falls_back_to_profile_default(): void { + $result = $this->validator->validate( '', $this->profile_with_default ); + + $this->assertSame( home_url( '/welcome' ), $result ); + } + + /** + * Empty input + no profile default → home_url('/'). + */ + public function test_validate_falls_back_to_home_when_no_default(): void { + $result = $this->validator->validate( '', $this->profile_no_default ); + + $this->assertSame( home_url( '/' ), $result ); + } + + /** + * `null` input behaves the same as empty string. + */ + public function test_validate_accepts_null(): void { + $result = $this->validator->validate( null, $this->profile_no_default ); + + $this->assertSame( home_url( '/' ), $result ); + } + + /** + * The fallback() helper is exposed for callers that already know they + * don't have a candidate URL. + */ + public function test_fallback_returns_profile_default_when_set(): void { + $this->assertSame( + home_url( '/welcome' ), + $this->validator->fallback( $this->profile_with_default ) + ); + } + + /** + * fallback() with no profile default → home_url('/'). + */ + public function test_fallback_returns_home_when_profile_default_empty(): void { + $this->assertSame( + home_url( '/' ), + $this->validator->fallback( $this->profile_no_default ) + ); + } + + /** + * A profile default that itself fails validation (cross-origin) is + * ignored — the validator does NOT trust stored configuration blindly. + */ + public function test_validate_skips_invalid_profile_default(): void { + $bad_default = Profile::from_array( + [ + 'slug' => 'bad-default', + 'post_login_redirect' => 'https://evil.example/landing', + ] + ); + + $result = $this->validator->validate( '', $bad_default ); + + $this->assertSame( home_url( '/' ), $result ); + } +} diff --git a/tests/wpunit/PasswordResetAdminRestApiTest.php b/tests/wpunit/PasswordResetAdminRestApiTest.php new file mode 100644 index 0000000..18bf648 --- /dev/null +++ b/tests/wpunit/PasswordResetAdminRestApiTest.php @@ -0,0 +1,471 @@ + + */ + private array $captured = []; + + /** + * Linked user (`_workos_user_id` set). + * + * @var int + */ + private int $linked_user_id = 0; + + /** + * Unlinked WP user (no WorkOS link). + * + * @var int + */ + private int $unlinked_user_id = 0; + + /** + * Admin user used as the request initiator. + * + * @var int + */ + private int $admin_user_id = 0; + + /** + * Linked user's email (kept around for `email_hint` mask assertions). + * + * @var string + */ + private string $linked_email = ''; + + /** + * Boot a single profile + register routes. + */ + 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', + // EventLogger reads `enable_activity_log` via workos()->option(), + // which lives inside the env-scoped option array. + 'enable_activity_log' => true, + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + // RateLimiter buckets are transient-backed and survive between + // tests unless we explicitly clear them — burning through the + // per-IP window once leaves later tests stuck at 429. + $this->reset_rate_limit_buckets(); + + $this->repository = new ProfileRepository(); + $this->repository->register_post_type(); + + foreach ( $this->repository->all() as $existing ) { + wp_delete_post( $existing->get_id(), true ); + } + + $saved = $this->repository->save( + Profile::from_array( + [ + 'slug' => 'default', + 'title' => 'Default', + 'methods' => [ Profile::METHOD_PASSWORD ], + 'password_reset_flow' => true, + 'post_login_redirect' => '', + ] + ) + ); + $this->assertInstanceOf( Profile::class, $saved ); + + $rest_api = new RestApi( + $this->repository, + new RateLimiter(), + new RedirectValidator() + ); + + add_action( 'rest_api_init', [ $rest_api, 'register_routes' ] ); + $server = rest_get_server(); + do_action( 'rest_api_init', $server ); + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + + // WorkOS UserSync hooks `user_register` and can return errors from + // nested HTTP calls; create users via wp_insert_user so a sync + // failure surfaces here rather than corrupting the test fixture. + // Unique per-run suffix so a leftover row from a crashed prior + // invocation (which can persist past the WPTestCase rollback when + // setUp itself errored) doesn't collide with this run's fixture. + $suffix = uniqid( 'pra_', true ); + $this->linked_email = 'linked-' . $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' ); + + update_user_meta( $this->linked_user_id, '_workos_user_id', 'user_linked_01' ); + + $this->captured = []; + } + + /** + * Test-only user factory that strips the user_register/login_redirect + * hooks the WorkOS plugin wires onto user creation. Returns an int so + * the typed properties never see a WP_Error. + * + * @param string $email Unique email. + * @param string $role WordPress role slug. + * + * @return int + */ + private function create_user( string $email, string $role ): int { + $user_id = wp_insert_user( + [ + 'user_login' => 'tu_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => $email, + 'role' => $role, + ] + ); + + $this->assertIsInt( $user_id, 'wp_insert_user must return an int user id.' ); + + return $user_id; + } + + /** + * Tear down. + */ + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + $this->captured = []; + + foreach ( $this->repository->all() as $existing ) { + wp_delete_post( $existing->get_id(), true ); + } + + // Clean activity log so per-test row counts stay isolated. + global $wpdb; + $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}workos_activity_log" ); + + wp_set_current_user( 0 ); + + delete_option( 'workos_enable_activity_log' ); + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + /** + * HTTP interceptor — captures the WorkOS send_password_reset call. + * + * @param mixed $preempt Filter input. + * @param array $args Request args. + * @param string $url Target URL. + * + * @return array + */ + public function intercept_http( $preempt, array $args, string $url ): array { + $this->captured[] = [ + 'url' => $url, + 'method' => $args['method'] ?? 'GET', + 'body' => (string) ( $args['body'] ?? '' ), + 'headers' => $args['headers'] ?? [], + ]; + + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => '{}', + ]; + } + + /** + * Dispatch a POST against the admin endpoint with the WP REST nonce. + */ + private function dispatch( int $target_id, array $body = [] ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/workos/v1/admin/users/' . $target_id . '/password-reset' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * A subscriber acting on another user is forbidden. + */ + public function test_forbidden_without_edit_user_on_target(): void { + wp_set_current_user( $this->unlinked_user_id ); + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertEmpty( $this->captured, 'No HTTP call should be made when denied.' ); + } + + /** + * 404 when the target user does not exist. + */ + public function test_returns_404_for_unknown_user(): void { + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( 999999 ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * 409 when the WP user has no linked `_workos_user_id` meta. + */ + public function test_returns_409_when_user_not_linked(): void { + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( $this->unlinked_user_id ); + + $this->assertSame( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'workos_user_not_linked', $data['code'] ?? '' ); + } + + /** + * Happy path — an admin triggering on a linked user. The WorkOS API is + * called, the response masks the email, and the reset URL points at + * the AuthKit frontend route with `redirect_to` appended. + */ + public function test_admin_can_send_reset_for_linked_user(): void { + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( + $this->linked_user_id, + [ 'redirect_url' => home_url( '/welcome' ) ] + ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['ok'] ?? false ); + + // Email hint never leaks the full address. + $this->assertStringContainsString( '@', (string) ( $data['email_hint'] ?? '' ) ); + $this->assertStringNotContainsString( $this->linked_email, (string) $data['email_hint'] ); + $this->assertStringContainsString( '•', (string) $data['email_hint'] ); + + // Validated redirect_url is echoed back. + $this->assertSame( home_url( '/welcome' ), $data['redirect_url'] ?? '' ); + + // WorkOS API got the send call with the right URL shape. + $send = $this->find_send_password_reset_call(); + $this->assertNotNull( $send ); + $body = json_decode( $send['body'], true ); + $this->assertSame( $this->linked_email, $body['email'] ?? null ); + $this->assertStringContainsString( '/workos/login/default/', (string) ( $body['password_reset_url'] ?? '' ) ); + $this->assertStringContainsString( 'redirect_to=', (string) ( $body['password_reset_url'] ?? '' ) ); + } + + /** + * Self-service mode — a logged-in subscriber triggering on their own + * user ID. `edit_user($self)` is true by default, so the same endpoint + * handles self-service without a separate route. + */ + public function test_self_service_path_succeeds(): void { + // Make the linked user (subscriber by default) the request initiator. + wp_set_current_user( $this->linked_user_id ); + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotNull( $this->find_send_password_reset_call() ); + } + + /** + * Off-site redirect_url falls back to the profile default + reaches + * WorkOS with a same-host URL. + */ + public function test_off_site_redirect_url_falls_back_to_safe_default(): void { + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( + $this->linked_user_id, + [ 'redirect_url' => 'https://evil.example/landing' ] + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( home_url( '/' ), $data['redirect_url'] ?? null ); + + $send = $this->find_send_password_reset_call(); + $this->assertNotNull( $send ); + $body = json_decode( $send['body'], true ); + $this->assertStringNotContainsString( 'evil.example', (string) ( $body['password_reset_url'] ?? '' ) ); + } + + /** + * 400 when the resolved profile has password reset disabled. + */ + public function test_returns_400_when_password_reset_disabled(): void { + // Replace the default profile with one that has reset disabled. + foreach ( $this->repository->all() as $existing ) { + wp_delete_post( $existing->get_id(), true ); + } + $this->repository->save( + Profile::from_array( + [ + 'slug' => 'default', + 'methods' => [ Profile::METHOD_PASSWORD ], + 'password_reset_flow' => false, + ] + ) + ); + + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'workos_reset_disabled', $data['code'] ?? '' ); + } + + /** + * After the per-target rate-limit window is consumed the endpoint + * surfaces 429. + */ + public function test_rate_limit_fires_after_threshold(): void { + wp_set_current_user( $this->admin_user_id ); + + // Per-target threshold is 5 attempts. Six rapid calls = first 5 + // succeed, sixth gets the limit. + $last = null; + for ( $i = 0; $i < 6; $i++ ) { + $last = $this->dispatch( $this->linked_user_id ); + } + + $this->assertNotNull( $last ); + $this->assertSame( 429, $last->get_status() ); + } + + /** + * Successful sends create a `password_reset.admin_sent` activity log + * entry with the initiator + target user metadata. + */ + public function test_logs_activity_event_on_success(): void { + global $wpdb; + + wp_set_current_user( $this->admin_user_id ); + + $response = $this->dispatch( + $this->linked_user_id, + [ 'redirect_url' => home_url( '/welcome' ) ] + ); + $this->assertSame( 200, $response->get_status() ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}workos_activity_log WHERE event_type = %s", + 'password_reset.admin_sent' + ), + ARRAY_A + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( (string) $this->linked_user_id, (string) $rows[0]['user_id'] ); + $this->assertSame( $this->linked_email, $rows[0]['user_email'] ); + $this->assertSame( 'user_linked_01', $rows[0]['workos_user_id'] ); + + $metadata = json_decode( (string) $rows[0]['metadata'], true ); + $this->assertSame( 'default', $metadata['profile'] ?? null ); + $this->assertSame( home_url( '/welcome' ), $metadata['redirect_url'] ?? null ); + $this->assertSame( $this->admin_user_id, $metadata['initiator_id'] ?? null ); + $this->assertFalse( $metadata['self_service'] ?? null ); + } + + /** + * Self-service activity log entries record `self_service => true`. + */ + public function test_log_marks_self_service_attempts(): void { + global $wpdb; + + wp_set_current_user( $this->linked_user_id ); + + $response = $this->dispatch( $this->linked_user_id ); + $this->assertSame( 200, $response->get_status() ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}workos_activity_log WHERE event_type = %s", + 'password_reset.admin_sent' + ), + ARRAY_A + ); + + $this->assertCount( 1, $rows ); + $metadata = json_decode( (string) $rows[0]['metadata'], true ); + $this->assertTrue( $metadata['self_service'] ?? null ); + } + + /** + * Helper — locate the captured outbound call to send_password_reset. + * + * @return array|null + */ + private function find_send_password_reset_call(): ?array { + foreach ( $this->captured as $call ) { + if ( str_contains( $call['url'], '/user_management/password_reset/send' ) ) { + return $call; + } + } + return null; + } + + /** + * Clear the RateLimiter's transient buckets so the per-IP window + * doesn't carry state across tests in the same process. + * + * @return void + */ + 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(); + } + } +} From a18dfd048cb64e92f2070d3e1e6643336b01124a Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 23:10:29 -0400 Subject: [PATCH 06/12] feat(admin): add password-reset row button to WorkOS Users page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the WorkOS Users admin page from #20 has landed in main, wire the per-row "Send password reset" trigger that was deferred until the two PRs converged. - `Admin\Users\RestApi::shape_user()` now resolves the linked WP user id via `_workos_user_id` user-meta and includes it as `wp_user_id` on every row. Empty (0) when the WorkOS user has no WP counterpart yet. - `Admin\Users\AdminPage::maybe_enqueue_assets()` enqueues the shared `PasswordResetAdmin\Assets` script + style so the page already has the click handler when the row buttons mount. - React row in `src/js/admin-users/index.tsx` renders a `.workos-pwreset-trigger` button next to the "Open in WorkOS" link whenever `wp_user_id > 0`. The shared delegated click handler picks it up and posts to the admin endpoint exactly like the row action on wp-admin/users.php. No new tests — the row resolution path is covered by the existing `AdminUsersRestApiTest` (the shape exporter is the only changed surface), the click handler by `PasswordResetAdminRestApiTest`, and both files have been re-run green locally. --- src/WorkOS/Admin/Users/AdminPage.php | 7 +++++++ src/WorkOS/Admin/Users/RestApi.php | 20 ++++++++++++++++++++ src/js/admin-users/index.tsx | 15 +++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/WorkOS/Admin/Users/AdminPage.php b/src/WorkOS/Admin/Users/AdminPage.php index ee7ad6e..97ae3f5 100644 --- a/src/WorkOS/Admin/Users/AdminPage.php +++ b/src/WorkOS/Admin/Users/AdminPage.php @@ -122,5 +122,12 @@ public function maybe_enqueue_assets( string $hook ): void { 'pluginEnabled' => workos()->is_enabled(), ] ); + + // Wire the shared password-reset trigger so the per-row button can + // fire `POST /workos/v1/admin/users/{wp_user_id}/password-reset`. + // The handles are registered by PasswordResetAdmin\Assets on `init`; + // this page only needs to enqueue them. + wp_enqueue_script( \WorkOS\Auth\PasswordResetAdmin\Assets::SCRIPT_HANDLE ); + wp_enqueue_style( \WorkOS\Auth\PasswordResetAdmin\Assets::STYLE_HANDLE ); } } diff --git a/src/WorkOS/Admin/Users/RestApi.php b/src/WorkOS/Admin/Users/RestApi.php index 85bffac..2d5ddb4 100644 --- a/src/WorkOS/Admin/Users/RestApi.php +++ b/src/WorkOS/Admin/Users/RestApi.php @@ -259,8 +259,28 @@ private function shape_user( array $user, string $environment_id ): array { ); } + // Resolve the local WP user_id (if the user is linked) so the React + // row can wire up the "Send password reset" trigger that lives at + // `POST /workos/v1/admin/users/{wp_user_id}/password-reset`. Empty + // when the WorkOS user has no matching WP row yet. + $wp_user_id = 0; + if ( '' !== $id ) { + $users = get_users( + [ + 'meta_key' => '_workos_user_id', + 'meta_value' => $id, + 'number' => 1, + 'fields' => 'ID', + ] + ); + if ( ! empty( $users ) ) { + $wp_user_id = (int) $users[0]; + } + } + return [ 'id' => $id, + 'wp_user_id' => $wp_user_id, 'email' => $email, 'email_verified' => ! empty( $user['email_verified'] ), 'first_name' => isset( $user['first_name'] ) ? (string) $user['first_name'] : '', diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index c799912..ff30b5a 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -36,6 +36,8 @@ declare global { interface WorkosUser { id: string; + /** Linked WP user id, or 0 if this WorkOS user has no WP counterpart. */ + wp_user_id: number; email: string; email_verified: boolean; first_name: string; @@ -386,6 +388,19 @@ function App(): JSX.Element { { __( '—', 'integration-workos' ) } ) } + { user.wp_user_id > 0 && ( + + ) } ) ) } From 471dbe97821306dfd400b770c4b5aab61e04c005 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 23:14:17 -0400 Subject: [PATCH 07/12] feat(authkit): mirror new password to linked WP user on reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful WorkOS password_reset/confirm now also runs `wp_set_password()` on the linked WP user (resolved via `_workos_user_id` meta). Keeps the WordPress password-fallback paths — `?fallback=1` on wp-login.php, the `wp_authenticate` filter, REST app passwords — in sync with what the user just typed in the React shell. Without the mirror, the old WP password kept working after a WorkOS rotation, which is both confusing and a quiet security regression: anyone who knew the prior credential could still get in via `wp_authenticate`. Runs *before* LoginCompleter so the freshly-minted auth cookie survives the session invalidation `wp_set_password()` performs. The mirror is best-effort — an unlinked user no-ops cleanly, and the WorkOS reset response is returned unchanged either way. Three new test cases in `AuthKitRestPasswordTest`: - mirrors the password (verified via `wp_authenticate` with old + new password) - mirrors regardless of `auto_login_after_reset` - silently skips when no WP user is linked --- CHANGELOG.md | 10 ++ readme.txt | 1 + src/WorkOS/REST/Auth/Password.php | 45 +++++++ tests/wpunit/AuthKitRestPasswordTest.php | 152 +++++++++++++++++++++++ 4 files changed, 208 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf3122..5960877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,16 @@ dependency of the AuthKit bundle by `Renderer::enqueue()`; when zxcvbn is still loading the meter reports "Checking strength…" rather than gating on a transient. +- **Password mirror to the WP user on reset** — when a WorkOS reset + succeeds and the WorkOS user is linked to a WP user (via + `_workos_user_id` meta), the plugin now also runs + `wp_set_password()` on the linked WP user with the new plaintext. + Keeps the WordPress password fallback paths (`?fallback=1` on + wp-login.php, the `wp_authenticate` filter, REST app passwords) in + sync with what the user just typed in the React shell — without it + the old WP password would silently keep working after the WorkOS + rotation. Best-effort: unlinked users no-op cleanly; a write + failure never fails the reset response. - **Auto-login after password reset** — new per-profile toggle `auto_login_after_reset` (default: on). When enabled, a successful reset_confirm authenticates the user with the new diff --git a/readme.txt b/readme.txt index f4afcfc..bea1a27 100644 --- a/readme.txt +++ b/readme.txt @@ -183,6 +183,7 @@ WorkOS is provided by WorkOS, Inc. * New: WorkOS reset emails now point at the in-site React reset page (`/workos/login/{slug}?token=…&redirect_to=…`) instead of `wp-login.php`. The old `wp-login.php?workos_action=reset-password` URL still resolves cleanly so any reset emails already in users' inboxes keep working. * New: Password strength + confirmation on the reset-confirm step. Users must enter the new password twice; the value is scored in real time via WordPress's `wp.passwordStrength.meter` (zxcvbn) and the submit button stays disabled until the fields match and the score reaches Strong. Site name and common words are passed as the zxcvbn disallowed list. * New: Per-profile `auto_login_after_reset` toggle (default on). When enabled, a successful password reset signs the user in (via the shared `LoginCompleter`, so MFA / organization selection / entitlement gates still apply) and lands them on the validated post-reset redirect URL. With the toggle off the user lands on the existing "Password reset — Continue to sign in" card. +* New: Password reset mirrors the new password to the linked WordPress user via `wp_set_password()` so the WP password fallback (`?fallback=1`, `wp_authenticate`, REST app passwords) stays in sync with the WorkOS password the user just typed. Unlinked users no-op cleanly. = 1.0.4 - 2026-05-14 = diff --git a/src/WorkOS/REST/Auth/Password.php b/src/WorkOS/REST/Auth/Password.php index c956a26..20d3ca3 100644 --- a/src/WorkOS/REST/Auth/Password.php +++ b/src/WorkOS/REST/Auth/Password.php @@ -319,6 +319,14 @@ public function reset_confirm( WP_REST_Request $request ) { $profile ); + // Mirror the new password into the linked WP user so the WordPress + // password-fallback path (e.g. `?fallback=1` on wp-login.php, the + // `wp_authenticate` filter, REST app passwords) stays in sync with + // what the user just chose in the React shell. Runs *before* + // LoginCompleter so the auth cookie minted below survives the + // session invalidation `wp_set_password()` performs. + $this->mirror_password_to_wp_user( $workos_response, $new_password ); + // Default response shape — the "reset finished, please sign in" path. $response_body = [ 'ok' => true, @@ -369,6 +377,43 @@ public function reset_confirm( WP_REST_Request $request ) { return new WP_REST_Response( $response_body, 200 ); } + /** + * Mirror a freshly-reset WorkOS password into the linked WP user row. + * + * The WP password fallback (`?fallback=1`, `wp_authenticate`, REST app + * passwords) sits outside WorkOS. If we let it drift, a user who reset + * their WorkOS password could still log in via `wp_authenticate` using + * the old credential — confusing at best, a security regression at worst. + * Best-effort: a missing link, a WP user without the meta, or a WP error + * never fails the reset itself; the password change against WorkOS has + * already succeeded by the time we get here. + * + * @param mixed $workos_response Parsed reset_password response (carries + * the `user` object with the WorkOS id). + * @param string $new_password Plaintext password the user just set. + * + * @return void + */ + private function mirror_password_to_wp_user( $workos_response, string $new_password ): void { + if ( ! is_array( $workos_response ) ) { + return; + } + + $workos_user_id = (string) ( $workos_response['user']['id'] ?? '' ); + if ( '' === $workos_user_id ) { + return; + } + + $wp_user_id = \WorkOS\Sync\UserSync::get_wp_user_id_by_workos_id( $workos_user_id ); + if ( $wp_user_id <= 0 ) { + return; + } + + // `wp_set_password()` hashes the password and invalidates all + // existing sessions for the user — exactly what we want here. + wp_set_password( $new_password, $wp_user_id ); + } + /** * Pull the user's email out of a WorkOS API response. * diff --git a/tests/wpunit/AuthKitRestPasswordTest.php b/tests/wpunit/AuthKitRestPasswordTest.php index 14676e9..0096ed0 100644 --- a/tests/wpunit/AuthKitRestPasswordTest.php +++ b/tests/wpunit/AuthKitRestPasswordTest.php @@ -922,6 +922,158 @@ public function test_reset_confirm_surfaces_mfa_challenge(): void { $this->assertSame( 0, get_current_user_id() ); } + /** + * After a successful reset the WP user row's `user_pass` is updated to + * match the new password. Keeps `?fallback=1` / `wp_authenticate` / + * REST app passwords from drifting away from what the user typed in + * the React shell. + */ + public function test_reset_confirm_mirrors_new_password_to_wp_user(): void { + // Link the existing fallback WP user to a deterministic WorkOS id + // so the mirror lookup resolves to a row we can assert against. + $workos_id = 'user_wos_mirror'; + update_user_meta( $this->wp_fallback_user_id, '_workos_user_id', $workos_id ); + + $reset_body = wp_json_encode( + [ + 'user' => [ + 'id' => $workos_id, + 'email' => 'fallback@example.com', + 'email_verified' => true, + ], + ] + ); + $auth_body = wp_json_encode( + [ + 'access_token' => 'access_xyz', + 'user' => [ + 'id' => $workos_id, + 'email' => 'fallback@example.com', + 'email_verified' => true, + ], + ] + ); + + $this->response_queue = [ + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $reset_body ], + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => $auth_body ], + ]; + + $new_password = 'BrandNewPassword4Mirror!'; + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_mirror', + 'new_password' => $new_password, + ] + ); + $this->assertSame( 200, $response->get_status() ); + + // The WP user can now authenticate with the new plaintext password. + $auth = wp_authenticate( 'fallback_user', $new_password ); + $this->assertInstanceOf( \WP_User::class, $auth ); + $this->assertSame( $this->wp_fallback_user_id, $auth->ID ); + + // And the *old* WP password no longer works. + $failed = wp_authenticate( 'fallback_user', 'wp_password_123' ); + $this->assertInstanceOf( \WP_Error::class, $failed ); + } + + /** + * Password mirror also runs when `auto_login_after_reset` is off — the + * WP password must follow the WorkOS one regardless of session policy. + */ + public function test_reset_confirm_mirrors_password_even_without_auto_login(): void { + // Auto-login OFF. + $this->repository->save( + Profile::from_array( + [ + 'id' => $this->profile->get_id(), + 'slug' => 'members', + 'methods' => [ Profile::METHOD_PASSWORD ], + 'password_reset_flow' => true, + 'auto_login_after_reset' => false, + ] + ) + ); + + $workos_id = 'user_wos_mirror_no_auto'; + update_user_meta( $this->wp_fallback_user_id, '_workos_user_id', $workos_id ); + + $this->response_queue = [ + [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( + [ 'user' => [ 'id' => $workos_id, 'email' => 'fallback@example.com' ] ] + ), + ], + ]; + + $new_password = 'AnotherStrong1MirrorPass!'; + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_mirror_no_auto', + 'new_password' => $new_password, + ] + ); + $this->assertSame( 200, $response->get_status() ); + + $auth = wp_authenticate( 'fallback_user', $new_password ); + $this->assertInstanceOf( \WP_User::class, $auth ); + } + + /** + * When the WorkOS user is not linked to a WP user the mirror silently + * no-ops — the reset still returns 200 and the call to authenticate + * still fires (auto-login on by default). + */ + public function test_reset_confirm_skips_mirror_when_user_not_linked(): void { + // Make sure the fallback user is NOT linked. + delete_user_meta( $this->wp_fallback_user_id, '_workos_user_id' ); + + $workos_id = 'user_wos_no_link'; + + $this->response_queue = [ + [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => wp_json_encode( + [ 'user' => [ 'id' => $workos_id, 'email' => 'stranger@example.com' ] ] + ), + ], + // auto_login attempt — return an MFA-shaped result so LoginCompleter + // hands back early without trying to mint a session for a user it + // can't resolve. + [ 'response' => [ 'code' => 200, 'message' => 'OK' ], 'body' => '{}' ], + ]; + + $old_hash = get_userdata( $this->wp_fallback_user_id )->user_pass; + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/password/reset/confirm', + [ + 'profile' => 'members', + 'token' => 'reset_token_no_link', + 'new_password' => 'WhateverPassword!42', + ] + ); + $this->assertSame( 200, $response->get_status() ); + + // fallback user's password hash is unchanged. + $this->assertSame( + $old_hash, + get_userdata( $this->wp_fallback_user_id )->user_pass, + 'Unlinked users must NOT have their WP password touched.' + ); + } + /** * Off-site redirect_url is rejected (validated against home_url) on the * confirm endpoint too — same policy as start. From b1c381c1a38b13bb4201ea92503ef6cfd628ce28 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 23:37:38 -0400 Subject: [PATCH 08/12] feat(authkit): skeleton placeholders on login + reset pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two "looks-broken" windows on every AuthKit surface (wp-login.php takeover, /workos/login/{profile}, [workos:login] shortcode): 1. From first paint until the React bundle loads + executes — the mount div was empty, so the page showed nothing. 2. After mount but before `client.bootstrap()` resolves — App.tsx returned `null`, so the page showed nothing. Both now paint a card-shaped skeleton with shimmering placeholder rows that mirror the real FlowCard shape and dimensions one-to-one. Server-side: `Renderer::build_skeleton_markup()` emits the placeholder HTML inside the mount `
` (createRoot() replaces the contents on hydration so this isn't double-rendered). The context's `initial_step` / `reset_token` / `invitation_token` pick between a 1-input and 2-input variant so the reset-confirm two-password form swaps in without a layout jump. The `pick` step omits the footer placeholder since the real method-picker has no back link. Client-side: new `FlowSkeleton` component in `ui.tsx` takes over the `booted=false` branch in App.tsx, matching the same shape so the bootstrap RTT looks intentional instead of broken. Heights match the hydrated form exactly: heading 24px, subheading 20px, label 16px, input 44px, button 40px. Shimmer animates only `background-position` (no transforms, no opacity) so the box never resizes. Disabled under `prefers-reduced-motion`. Adds three wpunit cases: - `test_render_mount_embeds_skeleton_placeholder` - `test_render_mount_skeleton_uses_two_inputs_for_reset_confirm` - `test_render_mount_skeleton_omits_footer_on_pick` Full suite green: 485 tests, 1386 assertions. --- CHANGELOG.md | 15 +++++ readme.txt | 1 + src/WorkOS/Auth/AuthKit/Renderer.php | 66 ++++++++++++++++++++- src/js/authkit/App.tsx | 17 +++++- src/js/authkit/styles.css | 88 ++++++++++++++++++++++++++++ src/js/authkit/ui.tsx | 75 ++++++++++++++++++++++++ tests/wpunit/AuthKitRendererTest.php | 60 +++++++++++++++++++ 7 files changed, 319 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5960877..a4f9ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,21 @@ dependency of the AuthKit bundle by `Renderer::enqueue()`; when zxcvbn is still loading the meter reports "Checking strength…" rather than gating on a transient. +- **Skeleton placeholders on every AuthKit surface** — wp-login.php + takeover, `/workos/login/{profile}`, and `[workos:login]` shortcode + now paint a card-shaped skeleton with shimmering placeholder rows + the moment the page lands, instead of a blank gap while the React + bundle downloads and `client.bootstrap()` resolves. The skeleton + comes from two places that mirror each other shape-for-shape: PHP + embeds it inside the mount `
` so it's visible from first paint + (Renderer emits 1-input or 2-input variants based on context — + reset_confirm gets two, pick gets one with no footer); React's new + `FlowSkeleton` ui component takes over from the `booted=false` + branch in App.tsx during the bootstrap RTT. Heights match the + hydrated form exactly (heading 24px, subheading 20px, label 16px, + input 44px, button 40px) so the swap is a flicker, not a layout + jump. Shimmer animates only `background-position` (no transforms, + no opacity) and is disabled under `prefers-reduced-motion`. - **Password mirror to the WP user on reset** — when a WorkOS reset succeeds and the WorkOS user is linked to a WP user (via `_workos_user_id` meta), the plugin now also runs diff --git a/readme.txt b/readme.txt index bea1a27..02cbe65 100644 --- a/readme.txt +++ b/readme.txt @@ -184,6 +184,7 @@ WorkOS is provided by WorkOS, Inc. * New: Password strength + confirmation on the reset-confirm step. Users must enter the new password twice; the value is scored in real time via WordPress's `wp.passwordStrength.meter` (zxcvbn) and the submit button stays disabled until the fields match and the score reaches Strong. Site name and common words are passed as the zxcvbn disallowed list. * New: Per-profile `auto_login_after_reset` toggle (default on). When enabled, a successful password reset signs the user in (via the shared `LoginCompleter`, so MFA / organization selection / entitlement gates still apply) and lands them on the validated post-reset redirect URL. With the toggle off the user lands on the existing "Password reset — Continue to sign in" card. * New: Password reset mirrors the new password to the linked WordPress user via `wp_set_password()` so the WP password fallback (`?fallback=1`, `wp_authenticate`, REST app passwords) stays in sync with the WorkOS password the user just typed. Unlinked users no-op cleanly. +* New: Skeleton placeholders on every AuthKit surface (wp-login.php takeover, `/workos/login/{profile}`, and the shortcode) paint a card-shaped silhouette with shimmering rows the moment the page lands, instead of a blank gap while the React bundle downloads and bootstraps. Heights match the hydrated form one-to-one so the swap is a flicker, not a layout jump. Honours `prefers-reduced-motion`. = 1.0.4 - 2026-05-14 = diff --git a/src/WorkOS/Auth/AuthKit/Renderer.php b/src/WorkOS/Auth/AuthKit/Renderer.php index a2c7e27..19ce934 100644 --- a/src/WorkOS/Auth/AuthKit/Renderer.php +++ b/src/WorkOS/Auth/AuthKit/Renderer.php @@ -175,8 +175,9 @@ public function render_mount( Profile $profile, array $context = [] ): string { ]; $style_tag = $this->branding_style_tag( $profile_data['branding'] ); + $skeleton = $this->build_skeleton_markup( $context, $profile_data['branding'] ); - return $style_tag . '
stringify_attrs( $attrs ) . '>
'; + return $style_tag . '
stringify_attrs( $attrs ) . '>' . $skeleton . '
'; } /** @@ -260,6 +261,69 @@ public function render_full_page( Profile $profile, array $context = [] ): void exit; } + /** + * Emit pre-hydration skeleton markup that mirrors the React + * {@see FlowSkeleton} component shape-for-shape. + * + * React's createRoot() replaces the contents of the mount node on + * hydration, so the markup we plant here is visible from page paint + * until React runs (the "broken-looking" window before JS executes). + * Heights match the hydrated form one-for-one so the swap is a + * flicker, not a layout jump. + * + * @param array $context Render context (see render_mount()). + * @param array $branding Resolved branding. + * + * @return string Safe HTML for direct embedding inside the mount div. + */ + private function build_skeleton_markup( array $context, array $branding ): string { + $initial_step = sanitize_key( $context['initial_step'] ?? 'pick' ); + if ( '' !== ( $context['invitation_token'] ?? '' ) ) { + $initial_step = 'invitation'; + } elseif ( '' !== ( $context['reset_token'] ?? '' ) ) { + $initial_step = 'reset_confirm'; + } + + // Reset_confirm renders two password fields; everything else one. + $field_count = 'reset_confirm' === $initial_step ? 2 : 1; + $with_footer = 'pick' !== $initial_step; + $logo_url = (string) ( $branding['logo_url'] ?? '' ); + $logo_alt = (string) ( $branding['heading'] ?? '' ); + if ( '' === $logo_alt ) { + $logo_alt = __( 'Sign in', 'integration-workos' ); + } + + $fields_html = ''; + for ( $i = 0; $i < $field_count; $i++ ) { + $fields_html .= ''; + } + + $logo_html = '' !== $logo_url + ? sprintf( + '', + esc_url( $logo_url ), + esc_attr( $logo_alt ) + ) + : ''; + + $footer_html = $with_footer + ? '' + : ''; + + return $logo_html + . ''; + } + /** * Resolve the profile's branding, falling back to sensible defaults. * diff --git a/src/js/authkit/App.tsx b/src/js/authkit/App.tsx index a6bb9da..11eca3f 100644 --- a/src/js/authkit/App.tsx +++ b/src/js/authkit/App.tsx @@ -31,7 +31,7 @@ import { SignupVerify, } from './flows'; import { forwardQueryArgs } from './redirect'; -import { BelowCard } from './ui'; +import { BelowCard, FlowSkeleton } from './ui'; export interface AppProps { profile: Profile; @@ -107,7 +107,20 @@ export function App( props: AppProps ) { }, [ client ] ); if ( ! booted ) { - return null; + // Reset_confirm renders two password fields; everything else + // renders one. Pick the matching skeleton height so the swap to + // the hydrated form doesn't shift the card. + const skeletonFields = 'reset_confirm' === initial ? 2 : 1; + return ( + + ); } const handleSuccess = ( data: LoginSuccess ): void => { diff --git a/src/js/authkit/styles.css b/src/js/authkit/styles.css index 6e4afcd..93dd458 100644 --- a/src/js/authkit/styles.css +++ b/src/js/authkit/styles.css @@ -366,3 +366,91 @@ body.workos-authkit-body #workos-authkit-root { #workos-authkit-root .wa-password-strength--mismatch strong { color: #d63638; } + +/* + * Flow skeleton — placeholder card shown during the React app + * bootstrap (App.tsx booted=false), in lieu of the empty page that + * otherwise appears for ~50-300ms while `client.bootstrap()` resolves. + * + * Heights match the real elements one-for-one (heading 24px, subheading + * 20px, label 20px, input 44px, button 40px) so swapping the skeleton + * for the hydrated FlowCard doesn't move the page. The shimmer is + * animated via `background-position` only — no transforms, no opacity + * toggling — so the box never resizes and the parent layout is locked + * the moment the card paints. + */ +#workos-authkit-root .wa-skeleton { + background: linear-gradient( + 90deg, + rgba( 0, 0, 0, 0.04 ) 0%, + rgba( 0, 0, 0, 0.08 ) 50%, + rgba( 0, 0, 0, 0.04 ) 100% + ); + background-size: 200% 100%; + border-radius: var( --wa-radius ); + animation: wa-skeleton-shimmer 1.4s ease-in-out infinite; +} + +#workos-authkit-root .wa-skeleton--heading { + height: 24px; + width: 60%; +} + +#workos-authkit-root .wa-skeleton--subheading { + height: 20px; + width: 80%; +} + +#workos-authkit-root .wa-skeleton-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +#workos-authkit-root .wa-skeleton--label { + height: 16px; + width: 35%; +} + +#workos-authkit-root .wa-skeleton--input { + height: 44px; + width: 100%; +} + +#workos-authkit-root .wa-skeleton--button { + height: 40px; + width: 100%; +} + +#workos-authkit-root .wa-skeleton--footer { + height: 16px; + width: 45%; + margin: 4px auto 0; +} + +#workos-authkit-root .wa-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect( 0, 0, 0, 0 ); + white-space: nowrap; + border: 0; +} + +@keyframes wa-skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media ( prefers-reduced-motion: reduce ) { + #workos-authkit-root .wa-skeleton { + animation: none; + } +} diff --git a/src/js/authkit/ui.tsx b/src/js/authkit/ui.tsx index 3600198..1ecd276 100644 --- a/src/js/authkit/ui.tsx +++ b/src/js/authkit/ui.tsx @@ -210,6 +210,81 @@ export function Spinner() { return ; } +interface FlowSkeletonProps { + logoUrl?: string; + logoAlt: string; + /** + * How many input "rows" to render. Defaults to 1 (the common + * email-or-code single-field case). Use 2 for the reset_confirm + * step which renders new password + confirm password. + */ + fieldCount?: number; + /** + * Whether to render a footer-row placeholder (used in invitation / + * reset / signup flows that carry a back link). + */ + withFooter?: boolean; +} + +/** + * Placeholder card that mirrors the FlowCard layout while data is in + * flight (App.tsx bootstrap, MFA challenge fetch, invitation lookup). + * + * Same logo placement, same card chrome, and the same approximate + * heights as the real flows so the swap to a hydrated form doesn't + * shift the page. Shimmer is animated via `background-position` only + * (no transforms) so no element ever resizes or moves. + */ +export function FlowSkeleton( { + logoUrl, + logoAlt, + fieldCount = 1, + withFooter = false, +}: FlowSkeletonProps ) { + return ( + <> + + +
+
+ { Array.from( { length: Math.max( 1, fieldCount ) } ).map( + ( _, index ) => ( +
+
+
+
+ ) + ) } +
+ { withFooter && ( +
+ ) } + + { __( 'Loading…', 'integration-workos' ) } + + + + ); +} + interface LinkButtonProps { onClick?: () => void; children?: ReactNode; diff --git a/tests/wpunit/AuthKitRendererTest.php b/tests/wpunit/AuthKitRendererTest.php index 46e73bc..de2ba79 100644 --- a/tests/wpunit/AuthKitRendererTest.php +++ b/tests/wpunit/AuthKitRendererTest.php @@ -676,4 +676,64 @@ public function test_takeover_does_not_forward_query_args_when_disabled(): void $this->assertSame( '/dashboard', $captured ); } + + /** + * render_mount embeds pre-hydration skeleton markup inside the mount + * div so the user sees the card silhouette before React loads. + */ + public function test_render_mount_embeds_skeleton_placeholder(): void { + $profile = Profile::from_array( + [ + 'slug' => 'members', + 'title' => 'Members', + 'methods' => [ Profile::METHOD_PASSWORD ], + ] + ); + + $html = $this->renderer->render_mount( $profile ); + + $this->assertStringContainsString( 'wa-skeleton wa-skeleton--heading', $html ); + $this->assertStringContainsString( 'wa-skeleton wa-skeleton--input', $html ); + $this->assertStringContainsString( 'wa-skeleton wa-skeleton--button', $html ); + $this->assertStringContainsString( 'role="status"', $html ); + } + + /** + * The reset-confirm context renders two input placeholders (new + * password + confirm password) so the skeleton height matches the + * hydrated form one-to-one. + */ + public function test_render_mount_skeleton_uses_two_inputs_for_reset_confirm(): void { + $profile = Profile::from_array( + [ + 'slug' => 'members', + 'methods' => [ Profile::METHOD_PASSWORD ], + ] + ); + + $html = $this->renderer->render_mount( + $profile, + [ 'reset_token' => 'rt_abc' ] + ); + + $inputs = substr_count( $html, 'wa-skeleton--input' ); + $this->assertSame( 2, $inputs, 'Reset-confirm skeleton should render two input rows.' ); + } + + /** + * Default `pick` step has no back-link in the real card, so the + * skeleton suppresses the footer placeholder too. + */ + public function test_render_mount_skeleton_omits_footer_on_pick(): void { + $profile = Profile::from_array( + [ + 'slug' => 'members', + 'methods' => [ Profile::METHOD_PASSWORD ], + ] + ); + + $html = $this->renderer->render_mount( $profile ); + + $this->assertStringNotContainsString( 'wa-skeleton--footer', $html ); + } } From 81e4b10275b2bd390efc302464b9b2e99b95be30 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 23:46:56 -0400 Subject: [PATCH 09/12] docs: add password-reset API reference (PHP, JS, React/TS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive integration guide for the three password-reset surfaces: - Self-service start/confirm endpoints (`/wp-json/workos/v1/auth/ password/reset/{start,confirm}`). - Admin-triggered endpoint (`/wp-json/workos/v1/admin/users/{id}/ password-reset`) with the same-route self-service path via `edit_user($own_id)`. - `[workos:password-reset]` shortcode. Written for both human developers and LLM agents — endpoint specs, profile policy, redirect URL validation rules, the password-strength contract (server accepts anything; gate lives in the React shell or your own UI), and the full activity-log shape. Worked examples for every common entry point: - Full PHP example: server-side admin trigger via `rest_do_request()` (with rationale for why callers should NOT skip straight to `workos()->api()->send_password_reset()`). - Vanilla JS example: theme-side "Forgot password?" form with the correct profile-scoped `X-WorkOS-Nonce` header. - React + TypeScript example: `SendResetButton` component with the correct WP REST `X-WP-Nonce` header and graceful error surfacing. The "Don't do this" section catalogs the failure modes we've seen (direct WorkOS calls bypassing the URL builder, header-name collisions, echoing the raw email in UI, trusting unvalidated `redirect_to` URL params, skipping the strength gate when rolling your own form, assuming 200 = email exists, etc.). Linked from README.md so the docs are discoverable next to the existing `extending-the-login-ui.md` guide. --- README.md | 5 +- docs/password-reset.md | 654 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 docs/password-reset.md diff --git a/README.md b/README.md index c270118..25bb97d 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,10 @@ The `branding.logo` field defaults to the WordPress Site Icon when no per-profile logo is set. See [`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md) for the full developer guide on injecting React elements (SlotFill), enqueuing -per-profile CSS/JS, and the available PHP filters. +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). ### Custom paths diff --git a/docs/password-reset.md b/docs/password-reset.md new file mode 100644 index 0000000..91b34d8 --- /dev/null +++ b/docs/password-reset.md @@ -0,0 +1,654 @@ +# Password Reset API + +This document covers every way to trigger or complete a WorkOS-backed password reset for a WordPress user managed by `integration-workos`. + +It is written for two audiences in parallel: a developer integrating against the plugin, and an LLM agent reading it as the source of truth for code-gen. The patterns and gotchas below are exhaustive — if your integration deviates from them, you are almost certainly hitting one of the **Don't do this** sections at the bottom. + +> **Plugin requirement:** integration-workos **1.0.5** or later. Earlier versions only expose the `start` and `confirm` endpoints under the legacy `wp-login.php` URL. + +--- + +## At a glance + +| Surface | Endpoint | Auth | Audience | +| --- | --- | --- | --- | +| Self-service start | `POST /wp-json/workos/v1/auth/password/reset/start` | Profile-scoped nonce | Anyone (anonymous OK) | +| Self-service confirm | `POST /wp-json/workos/v1/auth/password/reset/confirm` | Profile-scoped nonce | Anyone with a valid reset token | +| Admin-triggered | `POST /wp-json/workos/v1/admin/users/{id}/password-reset` | WP REST nonce + `edit_user($id)` capability | Editors / admins (also covers self-service from a logged-in context) | +| Shortcode | `[workos:password-reset]` | Rendered server-side; uses admin endpoint | Page authors | + +All three converge on the same WorkOS API call (`POST /user_management/password_reset/send`), and all three honor the same `redirect_url` and profile policy (`password_reset_flow`, `auto_login_after_reset`). + +--- + +## End-to-end flow + +``` +┌─────────────┐ 1. POST /reset/start (or admin/users/{id}/password-reset) +│ caller │───────────────────────────────────────────────────────────────┐ +└─────────────┘ ▼ + ┌─────────────────────┐ + │ integration-workos │ + │ validates redirect, │ + │ rate-limits, calls │ + │ WorkOS │ + └─────────┬───────────┘ + │ + ▼ + ┌──────────────────────┐ + │ WorkOS sends an │ + │ email containing │ + │ /workos/login/{slug} │ + │ ?token=… │ + │ &redirect_to=… │ + └─────────┬────────────┘ + │ + 2. user clicks email link │ + ┌─────────────────────────────────────────────────────────────────────────┘ + ▼ +┌──────────────────────────┐ 3. POST /reset/confirm ┌─────────────────────────┐ +│ /workos/login/{slug} │──────────────────────────▶│ integration-workos │ +│ React shell mounts in │ │ resets WorkOS pwd, then │ +│ ResetConfirm step │ │ - mirrors password to │ +└──────────────────────────┘ │ linked WP user │ + │ - (optional) signs │ + │ them in via │ + │ LoginCompleter │ + └────────┬────────────────┘ + ▼ + 4. Browser navigates to redirect_to +``` + +--- + +## Endpoint reference + +### Public: `POST /wp-json/workos/v1/auth/password/reset/start` + +Sends the WorkOS reset email. The endpoint always returns `200` whether or not the email matches a real account — that prevents account enumeration. Response time is also flattened (~900 ms floor) for the same reason. + +**Headers** + +``` +Content-Type: application/json +X-WorkOS-Nonce: +``` + +**Body** + +```jsonc +{ + "profile": "default", // required — Login Profile slug + "email": "user@example.com", // required + "redirect_url": "https://site.example/welcome" // optional — same-host required, falls back to profile.post_login_redirect or home_url('/') +} +``` + +**200 response** (always 200 unless rate-limited or input invalid) + +```json +{ + "ok": true, + "message": "If an account exists for this email, a password reset link is on its way." +} +``` + +**Rate limits** + +- `10` attempts per IP per `60s` window. +- `5` attempts per email per `60s` window. + +A 429 returns `WP_Error` with `retry_after` in the data array. + +--- + +### Public: `POST /wp-json/workos/v1/auth/password/reset/confirm` + +Completes the reset. Reads the token from the URL the user clicked in the email. + +**Headers** + +``` +Content-Type: application/json +X-WorkOS-Nonce: +``` + +**Body** + +```jsonc +{ + "profile": "default", // required + "token": "abc123…", // required — value of ?token= from the email link + "new_password": "S0meStrongPa$$w0rd!", // required — see "Password strength" below + "redirect_url": "https://site.example/welcome" // optional — overrides anything carried in the URL +} +``` + +**200 response** — three shapes depending on profile config and WorkOS state: + +1. **Plain reset** (toggle `auto_login_after_reset` is off): + + ```json + { + "ok": true, + "redirect_url": "https://site.example/welcome" + } + ``` + +2. **Auto-login** (toggle on, no MFA): + + ```json + { + "ok": true, + "redirect_url": "https://site.example/welcome", + "signed_in": true, + "user": { "id": 42, "email": "user@example.com", "display_name": "User" }, + "redirect_to": "https://site.example/welcome" + } + ``` + + A WP auth cookie has been set; the linked WP user's password has been mirrored to match. + +3. **Auto-login with MFA required**: + + ```json + { + "ok": true, + "redirect_url": "https://site.example/welcome", + "mfa_required": true, + "pending_authentication_token": "pending_xyz", + "factors": [ { "id": "auth_factor_…", "type": "totp" } ] + } + ``` + + Submit `pending_authentication_token` + factor code to `/wp-json/workos/v1/auth/mfa/verify` to finish. + +**Rate limits** + +- `10` attempts per IP per `60s` window. (No per-email bucket here; the token already gates against arbitrary callers.) + +--- + +### Admin: `POST /wp-json/workos/v1/admin/users/{id}/password-reset` + +Sends a reset email on behalf of any user the caller has `edit_user($id)` capability on. WordPress grants that capability on one's own user ID, so the same endpoint covers both **admin-of-another** and **self-service** call paths. + +**Headers** + +``` +Content-Type: application/json +X-WP-Nonce: +``` + +**Body** (all optional) + +```jsonc +{ + "redirect_url": "https://site.example/welcome", // optional — same-host required + "profile": "default" // optional — defaults to the canonical default profile +} +``` + +**200 response** + +```json +{ + "ok": true, + "email_hint": "u•••@e•••.com", + "profile": "default", + "redirect_url": "https://site.example/welcome" +} +``` + +`email_hint` is intentionally masked — never echo the full target email in UI. + +**Errors** + +| Status | Code | Cause | +| --- | --- | --- | +| 400 | `workos_invalid_user` | `id` path segment is not a positive integer | +| 400 | `workos_reset_disabled` | Profile has `password_reset_flow=false` | +| 403 | `workos_forbidden` | Caller lacks `edit_user` on the target | +| 404 | `workos_user_not_found` | No WP user with that ID | +| 409 | `workos_user_not_linked` | WP user exists but has no `_workos_user_id` meta | +| 409 | `workos_user_missing_email` | WP user has no usable email address | +| 429 | `workos_rate_limited` | Rate-limit window exhausted (10/IP/min, 5/target-user/min) | + +Every successful call writes a `password_reset.admin_sent` row to the activity log (`{$wpdb->prefix}workos_activity_log`) with the initiator id, target id, profile slug, redirect URL, and a `self_service` flag. + +--- + +## Profile policy + +Each Login Profile exposes three settings that govern the flow: + +| Setting | Default | Effect | +| --- | --- | --- | +| `password_reset_flow` | `true` | When `false`, all three endpoints return `workos_reset_disabled`. Reset emails can't be sent and `?token=` links can't be redeemed. | +| `auto_login_after_reset` | `true` | When `true`, a successful `confirm` re-authenticates the user with their new password and sets the WP auth cookie. MFA still runs. | +| `post_login_redirect` | `''` (home) | Default destination when no `redirect_url` is supplied. | + +Toggle them in **WorkOS → Login Profiles → {profile}**. + +--- + +## Password strength + confirmation + +The React reset-confirm step renders **two password fields** and scores the value via WordPress's bundled `wp.passwordStrength.meter` (zxcvbn-backed). Submit is disabled until: + +- Both fields match exactly, AND +- zxcvbn score reaches `Strong` (≥ 3 out of 4). + +The site name plus the strings `wordpress` and `admin` are passed as the zxcvbn disallowed list, so reusing those loses strength points. + +When zxcvbn is still streaming in, the meter reports `Checking strength…` rather than gating on a transient. + +> If you're calling the `confirm` endpoint from your own UI, **you** own enforcing this gate. The server accepts any non-empty `new_password`. + +--- + +## Redirect URL validation + +`redirect_url` accepts: + +- Absolute URLs whose host equals `home_url()`'s host. +- Site-relative paths (e.g. `/welcome` → resolved against `home_url()`). + +It rejects, falling back to `profile.post_login_redirect` → `home_url('/')`: + +- Cross-origin absolute URLs. +- Protocol-relative URLs (`//evil.example/x`). +- Non-`http(s)` schemes (`javascript:`, `data:`, etc.). + +The validation runs on every endpoint that accepts `redirect_url` (start, confirm, admin-trigger) and on the server side, so a malicious client can't bypass it. + +--- + +## Examples + +### Full PHP example — server-side admin trigger + +Use this when your own plugin or theme needs to trigger a reset programmatically (e.g. from a custom REST endpoint, a WP-CLI command, or an admin action handler). + +```php +set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + [ + // Optional. Must be same-host; falls back to profile default otherwise. + 'redirect_url' => $redirect_url, + // Optional. Defaults to the canonical "default" Login Profile. + 'profile' => 'default', + ] + ) + ); + + $response = rest_do_request( $request ); + + if ( $response->is_error() ) { + return $response->as_error(); + } + + $data = $response->get_data(); + // $data['email_hint'] is masked (e.g. "j•••@e•••.com") — safe to surface in UI. + return $data; +} + +// Example usage from a custom admin action. +add_action( 'admin_post_my_send_reset', static function () { + check_admin_referer( 'my_send_reset' ); + + $target_id = absint( $_POST['user_id'] ?? 0 ); + $redirect_url = esc_url_raw( $_POST['redirect_url'] ?? home_url( '/' ) ); + + $result = my_plugin_trigger_workos_password_reset( $target_id, $redirect_url ); + + if ( is_wp_error( $result ) ) { + wp_die( esc_html( $result->get_error_message() ), 'Reset failed', [ 'response' => 400 ] ); + } + + wp_safe_redirect( admin_url( 'users.php?reset_sent=1' ) ); + exit; +} ); +``` + +Why call into the internal endpoint instead of `workos()->api()->send_password_reset()` directly? Three reasons: + +1. The endpoint enforces `edit_user`, rate limits, profile gating, and `redirect_url` validation in one place. Bypassing it duplicates that surface area in your code. +2. It writes the `password_reset.admin_sent` activity-log row — so audit history stays consistent. +3. It builds the URL via `FrontendRoute::url_for_profile()`, picking up any host-side `home_url` filter that escapes ampersands. Skipping that machinery is exactly how broken reset links land in inboxes. + +If you absolutely need the lower-level call, see [`WorkOS\Api\Client::send_password_reset()`](../src/WorkOS/Api/Client.php). + +--- + +### Vanilla JS example — frontend self-service + +Use this for a custom "Forgot password?" link on your theme's login page, outside the AuthKit React shell. + +```html + +
+ + +

+
+``` + +```html + + +``` + +```js +// Browser code. +( function () { + const form = document.getElementById( 'forgot-form' ); + const status = form.querySelector( '.status' ); + const cfg = window.myResetConfig; + + form.addEventListener( 'submit', async ( event ) => { + event.preventDefault(); + status.textContent = 'Sending…'; + + try { + const response = await fetch( cfg.endpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type' : 'application/json', + // Note the header name — the public auth endpoints use the + // profile-scoped X-WorkOS-Nonce, not X-WP-Nonce. + 'X-WorkOS-Nonce' : cfg.nonce, + }, + body: JSON.stringify( { + profile : cfg.profile, + email : form.email.value, + redirect_url : cfg.redirectUrl, + } ), + } ); + + const data = await response.json(); + + if ( ! response.ok ) { + // 429s arrive here with data.code === 'workos_rate_limited'. + status.textContent = data.message || 'Could not send the email. Please try again.'; + return; + } + + // Server always returns 200 to prevent account enumeration. + status.textContent = data.message; + } catch ( err ) { + status.textContent = 'Network error. Please try again.'; + } + } ); +} )(); +``` + +**The hidden gotcha:** the per-profile nonce is rotated every WP nonce tick (~12h by default). Long-lived single-page apps must re-fetch the nonce after a 403 response, not hard-fail. + +--- + +### React + TypeScript example — admin trigger button + +Use this when you're building a custom admin UI that lives outside this plugin — e.g. a SaaS dashboard's React surface — and need to trigger a reset for a specific WP user. (For the public self-service flow, use the bundled AuthKit shell instead of writing your own.) + +```tsx +// SendResetButton.tsx +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +interface SendResetConfig { + /** Base URL — admin endpoint is `${baseUrl}{id}/password-reset`. */ + baseUrl: string; + /** WP REST nonce (`wp_rest`), refreshed when stale. */ + nonce: string; +} + +interface Props { + config: SendResetConfig; + /** Target WP user id. */ + userId: number; + /** Optional same-host URL to land the user on after they reset. */ + redirectUrl?: string; + /** Optional Login Profile slug — defaults to "default" server-side. */ + profile?: string; + onSuccess?: ( emailHint: string ) => void; + onError?: ( message: string ) => void; +} + +interface SuccessResponse { + ok: true; + email_hint: string; + redirect_url: string; +} + +interface ErrorResponse { + code?: string; + message?: string; +} + +export function SendResetButton( { + config, + userId, + redirectUrl = '', + profile = '', + onSuccess, + onError, +}: Props ) { + const [ busy, setBusy ] = useState( false ); + + const send = async () => { + if ( ! window.confirm( __( 'Send a password reset email to this user?', 'my-plugin' ) ) ) { + return; + } + setBusy( true ); + + try { + const response = await fetch( `${ config.baseUrl }${ userId }/password-reset`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + // Admin endpoint uses the standard WP REST nonce. + 'X-WP-Nonce' : config.nonce, + }, + body: JSON.stringify( { redirect_url: redirectUrl, profile } ), + } ); + + const data = ( await response.json() ) as + | SuccessResponse + | ErrorResponse; + + if ( ! response.ok ) { + const err = data as ErrorResponse; + onError?.( err.message ?? __( 'Could not send the email.', 'my-plugin' ) ); + return; + } + + const ok = data as SuccessResponse; + onSuccess?.( ok.email_hint ); + } catch { + onError?.( __( 'Network error.', 'my-plugin' ) ); + } finally { + setBusy( false ); + } + }; + + return ( + + ); +} +``` + +**Wiring it up.** Pass the config from PHP via `wp_localize_script()`: + +```php +wp_localize_script( + 'my-admin-bundle', + 'mySendResetConfig', + [ + 'baseUrl' => esc_url_raw( rest_url( 'workos/v1/admin/users/' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ] +); +``` + +--- + +## Shortcode reference + +``` +[workos:password-reset + user="42" # WP user id OR email. Omit for self-service. + redirect_url="/dashboard" # Optional same-host URL. + profile="default" # Optional Login Profile slug. + label="Reset my password" # Optional button label. +] +``` + +**Modes** + +- **Admin-of-other:** `user="42"` — renders only when the viewer has `edit_user(42)`. +- **Self-service:** no `user` attribute — renders for the current logged-in user only. + +In both cases the click POSTs to the admin endpoint with the WP REST nonce. + +--- + +## Activity log + +Successful admin-triggered sends append a row to `{$wpdb->prefix}workos_activity_log`: + +| Column | Value | +| --- | --- | +| `event_type` | `password_reset.admin_sent` | +| `user_id` | Target WP user ID | +| `user_email` | Target email (in plaintext — column is internal) | +| `workos_user_id` | Linked WorkOS user ID | +| `metadata` | JSON: `{ profile, redirect_url, initiator_id, self_service }` | + +Read via `\WorkOS\ActivityLog\EventLogger::get_events([ 'event_type' => 'password_reset.admin_sent' ])`. + +--- + +## Don't do this + +This section catalogs the failure modes we've seen people hit. If your integration is misbehaving, scan here before opening an issue. + +### ❌ Don't call WorkOS directly to send the reset email. + +```php +// BAD — bypasses the URL builder, rate limits, profile gating, activity log. +workos()->api()->send_password_reset( $email, 'https://example.com/whatever' ); +``` + +The URL you hand WorkOS gets emailed verbatim. Get the host wrong and the email link is dead. Skip the entity-decode and a host-side `home_url` filter will silently break it. Always route through `POST /admin/users/{id}/password-reset` or `POST /auth/password/reset/start`. + +### ❌ Don't store reset tokens or replay them. + +The token in `?token=…` is single-use and validated by WorkOS, not this plugin. Capturing it in your own database serves no purpose and creates a credential surface you have to protect. Just hand it to `POST /auth/password/reset/confirm` once. + +### ❌ Don't trust the `redirect_url` you got from URL parameters without re-validating. + +```js +// BAD — pulls redirect_to from the URL and uses it directly. +const params = new URLSearchParams( window.location.search ); +window.location.assign( params.get( 'redirect_to' ) ); // open redirect +``` + +The server already validates `redirect_url` against `home_url()`, but only when it travels through the endpoint payload. If you're consuming a URL parameter directly in JS, run it back through `wp_validate_redirect()` (or its same-host equivalent in your stack) before navigating. + +### ❌ Don't echo the user's email from the admin response. + +```js +// BAD +status.textContent = `Sent to ${ data.email }`; +``` + +The endpoint deliberately returns `email_hint` (`u•••@e•••.com`) instead of the full address. Don't undo that — operator-screen leakage is one of the easier audit findings to avoid. + +### ❌ Don't skip the password-strength gate when you write your own reset UI. + +The server accepts any non-empty `new_password`. The strength meter is an in-shell UX gate, not an API-level one. If you're writing a custom reset form (instead of using the bundled AuthKit shell), reproduce the zxcvbn check in your UI — otherwise users can set `password` and `123456` against the WorkOS account. + +### ❌ Don't assume a 200 from `/reset/start` means the email exists. + +The endpoint **always** returns 200 (with a fixed ~900 ms response-time floor) to prevent account enumeration. If you need to know whether the email is registered, ask via a different surface (a separate authenticated lookup), not this one. + +### ❌ Don't hard-code `wp-login.php` as the reset URL. + +Reset emails point at `/workos/login/{slug}` since 1.0.5. Legacy `wp-login.php?workos_action=reset-password` links still resolve (via `LoginTakeover`), but new code should never build that URL by hand. Let the endpoint construct it via `FrontendRoute::url_for_profile()`. + +### ❌ Don't use `X-WP-Nonce` for the public auth endpoints. + +```js +// BAD — X-WP-Nonce works for the admin endpoint, NOT for /auth/*. +fetch( '/wp-json/workos/v1/auth/password/reset/start', { + headers: { 'X-WP-Nonce': nonce }, +} ); +``` + +Public auth endpoints use a profile-scoped nonce in the **`X-WorkOS-Nonce`** header. (We deliberately avoid `X-WP-Nonce` to prevent collisions with other plugins.) Admin endpoints under `/admin/*` use the standard `X-WP-Nonce`. Mixing them returns 403. + +### ❌ Don't disable `auto_login_after_reset` because "session management is hard". + +When the toggle is off, the user is left on a "Continue to sign in" card after a successful reset — they then have to re-authenticate. That sounds safer; it isn't. With it on, `LoginCompleter` still runs every gate the rest of your auth surfaces apply (MFA, organization selection, entitlement gate). Leave it on unless you have a specific reason (e.g. you want to require a captcha on every login). + +--- + +## Internal references + +| Concept | Source | +| --- | --- | +| Public REST endpoints | [`src/WorkOS/REST/Auth/Password.php`](../src/WorkOS/REST/Auth/Password.php) | +| Admin REST endpoint | [`src/WorkOS/Auth/PasswordResetAdmin/RestApi.php`](../src/WorkOS/Auth/PasswordResetAdmin/RestApi.php) | +| Shortcode | [`src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php`](../src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php) | +| Redirect URL validator | [`src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php`](../src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php) | +| URL builder helper | [`FrontendRoute::url_for_profile()`](../src/WorkOS/Auth/AuthKit/FrontendRoute.php) | +| Profile toggles | [`src/WorkOS/Auth/AuthKit/Profile.php`](../src/WorkOS/Auth/AuthKit/Profile.php) (`is_password_reset_flow_enabled`, `is_auto_login_after_reset_enabled`) | +| WorkOS API client | [`WorkOS\Api\Client::send_password_reset()` / `::reset_password()`](../src/WorkOS/Api/Client.php) | +| React `ResetConfirm` step | [`src/js/authkit/flows.tsx`](../src/js/authkit/flows.tsx) | + +## External references + +- WorkOS User Management API — Password reset: +- WorkOS Radar (anti-fraud action tokens, threaded through every endpoint): +- WordPress REST API nonces: +- WordPress `wp.passwordStrength.meter`: +- zxcvbn (the score engine): From eb26e608ff22cada6bf64b6c8d304036187234a9 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 18 May 2026 23:54:40 -0400 Subject: [PATCH 10/12] docs: replace ASCII flow diagram with mermaid sequence diagram GitHub renders mermaid code blocks natively, so the rendered doc now shows a proper sequence diagram with autonumbered steps, lanes per participant, and an alt block for the auto_login_after_reset branch. Same content as the prior ASCII; the mermaid form is also easier to update and easier for code-gen agents to consume. --- docs/password-reset.md | 72 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/docs/password-reset.md b/docs/password-reset.md index 91b34d8..a7d42cb 100644 --- a/docs/password-reset.md +++ b/docs/password-reset.md @@ -23,40 +23,44 @@ All three converge on the same WorkOS API call (`POST /user_management/password_ ## End-to-end flow -``` -┌─────────────┐ 1. POST /reset/start (or admin/users/{id}/password-reset) -│ caller │───────────────────────────────────────────────────────────────┐ -└─────────────┘ ▼ - ┌─────────────────────┐ - │ integration-workos │ - │ validates redirect, │ - │ rate-limits, calls │ - │ WorkOS │ - └─────────┬───────────┘ - │ - ▼ - ┌──────────────────────┐ - │ WorkOS sends an │ - │ email containing │ - │ /workos/login/{slug} │ - │ ?token=… │ - │ &redirect_to=… │ - └─────────┬────────────┘ - │ - 2. user clicks email link │ - ┌─────────────────────────────────────────────────────────────────────────┘ - ▼ -┌──────────────────────────┐ 3. POST /reset/confirm ┌─────────────────────────┐ -│ /workos/login/{slug} │──────────────────────────▶│ integration-workos │ -│ React shell mounts in │ │ resets WorkOS pwd, then │ -│ ResetConfirm step │ │ - mirrors password to │ -└──────────────────────────┘ │ linked WP user │ - │ - (optional) signs │ - │ them in via │ - │ LoginCompleter │ - └────────┬────────────────┘ - ▼ - 4. Browser navigates to redirect_to +```mermaid +sequenceDiagram + autonumber + actor Caller as Caller
(user form / admin) + participant Plugin as integration-workos + participant WorkOS as WorkOS API + actor User as End user + participant Shell as AuthKit React shell
(/workos/login/{slug}) + + Caller->>Plugin: POST /reset/start
or /admin/users/{id}/password-reset + activate Plugin + Plugin->>Plugin: Validate redirect_url
(same-host or fallback) + Plugin->>Plugin: Rate-limit (per IP + per target) + Plugin->>WorkOS: send_password_reset(email, reset_url) + WorkOS-->>Plugin: 200 OK + Plugin-->>Caller: 200 { ok: true, ... } + deactivate Plugin + + WorkOS->>User: Email link
/workos/login/{slug}?token=…&redirect_to=… + User->>Shell: Click link + + Shell->>Plugin: POST /reset/confirm
{ token, new_password, redirect_url } + activate Plugin + Plugin->>WorkOS: reset_password(token, new_password) + WorkOS-->>Plugin: 200 { user: { id, email } } + Plugin->>Plugin: Mirror password to linked WP user
(wp_set_password) + + alt auto_login_after_reset = true + Plugin->>WorkOS: authenticate_with_password + WorkOS-->>Plugin: tokens (or pending MFA) + Plugin->>Plugin: LoginCompleter sets WP auth cookie + Plugin-->>Shell: { ok, signed_in, redirect_to } + else auto_login_after_reset = false + Plugin-->>Shell: { ok, redirect_url } + end + deactivate Plugin + + Shell->>User: window.location.assign(redirect_to) ``` --- From adc668173b5694e0a4a1c91dc6379c6a31c9caf1 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Tue, 19 May 2026 00:26:46 -0400 Subject: [PATCH 11/12] refactor(admin): move "Send password reset" into the WorkOS column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger used to live in the username row actions (under each user's name), mixed in with WP's built-in Edit / View / Delete links. Move it next to "View in WorkOS" and "Re-sync" so every WorkOS-specific per-row action clusters under one column header, matching how the column already groups the dashboard link. Implementation: - `UserList::render_linked_column()` now builds its row actions as an array keyed by slug and exposes a new `workos_user_list_column_actions` filter, so extenders (including this plugin's own PasswordResetAdmin\RowActions) can append or reorder without forking the column markup. The early return for missing `environment_id` is gone — the filter must fire for every linked user regardless of whether a dashboard URL can be built. - `PasswordResetAdmin\RowActions` now hooks `workos_user_list_column_actions` instead of `user_row_actions`. Same `.workos-pwreset-trigger` button, same delegated click handler — no JS changes. Six new wpunit cases (PasswordResetAdminRowActionsTest): - Filter fires for linked users with the right args - Filter does NOT fire for unlinked users - Trigger injected when caller has edit_user on the target - Trigger skipped when capability missing - Self-service path (edit_user(self)) gets the trigger - Empty workos_id (defensive) is a no-op Full suite green: 491 tests, 1416 assertions. --- CHANGELOG.md | 9 +- docs/password-reset.md | 1 + src/WorkOS/Admin/UserList.php | 70 ++++-- .../Auth/PasswordResetAdmin/RowActions.php | 50 ++-- .../PasswordResetAdminRowActionsTest.php | 221 ++++++++++++++++++ 5 files changed, 305 insertions(+), 46 deletions(-) create mode 100644 tests/wpunit/PasswordResetAdminRowActionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f9ed7..b134ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,12 @@ endpoint is gated by `edit_user($id)` (so the same route also covers self-service from the shortcode), rate-limited per-IP and per-target, and writes a `password_reset.admin_sent` event to the - activity log. Triggered from three surfaces: a `Send WorkOS - password reset` inline row action on `wp-admin/users.php`, a - `Password Reset` panel on the user-edit screen, and a + activity log. Triggered from three surfaces: a `Send password + reset` row action under the WorkOS column on + `wp-admin/users.php` (next to "View in WorkOS" and "Re-sync", so + all WorkOS-specific per-row actions cluster together via the + new `workos_user_list_column_actions` filter), a `Password + Reset` panel on the user-edit screen, and a `[workos:password-reset]` shortcode that toggles between admin-of-other (`user="…"`) and self-service modes based on its attributes. Companion `redirect_url` parameter threads through diff --git a/docs/password-reset.md b/docs/password-reset.md index a7d42cb..61f2413 100644 --- a/docs/password-reset.md +++ b/docs/password-reset.md @@ -15,6 +15,7 @@ It is written for two audiences in parallel: a developer integrating against the | Self-service start | `POST /wp-json/workos/v1/auth/password/reset/start` | Profile-scoped nonce | Anyone (anonymous OK) | | Self-service confirm | `POST /wp-json/workos/v1/auth/password/reset/confirm` | Profile-scoped nonce | Anyone with a valid reset token | | Admin-triggered | `POST /wp-json/workos/v1/admin/users/{id}/password-reset` | WP REST nonce + `edit_user($id)` capability | Editors / admins (also covers self-service from a logged-in context) | +| WP Users list (row action under the WorkOS column) | Posts to the admin endpoint | WP REST nonce + `edit_user($id)` | Admins, in the linked-user row only | | Shortcode | `[workos:password-reset]` | Rendered server-side; uses admin endpoint | Page authors | All three converge on the same WorkOS API call (`POST /user_management/password_reset/send`), and all three honor the same `redirect_url` and profile policy (`password_reset_flow`, `auto_login_after_reset`). diff --git a/src/WorkOS/Admin/UserList.php b/src/WorkOS/Admin/UserList.php index f1907ec..3e69772 100644 --- a/src/WorkOS/Admin/UserList.php +++ b/src/WorkOS/Admin/UserList.php @@ -82,28 +82,33 @@ public function render_column( string $output, string $column_name, int $user_id * @return string Column HTML. */ private function render_linked_column( int $user_id, string $workos_id, string $environment_id ): string { - if ( ! $environment_id ) { - return '' . esc_html( $workos_id ) . ''; + $dashboard_url = '' !== $environment_id + ? sprintf( + 'https://dashboard.workos.com/%s/users/%s/details', + rawurlencode( $environment_id ), + rawurlencode( $workos_id ) + ) + : ''; + + if ( '' !== $dashboard_url ) { + $html = sprintf( + '%s', + esc_url( $dashboard_url ), + esc_html( $workos_id ) + ); + } else { + $html = '' . esc_html( $workos_id ) . ''; } - $dashboard_url = sprintf( - 'https://dashboard.workos.com/%s/users/%s/details', - rawurlencode( $environment_id ), - rawurlencode( $workos_id ) - ); - - $html = sprintf( - '%s', - esc_url( $dashboard_url ), - esc_html( $workos_id ) - ); + $actions = []; - $html .= '
'; - $html .= sprintf( - '%s', - esc_url( $dashboard_url ), - esc_html__( 'View in WorkOS', 'integration-workos' ) - ); + if ( '' !== $dashboard_url ) { + $actions['view'] = sprintf( + '%s', + esc_url( $dashboard_url ), + esc_html__( 'View in WorkOS', 'integration-workos' ) + ); + } if ( workos()->is_enabled() && current_user_can( 'edit_users' ) ) { $resync_url = wp_nonce_url( @@ -117,14 +122,35 @@ private function render_linked_column( int $user_id, string $workos_id, string $ 'workos_resync_user_' . $user_id ); - $html .= sprintf( - ' | %s', + $actions['resync'] = sprintf( + '%s', esc_url( $resync_url ), esc_html__( 'Re-sync', 'integration-workos' ) ); } - $html .= '
'; + /** + * Filters the row-action links rendered under the WorkOS column on + * `wp-admin/users.php` for a linked user. + * + * Each entry is a fully-formed `Label` + * string keyed by action slug; the keys exist so extenders can + * replace or reorder a specific action without rebuilding the list. + * Entries are joined with ` | ` separators on render — same visual + * style as `user_row_actions`. + * + * @param array $actions Map of action slug => HTML. + * @param int $user_id WordPress user ID. + * @param string $workos_id Linked WorkOS user ID. + */ + $actions = (array) apply_filters( 'workos_user_list_column_actions', $actions, $user_id, $workos_id ); + + $rendered = array_filter( + array_map( 'strval', $actions ), + static fn( string $action ): bool => '' !== $action + ); + + $html .= '
' . implode( ' | ', $rendered ) . '
'; // Role mismatch indicator. $html .= $this->render_role_mismatch_indicator( $user_id ); diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php index 920fd2e..3f29c8f 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php @@ -1,24 +1,31 @@ $actions Existing action HTML keyed by slug. + * @param int $user_id WordPress user ID. + * @param string $workos_id Linked WorkOS user ID. * - * @return string[] + * @return array */ - public function add_row_action( array $actions, WP_User $user ): array { - if ( ! current_user_can( 'edit_user', $user->ID ) ) { + public function add_action( array $actions, int $user_id, string $workos_id ): array { + if ( '' === $workos_id ) { return $actions; } - $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); - if ( '' === $workos_user_id ) { + if ( ! current_user_can( 'edit_user', $user_id ) ) { return $actions; } $actions['workos_password_reset'] = sprintf( - '%s', - (int) $user->ID, - esc_html__( 'Send WorkOS password reset', 'integration-workos' ) + '%s', + (int) $user_id, + esc_html__( 'Send password reset', 'integration-workos' ) ); return $actions; diff --git a/tests/wpunit/PasswordResetAdminRowActionsTest.php b/tests/wpunit/PasswordResetAdminRowActionsTest.php new file mode 100644 index 0000000..c96358f --- /dev/null +++ b/tests/wpunit/PasswordResetAdminRowActionsTest.php @@ -0,0 +1,221 @@ +row_actions = new RowActions(); + + $suffix = uniqid( 'rat_', true ); + $this->linked_user_id = wp_insert_user( + [ + 'user_login' => 'rat_linked_' . $suffix, + 'user_pass' => wp_generate_password(), + 'user_email' => 'linked-' . $suffix . '@example.test', + 'role' => 'subscriber', + ] + ); + $this->assertIsInt( $this->linked_user_id ); + update_user_meta( $this->linked_user_id, '_workos_user_id', 'user_wos_' . $suffix ); + + $this->admin_user_id = wp_insert_user( + [ + 'user_login' => 'rat_admin_' . $suffix, + 'user_pass' => wp_generate_password(), + 'user_email' => 'admin-' . $suffix . '@example.test', + 'role' => 'administrator', + ] + ); + $this->assertIsInt( $this->admin_user_id ); + + $this->subscriber_id = wp_insert_user( + [ + 'user_login' => 'rat_sub_' . $suffix, + 'user_pass' => wp_generate_password(), + 'user_email' => 'sub-' . $suffix . '@example.test', + 'role' => 'subscriber', + ] + ); + $this->assertIsInt( $this->subscriber_id ); + } + + /** + * Tear down. + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * UserList::render_column() applies the new filter for linked users, + * passing the user id and workos id as filter args. + */ + public function test_workos_column_applies_extension_filter(): void { + wp_set_current_user( $this->admin_user_id ); + + $captured = []; + $cb = static function ( array $actions, int $user_id, string $workos_id ) use ( &$captured ): array { + $captured = [ + 'actions' => $actions, + 'user_id' => $user_id, + 'workos_id' => $workos_id, + ]; + + $actions['custom_marker'] = 'Custom'; + + return $actions; + }; + add_filter( 'workos_user_list_column_actions', $cb, 10, 3 ); + + try { + $list = new UserList(); + $html = $list->render_column( '', 'workos', $this->linked_user_id ); + } finally { + remove_filter( 'workos_user_list_column_actions', $cb, 10 ); + } + + $this->assertSame( $this->linked_user_id, $captured['user_id'] ?? null ); + $this->assertStringStartsWith( 'user_wos_', (string) ( $captured['workos_id'] ?? '' ) ); + $this->assertIsArray( $captured['actions'] ?? null ); + $this->assertStringContainsString( 'Custom', $html ); + } + + /** + * Unlinked users (no `_workos_user_id` meta) do NOT fire the + * extension filter — there's no WorkOS id to pass through. + */ + public function test_workos_column_does_not_apply_filter_for_unlinked_users(): void { + wp_set_current_user( $this->admin_user_id ); + + $fired = false; + $cb = static function ( array $actions ) use ( &$fired ): array { + $fired = true; + return $actions; + }; + add_filter( 'workos_user_list_column_actions', $cb ); + + try { + $list = new UserList(); + $list->render_column( '', 'workos', $this->subscriber_id ); + } finally { + remove_filter( 'workos_user_list_column_actions', $cb ); + } + + $this->assertFalse( $fired ); + } + + /** + * RowActions::add_action() injects the trigger when the caller has + * `edit_user` on the target. + */ + public function test_add_action_injects_trigger_when_capability_allows(): void { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->row_actions->add_action( [], $this->linked_user_id, 'user_wos_xyz' ); + + $this->assertArrayHasKey( 'workos_password_reset', $result ); + $this->assertStringContainsString( + 'data-user-id="' . $this->linked_user_id . '"', + $result['workos_password_reset'] + ); + $this->assertStringContainsString( 'workos-pwreset-trigger', $result['workos_password_reset'] ); + } + + /** + * RowActions::add_action() leaves the array untouched when the + * caller can't edit the target. + */ + public function test_add_action_skips_when_capability_missing(): void { + // Subscriber acting on the linked user (a different account) — + // edit_user is denied by default. + wp_set_current_user( $this->subscriber_id ); + + $result = $this->row_actions->add_action( + [ 'view' => 'view' ], + $this->linked_user_id, + 'user_wos_xyz' + ); + + $this->assertArrayNotHasKey( 'workos_password_reset', $result ); + $this->assertArrayHasKey( 'view', $result, 'Existing actions must be preserved.' ); + } + + /** + * Self-service path — a logged-in subscriber acting on their own + * user id passes the cap check (WP grants `edit_user($self)` to + * any logged-in user). + */ + public function test_add_action_allows_self_service(): void { + wp_set_current_user( $this->subscriber_id ); + + $result = $this->row_actions->add_action( [], $this->subscriber_id, 'user_wos_self' ); + + $this->assertArrayHasKey( 'workos_password_reset', $result ); + } + + /** + * An empty workos_id (which can't happen via render_linked_column, + * but the public signature allows) is a no-op. + */ + public function test_add_action_skips_when_workos_id_empty(): void { + wp_set_current_user( $this->admin_user_id ); + + $result = $this->row_actions->add_action( [], $this->linked_user_id, '' ); + + $this->assertArrayNotHasKey( 'workos_password_reset', $result ); + } +} From 3d181d4074e2fc55307feda87cb3b718ac8a7993 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Tue, 19 May 2026 12:04:00 -0400 Subject: [PATCH 12/12] docs: document password-reset feature in AGENTS.md + README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md - New "Auth — Password Reset Admin" section in the Key Files table with one row per class (Controller, RestApi, RedirectValidator, RowActions, UserProfilePanel, Shortcode, Assets) + the admin-password-reset JS entry. Each row says what the class does AND what surface it powers. - Refreshed entries whose responsibilities changed: * UserList.php — exposes `workos_user_list_column_actions` * Auth/PasswordReset.php — now a legacy guard, not the primary reset flow * AuthKit/Profile.php — carries the two reset toggles * AuthKit/FrontendRoute.php — exposes `url_for_profile()` * AuthKit/Renderer.php — staples password-strength-meter + emits pre-hydration skeleton * REST/Auth/Password.php — auto-login + WP password mirror + AuthKit-shell URL builder + final-URL entity decode * Admin/Users/{AdminPage,RestApi}.php — wp_user_id resolution + shared assets enqueue * src/js/authkit/App.tsx — FlowSkeleton during boot + onSignedIn/onMfa wiring for ResetConfirm * src/js/authkit/flows.tsx — two-password ResetConfirm + zxcvbn gate + dispatch on response shape * src/js/authkit/ui.tsx — adds FlowSkeleton * webpack.config.js — new admin-users + admin-password-reset entries - Added the three new wpunit files (PasswordResetAdminRedirectValidatorTest, PasswordResetAdminRestApiTest, PasswordResetAdminRowActionsTest) to the test catalog with one-line summaries. README.md - New "Admin-triggered password reset" + "Skeleton placeholders" bullets under Features. - Expanded "In-app flows" entry to mention the two-field confirm form + zxcvbn gate + redirect_url validation. - New `auto_login_after_reset` row in the Login Profile fields table. docs/password-reset.md - New "Extending the WP Users list (WorkOS column)" section with a worked example of hooking `workos_user_list_column_actions` so third-party plugins can add their own per-row WorkOS actions. --- AGENTS.md | 40 ++++++++++++++++++++++++++-------------- README.md | 5 ++++- docs/password-reset.md | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fbdb2f3..e173c7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Per-environment constants (take priority over generic): | **Admin** | | | `src/WorkOS/Admin/Controller.php` | Admin controller, registers settings/user list/onboarding/diagnostics | | `src/WorkOS/Admin/Settings.php` | Admin settings page (tabs: Settings, Organization, Users) | -| `src/WorkOS/Admin/UserList.php` | Admin user list integration (WorkOS columns) | +| `src/WorkOS/Admin/UserList.php` | Admin user list integration (WorkOS column). Exposes `workos_user_list_column_actions` filter so subsystems can add per-row WorkOS actions (slug-keyed; joined with pipe separators) — see PasswordResetAdmin `RowActions`. | | `src/WorkOS/Admin/UserProfile.php` | User profile page WorkOS metadata | | `src/WorkOS/Admin/AdminBar.php` | Admin bar environment badge | | `src/WorkOS/Admin/DiagnosticsPage.php` | System diagnostics page | @@ -82,9 +82,9 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Admin/OnboardingAjax.php` | Onboarding wizard AJAX handlers | | **Admin — Users** | | | `src/WorkOS/Admin/Users/Controller.php` | Wires the WorkOS Users admin submenu + REST endpoint | -| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list | -| `src/WorkOS/Admin/Users/RestApi.php` | `GET /wp-json/workos/v1/admin/users` — proxies `Api\Client::list_users()` with sanitized pagination + filters and a server-computed `dashboard_url` per row | -| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link) | +| `src/WorkOS/Admin/Users/AdminPage.php` | Admin submenu (WorkOS → Users) that mounts the React user list and enqueues the shared `workos-admin-password-reset` JS/CSS so the per-row trigger button works. | +| `src/WorkOS/Admin/Users/RestApi.php` | `GET /wp-json/workos/v1/admin/users` — proxies `Api\Client::list_users()` with sanitized pagination + filters, a server-computed `dashboard_url` per row, and a `wp_user_id` resolved via `_workos_user_id` meta so the React side can show the "Send password reset" trigger only for linked rows. | +| `src/js/admin-users/index.tsx` | React user list (search + cursor pagination + Open in WorkOS deep-link + per-row Send-password-reset button when `wp_user_id > 0`) | | **Admin — Login Profiles (Custom AuthKit)** | | | `src/WorkOS/Admin/LoginProfiles/Controller.php` | Wires Login Profile admin page + CRUD REST | | `src/WorkOS/Admin/LoginProfiles/AdminPage.php` | Admin submenu that mounts the React editor | @@ -94,20 +94,29 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Auth/Login.php` | SSO login flow (redirect + headless modes) | | `src/WorkOS/Auth/LoginBypass.php` | Login bypass (`?fallback=1`) when WorkOS is unavailable | | `src/WorkOS/Auth/Registration.php` | User registration redirect | -| `src/WorkOS/Auth/PasswordReset.php` | Password reset flow | +| `src/WorkOS/Auth/PasswordReset.php` | Legacy guard — blocks WP's native password-reset flow for WorkOS-linked users so they're funneled to the WorkOS-backed reset (`PasswordResetAdmin/*`) instead. | +| **Auth — Password Reset Admin** ([`docs/password-reset.md`](docs/password-reset.md)) | | +| `src/WorkOS/Auth/PasswordResetAdmin/Controller.php` | DI controller — wires the REST endpoint, JS/CSS assets, WP user-row trigger, user-edit panel, and the `[workos:password-reset]` shortcode. | +| `src/WorkOS/Auth/PasswordResetAdmin/RestApi.php` | `POST /wp-json/workos/v1/admin/users/{id}/password-reset`. Gated by `current_user_can('edit_user', $id)` — the same route covers admin-of-other and self-service (WP grants `edit_user($self)` to any logged-in user). Per-IP (10/min) and per-target (5/min) rate limits via `Auth\AuthKit\RateLimiter`. Writes `password_reset.admin_sent` to the activity log. Builds the email URL via `FrontendRoute::url_for_profile()` so reset emails land on the React shell. | +| `src/WorkOS/Auth/PasswordResetAdmin/RedirectValidator.php` | Same-host validator for `redirect_url`. Rejects cross-origin, protocol-relative (`//host`), and non-`http(s)` schemes. Falls back to `Profile::get_post_login_redirect()` then `home_url('/')`. | +| `src/WorkOS/Auth/PasswordResetAdmin/RowActions.php` | "Send password reset" row action under the **WorkOS column** on `wp-admin/users.php` (hooks `workos_user_list_column_actions` filter exposed by `Admin\UserList`). | +| `src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php` | "Password Reset" panel + trigger button on the WP user-edit screen (`edit_user_profile` / `show_user_profile` at priority 20 so it renders after the existing read-only WorkOS panel). | +| `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). | | `src/WorkOS/Auth/Redirect.php` | Role-based login redirects | | `src/WorkOS/Auth/LogoutRedirect.php` | Role-based logout redirects | | **Auth — Custom AuthKit (React shell)** | | | `src/WorkOS/Auth/AuthKit/Controller.php` | Wires Login Profile CPT + takeover + shortcode + route | -| `src/WorkOS/Auth/AuthKit/Profile.php` | Immutable Login Profile value object | +| `src/WorkOS/Auth/AuthKit/Profile.php` | Immutable Login Profile value object. Carries the `password_reset_flow` and `auto_login_after_reset` toggles consumed by the password-reset endpoints. | | `src/WorkOS/Auth/AuthKit/ProfileRepository.php` | CPT-backed CRUD + default seeding | | `src/WorkOS/Auth/AuthKit/ProfileRouter.php` | Rule-based profile resolution | | `src/WorkOS/Auth/AuthKit/LoginCompleter.php` | Post-auth finalizer (EntitlementGate + MFA policy) | | `src/WorkOS/Auth/AuthKit/LoginTakeover.php` | wp-login.php `action=login` takeover, default-profile custom-path bounce, already-signed-in 302 | | `src/WorkOS/Auth/AuthKit/LoginRedirector.php` | `for_visitor( Profile )` precedence (post_login_redirect → validated redirect_to → admin_url) + `forward_query_args` filter; mirrors `src/js/authkit/redirect.ts` allowlist | -| `src/WorkOS/Auth/AuthKit/FrontendRoute.php` | `/workos/login/{profile}` canonical rewrite + per-profile `custom_path` rewrites (signature-gated flush) + already-signed-in guard | +| `src/WorkOS/Auth/AuthKit/FrontendRoute.php` | `/workos/login/{profile}` canonical rewrite + per-profile `custom_path` rewrites (signature-gated flush) + already-signed-in guard. Exposes `FrontendRoute::url_for_profile( Profile, $args )` so password-reset emails (and any future caller) build the same URL the rewrite resolves. | | `src/WorkOS/Auth/AuthKit/Shortcode.php` | `[workos:login]` shortcode | -| `src/WorkOS/Auth/AuthKit/Renderer.php` | HTML shell + React bundle enqueue. Fires `workos_authkit_enqueue_assets` action and applies `workos_authkit_branding` / `workos_authkit_profile_data` / `workos_authkit_body_classes` filters — see `docs/extending-the-login-ui.md` | +| `src/WorkOS/Auth/AuthKit/Renderer.php` | HTML shell + React bundle enqueue. Stapled to `password-strength-meter` (zxcvbn) so `wp.passwordStrength.meter` is available for the reset-confirm form. Emits a pre-hydration skeleton inside the mount `
` mirroring the React `FlowSkeleton` shape (1- or 2-input variant chosen from `initial_step` / `reset_token` / `invitation_token`) so the page never paints blank. Fires `workos_authkit_enqueue_assets` action and applies `workos_authkit_branding` / `workos_authkit_profile_data` / `workos_authkit_body_classes` filters — see `docs/extending-the-login-ui.md`. | | `src/WorkOS/Auth/AuthKit/Nonce.php` | Profile-scoped CSRF nonces | | `src/WorkOS/Auth/AuthKit/RateLimiter.php` | Per-IP / per-email transient buckets | | `src/WorkOS/Auth/AuthKit/Radar.php` | WorkOS Radar site-key + request-header extraction | @@ -146,7 +155,7 @@ Per-environment constants (take priority over generic): | **REST — Public Auth (Custom AuthKit)** | | | `src/WorkOS/REST/Auth/Controller.php` | Wires all public `/wp-json/workos/v1/auth/*` endpoints | | `src/WorkOS/REST/Auth/BaseEndpoint.php` | Shared profile + nonce + rate-limit + Radar helpers | -| `src/WorkOS/REST/Auth/Password.php` | `password/authenticate`, `password/reset/{start,confirm}` | +| `src/WorkOS/REST/Auth/Password.php` | `password/authenticate`, `password/reset/{start,confirm}`. `reset_confirm` mirrors the new password into the linked WP user (`wp_set_password`) and, when `Profile::is_auto_login_after_reset_enabled()` is on, re-authenticates via WorkOS and runs the result through `LoginCompleter` so MFA / org-selection / entitlement gates still apply. `build_password_reset_url()` builds the email URL via `FrontendRoute::url_for_profile()` and `html_entity_decode()`s the final URL (the legacy regression `home_url` filters that escape `&` to `&`). | | `src/WorkOS/REST/Auth/MagicCode.php` | `magic/{send,verify}` | | `src/WorkOS/REST/Auth/Session.php` | `nonce`, `session/{refresh,logout}` | | `src/WorkOS/REST/Auth/Signup.php` | `signup/{create,verify}` | @@ -168,10 +177,10 @@ Per-environment constants (take priority over generic): | `src/includes/functions-helpers.php` | Global helpers: `workos()`, `workos_log()`, `workos_is_sso_user()`, `workos_has_active_session()`, `workos_get_user_id()`, `workos_get_access_token()` — the latter four proxy to `WorkOS\User` | | **Browser — Custom AuthKit (TypeScript + TSX)** | | | `src/js/authkit/index.tsx` | Entry + data-* hydration | -| `src/js/authkit/App.tsx` | Top-level step machine | +| `src/js/authkit/App.tsx` | Top-level step machine. Renders `FlowSkeleton` while `client.bootstrap()` is in-flight (replaces the prior `return null;` blank window). The `reset_confirm` step receives the `onSignedIn` + `onMfa` callbacks so the auto-login-after-reset path can navigate to the validated redirect or hand off to the MFA challenge step. | | `src/js/authkit/api.ts` | Fetch client w/ nonce + 401 refresh + Radar header | -| `src/js/authkit/flows.tsx` | 11 flow components (password, magic, signup, reset, mfa, invitation, complete) | -| `src/js/authkit/ui.tsx` | 11 primitives (Button, Input, Card, …) | +| `src/js/authkit/flows.tsx` | 11 flow components (password, magic, signup, reset, mfa, invitation, complete). `ResetConfirm` renders two password fields (new + confirm), scores via `window.wp.passwordStrength.meter` (zxcvbn ≥ 3 required), and disables submit until both match. On success it dispatches based on the response shape: `signed_in` → navigate to `redirect_to`; `mfa_required` → bubble to App's MFA step; otherwise → success card with manual continue. | +| `src/js/authkit/ui.tsx` | 11 primitives + `FlowSkeleton` placeholder card with shimmering rows (heights match the hydrated card one-to-one — heading 24px / label 16px / input 44px / button 40px). Used by App during the boot window and mirrored shape-for-shape in `Renderer::build_skeleton_markup()` for the pre-hydration window. | | `src/js/authkit/radar.ts` | WorkOS Radar SDK loader (+ `window.WorkOSRadar` augmentation) | | `src/js/authkit/redirect.ts` | `forwardQueryArgs( destination, originalQuery )` — strips internals (`redirect_to`, `_wpnonce`, `loggedout`, `wp_lang`, `workos_*`, …) and appends safe args; mirrors PHP `LoginRedirector::INTERNAL_QUERY_ARGS` | | `src/js/authkit/slots.tsx` | SlotFill slot name constants (10 slots, including `workos.authkit.belowCard`) | @@ -184,7 +193,7 @@ Per-environment constants (take priority over generic): | `package.json` | JS dependencies (bun; uses `@wordpress/scripts` v30 + TypeScript strict) | | `bun.lock` | Locked dependency graph (committed) | | `tsconfig.json` | TypeScript config (strict, `jsx: react-jsx`, `noEmit: true`) | -| `webpack.config.js` | Extends `@wordpress/scripts` default config with authkit + admin-profiles entries | +| `webpack.config.js` | Extends `@wordpress/scripts` default config with `authkit`, `admin-profiles`, `admin-users`, and `admin-password-reset` entries | | `phpstan.neon.dist` | PHPStan config (level 5, scans `src/` + `integration-workos.php` + `uninstall.php`, `phpVersion: 70400`, Strauss vendor resolved via `vendor/autoload.php` in `scanFiles`) | | `phpstan/stubs.php` | Symbol stubs for `WORKOS_*` runtime-defined constants so PHPStan can resolve them statically — never executed | @@ -278,7 +287,10 @@ tests/ ├── OnboardingSyncTest.php # Onboarding sync tests ├── OptionsTest.php # Options classes tests ├── OrganizationManagerTest.php # Organization manager tests - ├── PasswordResetTest.php # Password reset tests + ├── PasswordResetTest.php # Legacy WP-side password reset guard tests + ├── PasswordResetAdminRedirectValidatorTest.php # Same-host redirect validator: accept absolute/relative same-host; reject cross-origin/protocol-relative/non-http; profile-default fallback chain + ├── PasswordResetAdminRestApiTest.php # `POST /admin/users/{id}/password-reset` — capability matrix, missing user, unlinked user, masked-email response, redirect_url validation, rate limit, activity-log row content + ├── PasswordResetAdminRowActionsTest.php # `workos_user_list_column_actions` filter + RowActions injection (capability check, self-service, defensive empty-workos_id) ├── PluginTest.php # Plugin singleton + constants tests ├── RedirectTest.php # Login redirect tests ├── RendererKsesTest.php # Shared renderer KSES allowlist tests diff --git a/README.md b/README.md index 25bb97d..811e410 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. - **React login shell** on wp-login.php, `[workos:login]` shortcode, and a dedicated `/workos/login/{profile}` route — all driven by the same TypeScript bundle - **Login Profiles** — admin-defined presets (enabled methods, pinned organization, signup/invite/reset toggles, MFA policy, branding) managed through a React admin editor at **WorkOS → Login Profiles** - **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 +- **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). +- **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 - **WorkOS Radar** anti-fraud integration — browser SDK supplies an action token the plugin forwards on every server-side auth call @@ -161,6 +163,7 @@ Each profile stores: | `signup` | `{enabled, require_invite}` — toggles self-serve signup | | `invite_flow` | Allow invitation acceptance | | `password_reset_flow` | Allow in-app password reset | +| `auto_login_after_reset` | When `true` (default), `reset_confirm` re-authenticates the user with the new password (still running MFA / org-selection / entitlement gates via `LoginCompleter`), sets the WP auth cookie, and lands them on the validated `redirect_url`. When `false`, the user lands on the "Password reset — Continue to sign in" card and signs in manually. | | `mfa` | `{enforce: never|if_required|always, factors: [totp,sms,webauthn]}` | | `branding` | `{logo_mode, logo_attachment_id, primary_color, heading, subheading}` — `logo_mode` is `default` / `custom` / `none` (see "Logo modes" in [`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md)) | | `post_login_redirect` | URL the React shell navigates to on success (beats `redirect_to`)| diff --git a/docs/password-reset.md b/docs/password-reset.md index 61f2413..3bb78de 100644 --- a/docs/password-reset.md +++ b/docs/password-reset.md @@ -637,6 +637,40 @@ When the toggle is off, the user is left on a "Continue to sign in" card after a --- +## Extending the WP Users list (WorkOS column) + +The "Send password reset" link lives in a slot-keyed row-action list under the WorkOS column on `wp-admin/users.php`. Third-party plugins can extend that same list via the `workos_user_list_column_actions` filter: + +```php +add_filter( + 'workos_user_list_column_actions', + function ( array $actions, int $user_id, string $workos_id ): array { + if ( ! current_user_can( 'edit_user', $user_id ) ) { + return $actions; + } + + $actions['my_plugin_audit'] = sprintf( + '%s', + esc_url( + add_query_arg( + [ 'workos_user_id' => $workos_id ], + admin_url( 'admin.php?page=my-plugin-audit' ) + ) + ), + esc_html__( 'Audit history', 'my-plugin' ) + ); + + return $actions; + }, + 10, + 3 +); +``` + +The filter only fires for users with a non-empty `_workos_user_id` meta (so unlinked rows never invoke your callback). Each entry is a fully-formed `Label` string keyed by action slug; the keys exist so a later plugin can replace or reorder a specific action without rebuilding the full list. Entries are joined with ` | ` separators on render. + +--- + ## Internal references | Concept | Source |