{"id":47741305,"url":"https://github.com/bordoni/integration-workos","last_synced_at":"2026-05-12T01:02:20.933Z","repository":{"id":343298127,"uuid":"1158235488","full_name":"bordoni/integration-workos","owner":"bordoni","description":"Enterprise identity management for WordPress powered by WorkOS. SSO, directory sync, MFA, and user management.","archived":false,"fork":false,"pushed_at":"2026-04-22T14:59:30.000Z","size":759,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-22T15:14:55.074Z","etag":null,"topics":["wordpress-plugin","workos"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bordoni.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":"bordoni"}},"created_at":"2026-02-15T02:29:46.000Z","updated_at":"2026-04-06T04:59:02.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bordoni/integration-workos","commit_stats":null,"previous_names":["bordoni/integration-workos"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/bordoni/integration-workos","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bordoni%2Fintegration-workos","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bordoni%2Fintegration-workos/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bordoni%2Fintegration-workos/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bordoni%2Fintegration-workos/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bordoni","download_url":"https://codeload.github.com/bordoni/integration-workos/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bordoni%2Fintegration-workos/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32319560,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-26T23:26:28.701Z","status":"online","status_checked_at":"2026-04-27T02:00:06.769Z","response_time":128,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["wordpress-plugin","workos"],"created_at":"2026-04-02T23:53:48.538Z","updated_at":"2026-04-27T02:01:31.480Z","avatar_url":"https://github.com/bordoni.png","language":"PHP","funding_links":["https://github.com/sponsors/bordoni"],"categories":[],"sub_categories":[],"readme":"# Integration with WorkOS\n\nEnterprise identity management for WordPress powered by [WorkOS](https://workos.com). SSO, directory sync, MFA, and user management.\n\n**Requires PHP:** 7.4+\n**Requires WordPress:** 5.9+\n**License:** GPL-2.0-or-later\n\n## Features\n\n### Custom AuthKit (WordPress-hosted login)\n\n- **React login shell** on wp-login.php, `[workos:login]` shortcode, and a dedicated `/workos/login/{profile}` route — all driven by the same TypeScript bundle\n- **Login Profiles** — admin-defined presets (enabled methods, pinned organization, signup/invite/reset toggles, MFA policy, branding) managed through a React admin editor at **WorkOS → Login Profiles**\n- **Sign-in methods**: email + password, magic code, social OAuth (Google, Microsoft, GitHub, Apple), passkey\n- **In-app flows**: self-serve sign-up with email verification, invitation acceptance, password reset\n- **MFA** — TOTP, SMS, WebAuthn/passkey with full enrollment + challenge UI; profile-level `mfa.enforce` (`never` / `if_required` / `always`) and factor allowlist\n- **Profile routing rules** — ordered `redirect_to` glob / `referrer_host` / `user_role` matchers pick the right profile per request\n- **WorkOS Radar** anti-fraud integration — browser SDK supplies an action token the plugin forwards on every server-side auth call\n- **No WorkOS PHP SDK** — every WorkOS call is `wp_remote_request` from PHP, so the API key never leaves the server\n\n### Base platform\n\n- **Single Sign-On (SSO)** via WorkOS AuthKit — redirect or headless login modes (legacy path, selectable per-profile)\n- **Directory Sync (SCIM)** — automatic user provisioning and deprovisioning from your identity provider\n- **Role Mapping** — map WorkOS organization roles to WordPress roles\n- **Organization Management** — local caching of WorkOS organizations with multisite support\n- **Entitlement Gate** — require organization membership to log in\n- **Webhook Processing** — real-time sync of user, organization, and directory events\n- **REST API Authentication** — Bearer token auth for headless/decoupled WordPress\n- **Legacy Login Button** — Gutenberg block and classic widget (AuthKit-redirect flow)\n- **Login Bypass** — access the native WordPress login form via `?fallback=1` when WorkOS is unavailable\n- **Activity Logging** — local database table with admin viewer for tracking authentication and sync events\n- **Audit Logging** — forward WordPress events (login, logout, post changes, user changes) to WorkOS Audit Logs\n- **Role-Based Login Redirects** — send users to different URLs after login based on their WordPress role\n- **Role-Based Logout Redirects** — send users to different URLs after logout based on their WordPress role\n- **Password Reset Integration** — redirect password reset to WorkOS or fall back to WordPress\n- **Registration Redirect** — redirect registration to WorkOS AuthKit\n- **Admin Bar Badge** — shows the active WorkOS environment (production/staging) in the admin bar\n- **Changelog Page** — in-admin changelog viewer rendered from CHANGELOG.md\n- **Diagnostics Page** — system health checks and configuration status\n- **Onboarding Wizard** — guided setup for initial plugin configuration and user sync\n- **WP-CLI Commands** — full CLI access for scripting, bulk operations, and diagnostics\n\n## Screenshots\n\n| Custom AuthKit login | Login Profiles | WorkOS settings | Role mapping \u0026amp; redirects |\n| :---: | :---: | :---: | :---: |\n| \u003ca href=\".wordpress-org/screenshot-1.png\"\u003e\u003cimg src=\".wordpress-org/screenshot-1.png\" width=\"320\" alt=\"Branded Custom AuthKit login\"\u003e\u003c/a\u003e | \u003ca href=\".wordpress-org/screenshot-2.png\"\u003e\u003cimg src=\".wordpress-org/screenshot-2.png\" width=\"320\" alt=\"Login Profiles list\"\u003e\u003c/a\u003e | \u003ca href=\".wordpress-org/screenshot-3.png\"\u003e\u003cimg src=\".wordpress-org/screenshot-3.png\" width=\"320\" alt=\"WorkOS settings\"\u003e\u003c/a\u003e | \u003ca href=\".wordpress-org/screenshot-4.png\"\u003e\u003cimg src=\".wordpress-org/screenshot-4.png\" width=\"320\" alt=\"Role mapping and redirects\"\u003e\u003c/a\u003e |\n| Branded login your site visitors see — logo, brand color, and the sign-in methods you enable. | Pick sign-in methods, pin an organization, set MFA policy, brand each card without code. | Switch Production / Staging, manage API credentials, and pick the login mode. | Map WorkOS roles to WP roles and send users to role-specific URLs. |\n\n## Installation\n\n### From a Release ZIP\n\n1. Download the latest `.zip` from the [Releases](https://github.com/bordoni/integration-workos/releases) page.\n2. In WordPress admin, go to **Plugins \u003e Add New \u003e Upload Plugin** and upload the ZIP file.\n3. Activate the plugin.\n4. Navigate to **Settings \u003e WorkOS** to configure.\n\n### From Source (Development)\n\n1. Clone the repository into `wp-content/plugins/integration-workos/`.\n2. Run `composer install` to install PHP dependencies.\n3. Run `bun install \u0026\u0026 bun run build` to install JS dependencies and build assets.\n4. Activate the plugin in WordPress admin.\n5. Navigate to **Settings \u003e WorkOS** to configure.\n\n## Configuration\n\n### Admin UI\n\nGo to **Settings \u003e WorkOS** in the WordPress admin. The settings page has three tabs:\n\n| Tab | Contents |\n|---|---|\n| **Settings** | API Key, Client ID, Environment ID, webhook secret, login mode, password fallback, audit logging toggle |\n| **Organization** | Select or create a WorkOS organization |\n| **Users** | Deprovision action, content reassignment, role mapping table |\n\nThe plugin supports two environments — **Production** and **Staging** — with separate credentials for each. An admin bar badge shows which environment is active.\n\n### wp-config.php Constants\n\nAll credentials can be set via constants in `wp-config.php`, which take precedence over database values:\n\n```php\n// Generic (used for any environment)\ndefine( 'WORKOS_API_KEY', 'sk_live_...' );\ndefine( 'WORKOS_CLIENT_ID', 'client_...' );\ndefine( 'WORKOS_WEBHOOK_SECRET', 'whsec_...' );\ndefine( 'WORKOS_ORGANIZATION_ID', 'org_...' );\ndefine( 'WORKOS_ENVIRONMENT_ID', 'environment_...' );\ndefine( 'WORKOS_ENVIRONMENT', 'production' ); // Lock active environment\n\n// Per-environment (takes priority over generic)\ndefine( 'WORKOS_PRODUCTION_API_KEY', 'sk_live_...' );\ndefine( 'WORKOS_STAGING_API_KEY', 'sk_test_...' );\n```\n\n## Authentication\n\nThe plugin ships three login modes. Each Login Profile picks its `mode`:\n`custom` uses the React shell, `authkit_redirect` uses the legacy redirect\npath. `login_mode=headless` (global) remains available for custom forms.\n\n### Custom Mode — React shell (default for the `default` Login Profile)\n\n`wp-login.php?action=login` is intercepted on `login_init` and the React shell\nrenders in its place. All other actions — `logout`, `register`,\n`lostpassword`, `resetpass`, `confirmaction`, `postpass`, `?fallback=1`,\n`?workos=0` — pass through to core WP so WooCommerce, WP-CLI password\nresets, and email confirmation links keep working.\n\nThe shell also mounts on `[workos:login profile=\"slug\"]` and the\n`/workos/login/{profile}` rewrite. Every mount reads configuration from\n`data-*` attributes emitted by `Auth\\AuthKit\\Renderer` and talks to\n`/wp-json/workos/v1/auth/*` for everything — no WorkOS calls are proxied\nthrough the browser.\n\n### AuthKit-Redirect Mode (legacy)\n\nSet a Login Profile's `mode` to `authkit_redirect` and `wp-login.php` sends\nusers to WorkOS's hosted AuthKit. WorkOS returns to `/workos/callback` where\nthe plugin exchanges the authorization code. Use this mode for SSO\n(SAML/OIDC) or any profile that needs WorkOS-hosted UX.\n\n### Headless Mode\n\nThe plugin intercepts WordPress's `authenticate` filter and validates\ncredentials directly against the WorkOS API using email and password. This\nmode is useful for custom login forms you render yourself.\n\n### Password Fallback\n\nWhen enabled, WordPress native password authentication remains available\nalongside WorkOS. Password reset and registration forms fall back to\nWordPress defaults.\n\n### Login Bypass\n\nIf WorkOS is down or misconfigured, users can access the native WordPress\nlogin form by appending `?fallback=1` to the login URL. This bypasses the\nWorkOS redirect — and the React shell takeover — entirely.\n\n## Login Profiles\n\nA Login Profile is a stored configuration unit that governs a single login\nentry point. Manage profiles under **WorkOS → Login Profiles** (React\neditor backed by `/wp-json/workos/v1/admin/profiles`).\n\nEach profile stores:\n\n| Field                    | Purpose                                                          |\n|--------------------------|------------------------------------------------------------------|\n| `slug`                   | URL-friendly id; reserved `default` drives wp-login.php takeover |\n| `custom_path`            | Optional arbitrary URL path (e.g. `members`, `team/login`, `login`) that mounts the same React shell — see \"Custom paths\" below. Empty means only `/workos/login/{slug}` is registered. Available on every profile, including the reserved `default` (when set, `/wp-login.php?action=login` 302s to it) |\n| `title`                  | Admin-facing label                                               |\n| `methods[]`              | Enabled sign-in methods (any subset of `password`, `magic_code`, `oauth_google`, `oauth_microsoft`, `oauth_github`, `oauth_apple`, `passkey`) |\n| `organization_id`        | Server-side pinned WorkOS org; passed on auth calls              |\n| `signup`                 | `{enabled, require_invite}` — toggles self-serve signup          |\n| `invite_flow`            | Allow invitation acceptance                                      |\n| `password_reset_flow`    | Allow in-app password reset                                      |\n| `mfa`                    | `{enforce: never|if_required|always, factors: [totp,sms,webauthn]}` |\n| `branding`               | `{logo_mode, logo_attachment_id, primary_color, heading, subheading}` — `logo_mode` is `default` / `custom` / `none` (see \"Logo modes\" in [`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md)) |\n| `post_login_redirect`    | URL the React shell navigates to on success (beats `redirect_to`)|\n| `forward_query_args`     | When `true`, appends safe inbound query args (`utm_*`, `ref`, custom params — never `redirect_to`, `_wpnonce`, `loggedout`, `wp_lang`, `workos_*`, etc.) to the post-login destination |\n| `mode`                   | `custom` (React) or `authkit_redirect` (legacy)                  |\n\nThe `branding.logo` field defaults to the WordPress Site Icon when no\nper-profile logo is set. See\n[`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md) for the\nfull developer guide on injecting React elements (SlotFill), enqueuing\nper-profile CSS/JS, and the available PHP filters.\n\n### Custom paths\n\nAny profile (including the reserved `default`) can claim an arbitrary\nURL path on top of the canonical `/workos/login/{slug}` rewrite. Set\nthe **Custom path** field in the editor to e.g. `members` and\n`https://yoursite.com/members/` mounts the same React shell. The\nshortcode `[workos:login profile=\"members\"]` keeps working too.\n\n- Both URLs are always live — adding a custom path never disables the\n  canonical `/workos/login/{slug}` rewrite.\n- When the **default** profile owns a non-empty `custom_path`,\n  `/wp-login.php?action=login` 302s to it with every inbound `$_GET`\n  preserved (so `redirect_to`, `interim-login`, `reauth`, `instance`,\n  `wp_lang`, etc. survive the bounce). `?loggedout`, `?fallback=1`,\n  `LoginBypass`, and non-`login` actions short-circuit the redirect.\n- Reserved segments (`wp-admin`, `wp-includes`, `wp-content`, `wp-json`,\n  `workos`, `feed`, `comments`, `trackback`) are rejected at save time\n  so you can't shadow core URLs. `login`, `admin`, and `signin` are\n  intentionally allowed so the default profile can bounce\n  `/wp-login.php` to a clean URL.\n- Saving a profile triggers exactly one soft `flush_rewrite_rules( false )`\n  on the next request when the custom-path set actually changes\n  (signature stored in the `workos_custom_paths_signature` option).\n\n### Already signed-in visitors\n\nA visitor that hits any AuthKit surface (wp-login.php takeover,\n`/workos/login/{slug}`, a custom path) while logged in is 302'd\nstraight to their post-login destination. The precedence is centralized\nin `Auth\\AuthKit\\LoginRedirector::for_visitor( Profile $profile )`:\nprofile `post_login_redirect` → validated `redirect_to` → `admin_url()`.\nThe `[workos:login]` shortcode can't redirect from inside `the_content`\n(headers are already sent), so it renders an inline \"You're already\nsigned in as {name}. [Continue]\" notice that links to the same URL.\n\n### Profile routing rules\n\nRules stored under the `workos_profile_routing_rules` option pick the right\nprofile when no slug is explicit. Each rule is\n`{ profile: slug, matcher: { type, value } }` where `type` is one of\n`redirect_to` (glob), `referrer_host` (exact host), or `user_role` (role slug).\nRules evaluate top-down; first match wins; the `default` profile is the\nfallback.\n\n### MFA policy\n\nProfile-level MFA enforcement is applied by `Auth\\AuthKit\\LoginCompleter`:\n\n- `enforce: never` — single-step login is always accepted\n- `enforce: if_required` (default) — MFA step surfaces when WorkOS returns\n  a pending factor; otherwise single-step is accepted\n- `enforce: always` — single-step success is rejected with\n  `workos_authkit_mfa_required`; the user must enroll a factor first\n\nFactor types WorkOS returns in a pending-factor response are checked against\nthe profile's `mfa.factors` allowlist; disallowed types are rejected.\n\n### WorkOS Radar\n\nSet `workos_radar_site_key` (plugin option) or define\n`WORKOS_RADAR_SITE_KEY` to enable Radar. The React shell loads\n`https://radar.workos.com/v1/radar.js`, fetches an action token, and the\nplugin forwards it as `X-WorkOS-Radar-Action-Token` on every\nuser-management auth call, so WorkOS can score the attempt server-side.\n\n## Webhooks\n\nConfigure your WorkOS dashboard to send webhooks to:\n\n```\nhttps://yoursite.com/wp-json/workos/v1/webhook\n```\n\nThe plugin processes these event types:\n\n| Category | Events |\n|---|---|\n| **Users** | `user.created`, `user.updated`, `user.deleted` |\n| **Directory Sync** | `dsync.user.created`, `dsync.user.updated`, `dsync.user.deleted`, `dsync.group.user_added`, `dsync.group.user_removed` |\n| **Organizations** | `organization.created`, `organization.updated` |\n| **Memberships** | `organization_membership.created`, `organization_membership.updated`, `organization_membership.deleted` |\n| **Connections** | `connection.activated`, `connection.deactivated`, `connection.deleted` |\n| **Auth** | `authentication.email_verification_succeeded` |\n\nAll events are verified against the webhook signing secret before processing.\n\n## REST API Authentication\n\nThe plugin adds Bearer token authentication to the WordPress REST API. Send the WorkOS access token in the `Authorization` header:\n\n```\nAuthorization: Bearer \u003cworkos_access_token\u003e\n```\n\nThe token is verified using WorkOS JWKS and mapped to a WordPress user via their linked WorkOS ID.\n\n## Helpers for Third-Party Integrations\n\nThe plugin exposes a stable, read-only API for checking WorkOS state on\na WP user so integrations don't need to know what meta keys the plugin\nstores. Everything lives in `WorkOS\\User` (instance-free static class)\nwith matching global function shortcuts.\n\n### `WorkOS\\User` methods\n\n| Method                                | Returns  | Purpose                                                |\n|---------------------------------------|----------|--------------------------------------------------------|\n| `User::is_sso( $user_id = 0 )`        | `bool`   | User is linked to a WorkOS identity (persistent)       |\n| `User::has_active_session( $user_id = 0 )` | `bool` | User currently has a stored WorkOS access token       |\n| `User::get_workos_id( $user_id = 0 )` | `string` | WorkOS `user_...` identifier, or `''`                  |\n| `User::get_access_token( $user_id = 0 )` | `string` | Current WorkOS access token (treat as opaque)       |\n| `User::get_refresh_token( $user_id = 0 )` | `string` | Stored WorkOS refresh token                         |\n| `User::get_session_id( $user_id = 0 )` | `string` | WorkOS `sid` claim for the active session             |\n| `User::get_organization_id( $user_id = 0 )` | `string` | WorkOS organization id pinned to the user        |\n| `User::snapshot( $user_id = 0 )`      | `array`  | All of the above in one predictable payload            |\n\nAll methods accept `0` (or omitted) to target the currently-authenticated\nuser. All return empty strings / `false` safely when no user is available,\nso there's no need to null-check `get_current_user_id()` first.\n\nThe meta keys are also exposed as constants (`User::META_WORKOS_ID`,\n`META_ACCESS_TOKEN`, etc.) for callers that need them in SQL queries or\nREST schemas.\n\n### Global function shortcuts\n\n```php\nworkos_is_sso_user( $user_id = 0 );       // bool   — is the user linked?\nworkos_has_active_session( $user_id = 0 ); // bool  — is a session stored?\nworkos_get_user_id( $user_id = 0 );       // string — WorkOS user id\nworkos_get_access_token( $user_id = 0 );  // string — current access token\n```\n\n### Distinguishing \"linked\" vs \"currently signed in\"\n\n- **`is_sso()`** remains true after the user logs out. Use it when you\n  want to know whether an account was ever provisioned via WorkOS.\n- **`has_active_session()`** flips to false on `wp_logout` (the plugin\n  clears the access token server-side). Use it when you want to know\n  whether a request is running under a live WorkOS session.\n\n### Example: augment a REST response payload\n\n```php\nuse WorkOS\\User;\n\nadd_filter( 'my_plugin_response', function ( array $data ): array {\n    if ( ! User::is_sso() ) {\n        return $data;\n    }\n\n    $data['workos'] = [\n        'linked'          =\u003e true,\n        'active_session'  =\u003e User::has_active_session(),\n        'workos_user_id'  =\u003e User::get_workos_id(),\n        'organization_id' =\u003e User::get_organization_id(),\n    ];\n\n    return $data;\n} );\n```\n\nOr with the function shortcuts inside a non-namespaced file:\n\n```php\nfunction my_plugin_add_workos_data( array $data ): array {\n    if ( ! workos_has_active_session() ) {\n        return $data;\n    }\n\n    $data['workos_user_id'] = workos_get_user_id();\n    // ...\n    return $data;\n}\n```\n\nNote: neither helper verifies that the access token is still valid\nagainst WorkOS's JWKS. If you need authoritative session state (e.g.\nbefore authorizing a sensitive action), verify via\n`workos()-\u003eapi()-\u003everify_access_token( $token )`.\n\n## Hooks Reference\n\n### Filters\n\n#### `workos_redirect_urls`\n\nFilter the full role-to-redirect entry map from settings. Each entry is an array with `url` (string) and `first_login_only` (bool). Allows adding, removing, or overriding entries programmatically.\n\n**Parameters:**\n\n- `array $map` — Associative array of WordPress role slug to redirect entry (`['url' =\u003e string, 'first_login_only' =\u003e bool]`).\n\n**Example:**\n\n```php\nadd_filter( 'workos_redirect_urls', function ( $map ) {\n    $map['subscriber'] = [ 'url' =\u003e '/welcome', 'first_login_only' =\u003e true ];\n    return $map;\n} );\n```\n\n#### `workos_redirect_url`\n\nFilter the final redirect URL for a specific user. Return an empty string to skip the role-based redirect.\n\n**Parameters:**\n\n- `string $url` — The role-based redirect URL (empty if no match).\n- `WP_User $user` — The authenticated user.\n- `string $role` — The user's primary WordPress role.\n- `bool $is_first_login` — Whether this is the user's first login via WorkOS.\n\n**Example:**\n\n```php\nadd_filter( 'workos_redirect_url', function ( $url, $user, $role, $is_first_login ) {\n    if ( $role === 'editor' ) {\n        return '/editor-guide';\n    }\n    return $url;\n}, 10, 4 );\n```\n\n#### `workos_redirect_should_apply`\n\nWhether the role-based redirect should apply at all for this request. Return `false` to skip entirely.\n\n**Parameters:**\n\n- `bool $should_apply` — Whether to apply the role-based redirect (default `true`).\n- `WP_User $user` — The authenticated user.\n- `string $requested_redirect` — The current redirect URL.\n\n**Example:**\n\n```php\nadd_filter( 'workos_redirect_should_apply', function ( $should_apply, $user ) {\n    // Never redirect administrators.\n    if ( in_array( 'administrator', $user-\u003eroles, true ) ) {\n        return false;\n    }\n    return $should_apply;\n}, 10, 2 );\n```\n\n#### `workos_redirect_is_explicit`\n\nWhether the current `redirect_to` value is considered \"explicit\" (user-initiated). By default, any `redirect_to` that is not `admin_url()` or empty is treated as explicit, meaning the role-based redirect is skipped in favor of the user's intended destination.\n\n**Parameters:**\n\n- `bool $is_explicit` — Whether the redirect is explicit.\n- `string $redirect_to` — The redirect URL.\n- `WP_User $user` — The authenticated user.\n\n#### `workos_redirect_first_login_only`\n\nOverride the per-entry \"first login only\" setting programmatically.\n\n**Parameters:**\n\n- `bool $first_login_only` — Whether to redirect only on first login.\n- `string $role` — The user's primary WordPress role.\n- `WP_User $user` — The authenticated user.\n\n#### `workos_logout_redirect_urls`\n\nFilter the full role-to-logout-redirect URL map from settings. Unlike login redirects, each entry is a simple URL string (no `first_login_only` option).\n\n**Parameters:**\n\n- `array $map` — Associative array of WordPress role slug to logout redirect URL (string).\n\n**Example:**\n\n```php\nadd_filter( 'workos_logout_redirect_urls', function ( $map ) {\n    $map['subscriber'] = '/goodbye';\n    $map['editor']     = '/editor-farewell';\n    return $map;\n} );\n```\n\n#### `workos_logout_redirect_url`\n\nFilter the final logout redirect URL for a specific user. Return an empty string to skip the role-based logout redirect.\n\n**Parameters:**\n\n- `string $url` — The role-based logout redirect URL (empty if no match).\n- `WP_User $user` — The authenticated user.\n- `string $role` — The user's primary WordPress role.\n\n**Example:**\n\n```php\nadd_filter( 'workos_logout_redirect_url', function ( $url, $user, $role ) {\n    if ( $role === 'administrator' ) {\n        return '/admin-logged-out';\n    }\n    return $url;\n}, 10, 3 );\n```\n\n#### `workos_logout_redirect_should_apply`\n\nWhether the role-based logout redirect should apply at all for this request. Return `false` to skip entirely.\n\n**Parameters:**\n\n- `bool $should_apply` — Whether to apply the role-based logout redirect (default `true`).\n- `WP_User $user` — The authenticated user.\n- `string $redirect_to` — The current logout redirect URL.\n\n**Example:**\n\n```php\nadd_filter( 'workos_logout_redirect_should_apply', function ( $should_apply, $user ) {\n    // Never redirect administrators on logout.\n    if ( in_array( 'administrator', $user-\u003eroles, true ) ) {\n        return false;\n    }\n    return $should_apply;\n}, 10, 2 );\n```\n\n### Actions\n\n#### `workos_user_created`\n\nFires when a brand-new WordPress user is created via WorkOS authentication. Does NOT fire for email-match auto-links (existing users matched by email).\n\n**Parameters:**\n\n- `int $user_id` — WordPress user ID.\n- `array $workos_user` — WorkOS user data array.\n\n#### `workos_redirect_before`\n\nFires just before a role-based login redirect is applied.\n\n**Parameters:**\n\n- `string $url` — The redirect URL.\n- `WP_User $user` — The authenticated user.\n- `bool $is_first_login` — Whether this is the user's first login via WorkOS.\n\n#### `workos_redirect_skipped`\n\nFires when a role-based login redirect is skipped. Useful for logging or debugging redirect behavior.\n\n**Parameters:**\n\n- `WP_User $user` — The authenticated user.\n- `string $reason` — Reason the redirect was skipped. One of: `filtered_out`, `explicit_redirect`, `not_first_login`, `no_matching_role_url`.\n\n#### `workos_logout_redirect_before`\n\nFires just before a role-based logout redirect is applied.\n\n**Parameters:**\n\n- `string $url` — The logout redirect URL.\n- `WP_User $user` — The authenticated user.\n\n#### `workos_logout_redirect_skipped`\n\nFires when a role-based logout redirect is skipped.\n\n**Parameters:**\n\n- `WP_User $user` — The authenticated user.\n- `string $reason` — Reason the logout redirect was skipped. One of: `filtered_out`, `no_matching_role_url`.\n\n#### `workos_login_profile_saved`\n\nFires after a Login Profile is created or updated through the admin REST\nAPI (`/wp-json/workos/v1/admin/profiles`).\n\n**Parameters:**\n\n- `WorkOS\\Auth\\AuthKit\\Profile $profile` — The saved profile (post-validation, with assigned ID).\n\n#### `workos_login_profile_deleted`\n\nFires after a Login Profile is deleted through the admin REST API. Useful\nfor invalidating caches or rewrite-rule signatures keyed on profile data\n(this is exactly how `Auth\\AuthKit\\FrontendRoute` knows to rebuild its\ncustom-path rewrites).\n\n**Parameters:**\n\n- `WorkOS\\Auth\\AuthKit\\Profile $profile` — The profile that was deleted.\n\n## Database Tables\n\nThe plugin creates four custom tables on activation:\n\n| Table | Purpose |\n|---|---|\n| `{prefix}_workos_organizations` | Cached WorkOS organization data (name, slug, domains) |\n| `{prefix}_workos_org_memberships` | User-to-organization memberships with roles |\n| `{prefix}_workos_org_sites` | Organization-to-site mapping (multisite) |\n| `{prefix}_workos_activity_log` | Local activity log for authentication and sync events |\n\nUser linking is stored in standard WordPress usermeta (`_workos_user_id`, `_workos_org_id`, `_workos_last_synced_at`, `_workos_deactivated`).\n\n## WP-CLI Commands\n\nAll commands are registered under the `wp workos` namespace.\n\n### Status\n\n```bash\n# Show plugin configuration and health\nwp workos status\n\n# Output as JSON\nwp workos status --format=json\n```\n\nDisplays: environment, API key (masked), client ID, organization ID, environment ID, enabled status, login mode, database version, and plugin version. The `source` column shows whether each value comes from a `constant` or the `database`.\n\n---\n\n### User Management\n\n```bash\n# Get a user with WorkOS metadata\nwp workos user get \u003cid\u003e [--by=\u003cid|email|workos_id\u003e] [--format=\u003cformat\u003e]\n\n# List users with WorkOS link status\nwp workos user list [--linked] [--unlinked] [--role=\u003crole\u003e] [--format=\u003cformat\u003e] [--fields=\u003cfields\u003e]\n\n# Get user IDs for piping to other commands\nwp workos user list --unlinked --format=ids\n\n# Link a WP user to a WorkOS user (validates via API)\nwp workos user link \u003cwp_user_id\u003e \u003cworkos_user_id\u003e\n\n# Remove WorkOS link from a user\nwp workos user unlink \u003cwp_user_id\u003e [--yes]\n\n# Sync a single user: push to WorkOS (default) or pull from WorkOS\nwp workos user sync \u003cwp_user_id\u003e [--direction=\u003cpush|pull\u003e]\n\n# Import a single WorkOS user into WordPress\nwp workos user import \u003cworkos_user_id\u003e [--porcelain] [--yes]\n```\n\n**Examples:**\n\n```bash\n# Find a user by email and show their WorkOS metadata\nwp workos user get admin@example.com --by=email\n\n# Look up a user by their WorkOS ID\nwp workos user get user_01HXYZ --by=workos_id --format=json\n\n# List all users that haven't been synced to WorkOS yet\nwp workos user list --unlinked --role=subscriber\n\n# Pull latest profile data from WorkOS for a linked user\nwp workos user sync 42 --direction=pull\n\n# Import a WorkOS user and get just the WP user ID\nwp workos user import user_01HXYZ --porcelain --yes\n```\n\n---\n\n### Organization Management\n\n```bash\n# List local organizations\nwp workos org list [--source=\u003clocal|remote\u003e] [--format=\u003cformat\u003e]\n\n# Get a single organization\nwp workos org get \u003cid\u003e [--by=\u003cid|workos_id|remote\u003e] [--format=\u003cformat\u003e]\n\n# Sync an organization from WorkOS API to local database\nwp workos org sync \u003cworkos_org_id\u003e\n\n# List organization members\nwp workos org members \u003cid\u003e [--by=\u003cid|workos_id\u003e] [--format=\u003cformat\u003e]\n\n# Add a user to a local organization\nwp workos org add-member \u003corg_id\u003e \u003cuser_id\u003e [--role=\u003crole\u003e]\n\n# Remove a user from a local organization\nwp workos org remove-member \u003corg_id\u003e \u003cuser_id\u003e [--yes]\n```\n\n**Examples:**\n\n```bash\n# List organizations from the WorkOS API\nwp workos org list --source=remote --format=json\n\n# Fetch an org directly from WorkOS without needing a local record\nwp workos org get org_01HXYZ --by=remote\n\n# Sync an organization and view its members\nwp workos org sync org_01HXYZ\nwp workos org members org_01HXYZ --by=workos_id\n\n# Add a user to an org as admin\nwp workos org add-member 1 42 --role=admin\n```\n\n---\n\n### Bulk Sync Operations\n\nAll bulk commands support `--dry-run` to preview changes, `--yes` to skip confirmation, `--limit` to cap the number of items processed, and display progress bars during execution.\n\n```bash\n# Push all unlinked WP users to WorkOS\nwp workos sync push [--role=\u003crole\u003e] [--limit=\u003cn\u003e] [--dry-run] [--yes]\n\n# Re-sync all linked users from WorkOS\nwp workos sync pull [--limit=\u003cn\u003e] [--dry-run] [--yes]\n\n# Import WorkOS users into WordPress\nwp workos sync import [--organization_id=\u003cid\u003e] [--limit=\u003cn\u003e] [--dry-run] [--yes]\n\n# Import all organizations from WorkOS\nwp workos sync orgs [--limit=\u003cn\u003e] [--dry-run] [--yes]\n```\n\n**Examples:**\n\n```bash\n# Preview what a full user push would do\nwp workos sync push --dry-run\n\n# Push only subscribers, 50 at a time\nwp workos sync push --role=subscriber --limit=50 --yes\n\n# Re-sync all linked users from WorkOS\nwp workos sync pull --yes\n\n# Import the first 10 WorkOS users from a specific organization\nwp workos sync import --organization_id=org_01HXYZ --limit=10 --yes\n\n# Import all organizations\nwp workos sync orgs --yes\n```\n\n**Output format:** All bulk commands report a summary on completion:\n\n```\nSuccess: 45 synced, 2 failed, 3 skipped.\n```\n\nNon-fatal errors are shown as warnings during execution and counted in the summary.\n\n---\n\n### Common Options\n\nAll commands that display data support these output formats:\n\n| Flag | Format |\n|---|---|\n| `--format=table` | ASCII table (default) |\n| `--format=json` | JSON array |\n| `--format=yaml` | YAML |\n| `--format=csv` | CSV |\n| `--format=ids` | Space-separated IDs (user list only) |\n\n## Development\n\n### Requirements\n\n- PHP 7.4+\n- Composer\n- Node.js 20+\n- bun\n- [slic](https://github.com/stellarwp/slic) (for running tests)\n\n### Setup\n\n```bash\ncomposer install\nbun install      # Pulls @wordpress/scripts, TypeScript, @types/react\nbun run build    # Build JS/CSS assets\n```\n\n### Browser code (TypeScript + TSX)\n\nThe React shell (`src/js/authkit/`) and the admin Profile editor\n(`src/js/admin-profiles/`) are TypeScript. `@wordpress/scripts` v30\ntranspiles `.ts` / `.tsx` natively via its default babel preset; no extra\nbuild config is required. Type-check with:\n\n```bash\nbun run lint:ts      # tsc --noEmit against src/js/authkit + src/js/admin-profiles\n```\n\nShared types live in `src/js/authkit/types.ts` and mirror\n`Profile::to_array()` from `src/WorkOS/Auth/AuthKit/Profile.php`.\n\n### Running Tests\n\n```bash\n# Using slic (Docker-based)\ncd wp-content/plugins\nslic here\nslic use integration-workos\nslic run wpunit\n\n# Using Composer\ncomposer test:wpunit\n```\n\n### Code Standards\n\n```bash\ncomposer lint         # PHP (check)\ncomposer lint:fix     # PHP (auto-fix)\nbun run lint:ts       # TypeScript\n```\n\n### Static Analysis (PHPStan)\n\nPHPStan runs at **level 5** against `src/`, `integration-workos.php`,\nand `uninstall.php`. The config lives in `phpstan.neon.dist` and is\nenforced by the `PHPStan` GitHub Actions job — PRs to `main` cannot\nmerge while it's red.\n\n```bash\ncomposer phpstan            # Run analysis (--memory-limit=1G)\ncomposer phpstan:baseline   # Generate phpstan-baseline.neon (fix-everything policy: do not commit)\n```\n\nThe stack:\n\n- `phpstan/phpstan` ^2 — analyzer\n- `szepeviktor/phpstan-wordpress` — WordPress core stubs + WP-aware\n  inference (handles `apply_filters`, `wp_remote_request`, hook\n  signatures)\n- `php-stubs/wp-cli-stubs` — `WP_CLI`, `WP_CLI_Command`,\n  `WP_CLI\\Formatter`, etc. for the `src/WorkOS/CLI/*` commands\n- `phpstan/extension-installer` — auto-registers extension neon files\n\n`phpstan/stubs.php` declares the `WORKOS_*` constants that\n`Plugin::init()` defines at runtime so PHPStan can resolve them at\nparse time. Strauss-prefixed `WorkOS\\Vendor\\…` classes are picked up\nautomatically via `vendor/autoload.php` in `scanFiles`.\n\n**Policy: no baseline.** Findings must be fixed in the PR that\nintroduces them. The `composer phpstan:baseline` script exists as a\nsafety hatch, but the resulting file should not be committed without\ndiscussion.\n\n### Architecture\n\nThe plugin uses a DI container (di52) with a feature-controller pattern:\n\n```\nintegration-workos.php            # Entry point\nsrc/WorkOS/Plugin.php             # Bootstrap, container init, activation hook\nsrc/WorkOS/Controller.php         # Main controller, registers feature controllers\nsrc/WorkOS/Config.php             # Centralized config with constant overrides\nsrc/WorkOS/\n  Admin/\n    Controller.php                # Settings UI, user list, onboarding, diagnostics\n    LoginProfiles/\n      Controller.php              # Admin controller for Login Profile editor\n      AdminPage.php               # Renders the React admin mount point\n      RestApi.php                 # /wp-json/workos/v1/admin/profiles CRUD\n  Auth/\n    Controller.php                # Login, registration, password reset, redirects\n    Login.php                     # Legacy AuthKit redirect + headless flows\n    AuthKit/                      # Custom AuthKit (React shell) — see below\n      Controller.php              # Wires everything, registers CPT + takeover\n      Profile.php                 # Immutable Profile value object\n      ProfileRepository.php       # CPT-backed CRUD\n      ProfileRouter.php           # Rule-based profile resolution\n      LoginCompleter.php          # Shared post-auth finalizer (entitlement + MFA)\n      LoginTakeover.php           # wp-login.php takeover (action=login only) + default-profile custom-path bounce\n      LoginRedirector.php         # Already-signed-in visitor redirect + forward_query_args helper\n      FrontendRoute.php           # /workos/login/{profile} + per-profile custom_path rewrites\n      Shortcode.php               # [workos:login] shortcode\n      Renderer.php                # HTML shell + bundle enqueue + logo fallback chain\n      Nonce.php                   # Profile-scoped CSRF nonces\n      RateLimiter.php             # Per-IP / per-email transient buckets\n      Radar.php                   # Site-key resolution + request-header extraction\n      ModeSyncer.php              # Keeps global login_mode option in sync with the default profile's mode\n  REST/\n    Controller.php                # REST controller (registers Auth and TokenAuth)\n    TokenAuth.php                 # REST API Bearer token authentication\n    Auth/                         # Public /wp-json/workos/v1/auth/* endpoints\n      Controller.php              # Wires all endpoint classes\n      BaseEndpoint.php            # Shared profile + nonce + rate-limit + Radar\n      Password.php                # password/authenticate + reset/{start,confirm}\n      MagicCode.php               # magic/{send,verify}\n      Session.php                 # nonce + session/{refresh,logout}\n      Signup.php                  # signup/{create,verify}\n      Invitation.php              # invitation/{token} + invitation/accept\n      OAuth.php                   # oauth/authorize-url\n      Mfa.php                     # mfa/{challenge,verify,factors,…}\n  Sync/Controller.php             # UserSync, RoleMapper, DirectorySync, AuditLog\n  Webhook/Controller.php          # Webhook receiver + signature verification\n  Organization/Controller.php     # Organization management, entitlement gate\n  CLI/Controller.php              # WP-CLI commands\n  UI/Controller.php               # Legacy login button (shortcode, block, widget)\n  ActivityLog/Controller.php      # Local activity logging\n\nsrc/js/\n  authkit/                        # Custom AuthKit React shell (TypeScript + TSX)\n    index.tsx                     # Mount + data-* hydration\n    App.tsx                       # Step machine\n    api.ts                        # Fetch client w/ nonce + refresh + Radar\n    flows.tsx                     # 11 flow components (password, magic, mfa, ...)\n    ui.tsx                        # 11 primitives (Button, Input, Card, ...)\n    radar.ts                      # WorkOS Radar SDK loader\n    redirect.ts                   # forwardQueryArgs() — strips internals, mirrors LoginRedirector allowlist\n    slots.tsx                     # SlotFill slot name constants (10 slots)\n    types.ts                      # Shared interfaces\n    styles.css                    # Scoped styles (CSS vars)\n  admin-profiles/\n    index.tsx                     # Admin Login Profile editor (CRUD)\n    styles.css                    # Scoped admin styles\n```\n\nEach controller extends `Contracts\\Controller` and implements `isActive()`\nfor conditional activation (e.g., `Admin\\Controller` only activates in\n`is_admin()`, `CLI\\Controller` only activates under `WP_CLI`). The new\n`Auth\\AuthKit\\Controller` and `REST\\Auth\\Controller` are always active —\nthe former because the CPT and wp-login.php takeover must boot on every\nrequest, the latter because anonymous visitors need to reach the auth\nendpoints.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbordoni%2Fintegration-workos","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbordoni%2Fintegration-workos","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbordoni%2Fintegration-workos/lists"}