Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ Per-environment constants (take priority over generic):
| `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 and enqueues the shared `workos-admin-password-reset` JS/CSS so the per-row trigger button works. Also localizes `changeEmailUrl` + `changeEmailEnabled` for the React-native "Change email" action (handled in-bundle, so it does *not* enqueue the shared change-email handler). |
| `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/WorkOS/Admin/Users/LockoutsRestApi.php` | `POST /wp-json/workos/v1/admin/lockouts/{check,clear}` (`edit_users`) over `RateLimit\Lockouts`. POST so the address stays out of URLs. `AdminPage` localizes `lockoutsUrl` + `canManageLockouts`. |
| `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` + native "Change email" button with its own modal, immediate POST, and in-place row refresh) |
| `src/js/admin-users/lockouts.tsx` | "Sign-in lockouts" section below the user list (only with `canManageLockouts`): email in, one row per bucket with used/limit per window and lock time, Clear per bucket and Clear all |
| **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 |
Expand Down Expand Up @@ -151,6 +153,8 @@ Per-environment constants (take priority over generic):
| **Rate Limiting** ([`docs/rate-limiting.md`](docs/rate-limiting.md)) | |
| `src/WorkOS/RateLimit/TieredRateLimiter.php` | Stacks windows into a policy and owns the policy constants. `policy()` applies the `workos_rate_limit_policy( $policy, $bucket )` filter on attempt, refund and reset. A refused attempt fires `workos_rate_limited( $bucket, $subject_hash, $retry_after )`; `hash_subject()` is a salted HMAC so hooks and logs never see the raw email or IP. `ActivityLog\Controller` records it as `rate_limited`. |
| `src/WorkOS/RateLimit/RateLimiter.php`, `BaseRateLimiter.php`, `DatabaseRateLimiter.php`, `ObjectCacheRateLimiter.php`, `Controller.php` | Single-window contract, transient and object-cache storage, container binding |
| `src/WorkOS/RateLimit/CountsAttempts.php` | Optional read-only `count()` on a limiter; both shipped implementations provide it, and `TieredRateLimiter::status()` uses it. |
| `src/WorkOS/RateLimit/Lockouts.php` | Support lookup for one address: the email-keyed buckets (`password_email`, `magic_verify_email`, `magic_send_email`, `pw_reset_email`, `signup_email`, `otp_send_recipient`, `password_otp_recipient`), each keyed exact or by mailbox like its endpoint. `check()` returns per-tier counts and the lock time; `clear()` resets one or all and logs `rate_limit_cleared` (actor, source, bucket → subject hash). |
| **UI (Login Button)** | |
| `src/WorkOS/UI/Controller.php` | UI controller, registers block/widget |
| `src/WorkOS/UI/Block.php` | Gutenberg block registration |
Expand Down Expand Up @@ -196,6 +200,7 @@ Per-environment constants (take priority over generic):
| `src/WorkOS/CLI/UserCommand.php` | `wp workos user` commands |
| `src/WorkOS/CLI/OrgCommand.php` | `wp workos org` commands |
| `src/WorkOS/CLI/SyncCommand.php` | `wp workos sync` commands |
| `src/WorkOS/CLI/RateLimitCommand.php` | `wp workos ratelimit check <email>` (one row per window: count, limit, time left, locked) and `clear <email> [--bucket=<bucket>]`, both through `RateLimit\Lockouts` |
| **Database** | |
| `src/WorkOS/Database/Schema.php` | DB schema creation and upgrades |
| **Helpers** | |
Expand Down Expand Up @@ -344,6 +349,8 @@ tests/
├── LoginBypassTest.php # Login bypass tests
├── LoginLogoutTest.php # Login logout session revocation tests
├── LoginSessionTest.php # Login session tests
├── LockoutsRestApiTest.php # `POST /admin/lockouts/{check,clear}`: edit_users gate, check, clear + log, bad input
├── LockoutsTest.php # Lockout lookup keyed like the endpoints, filtered limits, clear one/all/every tier, `rate_limit_cleared` entry
├── LoginTokensTest.php # Login tokens tests
├── LogoutRedirectTest.php # Logout redirect tests
├── OnboardingSyncTest.php # Onboarding sync tests
Expand All @@ -355,6 +362,7 @@ tests/
├── PasswordResetAdminRowActionsTest.php # `workos_user_list_column_actions` filter + RowActions injection (capability check, self-service, defensive empty-workos_id)
├── PluginTest.php # Plugin singleton + constants tests
├── RateLimitActivityLogTest.php # One `rate_limited` activity-log row per refused attempt, hashed subject, no raw email or IP
├── RateLimitCommandTest.php # `wp workos ratelimit check/clear` against WP-CLI doubles (`tests/_support/WpCliStubs.php`)
├── RedirectTest.php # Login redirect tests
├── RendererKsesTest.php # Shared renderer KSES allowlist tests
├── RoleMapperTest.php # Role mapper tests
Expand Down
46 changes: 42 additions & 4 deletions docs/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ Everything lives in `src/WorkOS/RateLimit/`:
| `BaseRateLimiter` | Window arithmetic, key derivation, the `429` shape. |
| `DatabaseRateLimiter` | Counters in transients. |
| `ObjectCacheRateLimiter` | Counters in the object cache, incremented atomically. |
| `TieredRateLimiter` | Stacks windows into a policy. Owns the policy constants. |
| `Controller` | Binds the implementation and the wrapper. |
| `TieredRateLimiter` | Stacks windows into a policy. Owns the policy constants, the policy filter and the `workos_rate_limited` action. |
| `CountsAttempts` | Optional interface: read a counter without charging it. Both implementations provide it. |
| `Lockouts` | Looks up and clears the email-keyed buckets for one address. |
| `Controller` | Binds the implementation, the wrapper and `Lockouts`. |

Client IP resolution lives outside this module, in `WorkOS\Http\ClientIp`, because the activity log and the audit log need the same answer. See [Who the caller is](#who-the-caller-is).

Expand Down Expand Up @@ -142,6 +144,37 @@ do_action( 'workos_rate_limited', string $bucket, string $subject_hash, int $ret

When the activity log is enabled, the plugin hooks this action and writes one `rate_limited` entry with the bucket, the subject hash and `retry_after` in its details. The entry's user and IP columns stay empty on purpose: for a per-IP or per-email bucket they would hold the subject the hash is hiding. The activity log screen can filter on `rate_limited`.

## Looking up and clearing lockouts

When a customer is locked out, support can see which limits are blocking an address and clear them, from WP-CLI or the WorkOS Users screen. Both go through `WorkOS\RateLimit\Lockouts`.

Only the buckets keyed on an email address are covered: `password_email`, `magic_verify_email`, `magic_send_email`, `pw_reset_email`, `signup_email`, `otp_send_recipient` and `password_otp_recipient`. Per-IP buckets aren't tied to a customer, and the user-ID buckets guard admin and account actions, not sign-in.

Each bucket is keyed the way its endpoint keys it, so the address a customer typed finds the counters their requests hit: lowercased and trimmed for the exact buckets, reduced to the mailbox for the rest (see [Which mailbox](#which-mailbox)). Limits come from the filtered policy.

```sh
# One row per window: count, limit, time left, locked.
wp workos ratelimit check jane@example.com
wp workos ratelimit check jane@example.com --format=json

# Clear every bucket, or one.
wp workos ratelimit clear jane@example.com
wp workos ratelimit clear jane@example.com --bucket=password_email --user=admin
```

A bucket is locked while any of its windows is full, and stays locked until the last full window rolls. Clearing resets the current window of every tier, so the next request goes through.

On **WorkOS → Users**, a "Sign-in lockouts" section below the user list takes an email and shows the same buckets, with a Clear button per bucket and a Clear all. It is shown to users with `edit_users`, and calls two routes gated by the same capability:

| Route | Body | Returns |
|---|---|---|
| `POST /wp-json/workos/v1/admin/lockouts/check` | `email` | `buckets` (as `Lockouts::check()`), `locked` count |
| `POST /wp-json/workos/v1/admin/lockouts/clear` | `email`, optional `bucket` | `cleared` bucket names |

Both are POST so the address stays out of URLs and access logs.

Every clear writes one `rate_limit_cleared` activity-log entry. The user column is whoever cleared it (on the CLI, the `--user` the command ran as), and the details hold `source` (`cli` or `admin`) and each cleared bucket with its subject hash, which matches the `subject_hash` of that bucket's `rate_limited` entries. The address itself is never stored.

## Where limits apply

Every public auth route is throttled by requester. A subset also counts as a send.
Expand Down Expand Up @@ -306,7 +339,10 @@ Hooks and log entries carry `TieredRateLimiter::hash_subject()`, never the email

| File | Covers |
|---|---|
| `tests/wpunit/RateLimiterTest.php` | The single-window contract, run against **both** implementations via a data provider. |
| `tests/wpunit/RateLimiterTest.php` | The single-window contract, run against **both** implementations via a data provider, including `count()`. |
| `tests/wpunit/LockoutsTest.php` | Lookup keyed like the endpoints (exact and mailbox), filtered limits, clearing one or all buckets and every tier, the `rate_limit_cleared` entry. |
| `tests/wpunit/LockoutsRestApiTest.php` | The admin routes: `edit_users` required (signed out, subscriber, editor refused), check, clear, bad input. |
| `tests/wpunit/RateLimitCommandTest.php` | `wp workos ratelimit check` / `clear`, run against WP-CLI doubles in `tests/_support/WpCliStubs.php`. |
| `tests/wpunit/TieredRateLimiterTest.php` | Tier ordering and charging, against an in-memory fake limiter, plus a guard on the shipped policy constants; the policy filter (attempt, refund, reset, bad return values); the `workos_rate_limited` action and the subject hash. |
| `tests/wpunit/RateLimitActivityLogTest.php` | One `rate_limited` entry per refused attempt, with no raw email or IP in it; nothing written when the log is off. |
| `tests/wpunit/AuthKitRestPasswordTest.php` | Reserve/refund on authenticate; reset-start send caps; the shared bucket. |
Expand All @@ -316,7 +352,9 @@ Hooks and log entries carry `TieredRateLimiter::hash_subject()`, never the email

## Internal references

- `src/WorkOS/RateLimit/` — the module.
- `src/WorkOS/RateLimit/` — the module, including `Lockouts`.
- `src/WorkOS/CLI/RateLimitCommand.php` — `wp workos ratelimit`.
- `src/WorkOS/Admin/Users/LockoutsRestApi.php` and `src/js/admin-users/lockouts.tsx` — the lookup on the Users screen.
- `src/WorkOS/REST/Auth/BaseEndpoint.php` — the `rate_limit()` helper endpoints call.
- `src/WorkOS/Http/ClientIp.php` — caller identity, shared with the logs.
- `src/WorkOS/Email/AddressCanonicalizer.php` — which strings mean one mailbox.
Expand Down
2 changes: 1 addition & 1 deletion src/WorkOS/ActivityLog/AdminPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public function render_page(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$cleared = ! empty( SuperGlobals::get_get_var( 'cleared' ) );

$event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'rate_limited' ];
$event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'rate_limited', 'rate_limit_cleared' ];

?>
<div class="wrap">
Expand Down
2 changes: 2 additions & 0 deletions src/WorkOS/Admin/Users/AdminPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ public function maybe_enqueue_assets( string $hook ): void {
'defaultLimit' => 25,
'pluginEnabled' => workos()->is_enabled(),
'changeEmailEnabled' => $change_email_enabled,
'lockoutsUrl' => esc_url_raw( rest_url( LockoutsRestApi::NAMESPACE . LockoutsRestApi::BASE ) ),
'canManageLockouts' => current_user_can( 'edit_users' ),
]
);

Expand Down
4 changes: 4 additions & 0 deletions src/WorkOS/Admin/Users/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ protected function doRegister(): void {
$this->container->singleton( RestApi::class );
$this->container->get( RestApi::class );

// "Sign-in lockouts" lookup on the same screen.
$this->container->singleton( LockoutsRestApi::class );
$this->container->get( LockoutsRestApi::class );

$this->container->singleton( AdminPage::class );
$this->container->get( AdminPage::class )->register();
}
Expand Down
151 changes: 151 additions & 0 deletions src/WorkOS/Admin/Users/LockoutsRestApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php
/**
* Sign-in lockout REST API.
*
* @package WorkOS\Admin\Users
*/

namespace WorkOS\Admin\Users;

use WorkOS\RateLimit\Lockouts;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

defined( 'ABSPATH' ) || exit;

/**
* Admin routes behind the "Sign-in lockouts" lookup on the Users screen.
*
* POST /workos/v1/admin/lockouts/check { email }
* POST /workos/v1/admin/lockouts/clear { email, bucket? }
*
* POST for both so the address travels in the body and stays out of
* access logs. Gated by `edit_users`: clearing a lockout is an account
* action, not a settings change.
*/
class LockoutsRestApi {

public const NAMESPACE = 'workos/v1';
public const BASE = '/admin/lockouts';

/**
* Lockout service.
*
* @var Lockouts
*/
private Lockouts $lockouts;

/**
* Constructor.
*
* @param Lockouts $lockouts Lockout service.
*/
public function __construct( Lockouts $lockouts ) {
$this->lockouts = $lockouts;

add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}

/**
* Register REST routes.
*
* @return void
*/
public function register_routes(): void {
$email = [
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
];

register_rest_route(
self::NAMESPACE,
self::BASE . '/check',
[
'methods' => 'POST',
'callback' => [ $this, 'check' ],
'permission_callback' => [ $this, 'permission_check' ],
'args' => [ 'email' => $email ],
]
);

register_rest_route(
self::NAMESPACE,
self::BASE . '/clear',
[
'methods' => 'POST',
'callback' => [ $this, 'clear' ],
'permission_callback' => [ $this, 'permission_check' ],
'args' => [
'email' => $email,
'bucket' => [
'type' => 'string',
'required' => false,
'default' => '',
'sanitize_callback' => 'sanitize_key',
],
],
]
);
}

/**
* Require `edit_users`.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'edit_users' ) ) {
return new WP_Error(
'workos_forbidden',
__( 'You do not have permission to manage sign-in lockouts.', 'integration-workos' ),
[ 'status' => rest_authorization_required_code() ]
);
}

return true;
}

/**
* POST /admin/lockouts/check.
*
* @param WP_REST_Request $request REST request.
*
* @return WP_REST_Response|WP_Error
*/
public function check( WP_REST_Request $request ) {
$rows = $this->lockouts->check( (string) $request->get_param( 'email' ) );
if ( is_wp_error( $rows ) ) {
return $rows;
}

return new WP_REST_Response(
[
'buckets' => $rows,
'locked' => count( array_filter( array_column( $rows, 'locked' ) ) ),
],
200
);
}

/**
* POST /admin/lockouts/clear.
*
* @param WP_REST_Request $request REST request.
*
* @return WP_REST_Response|WP_Error
*/
public function clear( WP_REST_Request $request ) {
$cleared = $this->lockouts->clear(
(string) $request->get_param( 'email' ),
(string) $request->get_param( 'bucket' ),
'admin'
);
if ( is_wp_error( $cleared ) ) {
return $cleared;
}

return new WP_REST_Response( [ 'cleared' => $cleared ], 200 );
}
}
1 change: 1 addition & 0 deletions src/WorkOS/CLI/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ protected function doRegister(): void {
\WP_CLI::add_command( 'workos user', UserCommand::class );
\WP_CLI::add_command( 'workos org', OrgCommand::class );
\WP_CLI::add_command( 'workos sync', SyncCommand::class );
\WP_CLI::add_command( 'workos ratelimit', RateLimitCommand::class );
}

/**
Expand Down
Loading
Loading