From 4cf797735b5d2283163fbdf03c46bdfe450416ab Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Sat, 17 Oct 2026 16:11:19 +0000 Subject: [PATCH 1/6] feat(api): public helpers for a valid access token, password reset and logout URL Adds workos_get_valid_access_token(), workos_send_password_reset() and workos_get_logout_url(), so sites no longer call the WorkOS API themselves to refresh tokens, send resets or build the logout URL. - workos_get_valid_access_token() (User::get_valid_access_token()) returns the stored token while it has more than 60 seconds left, otherwise refreshes it through Api\Client::refresh_session() and stores the new pair with Login::store_tokens(). workos_get_access_token() keeps returning the token as stored. - workos_send_password_reset() calls the new PasswordResetAdmin\RestApi::send_for_user(), the code behind the admin reset route, so the rate limits, RedirectValidator check and activity log row are shared. The route now calls it after its capability check. - workos_get_logout_url() (User::get_logout_url()) returns the WorkOS sessions/logout URL for the stored session, or wp_logout_url() when there is none. - workos_get_user_id() already existed; it is documented with the rest in docs/php-helpers.md. Closes #63 --- AGENTS.md | 7 +- README.md | 21 +- docs/password-reset.md | 4 +- docs/php-helpers.md | 110 ++++ .../Auth/PasswordResetAdmin/RestApi.php | 54 +- src/WorkOS/User.php | 157 ++++- src/includes/functions-helpers.php | 96 ++- tests/wpunit/PublicHelpersTest.php | 612 ++++++++++++++++++ 8 files changed, 1035 insertions(+), 26 deletions(-) create mode 100644 docs/php-helpers.md create mode 100644 tests/wpunit/PublicHelpersTest.php diff --git a/AGENTS.md b/AGENTS.md index b3793c3..0468818 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Plugin.php` | Bootstrap, constants, container init, app boot | | `src/WorkOS/App.php` | Static facade for the DI container | | `src/WorkOS/Config.php` | Centralized config with constant overrides | -| `src/WorkOS/User.php` | Public read-only helper for WorkOS metadata on WP users (`is_sso`, `has_active_session`, `get_workos_id`, `get_access_token`, `snapshot`) | +| `src/WorkOS/User.php` | Public helper for WorkOS metadata on WP users (`is_sso`, `has_active_session`, `get_workos_id`, `get_access_token`, `snapshot`), plus two session helpers: `get_valid_access_token` (refreshes through `Api\Client::refresh_session()` + `Login::store_tokens()` when the token expires within `ACCESS_TOKEN_REFRESH_LEEWAY`) and `get_logout_url` (WorkOS `sessions/logout` URL for the stored session, else `wp_logout_url()`). See [`docs/php-helpers.md`](docs/php-helpers.md) | | `src/WorkOS/Controller.php` | Main controller, registers all feature controllers | | `src/WorkOS/Contracts/Container.php` | DI container (di52-based) | | `src/WorkOS/Contracts/Controller.php` | Abstract base controller with `isActive()` | @@ -100,7 +100,7 @@ Per-environment constants (take priority over generic): | `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/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. The work after the capability check lives in `send_for_user( $user_id, $redirect_url, $profile_slug )`, which `workos_send_password_reset()` also calls. | | `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). | @@ -196,7 +196,7 @@ Per-environment constants (take priority over generic): | **Database** | | | `src/WorkOS/Database/Schema.php` | DB schema creation and upgrades | | **Helpers** | | -| `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` | +| `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()` (as stored), `workos_get_valid_access_token()`, `workos_get_logout_url()` — these proxy to `WorkOS\User` — and `workos_send_password_reset()`, which calls `PasswordResetAdmin\RestApi::send_for_user()`. Documented with examples in [`docs/php-helpers.md`](docs/php-helpers.md) | | **Browser — Custom AuthKit (TypeScript + TSX)** | | | `src/js/authkit/index.tsx` | Entry + data-* hydration | | `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. | @@ -351,6 +351,7 @@ tests/ ├── 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 + ├── PublicHelpersTest.php # workos_get_valid_access_token (fresh, unlinked, signed out, expired/expiring → refresh, refresh failure, concurrent rotation), workos_send_password_reset (shared path, rate limit, redirect validation), workos_get_logout_url ├── RedirectTest.php # Login redirect tests ├── RendererKsesTest.php # Shared renderer KSES allowlist tests ├── RoleMapperTest.php # Role mapper tests diff --git a/README.md b/README.md index e28ba64..cde08a6 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,9 @@ The token is verified using WorkOS JWKS and mapped to a WordPress user via their The plugin exposes a stable, read-only API for checking WorkOS state on a WP user so integrations don't need to know what meta keys the plugin stores. Everything lives in `WorkOS\User` (instance-free static class) -with matching global function shortcuts. +with matching global function shortcuts. A few session helpers (a valid +access token, the logout URL, sending a password reset) sit alongside +them so integrations never call the WorkOS API themselves. ### `WorkOS\User` methods @@ -303,10 +305,13 @@ with matching global function shortcuts. | `User::get_session_id( $user_id = 0 )` | `string` | WorkOS `sid` claim for the active session | | `User::get_organization_id( $user_id = 0 )` | `string` | WorkOS organization id pinned to the user | | `User::snapshot( $user_id = 0 )` | `array` | All of the above in one predictable payload | +| `User::get_valid_access_token( $user_id = 0 )` | `string\|WP_Error` | Access token, refreshed first when it has expired or expires within 60s | +| `User::get_logout_url( $user_id = 0, $return_to = '' )` | `string` | WorkOS logout URL for the stored session, or `wp_logout_url()` | All methods accept `0` (or omitted) to target the currently-authenticated -user. All return empty strings / `false` safely when no user is available, -so there's no need to null-check `get_current_user_id()` first. +user. The read methods return empty strings / `false` safely when no user +is available, so there's no need to null-check `get_current_user_id()` +first. `get_valid_access_token()` returns a `WP_Error` instead. The meta keys are also exposed as constants (`User::META_WORKOS_ID`, `META_ACCESS_TOKEN`, etc.) for callers that need them in SQL queries or @@ -318,9 +323,17 @@ REST schemas. workos_is_sso_user( $user_id = 0 ); // bool — is the user linked? workos_has_active_session( $user_id = 0 ); // bool — is a session stored? workos_get_user_id( $user_id = 0 ); // string — WorkOS user id -workos_get_access_token( $user_id = 0 ); // string — current access token +workos_get_access_token( $user_id = 0 ); // string — current access token, as stored + +workos_get_valid_access_token( $user_id = 0 ); // string|WP_Error — refreshed when needed +workos_send_password_reset( $user_id, $args = [] ); // true|WP_Error — same path as the admin action +workos_get_logout_url( $user_id = 0, $return_to = '' ); // string — WorkOS or WP logout URL ``` +The last three call WorkOS (or build WorkOS URLs) through the plugin's +own client and settings. See [`docs/php-helpers.md`](docs/php-helpers.md) +for examples and error codes. + ### Distinguishing "linked" vs "currently signed in" - **`is_sso()`** remains true after the user logs out. Use it when you diff --git a/docs/password-reset.md b/docs/password-reset.md index 3bb78de..17e24e6 100644 --- a/docs/password-reset.md +++ b/docs/password-reset.md @@ -275,6 +275,8 @@ The validation runs on every endpoint that accepts `redirect_url` (start, confir 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). +The shortest way is `workos_send_password_reset( $wp_user_id, [ 'redirect_url' => … ] )`. It runs the same code as the endpoint (rate limits, profile gating, `redirect_url` validation, activity log) without a REST round-trip, and returns `true` or the endpoint's `WP_Error`. It leaves the `edit_user` check to you. See [`php-helpers.md`](php-helpers.md#workos_send_password_reset). The example below goes through the endpoint instead, which also does the capability check. + ```php 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`. +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`, `POST /auth/password/reset/start`, or `workos_send_password_reset()` from PHP. ### ❌ Don't store reset tokens or replay them. diff --git a/docs/php-helpers.md b/docs/php-helpers.md new file mode 100644 index 0000000..0187c7a --- /dev/null +++ b/docs/php-helpers.md @@ -0,0 +1,110 @@ +# PHP helpers + +Global functions for themes and plugins that build their own account pages on top of integration-workos. Each one is a thin wrapper over code the plugin already runs, so it uses the plugin's API client, keys and active environment. Don't call the WorkOS API yourself with the client secret, don't hard-code `https://api.workos.com`, and don't decode the access token to find its expiry or session ID. + +| Function | Returns | What it does | +| --- | --- | --- | +| `workos_get_user_id( int $user_id = 0 )` | `string` | WorkOS user ID (`user_…`) linked to the WP user, or `''` | +| `workos_get_access_token( int $user_id = 0 )` | `string` | Access token as stored. May be expired. Never calls WorkOS | +| `workos_get_valid_access_token( int $user_id = 0 )` | `string\|WP_Error` | Access token that is good for at least 60 more seconds, refreshed first if needed | +| `workos_send_password_reset( int $user_id, array $args = [] )` | `true\|WP_Error` | Sends the WorkOS reset email through the admin "Send password reset" path | +| `workos_get_logout_url( int $user_id = 0, string $return_to = '' )` | `string` | WorkOS logout URL for the current session, or `wp_logout_url()` | + +`$user_id = 0` (or omitted) means the current user. Each function has a `WorkOS\User` counterpart except `workos_send_password_reset()`, which calls `WorkOS\Auth\PasswordResetAdmin\RestApi::send_for_user()`. + +The other helpers (`workos_is_sso_user()`, `workos_has_active_session()`) are covered in the README under "Helpers for Third-Party Integrations". + +## `workos_get_valid_access_token()` + +Returns the stored access token while it has more than `WorkOS\User::ACCESS_TOKEN_REFRESH_LEEWAY` (60) seconds left. Otherwise it exchanges the stored refresh token with WorkOS and stores the new pair, the same calls `POST /wp-json/workos/v1/auth/session/refresh` makes. A refresh rotates both tokens, so any copy you kept of the old ones stops working. + +```php +$token = workos_get_valid_access_token(); +if ( is_wp_error( $token ) ) { + return $token; +} + +$response = wp_remote_get( + 'https://example.com/api/me', + [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ] ] +); +``` + +Errors: + +| Code | When | +| --- | --- | +| `workos_user_not_linked` | No user, or the user has no WorkOS link | +| `workos_no_session` | The user signed out (no tokens stored), or the token expired and there is no refresh token | +| `workos_refresh_failed` | WorkOS answered the refresh without an access token | +| `workos_api_error` (or a transport error) | WorkOS refused the refresh, for example because the session was revoked. The stored tokens are left as they were | + +If two requests refresh at once, the second one's refresh token is already spent. When that happens and the first one has stored a fresh token, the helper returns that token instead of the error. + +The expiry is read from the stored token without checking its signature. That's fine here: the token was stored server-side from a WorkOS response, and the result only decides whether to refresh. Use `workos()->api()->verify_access_token()` when you need to trust a token, and only pass a user ID your own code has authenticated. + +## `workos_send_password_reset()` + +Runs the same code as `POST /wp-json/workos/v1/admin/users/{id}/password-reset` (see [password-reset.md](password-reset.md)): + +- The user must exist, be linked to WorkOS and have a valid email. +- The Login Profile must have the password reset flow on. +- The per-IP (10 a minute) and per-user (5 a minute) limits apply, shared with the REST route. +- `redirect_url` goes through `RedirectValidator`: a URL on another host falls back to the profile's post-login redirect, then the home page. +- A `password_reset.admin_sent` row is written to the activity log. + +`$args`: + +| Key | Default | | +| --- | --- | --- | +| `redirect_url` | `''` | Where the user lands after setting the new password | +| `profile` | `'default'` | Login Profile slug whose reset page the email links to | + +The helper does not check capabilities; the REST route does. Check `edit_user` yourself when the call comes from a request: + +```php +add_action( 'admin_post_my_account_reset_password', static function () { + check_admin_referer( 'my_account_reset_password' ); + + $user_id = get_current_user_id(); + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_die( 'Not allowed.', 403 ); + } + + $sent = workos_send_password_reset( $user_id, [ 'redirect_url' => home_url( '/account/' ) ] ); + if ( is_wp_error( $sent ) ) { + wp_die( esc_html( $sent->get_error_message() ), 400 ); + } + + wp_safe_redirect( home_url( '/account/?reset=sent' ) ); + exit; +} ); +``` + +It returns the REST route's errors: `workos_user_not_found`, `workos_user_not_linked`, `workos_user_missing_email`, `workos_profile_not_found`, `workos_reset_disabled`, `workos_rate_limited`, or the WorkOS API error. + +## `workos_get_logout_url()` + +With a stored WorkOS session, returns WorkOS's logout URL for it: + +``` +https://api.workos.com/user_management/sessions/logout?session_id=session_…&return_to=… +``` + +`return_to` is left out when empty. WorkOS only redirects to URLs on the environment's sign-out redirect list (WorkOS dashboard, Redirects). With no stored session (not linked, or already signed out) it returns `wp_logout_url( $return_to )`. + +The two URLs end different things: + +- `wp_logout_url()` signs the user out of WordPress. The plugin then revokes the WorkOS session server-side and clears the stored tokens, so this ends both. +- The WorkOS URL sends the browser to WorkOS to end the WorkOS session. It doesn't clear the WordPress auth cookie. + +```php +// Ends the WorkOS session, then comes back to the home page. +printf( + '%s', + esc_url( workos_get_logout_url( 0, home_url( '/' ) ) ), + esc_html__( 'Sign out', 'my-theme' ) +); +``` + +Building the URL never calls WorkOS. diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php index 2c4c079..2955fea 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php @@ -165,8 +165,36 @@ public function check_permission( WP_REST_Request $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 ); + $result = $this->send_for_user( + absint( $request['id'] ), + (string) $request->get_param( 'redirect_url' ), + (string) $request->get_param( 'profile' ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } + + /** + * Send a WorkOS reset email for a WP user. + * + * The work behind the REST route, minus the capability check: the route + * checks `edit_user` before calling this, and `workos_send_password_reset()` + * leaves that to its caller. Everything else (linked-user and email + * checks, profile policy, rate limits, redirect validation, the activity + * log row) is shared. + * + * @param int $target_id WP user ID. + * @param string $redirect_url Optional post-reset redirect; checked by RedirectValidator. + * @param string $profile_slug Optional Login Profile slug; defaults to the default profile. + * + * @return array{ok: true, email_hint: string, profile: string, redirect_url: string}|WP_Error + */ + public function send_for_user( int $target_id, string $redirect_url = '', string $profile_slug = '' ) { + $user = get_userdata( $target_id ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'workos_user_not_found', @@ -193,7 +221,7 @@ public function send_reset( WP_REST_Request $request ) { ); } - $profile = $this->resolve_profile( (string) $request->get_param( 'profile' ) ); + $profile = $this->resolve_profile( $profile_slug ); if ( ! $profile instanceof Profile ) { return $profile; } @@ -219,10 +247,7 @@ public function send_reset( WP_REST_Request $request ) { return $rate_ok; } - $redirect_url = $this->redirect_validator->validate( - (string) $request->get_param( 'redirect_url' ), - $profile - ); + $redirect_url = $this->redirect_validator->validate( $redirect_url, $profile ); $reset_url = $this->build_reset_url( $profile, $redirect_url ); @@ -246,15 +271,12 @@ public function send_reset( WP_REST_Request $request ) { ] ); - return new WP_REST_Response( - [ - 'ok' => true, - 'email_hint' => $this->masker->mask( $email ), - 'profile' => $profile->get_slug(), - 'redirect_url' => $redirect_url, - ], - 200 - ); + return [ + 'ok' => true, + 'email_hint' => $this->masker->mask( $email ), + 'profile' => $profile->get_slug(), + 'redirect_url' => $redirect_url, + ]; } /** diff --git a/src/WorkOS/User.php b/src/WorkOS/User.php index fe6ddda..8b69e53 100644 --- a/src/WorkOS/User.php +++ b/src/WorkOS/User.php @@ -30,6 +30,15 @@ * Neither method verifies the access token is still valid against * WorkOS's JWKS — use `Api\Client::verify_access_token()` for that. * + * ### Session helpers + * + * Two methods go beyond reading meta: + * + * - {@see self::get_valid_access_token()} returns a token that hasn't + * expired, refreshing it (and rotating the stored tokens) when needed. + * - {@see self::get_logout_url()} builds the WorkOS logout URL for the + * stored session, or falls back to `wp_logout_url()`. + * * ### Example usage * * ```php @@ -52,7 +61,8 @@ * * ```php * if ( workos_is_sso_user() ) { ... } - * $token = workos_get_access_token( $user_id ); + * $token = workos_get_access_token( $user_id ); // As stored; may be expired. + * $token = workos_get_valid_access_token( $user_id ); // Refreshed when needed; may be WP_Error. * ``` */ final class User { @@ -84,6 +94,12 @@ final class User { /** User meta key — flag indicating this WP user's first login via WorkOS. */ public const META_FIRST_LOGIN = '_workos_first_login'; + /** + * Seconds before expiry at which {@see self::get_valid_access_token()} + * refreshes. Matches the React shell's pre-emptive refresh. + */ + public const ACCESS_TOKEN_REFRESH_LEEWAY = 60; + /** * Whether the user is linked to a WorkOS identity. * @@ -141,6 +157,114 @@ public static function get_access_token( int $user_id = 0 ): string { return self::meta_string( $user_id, self::META_ACCESS_TOKEN ); } + /** + * Get an access token for the user that is good for at least + * {@see self::ACCESS_TOKEN_REFRESH_LEEWAY} more seconds. + * + * Returns the stored token while it is fresh. When it has expired, is + * about to, or its expiry can't be read, the stored refresh token is + * exchanged through `Api\Client::refresh_session()` and the new pair is + * stored with `Auth\Login::store_tokens()`, the same calls the + * `POST /auth/session/refresh` endpoint makes. Unlike + * {@see self::get_access_token()}, this may call WorkOS and rotate the + * stored tokens. + * + * The expiry is read from the stored token without checking its + * signature: the token came from WorkOS and was stored server-side, and + * the result only decides whether to refresh. Only pass a user ID your + * own code trusts, never one taken from an unauthenticated request. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return string|\WP_Error Access token (JWT), or an error: + * `workos_user_not_linked` (no WorkOS link or no user), + * `workos_no_session` (signed out, or expired with no refresh token), + * `workos_refresh_failed` (WorkOS returned no token), + * or the API error from the refresh call. + */ + public static function get_valid_access_token( int $user_id = 0 ) { + $resolved = self::resolve_user_id( $user_id ); + if ( 0 === $resolved || ! self::is_sso( $resolved ) ) { + return new \WP_Error( + 'workos_user_not_linked', + __( 'This user is not linked to a WorkOS account.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + $access_token = self::get_access_token( $resolved ); + if ( '' !== $access_token && ! self::access_token_needs_refresh( $access_token ) ) { + return $access_token; + } + + $refresh_token = self::get_refresh_token( $resolved ); + if ( '' === $refresh_token ) { + return new \WP_Error( + 'workos_no_session', + __( 'This user has no active WorkOS session.', 'integration-workos' ), + [ 'status' => 401 ] + ); + } + + $response = workos()->api()->refresh_session( $refresh_token ); + + if ( ! is_wp_error( $response ) && ( empty( $response['access_token'] ) || ! is_string( $response['access_token'] ) ) ) { + $response = new \WP_Error( + 'workos_refresh_failed', + __( 'WorkOS did not return a new access token.', 'integration-workos' ), + [ 'status' => 502 ] + ); + } + + if ( is_wp_error( $response ) ) { + // Another request may have rotated the tokens while this one + // was waiting, which spends the refresh token we sent. Use its + // result if it left a fresh token behind. + $latest = self::get_access_token( $resolved ); + if ( '' !== $latest && $latest !== $access_token && ! self::access_token_needs_refresh( $latest ) ) { + return $latest; + } + + return $response; + } + + Auth\Login::store_tokens( $resolved, $response ); + + return $response['access_token']; + } + + /** + * Get the URL that ends the user's current WorkOS session. + * + * Points at WorkOS's `GET /user_management/sessions/logout` for the + * stored session ID, with `return_to` when given. WorkOS only redirects + * to `return_to` values on the environment's sign-out redirect list. + * With no stored session (not linked, or signed out) this returns + * `wp_logout_url( $return_to )`. + * + * The WorkOS URL ends the WorkOS session only; the WordPress auth + * cookie is untouched. `wp_logout_url()` already ends both, because the + * plugin revokes the WorkOS session server-side on `wp_logout`. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * @param string $return_to Optional URL to land on after sign-out. + * + * @return string + */ + public static function get_logout_url( int $user_id = 0, string $return_to = '' ): string { + $session_id = self::get_session_id( $user_id ); + if ( '' === $session_id || ! self::is_sso( $user_id ) ) { + return wp_logout_url( $return_to ); + } + + $args = [ 'session_id' => $session_id ]; + if ( '' !== $return_to ) { + $args['return_to'] = $return_to; + } + + return Api\Client::get_base_url() . '/user_management/sessions/logout?' . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ); + } + /** * Get the user's stored WorkOS refresh token. * @@ -202,6 +326,37 @@ public static function snapshot( int $user_id = 0 ): array { ]; } + /** + * Whether a stored access token has expired, expires within the + * leeway, or has no readable `exp` claim. + * + * Reads the claim without checking the signature; see + * {@see self::get_valid_access_token()} for why that is enough here. + * + * @param string $token JWT. + * + * @return bool + */ + private static function access_token_needs_refresh( string $token ): bool { + $parts = explode( '.', $token ); + if ( 3 !== count( $parts ) ) { + return true; + } + + $payload = json_decode( + // JWT payloads are base64url-encoded (RFC 7519); this reads the + // `exp` claim, it is not obfuscation. + (string) base64_decode( strtr( $parts[1], '-_', '+/' ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + true + ); + + if ( ! is_array( $payload ) || ! isset( $payload['exp'] ) || ! is_numeric( $payload['exp'] ) ) { + return true; + } + + return (int) $payload['exp'] - self::ACCESS_TOKEN_REFRESH_LEEWAY <= time(); + } + /** * Read a string-typed meta value, normalized to '' when absent. * diff --git a/src/includes/functions-helpers.php b/src/includes/functions-helpers.php index b914a5d..305b6a7 100644 --- a/src/includes/functions-helpers.php +++ b/src/includes/functions-helpers.php @@ -7,6 +7,8 @@ * @package WorkOS */ +use WorkOS\App; +use WorkOS\Auth\PasswordResetAdmin\RestApi; use WorkOS\User; defined( 'ABSPATH' ) || exit; @@ -93,7 +95,9 @@ function workos_get_user_id( int $user_id = 0 ): string { * Stored WorkOS access token (JWT) for a user, or empty string when absent. * * Treat as opaque — the token may be expired. Validate with - * `Api\Client::verify_access_token()` if authoritative status is needed. + * `Api\Client::verify_access_token()` if authoritative status is needed, + * or use {@see workos_get_valid_access_token()} to get one that is + * refreshed when it has expired. * * @param int $user_id WP user ID; 0 or omitted targets the current user. * @@ -102,3 +106,93 @@ function workos_get_user_id( int $user_id = 0 ): string { function workos_get_access_token( int $user_id = 0 ): string { return User::get_access_token( $user_id ); } + +/** + * WorkOS access token (JWT) for a user, refreshed first when it has + * expired or expires within a minute. + * + * Thin shortcut for {@see \WorkOS\User::get_valid_access_token()}. A + * refresh rotates the stored access and refresh tokens, the same way the + * `POST /wp-json/workos/v1/auth/session/refresh` endpoint does. Only pass + * a user ID your own code trusts. + * + * ```php + * $token = workos_get_valid_access_token(); + * if ( is_wp_error( $token ) ) { + * return $token; // Not linked, signed out, or the refresh failed. + * } + * $response = wp_remote_get( $url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ] ] ); + * ``` + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return string|\WP_Error Access token, or `workos_user_not_linked`, + * `workos_no_session`, `workos_refresh_failed` + * or the WorkOS API error. + */ +function workos_get_valid_access_token( int $user_id = 0 ) { + return User::get_valid_access_token( $user_id ); +} + +/** + * Send a WorkOS password-reset email to a user. + * + * Runs the same code as the admin "Send password reset" action + * (`POST /wp-json/workos/v1/admin/users/{id}/password-reset`): the user + * must be linked and have an email, the Login Profile must allow resets, + * the per-IP and per-user rate limits apply, `redirect_url` is checked by + * `RedirectValidator`, and a `password_reset.admin_sent` row is written to + * the activity log. + * + * It does not check capabilities. The REST route requires + * `current_user_can( 'edit_user', $user_id )`; do the same check before + * calling this from a request handler. + * + * ```php + * if ( current_user_can( 'edit_user', $user_id ) ) { + * $sent = workos_send_password_reset( $user_id, [ 'redirect_url' => home_url( '/account/' ) ] ); + * } + * ``` + * + * @param int $user_id WP user ID. + * @param array $args { + * Optional. + * + * @type string $redirect_url Where to send the user after the reset. Must be on this site; + * anything else falls back to the profile's post-login redirect. + * @type string $profile Login Profile slug. Defaults to the default profile. + * } + * + * @return true|\WP_Error True when WorkOS accepted the request; otherwise the same error + * the REST route returns (`workos_user_not_found`, `workos_user_not_linked`, + * `workos_user_missing_email`, `workos_profile_not_found`, + * `workos_reset_disabled`, `workos_rate_limited`, or the WorkOS API error). + */ +function workos_send_password_reset( int $user_id, array $args = [] ) { + $result = App::container()->get( RestApi::class )->send_for_user( + $user_id, + sanitize_text_field( (string) ( $args['redirect_url'] ?? '' ) ), + sanitize_title( (string) ( $args['profile'] ?? '' ) ) + ); + + return is_wp_error( $result ) ? $result : true; +} + +/** + * URL that ends the user's current WorkOS session. + * + * Thin shortcut for {@see \WorkOS\User::get_logout_url()}. Returns WorkOS's + * session logout URL for the stored session (WorkOS redirects to + * `$return_to` only when it is on the environment's sign-out redirect + * list), or `wp_logout_url( $return_to )` when the user has no WorkOS + * session. The WorkOS URL does not clear the WordPress auth cookie; + * `wp_logout_url()` ends both. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * @param string $return_to Optional URL to land on after sign-out. + * + * @return string + */ +function workos_get_logout_url( int $user_id = 0, string $return_to = '' ): string { + return User::get_logout_url( $user_id, $return_to ); +} diff --git a/tests/wpunit/PublicHelpersTest.php b/tests/wpunit/PublicHelpersTest.php new file mode 100644 index 0000000..68a448e --- /dev/null +++ b/tests/wpunit/PublicHelpersTest.php @@ -0,0 +1,612 @@ + + */ + private array $captured = []; + + /** + * Queued HTTP responses keyed by URL fragment. + * + * @var array + */ + private array $responses = []; + + /** + * Profile repository. + * + * @var ProfileRepository + */ + private ProfileRepository $repository; + + /** + * Set up: configure the plugin, seed the default profile, mock HTTP. + */ + public function setUp(): void { + parent::setUp(); + + \WorkOS\Config::set_active_environment( 'production' ); + update_option( + 'workos_production', + [ + 'api_key' => 'sk_test_fake', + 'client_id' => 'client_fake', + 'environment_id' => 'environment_test', + 'enable_activity_log' => true, + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $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 ); + } + $this->repository->save( + Profile::from_array( + [ + 'slug' => 'default', + 'title' => 'Default', + 'methods' => [ Profile::METHOD_PASSWORD ], + 'password_reset_flow' => true, + 'post_login_redirect' => '', + ] + ) + ); + + $this->captured = []; + $this->responses = []; + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + } + + /** + * Tear down. + */ + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + + foreach ( $this->repository->all() as $existing ) { + wp_delete_post( $existing->get_id(), true ); + } + + global $wpdb; + $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}workos_activity_log" ); + + $this->reset_rate_limit_buckets(); + wp_set_current_user( 0 ); + + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + /** + * HTTP interceptor: records the call and answers with the queued + * response for the first matching URL fragment, or `{}` with a 200. + * + * @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'] ?? '' ), + ]; + + foreach ( $this->responses as $fragment => $response ) { + if ( str_contains( $url, $fragment ) ) { + return [ + 'response' => [ + 'code' => $response['code'], + 'message' => 200 === $response['code'] ? 'OK' : 'Error', + ], + 'body' => (string) wp_json_encode( $response['body'] ), + ]; + } + } + + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => '{}', + ]; + } + + // ------------------------------------------------------------------ + // workos_get_valid_access_token() + // ------------------------------------------------------------------ + + /** + * A token that is still good for a while comes back as stored, with + * no call to WorkOS. + */ + public function test_valid_access_token_returns_stored_token_when_fresh(): void { + $token = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() + 600 ] ); + $user_id = $this->create_linked_user( $token, 'rt_01' ); + + $this->assertSame( $token, workos_get_valid_access_token( $user_id ) ); + $this->assertSame( $token, User::get_valid_access_token( $user_id ) ); + $this->assertEmpty( $this->captured, 'A fresh token must not trigger a refresh.' ); + } + + /** + * Omitting the user id targets the current user. + */ + public function test_valid_access_token_defaults_to_current_user(): void { + $token = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() + 600 ] ); + $user_id = $this->create_linked_user( $token, 'rt_01' ); + wp_set_current_user( $user_id ); + + $this->assertSame( $token, workos_get_valid_access_token() ); + } + + /** + * A WP user with no WorkOS link gets an error and no HTTP call. + */ + public function test_valid_access_token_errors_for_unlinked_user(): void { + $user_id = $this->create_plain_user(); + + $result = workos_get_valid_access_token( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_user_not_linked', $result->get_error_code() ); + $this->assertEmpty( $this->captured ); + } + + /** + * No logged-in user and no id: an error, not someone else's token. + */ + public function test_valid_access_token_errors_without_a_user(): void { + wp_set_current_user( 0 ); + + $result = workos_get_valid_access_token(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_user_not_linked', $result->get_error_code() ); + } + + /** + * A linked user who signed out (no tokens stored) has no session. + */ + public function test_valid_access_token_errors_when_signed_out(): void { + $user_id = $this->create_plain_user(); + update_user_meta( $user_id, User::META_WORKOS_ID, 'user_signed_out' ); + + $result = workos_get_valid_access_token( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_no_session', $result->get_error_code() ); + $this->assertEmpty( $this->captured ); + } + + /** + * An expired token is exchanged with the stored refresh token, and the + * rotated pair is stored the same way the login and session-refresh + * flows store it. + */ + public function test_valid_access_token_refreshes_expired_token(): void { + $expired = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() - 60 ] ); + $user_id = $this->create_linked_user( $expired, 'rt_original' ); + + $fresh = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() + 300 ] ); + $this->responses['/user_management/authenticate'] = [ + 'code' => 200, + 'body' => [ + 'access_token' => $fresh, + 'refresh_token' => 'rt_rotated', + 'user' => [ 'id' => 'user_linked' ], + ], + ]; + + $result = workos_get_valid_access_token( $user_id ); + + $this->assertSame( $fresh, $result ); + $this->assertSame( $fresh, get_user_meta( $user_id, User::META_ACCESS_TOKEN, true ) ); + $this->assertSame( 'rt_rotated', get_user_meta( $user_id, User::META_REFRESH_TOKEN, true ) ); + $this->assertSame( 'session_01', get_user_meta( $user_id, User::META_SESSION_ID, true ) ); + + $this->assertCount( 1, $this->captured ); + $this->assertStringStartsWith( Client::get_base_url() . '/user_management/authenticate', $this->captured[0]['url'] ); + $body = json_decode( $this->captured[0]['body'], true ); + $this->assertSame( 'refresh_token', $body['grant_type'] ?? null ); + $this->assertSame( 'rt_original', $body['refresh_token'] ?? null ); + } + + /** + * A token inside the leeway window (about to expire) is refreshed too. + */ + public function test_valid_access_token_refreshes_token_about_to_expire(): void { + $expiring = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() + 10 ] ); + $user_id = $this->create_linked_user( $expiring, 'rt_original' ); + + $fresh = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() + 300 ] ); + $this->responses['/user_management/authenticate'] = [ + 'code' => 200, + 'body' => [ + 'access_token' => $fresh, + 'refresh_token' => 'rt_rotated', + ], + ]; + + $this->assertSame( $fresh, workos_get_valid_access_token( $user_id ) ); + $this->assertCount( 1, $this->captured ); + } + + /** + * When WorkOS rejects the refresh, the caller gets the error and the + * stored tokens are left alone. + */ + public function test_valid_access_token_returns_error_when_refresh_fails(): void { + $expired = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() - 60 ] ); + $user_id = $this->create_linked_user( $expired, 'rt_revoked' ); + + $this->responses['/user_management/authenticate'] = [ + 'code' => 400, + 'body' => [ + 'error' => 'invalid_grant', + 'error_description' => 'Refresh token is invalid.', + ], + ]; + + $result = workos_get_valid_access_token( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_api_error', $result->get_error_code() ); + $this->assertSame( $expired, get_user_meta( $user_id, User::META_ACCESS_TOKEN, true ) ); + $this->assertSame( 'rt_revoked', get_user_meta( $user_id, User::META_REFRESH_TOKEN, true ) ); + } + + /** + * If another request rotated the tokens while this refresh was in + * flight (so WorkOS refused the spent refresh token), the fresh token + * that request stored is returned instead of the error. + */ + public function test_valid_access_token_uses_token_rotated_by_concurrent_refresh(): void { + $expired = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() - 60 ] ); + $user_id = $this->create_linked_user( $expired, 'rt_spent' ); + $fresh = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() + 300 ] ); + + $this->responses['/user_management/authenticate'] = [ + 'code' => 400, + 'body' => [ 'error' => 'invalid_grant' ], + ]; + $rotate = static function ( $preempt ) use ( $user_id, $fresh ) { + update_user_meta( $user_id, User::META_ACCESS_TOKEN, $fresh ); + return $preempt; + }; + add_filter( 'pre_http_request', $rotate, 5 ); + + $result = workos_get_valid_access_token( $user_id ); + + remove_filter( 'pre_http_request', $rotate, 5 ); + $this->assertSame( $fresh, $result ); + } + + /** + * A refresh response without an access token is treated as a failure. + */ + public function test_valid_access_token_errors_on_empty_refresh_response(): void { + $expired = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() - 60 ] ); + $user_id = $this->create_linked_user( $expired, 'rt_original' ); + + $result = workos_get_valid_access_token( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_refresh_failed', $result->get_error_code() ); + $this->assertSame( $expired, get_user_meta( $user_id, User::META_ACCESS_TOKEN, true ) ); + } + + /** + * An expired token with no refresh token to exchange is an error. + */ + public function test_valid_access_token_errors_when_expired_without_refresh_token(): void { + $expired = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() - 60 ] ); + $user_id = $this->create_linked_user( $expired, '' ); + + $result = workos_get_valid_access_token( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_no_session', $result->get_error_code() ); + $this->assertEmpty( $this->captured ); + } + + /** + * The existing `workos_get_access_token()` keeps returning the stored + * token as-is, expired or not, without calling WorkOS. + */ + public function test_existing_get_access_token_is_unchanged(): void { + $expired = $this->build_jwt( [ 'sid' => 'session_01', 'exp' => time() - 60 ] ); + $user_id = $this->create_linked_user( $expired, 'rt_original' ); + + $this->assertSame( $expired, workos_get_access_token( $user_id ) ); + $this->assertEmpty( $this->captured ); + } + + // ------------------------------------------------------------------ + // workos_send_password_reset() + // ------------------------------------------------------------------ + + /** + * Happy path: the reset goes out through the same call the admin + * endpoint makes, and the activity log gets the same row. + */ + public function test_send_password_reset_sends_and_logs(): void { + global $wpdb; + + $user_id = $this->create_linked_user( '', '' ); + $email = get_userdata( $user_id )->user_email; + wp_set_current_user( $user_id ); + + $result = workos_send_password_reset( $user_id, [ 'redirect_url' => home_url( '/account' ) ] ); + + $this->assertTrue( $result ); + + $send = $this->find_call( '/user_management/password_reset/send' ); + $this->assertNotNull( $send ); + $body = json_decode( $send['body'], true ); + $this->assertSame( $email, $body['email'] ?? null ); + $this->assertStringContainsString( '/workos/login/default/', (string) ( $body['password_reset_url'] ?? '' ) ); + $this->assertStringContainsString( 'redirect_to=', (string) ( $body['password_reset_url'] ?? '' ) ); + + $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->assertSame( home_url( '/account' ), $metadata['redirect_url'] ?? null ); + $this->assertTrue( $metadata['self_service'] ?? null ); + } + + /** + * A user with no WorkOS link gets the endpoint's error, and nothing is sent. + */ + public function test_send_password_reset_errors_for_unlinked_user(): void { + $user_id = $this->create_plain_user(); + + $result = workos_send_password_reset( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_user_not_linked', $result->get_error_code() ); + $this->assertNull( $this->find_call( '/user_management/password_reset/send' ) ); + } + + /** + * An unknown WP user id is an error. + */ + public function test_send_password_reset_errors_for_unknown_user(): void { + $result = workos_send_password_reset( 999999 ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_user_not_found', $result->get_error_code() ); + } + + /** + * An off-site redirect_url is replaced by RedirectValidator's fallback. + */ + public function test_send_password_reset_validates_redirect_url(): void { + $user_id = $this->create_linked_user( '', '' ); + + $result = workos_send_password_reset( $user_id, [ 'redirect_url' => 'https://evil.example/landing' ] ); + + $this->assertTrue( $result ); + $send = $this->find_call( '/user_management/password_reset/send' ); + $this->assertNotNull( $send ); + $body = json_decode( $send['body'], true ); + $this->assertStringNotContainsString( 'evil.example', (string) ( $body['password_reset_url'] ?? '' ) ); + } + + /** + * An unknown profile slug is rejected the same way the endpoint rejects it. + */ + public function test_send_password_reset_rejects_unknown_profile(): void { + $user_id = $this->create_linked_user( '', '' ); + + $result = workos_send_password_reset( $user_id, [ 'profile' => 'no-such-profile' ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_profile_not_found', $result->get_error_code() ); + } + + /** + * The helper shares the endpoint's per-user bucket: after 5 sends in a + * minute the sixth is refused. + */ + public function test_send_password_reset_is_rate_limited(): void { + $user_id = $this->create_linked_user( '', '' ); + + for ( $i = 0; $i < 5; $i++ ) { + $this->assertTrue( workos_send_password_reset( $user_id ) ); + } + + $result = workos_send_password_reset( $user_id ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_rate_limited', $result->get_error_code() ); + } + + // ------------------------------------------------------------------ + // workos_get_logout_url() + // ------------------------------------------------------------------ + + /** + * With a WorkOS session, the URL is the WorkOS logout endpoint for it. + */ + public function test_logout_url_uses_workos_session(): void { + $token = $this->build_jwt( [ 'sid' => 'session_abc', 'exp' => time() + 600 ] ); + $user_id = $this->create_linked_user( $token, 'rt_01' ); + + $url = workos_get_logout_url( $user_id, home_url( '/bye' ) ); + + $this->assertStringStartsWith( Client::get_base_url() . '/user_management/sessions/logout?', $url ); + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + $this->assertSame( 'session_abc', $query['session_id'] ?? null ); + $this->assertSame( home_url( '/bye' ), $query['return_to'] ?? null ); + $this->assertSame( $url, User::get_logout_url( $user_id, home_url( '/bye' ) ) ); + $this->assertEmpty( $this->captured, 'Building the URL must not call WorkOS.' ); + } + + /** + * Without return_to, only the session id is sent. + */ + public function test_logout_url_omits_empty_return_to(): void { + $token = $this->build_jwt( [ 'sid' => 'session_abc', 'exp' => time() + 600 ] ); + $user_id = $this->create_linked_user( $token, 'rt_01' ); + wp_set_current_user( $user_id ); + + $url = workos_get_logout_url(); + + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + $this->assertSame( [ 'session_id' => 'session_abc' ], $query ); + } + + /** + * A user with no WorkOS link gets the WordPress logout URL. + */ + public function test_logout_url_falls_back_for_unlinked_user(): void { + $user_id = $this->create_plain_user(); + + $this->assertSame( wp_logout_url( home_url( '/bye' ) ), workos_get_logout_url( $user_id, home_url( '/bye' ) ) ); + $this->assertSame( wp_logout_url(), workos_get_logout_url( $user_id ) ); + } + + /** + * A linked user with no current session also gets the WordPress logout URL. + */ + public function test_logout_url_falls_back_when_signed_out(): void { + $user_id = $this->create_plain_user(); + update_user_meta( $user_id, User::META_WORKOS_ID, 'user_signed_out' ); + + $this->assertSame( wp_logout_url(), workos_get_logout_url( $user_id ) ); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Create a linked WP user, optionally with stored tokens. The session id + * is stored through Login::store_tokens() just like a real sign-in. + * + * @param string $access_token Access token to store, or ''. + * @param string $refresh_token Refresh token to store, or ''. + * + * @return int + */ + private function create_linked_user( string $access_token, string $refresh_token ): int { + $user_id = $this->create_plain_user(); + update_user_meta( $user_id, User::META_WORKOS_ID, 'user_linked' ); + + \WorkOS\Auth\Login::store_tokens( + $user_id, + [ + 'access_token' => $access_token, + 'refresh_token' => $refresh_token, + ] + ); + + return $user_id; + } + + /** + * Create a WP user with no WorkOS link. Calls made by the plugin's own + * user_register hooks are dropped so tests only see what they trigger. + * + * @return int + */ + private function create_plain_user(): int { + $user_id = self::factory()->user->create( + [ 'user_email' => 'helpers-' . wp_generate_password( 8, false ) . '@example.test' ] + ); + + $this->captured = []; + + return $user_id; + } + + /** + * Build an unsigned JWT with the given payload. + * + * @param array $payload Claims. + * + * @return string + */ + private function build_jwt( array $payload ): string { + $header = rtrim( strtr( base64_encode( '{"alg":"RS256","typ":"JWT","kid":"k1"}' ), '+/', '-_' ), '=' ); + $body = rtrim( strtr( base64_encode( (string) wp_json_encode( $payload ) ), '+/', '-_' ), '=' ); + + return "{$header}.{$body}.sig"; + } + + /** + * Find the first captured call whose URL contains a fragment. + * + * @param string $fragment URL fragment. + * + * @return array|null + */ + private function find_call( string $fragment ): ?array { + foreach ( $this->captured as $call ) { + if ( str_contains( $call['url'], $fragment ) ) { + return $call; + } + } + + return null; + } + + /** + * Clear rate-limit buckets so per-user windows don't leak across tests. + * + * @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 9da6b2186edf2582d04d0dbab4974b07ebfe12e7 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Sat, 17 Oct 2026 16:17:20 +0000 Subject: [PATCH 2/6] feat(admin): plugin capabilities for WorkOS user management Add workos_view_users, workos_send_password_reset, workos_change_email and workos_manage_lockouts, resolved through map_meta_cap. Each maps to the core capability its check used before (manage_options, edit_user, edit_user, edit_users), so default roles keep exactly the access they had, including self-service password reset and email change. A role granted a capability directly gets that action; false denies it. Switch the password-reset and change-email checks and the WorkOS Users screen to the new capabilities. Settings stay on manage_options, and the immediate admin email change still needs edit_users. --- src/WorkOS/Admin/Users/AdminPage.php | 3 +- src/WorkOS/Admin/Users/Controller.php | 3 +- src/WorkOS/Auth/ChangeEmail/RestApi.php | 14 +- src/WorkOS/Auth/ChangeEmail/RowActions.php | 4 +- src/WorkOS/Auth/ChangeEmail/Shortcode.php | 6 +- .../Auth/ChangeEmail/UserProfilePanel.php | 3 +- .../Auth/PasswordResetAdmin/RestApi.php | 17 +- .../Auth/PasswordResetAdmin/RowActions.php | 4 +- .../Auth/PasswordResetAdmin/Shortcode.php | 7 +- .../PasswordResetAdmin/UserProfilePanel.php | 3 +- src/WorkOS/Capabilities.php | 150 ++++++ src/WorkOS/Controller.php | 5 + tests/wpunit/CapabilitiesTest.php | 429 ++++++++++++++++++ tests/wpunit/ChangeEmailRestApiTest.php | 43 ++ .../wpunit/PasswordResetAdminRestApiTest.php | 36 ++ 15 files changed, 704 insertions(+), 23 deletions(-) create mode 100644 src/WorkOS/Capabilities.php create mode 100644 tests/wpunit/CapabilitiesTest.php diff --git a/src/WorkOS/Admin/Users/AdminPage.php b/src/WorkOS/Admin/Users/AdminPage.php index f6b6c67..2e9d4c0 100644 --- a/src/WorkOS/Admin/Users/AdminPage.php +++ b/src/WorkOS/Admin/Users/AdminPage.php @@ -7,6 +7,7 @@ namespace WorkOS\Admin\Users; +use WorkOS\Capabilities; use WorkOS\Config; defined( 'ABSPATH' ) || exit; @@ -45,7 +46,7 @@ public function register_menu(): void { 'workos', __( 'WorkOS Users', 'integration-workos' ), __( 'Users', 'integration-workos' ), - 'manage_options', + Capabilities::VIEW_USERS, self::MENU_SLUG, [ $this, 'render' ] ); diff --git a/src/WorkOS/Admin/Users/Controller.php b/src/WorkOS/Admin/Users/Controller.php index 76f7501..cc2deed 100644 --- a/src/WorkOS/Admin/Users/Controller.php +++ b/src/WorkOS/Admin/Users/Controller.php @@ -14,7 +14,8 @@ * supporting REST endpoint. Always active — the REST routes need to be * registered during `rest_api_init` regardless of admin context, and the * AdminPage is gated internally via the `admin_menu` / `admin_enqueue_scripts` - * hooks plus a `manage_options` capability check. + * hooks plus a `workos_view_users` capability check (maps to + * `manage_options`; see {@see \WorkOS\Capabilities}). */ class Controller extends BaseController { diff --git a/src/WorkOS/Auth/ChangeEmail/RestApi.php b/src/WorkOS/Auth/ChangeEmail/RestApi.php index d2044c6..1f72dca 100644 --- a/src/WorkOS/Auth/ChangeEmail/RestApi.php +++ b/src/WorkOS/Auth/ChangeEmail/RestApi.php @@ -11,6 +11,7 @@ use WorkOS\RateLimit\TieredRateLimiter; use WorkOS\Email\AddressMask; use WorkOS\Http\ClientIp; +use WorkOS\Capabilities; use WP_Error; use WP_REST_Request; use WP_REST_Response; @@ -23,9 +24,10 @@ * * - `POST users/{id}/email-change` → initiate * - `POST users/{id}/email-change/confirm` → confirm (consumes token) - * - `POST users/{id}/email-change/cancel` → cancel (consumes cancel token OR uses edit_user) + * - `POST users/{id}/email-change/cancel` → cancel (consumes cancel token OR uses workos_change_email) * - * Auth model mirrors PasswordResetAdmin: `edit_user` on the target, + * Auth model mirrors PasswordResetAdmin: `workos_change_email` on the + * target, which maps to `edit_user` (see {@see \WorkOS\Capabilities}), * which the WP cap mapper grants both for self (the user's own ID) and * for admins acting on another account. * @@ -190,7 +192,7 @@ public function register_routes(): void { [ 'methods' => 'POST', 'callback' => [ $this, 'cancel' ], - // Permission may be granted via edit_user OR a valid cancel + // Permission may be granted via workos_change_email OR a valid cancel // token; the handler resolves both paths. 'permission_callback' => '__return_true', 'args' => [ @@ -202,7 +204,7 @@ public function register_routes(): void { } /** - * Permission callback for initiate — `edit_user` on the target. + * Permission callback for initiate — `workos_change_email` on the target. * * @param WP_REST_Request $request REST request. * @@ -218,7 +220,7 @@ public function check_permission_initiate( WP_REST_Request $request ) { ); } - if ( ! current_user_can( 'edit_user', $target_id ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $target_id ) ) { return new WP_Error( 'workos_forbidden', __( 'You do not have permission to change this user’s email.', 'integration-workos' ), @@ -631,7 +633,7 @@ public function cancel( WP_REST_Request $request ) { $token = (string) $request->get_param( 'token' ); $by_token = '' !== $token && $this->pending->verify_cancel( $record, $token ); - $by_capability = current_user_can( 'edit_user', $user->ID ); + $by_capability = current_user_can( Capabilities::CHANGE_EMAIL, $user->ID ); if ( ! $by_token && ! $by_capability ) { return new WP_Error( diff --git a/src/WorkOS/Auth/ChangeEmail/RowActions.php b/src/WorkOS/Auth/ChangeEmail/RowActions.php index 6d07ce8..9f7cd5f 100644 --- a/src/WorkOS/Auth/ChangeEmail/RowActions.php +++ b/src/WorkOS/Auth/ChangeEmail/RowActions.php @@ -8,6 +8,8 @@ namespace WorkOS\Auth\ChangeEmail; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -44,7 +46,7 @@ public function add_action( array $actions, int $user_id, string $workos_id ): a return $actions; } - if ( ! current_user_can( 'edit_user', $user_id ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $user_id ) ) { return $actions; } diff --git a/src/WorkOS/Auth/ChangeEmail/Shortcode.php b/src/WorkOS/Auth/ChangeEmail/Shortcode.php index 3cbd2f2..84c5303 100644 --- a/src/WorkOS/Auth/ChangeEmail/Shortcode.php +++ b/src/WorkOS/Auth/ChangeEmail/Shortcode.php @@ -7,6 +7,8 @@ namespace WorkOS\Auth\ChangeEmail; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -15,7 +17,7 @@ * Modes: * * - `user=""` — admin-of-other mode. Visible only to viewers - * with `edit_user` on the target. + * with `workos_change_email` on the target (maps to `edit_user`). * - no `user` attribute — self-service: the form pre-targets the * logged-in user. * @@ -64,7 +66,7 @@ public function render( $atts ): string { return ''; } - if ( ! current_user_can( 'edit_user', $target ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $target ) ) { return ''; } diff --git a/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php b/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php index be3180e..7085d44 100644 --- a/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php +++ b/src/WorkOS/Auth/ChangeEmail/UserProfilePanel.php @@ -7,6 +7,7 @@ namespace WorkOS\Auth\ChangeEmail; +use WorkOS\Capabilities; use WP_User; defined( 'ABSPATH' ) || exit; @@ -39,7 +40,7 @@ public function register(): void { * @return void */ public function render( WP_User $user ): void { - if ( ! current_user_can( 'edit_user', $user->ID ) ) { + if ( ! current_user_can( Capabilities::CHANGE_EMAIL, $user->ID ) ) { return; } diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php index 2c4c079..6260716 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RestApi.php @@ -13,6 +13,7 @@ use WorkOS\RateLimit\TieredRateLimiter; use WorkOS\Email\AddressMask; use WorkOS\Http\ClientIp; +use WorkOS\Capabilities; use WP_Error; use WP_REST_Request; use WP_REST_Response; @@ -24,7 +25,8 @@ * `POST /workos/v1/admin/users/{id}/password-reset`. * * Triggers a WorkOS password-reset email on behalf of another user. - * Requires `edit_user` on the target — which is true for an admin acting + * Requires `workos_send_password_reset` on the target, which maps to + * `edit_user` (see {@see \WorkOS\Capabilities}) — true for an admin acting * on someone else and *also* true for any logged-in user acting on their * own ID (WP's default mapping). One endpoint, two valid call paths. * @@ -125,9 +127,10 @@ public function register_routes(): void { } /** - * Permission callback — `edit_user` on the target user. + * Permission callback — `workos_send_password_reset` on the target user. * - * Granular by design: this is the WP capability that maps to "can edit + * Maps to `edit_user` unless a role is granted the capability directly. + * Granular by design: `edit_user` is the WP capability for "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. @@ -146,7 +149,7 @@ public function check_permission( WP_REST_Request $request ) { ); } - if ( ! current_user_can( 'edit_user', $target_id ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $target_id ) ) { return new WP_Error( 'workos_forbidden', __( 'You do not have permission to send a password reset for this user.', 'integration-workos' ), @@ -207,9 +210,9 @@ public function send_reset( WP_REST_Request $request ) { } $ip = $this->client_ip->get(); - // No send policy here: this route needs `edit_user` on the target and - // mails that account's own address, so the recipient is never - // caller-chosen. + // No send policy here: this route needs `workos_send_password_reset` + // on the target and mails that account's own address, so the + // recipient is never caller-chosen. $rate_ok = $this->rate_limiter->attempt( 'pw_reset_admin_ip', $ip, TieredRateLimiter::REQUEST_IP ); if ( is_wp_error( $rate_ok ) ) { return $rate_ok; diff --git a/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php index 3f29c8f..dc5c19a 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/RowActions.php @@ -8,6 +8,8 @@ namespace WorkOS\Auth\PasswordResetAdmin; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -53,7 +55,7 @@ public function add_action( array $actions, int $user_id, string $workos_id ): a return $actions; } - if ( ! current_user_can( 'edit_user', $user_id ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $user_id ) ) { return $actions; } diff --git a/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php b/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php index d6db52d..a0d1fce 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/Shortcode.php @@ -7,6 +7,8 @@ namespace WorkOS\Auth\PasswordResetAdmin; +use WorkOS\Capabilities; + defined( 'ABSPATH' ) || exit; /** @@ -15,7 +17,8 @@ * Two modes, picked by the `user` attribute: * * - `user=""` — admin-of-other mode. Visible only to viewers - * with `edit_user` on the target. Useful on internal dashboards. + * with `workos_send_password_reset` on the target (maps to + * `edit_user`). Useful on internal dashboards. * * - no `user` attribute — self-service mode. Visible to any logged-in * user; pre-targets their own account. @@ -65,7 +68,7 @@ public function render( $atts ): string { return ''; } - if ( ! current_user_can( 'edit_user', $target ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $target ) ) { return ''; } diff --git a/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php b/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php index 6f876df..6650d2f 100644 --- a/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php +++ b/src/WorkOS/Auth/PasswordResetAdmin/UserProfilePanel.php @@ -7,6 +7,7 @@ namespace WorkOS\Auth\PasswordResetAdmin; +use WorkOS\Capabilities; use WP_User; defined( 'ABSPATH' ) || exit; @@ -40,7 +41,7 @@ public function register(): void { * @return void */ public function render( WP_User $user ): void { - if ( ! current_user_can( 'edit_user', $user->ID ) ) { + if ( ! current_user_can( Capabilities::SEND_PASSWORD_RESET, $user->ID ) ) { return; } diff --git a/src/WorkOS/Capabilities.php b/src/WorkOS/Capabilities.php new file mode 100644 index 0000000..2c13b76 --- /dev/null +++ b/src/WorkOS/Capabilities.php @@ -0,0 +1,150 @@ + true, + * 'workos_view_users' => true, + * 'workos_send_password_reset' => true, + * ] ); + * ``` + * + * Once a user holds a plugin capability (through a role or on their own + * account), that grant decides the check instead of the core mapping. + * Setting it to `false` takes the action away even from a role whose + * core capabilities would allow it. + * + * See docs/capabilities.md. + */ +class Capabilities { + + /** Open the WorkOS → Users screen and its list endpoint. */ + public const VIEW_USERS = 'workos_view_users'; + + /** Send a WorkOS password-reset email for a user (checked per user). */ + public const SEND_PASSWORD_RESET = 'workos_send_password_reset'; + + /** Start or cancel an email change for a user (checked per user). */ + public const CHANGE_EMAIL = 'workos_change_email'; + + /** Clear sign-in lockouts. */ + public const MANAGE_LOCKOUTS = 'workos_manage_lockouts'; + + /** + * Plugin capability → the core capability it stands in for. + * + * `edit_user` is a per-user meta capability: every logged-in user has + * it for their own account, which is what keeps self-service password + * reset and email change working. + * + * @var array + */ + public const MAP = [ + self::VIEW_USERS => 'manage_options', + self::SEND_PASSWORD_RESET => 'edit_user', + self::CHANGE_EMAIL => 'edit_user', + self::MANAGE_LOCKOUTS => 'edit_users', + ]; + + /** + * Register the `map_meta_cap` filter. + * + * @return void + */ + public function register(): void { + add_filter( 'map_meta_cap', [ $this, 'map_meta_cap' ], 10, 4 ); + } + + /** + * Resolve a plugin capability to the primitive capabilities it needs. + * + * @param string[] $caps Primitive capabilities so far. + * @param string $cap Capability being checked. + * @param int $user_id User being checked. + * @param array $args Extra arguments (the target user ID for the per-user capabilities). + * + * @return string[] + */ + public function map_meta_cap( $caps, $cap, $user_id, $args ): array { + $caps = (array) $caps; + + if ( ! is_string( $cap ) || ! isset( self::MAP[ $cap ] ) ) { + return $caps; + } + + $user_id = (int) $user_id; + $args = array_values( (array) $args ); + + if ( $user_id > 0 && $this->is_set_on_user( $cap, $user_id ) ) { + return $this->granted_caps( $cap, $user_id, $args ); + } + + return map_meta_cap( self::MAP[ $cap ], $user_id, ...$args ); + } + + /** + * Caps required when the user holds the plugin capability directly. + * + * The user's own grant decides. On multisite, a per-user capability + * still can't target a super admin unless the caller is one, the same + * guard core applies to `edit_user`. + * + * @param string $cap Plugin capability. + * @param int $user_id User being checked. + * @param array $args Extra arguments. + * + * @return string[] + */ + private function granted_caps( string $cap, int $user_id, array $args ): array { + $target = isset( $args[0] ) ? (int) $args[0] : 0; + + if ( + 'edit_user' === self::MAP[ $cap ] + && is_multisite() + && $target > 0 + && $target !== $user_id + && is_super_admin( $target ) + && ! is_super_admin( $user_id ) + ) { + return [ 'do_not_allow' ]; + } + + return [ $cap ]; + } + + /** + * Whether the user's roles or own capabilities mention the capability. + * + * Reads the stored capabilities, so a grant of `false` counts as set + * (and denies). + * + * @param string $cap Plugin capability. + * @param int $user_id User being checked. + * + * @return bool + */ + private function is_set_on_user( string $cap, int $user_id ): bool { + $user = get_current_user_id() === $user_id ? wp_get_current_user() : get_userdata( $user_id ); + + return $user instanceof WP_User && array_key_exists( $cap, $user->allcaps ); + } +} diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index d46772b..fedfaa7 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -38,6 +38,11 @@ class Controller extends BaseController { * @return void */ protected function doRegister(): void { + // Plugin capabilities resolve on every request (admin, REST, front + // end shortcodes), so map them before any feature checks one. + $this->container->singleton( Capabilities::class ); + $this->container->get( Capabilities::class )->register(); + // Ahead of the features that resolve these at construction. $this->container->register( HttpController::class ); $this->container->register( EmailController::class ); diff --git a/tests/wpunit/CapabilitiesTest.php b/tests/wpunit/CapabilitiesTest.php new file mode 100644 index 0000000..ddaf8d6 --- /dev/null +++ b/tests/wpunit/CapabilitiesTest.php @@ -0,0 +1,429 @@ + + */ + private array $users = []; + + /** + * A second subscriber, the "other account" target. + * + * @var int + */ + private int $other_subscriber = 0; + + /** + * A second administrator, the "other admin" target. + * + * @var int + */ + private int $other_admin = 0; + + /** + * Create one user per default role plus the two targets. + */ + public function setUp(): void { + parent::setUp(); + + foreach ( [ 'administrator', 'editor', 'author', 'subscriber' ] as $role ) { + $this->users[ $role ] = $this->create_user( $role ); + } + + $this->other_subscriber = $this->create_user( 'subscriber' ); + $this->other_admin = $this->create_user( 'administrator' ); + + update_user_meta( $this->other_subscriber, '_workos_user_id', 'user_caps_other' ); + } + + /** + * Drop the custom role and log out. + */ + public function tearDown(): void { + remove_role( self::SUPPORT_ROLE ); + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Insert a user with the given role. + * + * @param string $role Role slug. + * + * @return int + */ + private function create_user( string $role ): int { + $user_id = wp_insert_user( + [ + 'user_login' => 'caps_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'user_email' => 'caps-' . uniqid( '', true ) . '@example.test', + 'role' => $role, + ] + ); + + $this->assertIsInt( $user_id ); + + return $user_id; + } + + /** + * Create a user on a fresh role holding only `read` plus the given caps. + * + * @param array $caps Capabilities to grant (or deny). + * + * @return int + */ + private function create_support_user( array $caps ): int { + remove_role( self::SUPPORT_ROLE ); + add_role( self::SUPPORT_ROLE, 'WorkOS Support', array_merge( [ 'read' => true ], $caps ) ); + + return $this->create_user( self::SUPPORT_ROLE ); + } + + /** + * Every check each capability replaced, as [ plugin cap, core cap, target ]. + * + * A null target means the check takes no object ID. + * + * @param int $self The acting user's ID. + * + * @return array + */ + private function checks( int $self ): array { + return [ + 'view users' => [ Capabilities::VIEW_USERS, 'manage_options', null ], + 'manage lockouts' => [ Capabilities::MANAGE_LOCKOUTS, 'edit_users', null ], + 'password reset, self' => [ Capabilities::SEND_PASSWORD_RESET, 'edit_user', $self ], + 'password reset, other' => [ Capabilities::SEND_PASSWORD_RESET, 'edit_user', $this->other_subscriber ], + 'password reset, other admin' => [ Capabilities::SEND_PASSWORD_RESET, 'edit_user', $this->other_admin ], + 'change email, self' => [ Capabilities::CHANGE_EMAIL, 'edit_user', $self ], + 'change email, other' => [ Capabilities::CHANGE_EMAIL, 'edit_user', $this->other_subscriber ], + 'change email, other admin' => [ Capabilities::CHANGE_EMAIL, 'edit_user', $this->other_admin ], + ]; + } + + /** + * Run one check for a user. + * + * @param int $user_id User to check. + * @param string $cap Capability. + * @param int|null $target Object ID, or null. + * + * @return bool + */ + private function can( int $user_id, string $cap, ?int $target ): bool { + return null === $target ? user_can( $user_id, $cap ) : user_can( $user_id, $cap, $target ); + } + + /** + * Expected answers per default role, so the matrix reads as a table. + * + * @return array}> + */ + public function role_provider(): array { + $self_service_only = [ + 'view users' => false, + 'manage lockouts' => false, + 'password reset, self' => true, + 'password reset, other' => false, + 'password reset, other admin' => false, + 'change email, self' => true, + 'change email, other' => false, + 'change email, other admin' => false, + ]; + + return [ + 'administrator' => [ + 'administrator', + array_fill_keys( array_keys( $self_service_only ), true ), + ], + 'editor' => [ 'editor', $self_service_only ], + 'author' => [ 'author', $self_service_only ], + 'subscriber' => [ 'subscriber', $self_service_only ], + ]; + } + + /** + * Each default role gets the same answer from the plugin capability as + * from the core capability the check used before, and that answer is the + * one in the table. + * + * @param string $role Role slug. + * @param array $expected Expected answer per check. + */ + #[DataProvider( 'role_provider' )] + public function test_default_roles_match_the_core_checks( string $role, array $expected ): void { + $user_id = $this->users[ $role ]; + + foreach ( $this->checks( $user_id ) as $label => [ $cap, $core, $target ] ) { + $this->assertSame( + $this->can( $user_id, $core, $target ), + $this->can( $user_id, $cap, $target ), + "{$role}: {$label} must match {$core}." + ); + $this->assertSame( $expected[ $label ], $this->can( $user_id, $cap, $target ), "{$role}: {$label}." ); + } + } + + /** + * The same holds through current_user_can(), which is what the + * plugin's checks call. + * + * @param string $role Role slug. + * @param array $expected Expected answer per check. + */ + #[DataProvider( 'role_provider' )] + public function test_current_user_can_matches_for_default_roles( string $role, array $expected ): void { + wp_set_current_user( $this->users[ $role ] ); + + foreach ( $this->checks( $this->users[ $role ] ) as $label => [ $cap, , $target ] ) { + $actual = null === $target ? current_user_can( $cap ) : current_user_can( $cap, $target ); + $this->assertSame( $expected[ $label ], $actual, "{$role}: {$label}." ); + } + } + + /** + * Logged-out visitors get nothing, as before. + */ + public function test_logged_out_visitor_has_no_capability(): void { + wp_set_current_user( 0 ); + + foreach ( $this->checks( 0 ) as $label => [ $cap, $core, $target ] ) { + $actual = null === $target ? current_user_can( $cap ) : current_user_can( $cap, $target ); + $this->assertFalse( $actual, "Logged out: {$label}." ); + } + } + + /** + * The documented set is exactly these four, each with its core mapping. + */ + public function test_map_lists_every_capability(): void { + $this->assertSame( + [ + 'workos_view_users' => 'manage_options', + 'workos_send_password_reset' => 'edit_user', + 'workos_change_email' => 'edit_user', + 'workos_manage_lockouts' => 'edit_users', + ], + Capabilities::MAP + ); + } + + /** + * Unrelated capabilities pass through the filter untouched. + */ + public function test_other_capabilities_are_not_touched(): void { + $this->assertSame( + [ 'some_cap' ], + ( new Capabilities() )->map_meta_cap( [ 'some_cap' ], 'some_cap', $this->users['subscriber'], [] ) + ); + } + + /** + * A role given only `workos_send_password_reset` can send a reset for + * anyone, keeps self-service email change, and can't do anything else. + */ + public function test_role_with_only_send_password_reset(): void { + $support = $this->create_support_user( [ Capabilities::SEND_PASSWORD_RESET => true ] ); + wp_set_current_user( $support ); + + $this->assertTrue( current_user_can( Capabilities::SEND_PASSWORD_RESET, $this->other_subscriber ) ); + $this->assertTrue( current_user_can( Capabilities::SEND_PASSWORD_RESET, $this->other_admin ) ); + $this->assertTrue( current_user_can( Capabilities::SEND_PASSWORD_RESET, $support ) ); + + $this->assertFalse( current_user_can( Capabilities::CHANGE_EMAIL, $this->other_subscriber ) ); + $this->assertTrue( current_user_can( Capabilities::CHANGE_EMAIL, $support ), 'Self-service still comes from edit_user.' ); + $this->assertFalse( current_user_can( Capabilities::VIEW_USERS ) ); + $this->assertFalse( current_user_can( Capabilities::MANAGE_LOCKOUTS ) ); + + // No core rights came along with it. + $this->assertFalse( current_user_can( 'manage_options' ) ); + $this->assertFalse( current_user_can( 'edit_users' ) ); + $this->assertFalse( current_user_can( 'edit_user', $this->other_subscriber ) ); + } + + /** + * Each capability granted on its own opens only its own check. + */ + public function test_each_capability_granted_alone(): void { + $support = $this->create_support_user( + [ + Capabilities::VIEW_USERS => true, + Capabilities::CHANGE_EMAIL => true, + Capabilities::MANAGE_LOCKOUTS => true, + ] + ); + wp_set_current_user( $support ); + + $this->assertTrue( current_user_can( Capabilities::VIEW_USERS ) ); + $this->assertTrue( current_user_can( Capabilities::CHANGE_EMAIL, $this->other_subscriber ) ); + $this->assertTrue( current_user_can( Capabilities::MANAGE_LOCKOUTS ) ); + $this->assertFalse( current_user_can( Capabilities::SEND_PASSWORD_RESET, $this->other_subscriber ) ); + $this->assertFalse( current_user_can( 'manage_options' ) ); + } + + /** + * Setting a capability to false on a role takes it away, even from a + * role whose core capabilities would allow it. + */ + public function test_capability_set_to_false_denies(): void { + $admin = get_role( 'administrator' ); + $admin->add_cap( Capabilities::SEND_PASSWORD_RESET, false ); + + try { + $user = new \WP_User( $this->users['administrator'] ); + $this->assertFalse( user_can( $user, Capabilities::SEND_PASSWORD_RESET, $this->other_subscriber ) ); + $this->assertTrue( user_can( $user, Capabilities::CHANGE_EMAIL, $this->other_subscriber ) ); + } finally { + $admin->remove_cap( Capabilities::SEND_PASSWORD_RESET ); + } + } + + /** + * A capability granted to one user (not a role) works the same way. + */ + public function test_capability_granted_to_a_single_user(): void { + $editor = new \WP_User( $this->users['editor'] ); + $editor->add_cap( Capabilities::VIEW_USERS ); + + $this->assertTrue( user_can( new \WP_User( $this->users['editor'] ), Capabilities::VIEW_USERS ) ); + $this->assertFalse( user_can( new \WP_User( $this->users['author'] ), Capabilities::VIEW_USERS ) ); + } + + // --------------------------------------------------------------------- + // The surfaces that switched to the plugin capabilities. + // --------------------------------------------------------------------- + + /** + * The users.php row actions follow their own capability. + */ + public function test_row_actions_follow_their_capability(): void { + $support = $this->create_support_user( [ Capabilities::SEND_PASSWORD_RESET => true ] ); + wp_set_current_user( $support ); + + $actions = ( new PasswordResetRowActions() )->add_action( [], $this->other_subscriber, 'user_caps_other' ); + $actions = ( new ChangeEmailRowActions() )->add_action( $actions, $this->other_subscriber, 'user_caps_other' ); + + $this->assertArrayHasKey( 'workos_password_reset', $actions ); + $this->assertArrayNotHasKey( 'workos_change_email', $actions ); + } + + /** + * Row actions for the default roles are unchanged. + */ + public function test_row_actions_unchanged_for_default_roles(): void { + wp_set_current_user( $this->users['administrator'] ); + $actions = ( new PasswordResetRowActions() )->add_action( [], $this->other_subscriber, 'user_caps_other' ); + $actions = ( new ChangeEmailRowActions() )->add_action( $actions, $this->other_subscriber, 'user_caps_other' ); + $this->assertArrayHasKey( 'workos_password_reset', $actions ); + $this->assertArrayHasKey( 'workos_change_email', $actions ); + + wp_set_current_user( $this->users['editor'] ); + $actions = ( new PasswordResetRowActions() )->add_action( [], $this->other_subscriber, 'user_caps_other' ); + $actions = ( new ChangeEmailRowActions() )->add_action( $actions, $this->other_subscriber, 'user_caps_other' ); + $this->assertSame( [], $actions ); + } + + /** + * The user-edit panels follow their own capability. + */ + public function test_profile_panels_follow_their_capability(): void { + $support = $this->create_support_user( [ Capabilities::CHANGE_EMAIL => true ] ); + wp_set_current_user( $support ); + $target = get_userdata( $this->other_subscriber ); + + ob_start(); + ( new PasswordResetPanel() )->render( $target ); + $reset_html = (string) ob_get_clean(); + + ob_start(); + ( new ChangeEmailPanel() )->render( $target ); + $change_html = (string) ob_get_clean(); + + $this->assertSame( '', $reset_html ); + $this->assertStringContainsString( 'workos-change-email-trigger', $change_html ); + } + + /** + * The shortcodes follow their own capability, and self-service still + * works for a subscriber. + */ + public function test_shortcodes_follow_their_capability(): void { + $support = $this->create_support_user( [ Capabilities::SEND_PASSWORD_RESET => true ] ); + wp_set_current_user( $support ); + + $atts = [ 'user' => (string) $this->other_subscriber ]; + $this->assertStringContainsString( 'workos-pwreset-trigger', ( new PasswordResetShortcode() )->render( $atts ) ); + $this->assertSame( '', ( new ChangeEmailShortcode() )->render( $atts ) ); + + wp_set_current_user( $this->other_subscriber ); + $this->assertStringContainsString( 'workos-pwreset-trigger', ( new PasswordResetShortcode() )->render( [] ) ); + $this->assertStringContainsString( 'workos-change-email-form', ( new ChangeEmailShortcode() )->render( [] ) ); + } + + /** + * The WorkOS → Users submenu is registered with the plugin capability, + * and a role holding only that capability reaches it. + */ + public function test_users_submenu_uses_view_users(): void { + global $submenu; + + $support = $this->create_support_user( [ Capabilities::VIEW_USERS => true ] ); + wp_set_current_user( $support ); + + $saved = $submenu ?? []; + try { + ( new UsersAdminPage() )->register_menu(); + + $entry = null; + foreach ( $submenu['workos'] ?? [] as $item ) { + if ( UsersAdminPage::MENU_SLUG === $item[2] ) { + $entry = $item; + } + } + + $this->assertNotNull( $entry, 'The Users submenu must be registered.' ); + $this->assertSame( Capabilities::VIEW_USERS, $entry[1] ); + } finally { + $submenu = $saved; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the test's own change. + } + } +} diff --git a/tests/wpunit/ChangeEmailRestApiTest.php b/tests/wpunit/ChangeEmailRestApiTest.php index edc484c..7753ca3 100644 --- a/tests/wpunit/ChangeEmailRestApiTest.php +++ b/tests/wpunit/ChangeEmailRestApiTest.php @@ -682,6 +682,49 @@ public function test_cancel_without_token_or_cap_is_403(): void { $this->assertSame( 403, $response->get_status() ); } + // ------------------------------------------------------- plugin capability + + public function test_role_with_change_email_cap_uses_verified_flow_for_others(): void { + // The capability opens the route, but the immediate admin commit + // still needs edit_users, so the change waits for verification. + add_role( 'workos_test_support', 'WorkOS Support', [ 'read' => true, 'workos_change_email' => true ] ); + + try { + wp_set_current_user( $this->create_user( 'support-' . uniqid() . '@example.test', 'workos_test_support' ) ); + + $new_email = 'support-set-' . uniqid() . '@example.test'; + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => $new_email ] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'committed', $response->get_data() ); + $this->assertNotEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + $this->assertSame( $this->linked_email, get_userdata( $this->linked_user_id )->user_email ); + + // The same capability cancels it. + $cancel = $this->dispatch( 'POST', '/workos/v1/users/' . $this->linked_user_id . '/email-change/cancel', [] ); + $this->assertSame( 200, $cancel->get_status() ); + $this->assertEmpty( get_user_meta( $this->linked_user_id, PendingChange::META_KEY, true ) ); + } finally { + remove_role( 'workos_test_support' ); + } + } + + public function test_editor_still_cannot_change_others_email(): void { + wp_set_current_user( $this->create_user( 'editor-' . uniqid() . '@example.test', 'editor' ) ); + + $response = $this->dispatch( + 'POST', + '/workos/v1/users/' . $this->linked_user_id . '/email-change', + [ 'new_email' => 'free-' . uniqid() . '@example.test' ] + ); + + $this->assertSame( 403, $response->get_status() ); + } + // ----------------------------------------------------------------- helpers } diff --git a/tests/wpunit/PasswordResetAdminRestApiTest.php b/tests/wpunit/PasswordResetAdminRestApiTest.php index c082e24..b10cb46 100644 --- a/tests/wpunit/PasswordResetAdminRestApiTest.php +++ b/tests/wpunit/PasswordResetAdminRestApiTest.php @@ -443,6 +443,42 @@ public function test_log_marks_self_service_attempts(): void { $this->assertTrue( $metadata['self_service'] ?? null ); } + /** + * A role given only `workos_send_password_reset` can send a reset for + * another user, and still can't reach the settings. + */ + public function test_role_with_only_send_password_reset_can_send_for_others(): void { + add_role( 'workos_test_support', 'WorkOS Support', [ 'read' => true, 'workos_send_password_reset' => true ] ); + + try { + wp_set_current_user( $this->create_user( 'support-' . uniqid() . '@example.test', 'workos_test_support' ) ); + + $response = $this->dispatch( $this->linked_user_id ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotNull( $this->find_send_password_reset_call() ); + + // Settings stay on manage_options. + $this->assertFalse( current_user_can( 'manage_options' ) ); + $settings = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/workos/v1/admin/profiles' ) ); + $this->assertSame( 403, $settings->get_status() ); + } finally { + remove_role( 'workos_test_support' ); + } + } + + /** + * Editors keep acting only on themselves. + */ + public function test_editor_still_cannot_send_for_others(): void { + wp_set_current_user( $this->create_user( 'editor-' . uniqid() . '@example.test', 'editor' ) ); + // Drop the sync call user creation makes. + $this->captured = []; + + $this->assertSame( 403, $this->dispatch( $this->linked_user_id )->get_status() ); + $this->assertEmpty( $this->captured ); + } + /** * Helper — locate the captured outbound call to send_password_reset. * From 5da83dabb79826e919b6537d31a0581572f47384 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Sat, 17 Oct 2026 16:17:27 +0000 Subject: [PATCH 3/6] feat(admin): gate the WorkOS Users list and its row actions by capability GET /workos/v1/admin/users now checks workos_view_users (still manage_options by default). Each row carries can_send_password_reset and can_change_email for the caller, and the React screen shows only the buttons the caller can use. --- src/WorkOS/Admin/Users/RestApi.php | 31 +++++---- src/js/admin-users/index.tsx | 8 ++- tests/wpunit/AdminUsersRestApiTest.php | 89 ++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/src/WorkOS/Admin/Users/RestApi.php b/src/WorkOS/Admin/Users/RestApi.php index ce69400..010b186 100644 --- a/src/WorkOS/Admin/Users/RestApi.php +++ b/src/WorkOS/Admin/Users/RestApi.php @@ -7,6 +7,7 @@ namespace WorkOS\Admin\Users; +use WorkOS\Capabilities; use WorkOS\Config; use WorkOS\Sync\UserLinks; use WP_Error; @@ -21,7 +22,7 @@ * Thin proxy over `Api\Client::list_users()` — the WorkOS upstream already * paginates, filters, and authorizes. Responsibilities here are: * - * 1. `manage_options` enforcement, + * 1. `workos_view_users` enforcement (maps to `manage_options`), * 2. parameter sanitization, * 3. enrichment of each user record with a `dashboard_url` so the React * side doesn't reconstruct it, @@ -97,12 +98,12 @@ public function register_routes(): void { } /** - * Permission check — admin capability required. + * Permission check — `workos_view_users` (maps to `manage_options`). * * @return true|WP_Error */ public function permission_check() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( Capabilities::VIEW_USERS ) ) { return new WP_Error( 'workos_forbidden', __( 'You do not have permission to manage WorkOS users.', 'integration-workos' ), @@ -273,16 +274,20 @@ private function shape_user( array $user, string $environment_id, array $wp_user $wp_user_id = '' !== $id ? (int) ( $wp_user_ids[ $id ] ?? 0 ) : 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'] : '', - 'last_name' => isset( $user['last_name'] ) ? (string) $user['last_name'] : '', - 'last_sign_in_at' => isset( $user['last_sign_in_at'] ) ? (string) $user['last_sign_in_at'] : '', - 'created_at' => isset( $user['created_at'] ) ? (string) $user['created_at'] : '', - 'updated_at' => isset( $user['updated_at'] ) ? (string) $user['updated_at'] : '', - 'dashboard_url' => $dashboard_url, + 'id' => $id, + 'wp_user_id' => $wp_user_id, + // What the caller may do with this row, so the screen only shows + // actions they can use. A row with no linked WP user has none. + 'can_send_password_reset' => $wp_user_id > 0 && current_user_can( Capabilities::SEND_PASSWORD_RESET, $wp_user_id ), + 'can_change_email' => $wp_user_id > 0 && current_user_can( Capabilities::CHANGE_EMAIL, $wp_user_id ), + 'email' => $email, + 'email_verified' => ! empty( $user['email_verified'] ), + 'first_name' => isset( $user['first_name'] ) ? (string) $user['first_name'] : '', + 'last_name' => isset( $user['last_name'] ) ? (string) $user['last_name'] : '', + 'last_sign_in_at' => isset( $user['last_sign_in_at'] ) ? (string) $user['last_sign_in_at'] : '', + 'created_at' => isset( $user['created_at'] ) ? (string) $user['created_at'] : '', + 'updated_at' => isset( $user['updated_at'] ) ? (string) $user['updated_at'] : '', + 'dashboard_url' => $dashboard_url, ]; } } diff --git a/src/js/admin-users/index.tsx b/src/js/admin-users/index.tsx index 43f7450..2b316a2 100644 --- a/src/js/admin-users/index.tsx +++ b/src/js/admin-users/index.tsx @@ -42,6 +42,10 @@ interface WorkosUser { id: string; /** Linked WP user id, or 0 if this WorkOS user has no WP counterpart. */ wp_user_id: number; + /** Whether the current admin may send this user a password reset. */ + can_send_password_reset: boolean; + /** Whether the current admin may change this user's email. */ + can_change_email: boolean; email: string; email_verified: boolean; first_name: string; @@ -530,7 +534,7 @@ function App(): JSX.Element { { __( '—', 'integration-workos' ) } ) } - { user.wp_user_id > 0 && ( + { user.can_send_password_reset && (