diff --git a/AGENTS.md b/AGENTS.md index 4492f97..090c3ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,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/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()` | @@ -79,6 +80,10 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Admin/DiagnosticsPage.php` | System diagnostics page | | `src/WorkOS/Admin/OnboardingPage.php` | Onboarding wizard UI | | `src/WorkOS/Admin/OnboardingAjax.php` | Onboarding wizard AJAX handlers | +| **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 | +| `src/WorkOS/Admin/LoginProfiles/RestApi.php` | `/wp-json/workos/v1/admin/profiles` CRUD (manage_options) | | **Auth** | | | `src/WorkOS/Auth/Controller.php` | Auth controller (login, registration, password reset, redirects) | | `src/WorkOS/Auth/Login.php` | SSO login flow (redirect + headless modes) | @@ -87,6 +92,19 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Auth/PasswordReset.php` | Password reset flow | | `src/WorkOS/Auth/Redirect.php` | Role-based login redirects | | `src/WorkOS/Auth/LogoutRedirect.php` | Role-based logout redirects | +| **Auth — Custom AuthKit (React shell)** | | +| `src/WorkOS/Auth/AuthKit/Controller.php` | Wires Login Profile CPT + takeover + shortcode + route | +| `src/WorkOS/Auth/AuthKit/Profile.php` | Immutable Login Profile value object | +| `src/WorkOS/Auth/AuthKit/ProfileRepository.php` | CPT-backed CRUD + default seeding | +| `src/WorkOS/Auth/AuthKit/ProfileRouter.php` | Rule-based profile resolution | +| `src/WorkOS/Auth/AuthKit/LoginCompleter.php` | Post-auth finalizer (EntitlementGate + MFA policy) | +| `src/WorkOS/Auth/AuthKit/LoginTakeover.php` | wp-login.php `action=login` takeover | +| `src/WorkOS/Auth/AuthKit/FrontendRoute.php` | `/workos/login/{profile}` rewrite + static `register_rewrite()` | +| `src/WorkOS/Auth/AuthKit/Shortcode.php` | `[workos_login_v2]` shortcode | +| `src/WorkOS/Auth/AuthKit/Renderer.php` | HTML shell + React bundle enqueue. Fires `workos_authkit_enqueue_assets` action and applies `workos_authkit_branding` / `workos_authkit_profile_data` / `workos_authkit_body_classes` filters — see `docs/extending-the-login-ui.md` | +| `src/WorkOS/Auth/AuthKit/Nonce.php` | Profile-scoped CSRF nonces | +| `src/WorkOS/Auth/AuthKit/RateLimiter.php` | Per-IP / per-email transient buckets | +| `src/WorkOS/Auth/AuthKit/Radar.php` | WorkOS Radar site-key + request-header extraction | | **Organization** | | | `src/WorkOS/Organization/Controller.php` | Organization controller | | `src/WorkOS/Organization/Manager.php` | Organization CRUD and caching | @@ -117,8 +135,18 @@ Per-environment constants (take priority over generic): | `src/WorkOS/Sync/AuditLog.php` | Forward WP events to WorkOS Audit Logs | | `src/WorkOS/Sync/AuditLogController.php` | Audit log controller | | **REST** | | -| `src/WorkOS/REST/Controller.php` | REST controller | -| `src/WorkOS/REST/TokenAuth.php` | REST API Bearer token authentication | +| `src/WorkOS/REST/Controller.php` | REST controller (registers TokenAuth + Auth) | +| `src/WorkOS/REST/TokenAuth.php` | REST API Bearer token authentication (no lazy refresh) | +| **REST — Public Auth (Custom AuthKit)** | | +| `src/WorkOS/REST/Auth/Controller.php` | Wires all public `/wp-json/workos/v1/auth/*` endpoints | +| `src/WorkOS/REST/Auth/BaseEndpoint.php` | Shared profile + nonce + rate-limit + Radar helpers | +| `src/WorkOS/REST/Auth/Password.php` | `password/authenticate`, `password/reset/{start,confirm}` | +| `src/WorkOS/REST/Auth/MagicCode.php` | `magic/{send,verify}` | +| `src/WorkOS/REST/Auth/Session.php` | `nonce`, `session/{refresh,logout}` | +| `src/WorkOS/REST/Auth/Signup.php` | `signup/{create,verify}` | +| `src/WorkOS/REST/Auth/Invitation.php` | `invitation/{token}`, `invitation/accept` | +| `src/WorkOS/REST/Auth/OAuth.php` | `oauth/authorize-url` | +| `src/WorkOS/REST/Auth/Mfa.php` | `mfa/{challenge,verify,factors,totp/enroll,sms/enroll,factor/delete}` | | **Webhook** | | | `src/WorkOS/Webhook/Controller.php` | Webhook controller | | `src/WorkOS/Webhook/Receiver.php` | Webhook event processing and signature verification | @@ -131,10 +159,24 @@ 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 `workos()` and `workos_log()` 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` | +| **Browser — Custom AuthKit (TypeScript + TSX)** | | +| `src/js/authkit/index.tsx` | Entry + data-* hydration | +| `src/js/authkit/App.tsx` | Top-level step machine | +| `src/js/authkit/api.ts` | Fetch client w/ nonce + 401 refresh + Radar header | +| `src/js/authkit/flows.tsx` | 11 flow components (password, magic, signup, reset, mfa, invitation, complete) | +| `src/js/authkit/ui.tsx` | 11 primitives (Button, Input, Card, …) | +| `src/js/authkit/radar.ts` | WorkOS Radar SDK loader (+ `window.WorkOSRadar` augmentation) | +| `src/js/authkit/types.ts` | Shared interfaces (Profile, AuthResult, MfaRequired, Step, …) | +| `src/js/authkit/styles.css` | Scoped shell styles (CSS vars) | +| `src/js/admin-profiles/index.tsx` | Admin Login Profile editor (CRUD) | +| `src/js/admin-profiles/styles.css` | Scoped admin styles | | **Build** | | | `composer.json` | PHP dependencies | -| `package.json` | JS dependencies (bun) | +| `package.json` | JS dependencies (bun; uses `@wordpress/scripts` v30 + TypeScript strict) | +| `bun.lock` | Locked dependency graph (committed) | +| `tsconfig.json` | TypeScript config (strict, `jsx: react-jsx`, `noEmit: true`) | +| `webpack.config.js` | Extends `@wordpress/scripts` default config with authkit + admin-profiles entries | ## Build System @@ -146,13 +188,18 @@ Per-environment constants (take priority over generic): ### Commands ```bash -bun install # Install dependencies -bun run build # Production build (wp-scripts) -bun run start # Development with watch -bun run lint:php # Lint PHP via PHPCS -bun run lint:php:fix # Auto-fix PHP lint issues +bun install # Install dependencies +bun run build # Production build (wp-scripts; transpiles .ts/.tsx via babel) +bun run start # Development with watch +bun run lint:ts # Type-check TypeScript (tsc --noEmit) +bun run lint:php # Lint PHP via PHPCS +bun run lint:php:fix # Auto-fix PHP lint issues ``` +`@wordpress/scripts` v30 transpiles `.ts` / `.tsx` natively via its default +babel preset (no ts-loader, no custom webpack rules). `tsc` runs in +`noEmit` mode purely for strict type-checking and editor IntelliSense. + ## Testing ### Prerequisites @@ -180,31 +227,48 @@ tests/ │ └── WpunitTester.php # Codeception actor ├── wpunit.suite.dist.yml # Suite configuration (WPLoader module) └── wpunit/ - ├── _bootstrap.php # Suite-level bootstrap - ├── ConfigTest.php # Config class tests - ├── DirectorySyncTest.php # Directory sync tests - ├── EntitlementGateTest.php # Entitlement gate tests - ├── EventLoggerTest.php # Activity log event logger tests - ├── LoginBypassTest.php # Login bypass tests - ├── LoginLogoutTest.php # Login logout session revocation tests - ├── LoginSessionTest.php # Login session tests - ├── LoginTokensTest.php # Login tokens tests - ├── LogoutRedirectTest.php # Logout redirect tests - ├── OnboardingSyncTest.php # Onboarding sync tests - ├── OptionsTest.php # Options classes tests - ├── OrganizationManagerTest.php # Organization manager tests - ├── PasswordResetTest.php # Password reset tests - ├── PluginTest.php # Plugin singleton + constants tests - ├── RedirectTest.php # Login redirect tests - ├── RoleMapperTest.php # Role mapper tests - ├── SchemaTest.php # Database schema tests - ├── UserSyncDeprovisionTest.php # User deprovisioning tests - ├── UserSyncFindOrCreateTest.php # User find-or-create tests - ├── UserSyncLinkTest.php # User linking tests - ├── UserSyncPushTest.php # Outbound user sync tests - ├── UserSyncResyncTest.php # User re-sync tests - ├── WebhookReceiverTest.php # Webhook receiver tests - └── WebhookSignatureTest.php # Webhook signature verification tests + ├── _bootstrap.php # Suite-level bootstrap + ├── ApiClientAuthKitTest.php # Api\Client Custom-AuthKit methods (magic, totp, reset, mfa, invitation grant, refresh) + ├── AuthKitLoginCompleterMfaTest.php # Profile MFA policy enforcement (enforce=always, factor allowlist) + ├── AuthKitLoginProfilesRestApiTest.php # /wp-json/workos/v1/admin/profiles CRUD + ├── AuthKitNonceTest.php # Profile-scoped nonce mint/verify + ├── AuthKitProfileRepositoryTest.php # Profile CRUD, default seeding, slug uniqueness + ├── AuthKitProfileRouterTest.php # Rule matching (redirect_to / referrer_host / user_role) + ├── AuthKitProfileTest.php # Profile value object + enum validation + ├── AuthKitRadarTest.php # Radar site-key resolution + header extraction + ├── AuthKitRateLimiterTest.php # Fixed-window IP + email bucket limits + ├── AuthKitRendererTest.php # Renderer markup + shortcode + takeover hook wiring + ├── AuthKitRestMagicSessionTest.php # magic/{send,verify}, session/{refresh,logout}, /nonce + ├── AuthKitRestMfaTest.php # mfa/{challenge,verify,factors,*/enroll,factor/delete} + ├── AuthKitRestPasswordTest.php # password/{authenticate,reset/start,reset/confirm} + ├── AuthKitRestSignupInvitationOAuthTest.php # signup/{create,verify}, invitation/{token,accept}, oauth/authorize-url + ├── ConfigTest.php # Config class tests + ├── DirectorySyncTest.php # Directory sync tests + ├── EntitlementGateTest.php # Entitlement gate tests + ├── EventLoggerTest.php # Activity log event logger tests + ├── LoginBypassTest.php # Login bypass tests + ├── LoginLogoutTest.php # Login logout session revocation tests + ├── LoginSessionTest.php # Login session tests + ├── LoginTokensTest.php # Login tokens tests + ├── LogoutRedirectTest.php # Logout redirect tests + ├── OnboardingSyncTest.php # Onboarding sync tests + ├── OptionsTest.php # Options classes tests + ├── OrganizationManagerTest.php # Organization manager tests + ├── PasswordResetTest.php # Password reset tests + ├── PluginTest.php # Plugin singleton + constants tests + ├── RedirectTest.php # Login redirect tests + ├── RendererKsesTest.php # Shared renderer KSES allowlist tests + ├── RoleMapperTest.php # Role mapper tests + ├── SchemaTest.php # Database schema tests + ├── TokenAuthRefreshTest.php # TokenAuth regression: rejects unsigned/expired Bearer JWTs + ├── UserHelperTest.php # WorkOS\User + `workos_is_sso_user`/`workos_has_active_session` helpers + ├── UserSyncDeprovisionTest.php # User deprovisioning tests + ├── UserSyncFindOrCreateTest.php # User find-or-create tests + ├── UserSyncLinkTest.php # User linking tests + ├── UserSyncPushTest.php # Outbound user sync tests + ├── UserSyncResyncTest.php # User re-sync tests + ├── WebhookReceiverTest.php # Webhook receiver tests + └── WebhookSignatureTest.php # Webhook signature verification tests ``` ### Writing Tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 6741479..1941678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,132 @@ # Changelog -## [1.0.0] - 2026-04-14 +## [1.0.0] - 2026-04-23 ### Added + +#### Custom AuthKit — WordPress-hosted login experience + +A React login shell mounted inside WordPress, replacing the redirect to +WorkOS's hosted AuthKit page for first-factor methods that don't +inherently require one. All WorkOS API calls stay server-side; the +browser only ever talks to `/wp-json/workos/v1/auth/*`. + +- **Login Profiles** — new `workos_login_profile` CPT + React admin + editor at WorkOS → Login Profiles. Each profile picks enabled sign-in + methods, pins an organization server-side, toggles signup / + invitation / password-reset flows, configures MFA policy and factor + allowlist, and carries branding (logo, primary color, headings). + Reserved `default` profile drives wp-login.php takeover. +- **wp-login.php takeover** — `action=login` renders the React shell. + `logout`, `register`, `lostpassword`, `resetpass`, `confirmaction`, + `postpass`, `?fallback=1`, `?workos=0` still pass through to WP + defaults. +- **Entry points** — `[workos_login_v2]` shortcode, `workos/login-form` + block, and `/workos/login/{profile}` rewrite all render the same + shell. +- **Profile routing rules** — ordered `redirect_to` glob / + `referrer_host` / `user_role` matchers pick which profile applies to + a request; first match wins, falls back to the `default` profile. +- **Sign-in methods** — email + password, magic code, social OAuth + (Google, Microsoft, GitHub, Apple), passkey. Legacy AuthKit redirect + mode remains per-profile selectable for SSO (SAML/OIDC). +- **Self-serve sign-up** — in-app account creation + email code + verification, gated by `signup.enabled` on the profile. +- **Invitation acceptance** — clicking a WorkOS invitation email lands + in the React shell, which consumes the token atomically via WorkOS's + invitation-token grant (caller cannot substitute email or force + verified state). +- **In-app password reset** — request + confirm handled by the React + shell; reset links route to `/wp-login.php?workos_action=reset-password` + which the takeover hands to the confirm step. +- **MFA** — TOTP + SMS + WebAuthn/passkey. Factor enrollment, + challenge, and verify are all in-app. Profile `mfa.enforce=always` + rejects single-step logins; `mfa.factors[]` allowlist is applied at + challenge time. +- **WorkOS Radar** integration — browser SDK produces an action token + on sensitive interactions; the token rides along as + `X-WorkOS-Radar-Action-Token` on every server-side auth call, so + WorkOS can score risk equivalently to the hosted AuthKit. +- **Public REST surface** at `/wp-json/workos/v1/auth/*`: + `password/{authenticate,reset/start,reset/confirm}`, + `magic/{send,verify}`, `signup/{create,verify}`, + `invitation/{token,accept}`, `oauth/authorize-url`, + `mfa/{challenge,verify,factors,totp/enroll,sms/enroll,factor/delete}`, + `session/{refresh,logout}`, `nonce`. Every mutation is guarded by a + profile-scoped WP nonce + per-IP and per-email rate limits. +- **Admin REST** at `/wp-json/workos/v1/admin/profiles` (gated by + `manage_options`) — full CRUD for Login Profiles consumed by the + React admin editor. Adds a sibling + `GET /admin/profiles/organizations` endpoint that returns the + active environment's WorkOS organizations (reusing the same + `workos_organizations_cache_{env}` transient as the settings page) + so the editor can present a searchable organization picker instead + of asking admins to paste `org_01…` IDs by hand. +- **Pinned-organization picker** in the Login Profile editor — a + select populated from WorkOS with a "Custom ID…" escape hatch for + orgs that aren't in the fetched list (legacy or paginated-out + records keep working). Falls back to a plain text input if WorkOS + is unconfigured or the API is unreachable. The Login Profiles list + now shows organization names instead of raw IDs. + +#### Browser internationalization + +- Every user-facing string in the React/TS/JS bundles (AuthKit shell, + Login Profile editor, onboarding, role mapping, redirect URLs, + login button block + frontend) now goes through + `@wordpress/i18n`'s `__`/`_n`/`sprintf` with the + `integration-workos` text domain. Every enqueued script calls + `wp_set_script_translations()` (block editor translations come + from `block.json` `textdomain`), and the previously unbundled + `login-button-frontend.js` now ships through webpack so its + `wp-i18n` dependency is declared in its `.asset.php`. + +#### Login UI extensibility + +- **WordPress SlotFill** — the AuthKit React shell mounts inside a + `SlotFillProvider` + `PluginArea scope="workos-authkit"`. Nine named + slots (`workos.authkit.beforeHeader`, `afterHeader`, `beforeForm`, + `afterForm`, `afterPrimaryAction`, `beforeFooter`, `afterFooter`, + `methodPicker.beforeMethods`, `methodPicker.afterMethods`) let other + plugins inject React elements via `registerPlugin()` + ``. + Each slot's `fillProps` carries the active `step`, `profileSlug`, + enabled `methods`, and the current `flow`. +- **`workos_authkit_enqueue_assets` action** — fires on every render + with the active `Profile` so extenders can `wp_enqueue_style()` / + `wp_enqueue_script()` per-profile CSS/JS, depending on the + `workos-authkit` handle for ordering. +- **New PHP filters** — `workos_authkit_branding` (override the + resolved branding before it lands in the data-profile JSON), + `workos_authkit_profile_data` (mutate the JSON payload sent to the + React shell), `workos_authkit_body_classes` (extend body CSS classes + on the full-page renderer; `workos-profile-{slug}` is added by + default). +- **`workos-authkit:mounted` DOM event** — dispatched on `document` + after the React shell mounts, carrying `{ profileSlug }` in + `event.detail` so non-React extenders can observe the lifecycle. +- **Logo control** — Login Profile editor now exposes a logo picker + (uses `wp.media`). When no per-profile logo is set, the AuthKit shell + falls back to the WordPress Site Icon (Settings → General). +- **`docs/extending-the-login-ui.md`** — developer guide covering all + three extension surfaces with working examples. + +#### Public API for third-party integrations + +- **`WorkOS\User`** — read-only helper class for querying WorkOS state + on a WP user without reaching into `get_user_meta()` directly. Methods: + `is_sso()`, `has_active_session()`, `get_workos_id()`, + `get_access_token()`, `get_refresh_token()`, `get_session_id()`, + `get_organization_id()`, `snapshot()`. All accept a `$user_id` argument + that defaults to the current user and handle unauthenticated requests + safely. Meta keys are exposed as class constants (`META_WORKOS_ID`, + `META_ACCESS_TOKEN`, etc.) for SQL/REST callers. +- **Global function shortcuts** — `workos_is_sso_user()`, + `workos_has_active_session()`, `workos_get_user_id()`, + `workos_get_access_token()` wrap the class methods for terse + procedural code. + +#### Base platform + - AuthKit redirect and headless API authentication flows. - Directory sync (SCIM) user provisioning and deprovisioning. - Role mapping between WorkOS organization roles and WordPress roles. @@ -11,3 +135,36 @@ - REST API authentication via WorkOS access tokens (JWT). - Admin settings page with full configuration UI. - Webhook receiver with signature verification. + +### Changed + +- Browser code (`src/js/authkit/*`, `src/js/admin-profiles/*`) is + TypeScript + TSX. `@wordpress/scripts` v30 transpiles `.ts`/`.tsx` + natively; `bun run lint:ts` runs strict type checking. +- `Auth\AuthKit\FrontendRoute::register_rewrite()` is the canonical + registration point for the `/workos/login/{profile}` rule; called + both from activation and the `init` hook (same convention as + `Auth\Login::register_rewrite()`). + +### Security + +Findings surfaced by the phase-6 `/security-review` on the custom +AuthKit branch and fixed before launch: + +- **Signature-verification bypass in `REST\TokenAuth` removed.** The + previous lazy-refresh path trusted the `sub` claim of an unverified + JWT to pick a user and exchange their stored refresh token. Clients + that need to rotate tokens must now use `POST /auth/session/refresh`, + which is authenticated via the WP auth cookie. +- **Invitation acceptance no longer allows account takeover.** The + endpoint now issues a single atomic call to WorkOS with + `grant_type=urn:workos:oauth:grant-type:invitation_token`. Callers + can no longer substitute an arbitrary email or force + `email_verified=true`; the invitation token is authoritatively bound + to its original recipient by WorkOS. +- **MFA factor delete enforces ownership.** `POST /auth/mfa/factor/delete` + verifies the factor belongs to the caller before forwarding to + WorkOS; mismatched factor IDs return 404. +- **Profile MFA policy is enforced at login.** `LoginCompleter` rejects + single-step success when `mfa.enforce=always`, and denies pending + factors whose `type` is not in the profile's `mfa.factors` allowlist. diff --git a/README.md b/README.md index 981bb41..74271a9 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,27 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. ## Features -- **Single Sign-On (SSO)** via WorkOS AuthKit — redirect or headless login modes +### Custom AuthKit (WordPress-hosted login) + +- **React login shell** on wp-login.php, `[workos_login_v2]` shortcode, `workos/login-form` block, and a dedicated `/workos/login/{profile}` route — all driven by the same TypeScript bundle +- **Login Profiles** — admin-defined presets (enabled methods, pinned organization, signup/invite/reset toggles, MFA policy, branding) managed through a React admin editor at **WorkOS → Login Profiles** +- **Sign-in methods**: email + password, magic code, social OAuth (Google, Microsoft, GitHub, Apple), passkey +- **In-app flows**: self-serve sign-up with email verification, invitation acceptance, password reset +- **MFA** — TOTP, SMS, WebAuthn/passkey with full enrollment + challenge UI; profile-level `mfa.enforce` (`never` / `if_required` / `always`) and factor allowlist +- **Profile routing rules** — ordered `redirect_to` glob / `referrer_host` / `user_role` matchers pick the right profile per request +- **WorkOS Radar** anti-fraud integration — browser SDK supplies an action token the plugin forwards on every server-side auth call +- **No WorkOS PHP SDK** — every WorkOS call is `wp_remote_request` from PHP, so the API key never leaves the server + +### Base platform + +- **Single Sign-On (SSO)** via WorkOS AuthKit — redirect or headless login modes (legacy path, selectable per-profile) - **Directory Sync (SCIM)** — automatic user provisioning and deprovisioning from your identity provider - **Role Mapping** — map WorkOS organization roles to WordPress roles - **Organization Management** — local caching of WorkOS organizations with multisite support - **Entitlement Gate** — require organization membership to log in - **Webhook Processing** — real-time sync of user, organization, and directory events - **REST API Authentication** — Bearer token auth for headless/decoupled WordPress -- **Login Button** — shortcode (`[workos_login]`), Gutenberg block, and classic widget +- **Legacy Login Button** — shortcode (`[workos_login]`), Gutenberg block, and classic widget (AuthKit-redirect flow) - **Login Bypass** — access the native WordPress login form via `?fallback=1` when WorkOS is unavailable - **Activity Logging** — local database table with admin viewer for tracking authentication and sync events - **Audit Logging** — forward WordPress events (login, logout, post changes, user changes) to WorkOS Audit Logs @@ -80,21 +93,106 @@ define( 'WORKOS_STAGING_API_KEY', 'sk_test_...' ); ## Authentication -### Redirect Mode (Default) +The plugin ships three login modes. Each Login Profile picks its `mode`: +`custom` uses the React shell, `authkit_redirect` uses the legacy redirect +path. `login_mode=headless` (global) remains available for custom forms. + +### Custom Mode — React shell (default for the `default` Login Profile) + +`wp-login.php?action=login` is intercepted on `login_init` and the React shell +renders in its place. All other actions — `logout`, `register`, +`lostpassword`, `resetpass`, `confirmaction`, `postpass`, `?fallback=1`, +`?workos=0` — pass through to core WP so WooCommerce, WP-CLI password +resets, and email confirmation links keep working. + +The shell also mounts on `[workos_login_v2 profile="slug"]`, a `workos/login-form` +Gutenberg block, and the `/workos/login/{profile}` rewrite. Every mount +reads configuration from `data-*` attributes emitted by `Auth\AuthKit\Renderer` +and talks to `/wp-json/workos/v1/auth/*` for everything — no WorkOS calls +are proxied through the browser. + +### AuthKit-Redirect Mode (legacy) -Users visiting `wp-login.php` are redirected to WorkOS AuthKit. After authentication, WorkOS redirects back to `/workos/callback` where the plugin exchanges the authorization code for user data and tokens. +Set a Login Profile's `mode` to `authkit_redirect` and `wp-login.php` sends +users to WorkOS's hosted AuthKit. WorkOS returns to `/workos/callback` where +the plugin exchanges the authorization code. Use this mode for SSO +(SAML/OIDC) or any profile that needs WorkOS-hosted UX. ### Headless Mode -The plugin intercepts WordPress's `authenticate` filter and validates credentials directly against the WorkOS API using email and password. This mode is useful for custom login forms. +The plugin intercepts WordPress's `authenticate` filter and validates +credentials directly against the WorkOS API using email and password. This +mode is useful for custom login forms you render yourself. ### Password Fallback -When enabled, WordPress native password authentication remains available alongside WorkOS. Password reset and registration forms fall back to WordPress defaults. +When enabled, WordPress native password authentication remains available +alongside WorkOS. Password reset and registration forms fall back to +WordPress defaults. ### Login Bypass -If WorkOS is down or misconfigured, users can access the native WordPress login form by appending `?fallback=1` to the login URL. This bypasses the WorkOS redirect entirely. +If WorkOS is down or misconfigured, users can access the native WordPress +login form by appending `?fallback=1` to the login URL. This bypasses the +WorkOS redirect — and the React shell takeover — entirely. + +## Login Profiles + +A Login Profile is a stored configuration unit that governs a single login +entry point. Manage profiles under **WorkOS → Login Profiles** (React +editor backed by `/wp-json/workos/v1/admin/profiles`). + +Each profile stores: + +| Field | Purpose | +|--------------------------|------------------------------------------------------------------| +| `slug` | URL-friendly id; reserved `default` drives wp-login.php takeover | +| `title` | Admin-facing label | +| `methods[]` | Enabled sign-in methods (any subset of `password`, `magic_code`, `oauth_google`, `oauth_microsoft`, `oauth_github`, `oauth_apple`, `passkey`) | +| `organization_id` | Server-side pinned WorkOS org; passed on auth calls | +| `signup` | `{enabled, require_invite}` — toggles self-serve signup | +| `invite_flow` | Allow invitation acceptance | +| `password_reset_flow` | Allow in-app password reset | +| `mfa` | `{enforce: never|if_required|always, factors: [totp,sms,webauthn]}` | +| `branding` | `{logo_attachment_id, primary_color, heading, subheading}` | +| `post_login_redirect` | URL the React shell navigates to on success (beats `redirect_to`)| +| `mode` | `custom` (React) or `authkit_redirect` (legacy) | + +The `branding.logo` field defaults to the WordPress Site Icon when no +per-profile logo is set. See +[`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md) for the +full developer guide on injecting React elements (SlotFill), enqueuing +per-profile CSS/JS, and the available PHP filters. + +### Profile routing rules + +Rules stored under the `workos_profile_routing_rules` option pick the right +profile when no slug is explicit. Each rule is +`{ profile: slug, matcher: { type, value } }` where `type` is one of +`redirect_to` (glob), `referrer_host` (exact host), or `user_role` (role slug). +Rules evaluate top-down; first match wins; the `default` profile is the +fallback. + +### MFA policy + +Profile-level MFA enforcement is applied by `Auth\AuthKit\LoginCompleter`: + +- `enforce: never` — single-step login is always accepted +- `enforce: if_required` (default) — MFA step surfaces when WorkOS returns + a pending factor; otherwise single-step is accepted +- `enforce: always` — single-step success is rejected with + `workos_authkit_mfa_required`; the user must enroll a factor first + +Factor types WorkOS returns in a pending-factor response are checked against +the profile's `mfa.factors` allowlist; disallowed types are rejected. + +### WorkOS Radar + +Set `workos_radar_site_key` (plugin option) or define +`WORKOS_RADAR_SITE_KEY` to enable Radar. The React shell loads +`https://radar.workos.com/v1/radar.js`, fetches an action token, and the +plugin forwards it as `X-WorkOS-Radar-Action-Token` on every +user-management auth call, so WorkOS can score the attempt server-side. ## Webhooks @@ -127,6 +225,91 @@ Authorization: Bearer The token is verified using WorkOS JWKS and mapped to a WordPress user via their linked WorkOS ID. +## Helpers for Third-Party Integrations + +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. + +### `WorkOS\User` methods + +| Method | Returns | Purpose | +|---------------------------------------|----------|--------------------------------------------------------| +| `User::is_sso( $user_id = 0 )` | `bool` | User is linked to a WorkOS identity (persistent) | +| `User::has_active_session( $user_id = 0 )` | `bool` | User currently has a stored WorkOS access token | +| `User::get_workos_id( $user_id = 0 )` | `string` | WorkOS `user_...` identifier, or `''` | +| `User::get_access_token( $user_id = 0 )` | `string` | Current WorkOS access token (treat as opaque) | +| `User::get_refresh_token( $user_id = 0 )` | `string` | Stored WorkOS refresh token | +| `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 | + +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. + +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 +REST schemas. + +### Global function shortcuts + +```php +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 +``` + +### Distinguishing "linked" vs "currently signed in" + +- **`is_sso()`** remains true after the user logs out. Use it when you + want to know whether an account was ever provisioned via WorkOS. +- **`has_active_session()`** flips to false on `wp_logout` (the plugin + clears the access token server-side). Use it when you want to know + whether a request is running under a live WorkOS session. + +### Example: augment a REST response payload + +```php +use WorkOS\User; + +add_filter( 'my_plugin_response', function ( array $data ): array { + if ( ! User::is_sso() ) { + return $data; + } + + $data['workos'] = [ + 'linked' => true, + 'active_session' => User::has_active_session(), + 'workos_user_id' => User::get_workos_id(), + 'organization_id' => User::get_organization_id(), + ]; + + return $data; +} ); +``` + +Or with the function shortcuts inside a non-namespaced file: + +```php +function my_plugin_add_workos_data( array $data ): array { + if ( ! workos_has_active_session() ) { + return $data; + } + + $data['workos_user_id'] = workos_get_user_id(); + // ... + return $data; +} +``` + +Note: neither helper verifies that the access token is still valid +against WorkOS's JWKS. If you need authoritative session state (e.g. +before authorizing a sensitive action), verify via +`workos()->api()->verify_access_token( $token )`. + ## Hooks Reference ### Filters @@ -504,17 +687,32 @@ All commands that display data support these output formats: - PHP 7.4+ - Composer -- bun (for JS/CSS assets) -- [slic](https://github.com/developer-starter/slic) (for running tests) +- Node.js 20+ +- bun +- [slic](https://github.com/stellarwp/slic) (for running tests) ### Setup ```bash composer install -bun install # Install JS dependencies -bun run build # Build JS/CSS assets +bun install # Pulls @wordpress/scripts, TypeScript, @types/react +bun run build # Build JS/CSS assets +``` + +### Browser code (TypeScript + TSX) + +The React shell (`src/js/authkit/`) and the admin Profile editor +(`src/js/admin-profiles/`) are TypeScript. `@wordpress/scripts` v30 +transpiles `.ts` / `.tsx` natively via its default babel preset; no extra +build config is required. Type-check with: + +```bash +bun run lint:ts # tsc --noEmit against src/js/authkit + src/js/admin-profiles ``` +Shared types live in `src/js/authkit/types.ts` and mirror +`Profile::to_array()` from `src/WorkOS/Auth/AuthKit/Profile.php`. + ### Running Tests ```bash @@ -531,11 +729,9 @@ composer test:wpunit ### Code Standards ```bash -# Check -composer lint - -# Auto-fix -composer lint:fix +composer lint # PHP (check) +composer lint:fix # PHP (auto-fix) +bun run lint:ts # TypeScript ``` ### Architecture @@ -544,19 +740,71 @@ The plugin uses a DI container (di52) with a feature-controller pattern: ``` integration-workos.php # Entry point -src/WorkOS/Plugin.php # Bootstrap, container init +src/WorkOS/Plugin.php # Bootstrap, container init, activation hook src/WorkOS/Controller.php # Main controller, registers feature controllers src/WorkOS/Config.php # Centralized config with constant overrides src/WorkOS/ - Admin/Controller.php # Settings UI, user list, onboarding, diagnostics - Auth/Controller.php # Login, registration, password reset, redirects + Admin/ + Controller.php # Settings UI, user list, onboarding, diagnostics + LoginProfiles/ + Controller.php # Admin controller for Login Profile editor + AdminPage.php # Renders the React admin mount point + RestApi.php # /wp-json/workos/v1/admin/profiles CRUD + Auth/ + Controller.php # Login, registration, password reset, redirects + Login.php # Legacy AuthKit redirect + headless flows + AuthKit/ # Custom AuthKit (React shell) — see below + Controller.php # Wires everything, registers CPT + takeover + Profile.php # Immutable Profile value object + ProfileRepository.php # CPT-backed CRUD + ProfileRouter.php # Rule-based profile resolution + LoginCompleter.php # Shared post-auth finalizer (entitlement + MFA) + LoginTakeover.php # wp-login.php takeover (action=login only) + FrontendRoute.php # /workos/login/{profile} rewrite + Shortcode.php # [workos_login_v2] shortcode + Renderer.php # HTML shell + bundle enqueue + Nonce.php # Profile-scoped CSRF nonces + RateLimiter.php # Per-IP / per-email transient buckets + Radar.php # Site-key resolution + request-header extraction + REST/ + Controller.php # REST controller (registers Auth and TokenAuth) + TokenAuth.php # REST API Bearer token authentication + Auth/ # Public /wp-json/workos/v1/auth/* endpoints + Controller.php # Wires all endpoint classes + BaseEndpoint.php # Shared profile + nonce + rate-limit + Radar + Password.php # password/authenticate + reset/{start,confirm} + MagicCode.php # magic/{send,verify} + Session.php # nonce + session/{refresh,logout} + Signup.php # signup/{create,verify} + Invitation.php # invitation/{token} + invitation/accept + OAuth.php # oauth/authorize-url + Mfa.php # mfa/{challenge,verify,factors,…} Sync/Controller.php # UserSync, RoleMapper, DirectorySync, AuditLog - REST/Controller.php # Bearer token authentication - Webhook/Controller.php # Webhook receiver + Webhook/Controller.php # Webhook receiver + signature verification Organization/Controller.php # Organization management, entitlement gate CLI/Controller.php # WP-CLI commands - UI/Controller.php # Login button (shortcode, block, widget) + UI/Controller.php # Legacy login button (shortcode, block, widget) ActivityLog/Controller.php # Local activity logging + +src/js/ + authkit/ # Custom AuthKit React shell (TypeScript + TSX) + index.tsx # Mount + data-* hydration + App.tsx # Step machine + api.ts # Fetch client w/ nonce + refresh + Radar + flows.tsx # 11 flow components (password, magic, mfa, ...) + ui.tsx # 11 primitives (Button, Input, Card, ...) + radar.ts # WorkOS Radar SDK loader + types.ts # Shared interfaces + styles.css # Scoped styles (CSS vars) + admin-profiles/ + index.tsx # Admin Login Profile editor (CRUD) + styles.css # Scoped admin styles ``` -Each controller extends `Contracts\Controller` and implements `isActive()` for conditional activation (e.g., `Admin\Controller` only activates in `is_admin()`, `CLI\Controller` only activates under `WP_CLI`). +Each controller extends `Contracts\Controller` and implements `isActive()` +for conditional activation (e.g., `Admin\Controller` only activates in +`is_admin()`, `CLI\Controller` only activates under `WP_CLI`). The new +`Auth\AuthKit\Controller` and `REST\Auth\Controller` are always active — +the former because the CPT and wp-login.php takeover must boot on every +request, the latter because anonymous visitors need to reach the auth +endpoints. diff --git a/bun.lock b/bun.lock index 492a200..5266645 100644 --- a/bun.lock +++ b/bun.lock @@ -4,13 +4,27 @@ "": { "name": "integration-workos", "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/wordpress__element": "^2.14.0", + "@types/wordpress__i18n": "^3.11.0", + "@wordpress/components": "^32.6.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/plugins": "^7.44.0", "@wordpress/scripts": "^30.0", + "typescript": "^5.5.0", }, }, }, "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@ariakit/core": ["@ariakit/core@0.4.20", "", {}, "sha512-DJbUnui0fM+2ZgiWLOMuFOmlWSJDNV3f6tqghIYRTWEm51TN/LoU6uM8og6/g7Nrwl4Uo5l8AoQT9Kkr/i/uRg=="], + + "@ariakit/react": ["@ariakit/react@0.4.26", "", { "dependencies": { "@ariakit/react-core": "0.4.26" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NcoPrYE4vgwyODAhdpNNuA7ldwODDuFqZl6jORPVDY3l+oRjl/OYwtQyyC3ZhC/4mjntYBYuKKrPJEizLmoxpg=="], + + "@ariakit/react-core": ["@ariakit/react-core@0.4.26", "", { "dependencies": { "@ariakit/core": "0.4.20", "@floating-ui/dom": "^1.0.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/Peh1KiVpjj79nCJIa6lEdzSTT9P9FZoy+CxByIFKL3YKdlXmDIIhS1E/tAqKbDq4ODVdynnqmrIDxE5wCoZYw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -239,6 +253,8 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -261,6 +277,10 @@ "@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + + "@date-fns/utc": ["@date-fns/utc@2.1.1", "", {}, "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="], + "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], @@ -271,6 +291,34 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/css": ["@emotion/css@11.13.5", "", { "dependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2" } }, "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w=="], + + "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/styled": ["@emotion/styled@11.14.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/is-prop-valid": "^1.3.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", "react": ">=16.8.0" } }, "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw=="], + + "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + "@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.41.0", "", { "dependencies": { "comment-parser": "1.4.1", "esquery": "^1.5.0", "jsdoc-type-pratt-parser": "~4.0.0" } }, "sha512-aKUhyn1QI5Ksbqcr3fFJj16p99QdjUxXAEuFst1Z47DRyoiMwivIH9MV/ARcJOCXVjPfjITciej8ZD2O/6qUmw=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -281,6 +329,14 @@ "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.0.8", "", { "dependencies": { "@floating-ui/dom": "^1.6.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], @@ -521,6 +577,18 @@ "@svgr/webpack": ["@svgr/webpack@8.1.0", "", { "dependencies": { "@babel/core": "^7.21.3", "@babel/plugin-transform-react-constant-elements": "^7.21.3", "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", "@svgr/core": "8.1.0", "@svgr/plugin-jsx": "8.1.0", "@svgr/plugin-svgo": "8.1.0" } }, "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA=="], + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + + "@tannin/compile": ["@tannin/compile@1.1.0", "", { "dependencies": { "@tannin/evaluate": "^1.2.0", "@tannin/postfix": "^1.1.0" } }, "sha512-n8m9eNDfoNZoxdvWiTfW/hSPhehzLJ3zW7f8E7oT6mCROoMNWCB4TYtv041+2FMAxweiE0j7i1jubQU4MEC/Gg=="], + + "@tannin/evaluate": ["@tannin/evaluate@1.2.0", "", {}, "sha512-3ioXvNowbO/wSrxsDG5DKIMxC81P0QrQTYai8zFNY+umuoHWRPbQ/TuuDEOju9E+jQDXmj6yI5GyejNuh8I+eg=="], + + "@tannin/plural-forms": ["@tannin/plural-forms@1.1.0", "", { "dependencies": { "@tannin/compile": "^1.1.0" } }, "sha512-xl9R2mDZO/qiHam1AgMnAES6IKIg7OBhcXqy6eDsRCdXuxAFPcjrej9HMjyCLE0DJ/8cHf0i5OQTstuBRhpbHw=="], + + "@tannin/postfix": ["@tannin/postfix@1.1.0", "", {}, "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw=="], + + "@tannin/sprintf": ["@tannin/sprintf@1.3.3", "", {}, "sha512-RwARl+hFwhzy0tg9atWcchLFvoQiOh4rrP7uG2N5E4W80BPCUX0ElcUR9St43fxB9EfjsW2df9Qp+UsTbvQDjA=="], + "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], @@ -555,6 +623,10 @@ "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], + "@types/gradient-parser": ["@types/gradient-parser@1.1.0", "", {}, "sha512-SaEcbgQscHtGJ1QL+ajgDTmmqU2f6T+00jZRcFlVHUW2Asivc84LNUev/UQFyu117AsdyrtI+qpwLvgjJXJxmw=="], + + "@types/highlight-words-core": ["@types/highlight-words-core@1.2.1", "", {}, "sha512-9VZUA5omXBfn+hDxFjUDu1FOJTBM3LmvqfDey+Z6Aa8B8/JmF5SMj6FBrjfgJ/Q3YXOZd3qyTDfJyMZSs/wCUA=="], + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], "@types/http-proxy": ["@types/http-proxy@1.17.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw=="], @@ -575,6 +647,8 @@ "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], + "@types/mousetrap": ["@types/mousetrap@1.6.15", "", {}, "sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw=="], + "@types/mysql": ["@types/mysql@2.15.26", "", { "dependencies": { "@types/node": "*" } }, "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ=="], "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], @@ -619,6 +693,10 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/wordpress__element": ["@types/wordpress__element@2.14.1", "", { "dependencies": { "@wordpress/element": "*" } }, "sha512-qMp7A1r4Fdnm36FgSIQpwZ39CY6DDEfb2P4TfaLHoaSeZcPBdzs4hdhmIRrThX/W9AqOQYmGHyZ8RMzjg88KEg=="], + + "@types/wordpress__i18n": ["@types/wordpress__i18n@3.11.0", "", { "dependencies": { "@wordpress/i18n": "*" } }, "sha512-l4OXE1LQH7IBqRXY//NGBCsM0jPv8sdUEubPaAzPxKUxYGe+I2UTREjwB0ejuh2+J0p90EhW3mX5FYM8a8lf2w=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -683,6 +761,10 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], @@ -719,33 +801,71 @@ "@webpack-cli/serve": ["@webpack-cli/serve@2.0.5", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ=="], + "@wordpress/a11y": ["@wordpress/a11y@4.44.0", "", { "dependencies": { "@wordpress/dom-ready": "^4.44.0", "@wordpress/i18n": "^6.17.0" } }, "sha512-VewBVprbT10DnsIbIamtBXz5jVlwI+nRroXkYsRbYJq63h/dHkD2nnOObIbIdFfMi5m33fwcs1a3v93vqs8WMQ=="], + "@wordpress/babel-preset-default": ["@wordpress/babel-preset-default@8.41.0", "", { "dependencies": { "@babel/core": "7.25.7", "@babel/plugin-syntax-import-attributes": "7.26.0", "@babel/plugin-transform-react-jsx": "7.25.7", "@babel/plugin-transform-runtime": "7.25.7", "@babel/preset-env": "7.25.7", "@babel/preset-typescript": "7.25.7", "@wordpress/browserslist-config": "^6.41.0", "@wordpress/warning": "^3.41.0", "browserslist": "^4.21.10", "core-js": "^3.31.0", "react": "^18.3.0" } }, "sha512-A+HTQTLGtHRdorr9aizhBdLR9RmQs/VVAHME1wXG1aPOtU3033PykwKrceSaKe9hYXHs/P3Nota83bMqqjUoUA=="], - "@wordpress/base-styles": ["@wordpress/base-styles@6.17.0", "", {}, "sha512-6o4Tp9rrGaa2ExnkCjvBZl9CVETFptb6NWtpikrkhGC2HtCSFhXWMzYheK0t+4xSJcssrpm6BMSAQGGGFm6+Tg=="], + "@wordpress/base-styles": ["@wordpress/base-styles@6.20.0", "", {}, "sha512-Dsug4Zxz2xOFtK6CGThKYXwCqC9Yztw2STKQzwztrX4yW+o6iDbzkxpcwdDhsaVJs0Jt9A4LmJpZPh+pUozzLA=="], "@wordpress/browserslist-config": ["@wordpress/browserslist-config@6.41.0", "", {}, "sha512-Cp9JcjdL6ZYVNUGgXR/XOO9Fueb3i0dzpvdpC1pB/iJldiQWYRPZ7LMCaJrwprG92/AfuU68BaR5CwTwrSN5tA=="], + "@wordpress/components": ["@wordpress/components@32.6.0", "", { "dependencies": { "@ariakit/react": "^0.4.22", "@date-fns/utc": "^2.1.1", "@emotion/cache": "^11.14.0", "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/styled": "^11.14.1", "@emotion/utils": "^1.4.2", "@floating-ui/react-dom": "2.0.8", "@types/gradient-parser": "1.1.0", "@types/highlight-words-core": "1.2.1", "@types/react": "^18.3.27", "@use-gesture/react": "^10.3.1", "@wordpress/a11y": "^4.44.0", "@wordpress/base-styles": "^6.20.0", "@wordpress/compose": "^7.44.0", "@wordpress/date": "^5.44.0", "@wordpress/deprecated": "^4.44.0", "@wordpress/dom": "^4.44.0", "@wordpress/element": "^6.44.0", "@wordpress/escape-html": "^3.44.0", "@wordpress/hooks": "^4.44.0", "@wordpress/html-entities": "^4.44.0", "@wordpress/i18n": "^6.17.0", "@wordpress/icons": "^12.2.0", "@wordpress/is-shallow-equal": "^5.44.0", "@wordpress/keycodes": "^4.44.0", "@wordpress/primitives": "^4.44.0", "@wordpress/private-apis": "^1.44.0", "@wordpress/rich-text": "^7.44.0", "@wordpress/warning": "^3.44.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", "csstype": "^3.2.3", "date-fns": "^3.6.0", "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "framer-motion": "^11.15.0", "gradient-parser": "1.1.1", "highlight-words-core": "^1.2.2", "is-plain-object": "^5.0.0", "memize": "^2.1.0", "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", "react-colorful": "^5.6.1", "react-day-picker": "^9.7.0", "remove-accents": "^0.5.0", "uuid": "^9.0.1" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA=="], + + "@wordpress/compose": ["@wordpress/compose@7.44.0", "", { "dependencies": { "@types/mousetrap": "^1.6.8", "@wordpress/deprecated": "^4.44.0", "@wordpress/dom": "^4.44.0", "@wordpress/element": "^6.44.0", "@wordpress/is-shallow-equal": "^5.44.0", "@wordpress/keycodes": "^4.44.0", "@wordpress/priority-queue": "^3.44.0", "@wordpress/undo-manager": "^1.44.0", "change-case": "^4.1.2", "mousetrap": "^1.6.5", "use-memo-one": "^1.1.1" }, "peerDependencies": { "react": "^18.0.0" } }, "sha512-NlMSR+sqEkHppjUM3irJhB0PLaWYoAgWFa7BL6xb94ciWxr4C5CIB0pSCXW8B0WNBPgS7q/xCeJGKGSfLkBgIQ=="], + + "@wordpress/data": ["@wordpress/data@10.44.0", "", { "dependencies": { "@wordpress/compose": "^7.44.0", "@wordpress/deprecated": "^4.44.0", "@wordpress/element": "^6.44.0", "@wordpress/is-shallow-equal": "^5.44.0", "@wordpress/priority-queue": "^3.44.0", "@wordpress/private-apis": "^1.44.0", "@wordpress/redux-routine": "^5.44.0", "deepmerge": "^4.3.0", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "redux": "^5.0.1", "rememo": "^4.0.2", "use-memo-one": "^1.1.1" }, "peerDependencies": { "react": "^18.0.0" } }, "sha512-NMOJ3sDAT+ZSKm5iMvL3JVstNxDdvW9rYbzMKYzyfXbfAi9zdlNfN3Pc/0ozsUfDwhn336mA/Wu9EBNc0P+Ajw=="], + + "@wordpress/date": ["@wordpress/date@5.44.0", "", { "dependencies": { "@wordpress/deprecated": "^4.44.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40" } }, "sha512-8TUnhQKqjnMyQij1dQgVtpiJ5luRueCgu9iXGUwfoYfS6YmTS8u7lACVxn+LtWwGuJNSeZS4Dghsq5DgeW6sUQ=="], + "@wordpress/dependency-extraction-webpack-plugin": ["@wordpress/dependency-extraction-webpack-plugin@6.41.0", "", { "dependencies": { "json2php": "^0.0.7" }, "peerDependencies": { "webpack": "^5.0.0" } }, "sha512-OYg+g+MFmHRRUcjl5hNOrBx56GRkp6MNhHuiAvYP7vFJ8dxwzKmk3AHfqr25QLt3K62ODHK1aaikE1dZt40kNg=="], + "@wordpress/deprecated": ["@wordpress/deprecated@4.44.0", "", { "dependencies": { "@wordpress/hooks": "^4.44.0" } }, "sha512-Yb2kPVP3vJnuJ87sQqWqt/QzRglEkXL6IJ1TnSyXKv7Jqke2Bh2UmSGLFn86e3ZHIbGkzRUYb5ZPGzaePPrQFQ=="], + + "@wordpress/dom": ["@wordpress/dom@4.44.0", "", { "dependencies": { "@wordpress/deprecated": "^4.44.0" } }, "sha512-W8uzlz83q73qO3fxl1Qcm69KvZqiXtcebEiXntO2lAyOtA5k/C3rbSwpGdTlgxFbQvg+SKbux17ZyztcB2p33Q=="], + + "@wordpress/dom-ready": ["@wordpress/dom-ready@4.44.0", "", {}, "sha512-YSiDpmelYLgFu0/Mki9OogEDO5t8Dr1pZnJU/RYRC7aawWGxidgNr0hael+9jO6pLAd+3LiAEV5cAvLg0V1pZQ=="], + "@wordpress/e2e-test-utils-playwright": ["@wordpress/e2e-test-utils-playwright@1.41.0", "", { "dependencies": { "change-case": "^4.1.2", "get-port": "^5.1.1", "lighthouse": "^12.2.2", "mime": "^3.0.0", "web-vitals": "^4.2.1" }, "peerDependencies": { "@playwright/test": ">=1", "@types/node": "^20.17.10" } }, "sha512-WgWvCMssst//kF1s7sBq92FKMAyQ6epbocNaF8R1i5opCMhEstXlqM9F78Ud15D/8buvbDAmHpumpBufMh93cg=="], "@wordpress/element": ["@wordpress/element@6.41.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.41.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-1rM2MhP2epOfsqOz2spOaGmCU/ZtwHdLEF5GkCNEih+Mt2v+oM1xWbSNvt8RoP7Fz7DepBfaDpl6rkVYZgAwkQ=="], - "@wordpress/escape-html": ["@wordpress/escape-html@3.41.0", "", {}, "sha512-xkif63w2OwcOZiJ+YKOmbuUnXAH0PM7YExA6UdQwxDGunED8L/4BY9f0nTEnDN9wFiUCYnqF4MIvMnWEnPwzUA=="], + "@wordpress/escape-html": ["@wordpress/escape-html@3.44.0", "", {}, "sha512-nAEshSe6IYFr3G8sfY8o9pYNTRKvxocQ3DXs3KUesmdaEtrtJSlDmrMOI3FIgaYfv1PP6d+cDZpsygp6IZGo2w=="], "@wordpress/eslint-plugin": ["@wordpress/eslint-plugin@22.22.0", "", { "dependencies": { "@babel/eslint-parser": "7.25.7", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", "@wordpress/babel-preset-default": "^8.36.0", "@wordpress/prettier-config": "^4.36.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^8.3.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.25.2", "eslint-plugin-jest": "^27.4.3", "eslint-plugin-jsdoc": "^46.4.6", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-playwright": "^0.15.3", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-react": "^7.27.0", "eslint-plugin-react-hooks": "^4.3.0", "globals": "^13.12.0", "requireindex": "^1.2.0" }, "peerDependencies": { "@babel/core": ">=7", "eslint": ">=8", "prettier": ">=3", "typescript": ">=5" }, "optionalPeers": ["prettier", "typescript"] }, "sha512-DLGm5i8Gn0vjkZGKF49U2pYME5Jl9AvmoMJB2G508d+sB/oTSkPmM0baUP7G5zxbd1aqfNTaD0KjdyGyWFFKOA=="], + "@wordpress/hooks": ["@wordpress/hooks@4.44.0", "", {}, "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA=="], + + "@wordpress/html-entities": ["@wordpress/html-entities@4.44.0", "", {}, "sha512-Vejleo4VvES7Ec4qX6p74DL8M6P15p0Law9+A8Wp4Vu8wg4TLtTNZE4Hfet1YoXwY9t6czty+KGISZpEG3Y7RA=="], + + "@wordpress/i18n": ["@wordpress/i18n@6.17.0", "", { "dependencies": { "@tannin/sprintf": "^1.3.2", "@wordpress/hooks": "^4.44.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "tannin": "^1.2.0" }, "bin": { "pot-to-php": "./tools/pot-to-php.js" } }, "sha512-v1SLBweg7CRzQ+5+WSC1U93i8h9d3AoB0YBvMsd6gWI5vO8Zh4YKlEMexvrHQC++WN83egwqux84fWEdeU0MUA=="], + + "@wordpress/icons": ["@wordpress/icons@12.2.0", "", { "dependencies": { "@wordpress/element": "^6.44.0", "@wordpress/primitives": "^4.44.0", "change-case": "4.1.2" }, "peerDependencies": { "react": "^18.0.0" } }, "sha512-Fiw7bmfHDNPjTdCrBF23/9K0VN/GUi73d2ZPZaeWdXhTmIX62T9KYvb1c+WnlBkX7GpXgJO6Q8mypQCY9mw5SQ=="], + + "@wordpress/is-shallow-equal": ["@wordpress/is-shallow-equal@5.44.0", "", {}, "sha512-TTqNqi3yYD/aKVouTkm6xCbFsG2w2XAnODNrobY2y3k+6Cka7iIEVqLJU9lG5pl7+SYXd9RE1N5UPlQTO3Qczg=="], + "@wordpress/jest-console": ["@wordpress/jest-console@8.41.0", "", { "dependencies": { "jest-matcher-utils": "^29.6.2", "jest-mock": "^29.6.2" }, "peerDependencies": { "jest": ">=29" } }, "sha512-onUDiCgX11jdgI/Bzy7gqBUBpoeA2zvvLwuTBd943bfJbw+f2rJkBgEeBlxwngqr42tcro/1Sdjx+0LiJuh3BA=="], "@wordpress/jest-preset-default": ["@wordpress/jest-preset-default@12.41.0", "", { "dependencies": { "@wordpress/jest-console": "^8.41.0", "babel-jest": "29.7.0" }, "peerDependencies": { "@babel/core": ">=7", "jest": ">=29" } }, "sha512-GHH5IzCMVTRHxYPzRbUneXcsh9Y4Tin7B39/FBzvadCcmZDnw4yWqe0VpHF6Zn85b8+mkuq5A6I3/b/FFG48NA=="], + "@wordpress/keycodes": ["@wordpress/keycodes@4.44.0", "", { "dependencies": { "@wordpress/i18n": "^6.17.0" } }, "sha512-dt8lfiTxnw9QqlS0DhvSOw4HbB4tlwv0/M++nEVYjpnIXIOsuH9/HYyHWhzIbSR2mw8S6TG6I4jktmKi/zemUA=="], + "@wordpress/npm-package-json-lint-config": ["@wordpress/npm-package-json-lint-config@5.41.0", "", { "peerDependencies": { "npm-package-json-lint": ">=6.0.0" } }, "sha512-TtPvEh9TFSMlSWscYxnfXpqYc3TiKQKPc48tIgc8IrK8g6UwdKRkEdL6EMBMdeP8XjQ44nwFzpE0v5NwqHNSOg=="], + "@wordpress/plugins": ["@wordpress/plugins@7.44.0", "", { "dependencies": { "@wordpress/components": "^32.6.0", "@wordpress/compose": "^7.44.0", "@wordpress/deprecated": "^4.44.0", "@wordpress/element": "^6.44.0", "@wordpress/hooks": "^4.44.0", "@wordpress/icons": "^12.2.0", "@wordpress/is-shallow-equal": "^5.44.0", "memize": "^2.0.1" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-zkztkOjjau1sQ8ezOGDvnT80x1JhAQ2fii5483LwJTYYWgknfRUzrbr1ohezSoarG7LCjjp/V+sHeuOZdbo6Sw=="], + "@wordpress/postcss-plugins-preset": ["@wordpress/postcss-plugins-preset@5.41.0", "", { "dependencies": { "@wordpress/base-styles": "^6.17.0", "autoprefixer": "^10.4.20", "postcss-import": "^16.1.1" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-3zWCfuySY6WfLLL3fmO8iJ+g3QNceb7NQXFGvSnPRYhR+tJdWLEC7spo+juho3WX7raYkqsxUjYekUmMcJTF9Q=="], "@wordpress/prettier-config": ["@wordpress/prettier-config@4.41.0", "", { "peerDependencies": { "prettier": ">=3" } }, "sha512-Zzy+ZvxV6JoUptL2J88w7CNjJbdG/ouILJYWSIm3PWoQQnLgNtGLXqquS2YVfOnOhqdIlcXvMrAFZ0ASQ70B4w=="], - "@wordpress/private-apis": ["@wordpress/private-apis@1.41.0", "", {}, "sha512-5bVOGCJGJyD+5IhGtdLxLBsbJd+B1Ohx93COLbqMY4pGAnhNifNgt1rgsYWUjqHVLh80EnvL4HQeMzEncydNLA=="], + "@wordpress/primitives": ["@wordpress/primitives@4.44.0", "", { "dependencies": { "@wordpress/element": "^6.44.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18.0.0" } }, "sha512-IqLL1EfhhyD9hp3G0q0Djp5HYbqXr7/f+FIj98SCovZnoo6YrVYwzFSrUvjFLr7RchyF19VzOEc0w0PpyhtxYA=="], + + "@wordpress/priority-queue": ["@wordpress/priority-queue@3.44.0", "", { "dependencies": { "requestidlecallback": "^0.3.0" } }, "sha512-L1BaCwWz/kMr8FMWITZ+Z/RgF7UiX0bikn5XOHGqiEh/3dLLBpCLItK51FA7lejvW1+t5EQf6rcSmeUEkIz1YQ=="], + + "@wordpress/private-apis": ["@wordpress/private-apis@1.44.0", "", {}, "sha512-fTR1HRshYIrN4yau/Z+zxY+oRFnJz/LS8XGeXx43PT5O4B25+4kO41ApdS9FG56erg8HqUB6HoqDUcReT5pzlQ=="], + + "@wordpress/redux-routine": ["@wordpress/redux-routine@5.44.0", "", { "dependencies": { "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "rungen": "^0.3.2" }, "peerDependencies": { "redux": ">=4" } }, "sha512-8BL3M85yv1Tx/pFgJxB9BhQYcQO/lQRkU8RzSGiQDpDmZPAFe4+5MJcymWVXExq1rKEy5sYJFNiGPB6NMGtr1g=="], + + "@wordpress/rich-text": ["@wordpress/rich-text@7.44.0", "", { "dependencies": { "@wordpress/a11y": "^4.44.0", "@wordpress/compose": "^7.44.0", "@wordpress/data": "^10.44.0", "@wordpress/deprecated": "^4.44.0", "@wordpress/dom": "^4.44.0", "@wordpress/element": "^6.44.0", "@wordpress/escape-html": "^3.44.0", "@wordpress/i18n": "^6.17.0", "@wordpress/keycodes": "^4.44.0", "@wordpress/private-apis": "^1.44.0", "colord": "2.9.3", "memize": "^2.1.0" }, "peerDependencies": { "react": "^18.0.0" } }, "sha512-WBcXdMpfg/vmI5TelxkO5lbwb2ZwT40vpuz5KTRPXyHM8RfLIXykv3jffIZdG6BkfDmz7N3KPY66BvdeCiebUA=="], "@wordpress/scripts": ["@wordpress/scripts@30.27.0", "", { "dependencies": { "@babel/core": "7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@svgr/webpack": "^8.0.1", "@wordpress/babel-preset-default": "^8.34.0", "@wordpress/browserslist-config": "^6.34.0", "@wordpress/dependency-extraction-webpack-plugin": "^6.34.0", "@wordpress/e2e-test-utils-playwright": "^1.34.0", "@wordpress/eslint-plugin": "^22.20.0", "@wordpress/jest-preset-default": "^12.34.0", "@wordpress/npm-package-json-lint-config": "^5.34.0", "@wordpress/postcss-plugins-preset": "^5.34.0", "@wordpress/prettier-config": "^4.34.0", "@wordpress/stylelint-config": "^23.26.0", "adm-zip": "^0.5.9", "babel-jest": "29.7.0", "babel-loader": "9.2.1", "browserslist": "^4.21.10", "chalk": "^4.0.0", "check-node-version": "^4.1.0", "copy-webpack-plugin": "^10.2.0", "cross-spawn": "^7.0.6", "css-loader": "^6.2.0", "cssnano": "^6.0.1", "cwd": "^0.10.0", "dir-glob": "^3.0.1", "eslint": "^8.3.0", "expect-puppeteer": "^4.4.0", "fast-glob": "^3.2.7", "filenamify": "^4.2.0", "jest": "^29.6.2", "jest-dev-server": "^10.1.4", "jest-environment-jsdom": "^29.6.2", "jest-environment-node": "^29.6.2", "json2php": "^0.0.9", "markdownlint-cli": "^0.31.1", "merge-deep": "^3.0.3", "mini-css-extract-plugin": "^2.9.2", "minimist": "^1.2.0", "npm-package-json-lint": "^6.4.0", "npm-packlist": "^3.0.0", "postcss": "^8.4.5", "postcss-loader": "^6.2.1", "prettier": "npm:wp-prettier@3.0.3", "puppeteer-core": "^23.10.1", "react-refresh": "^0.14.0", "read-pkg-up": "^7.0.1", "resolve-bin": "^0.4.0", "rtlcss": "^4.3.0", "sass": "^1.54.0", "sass-loader": "^16.0.3", "schema-utils": "^4.2.0", "source-map-loader": "^3.0.0", "stylelint": "^16.8.2", "terser-webpack-plugin": "^5.3.10", "url-loader": "^4.1.1", "webpack": "^5.97.0", "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" }, "peerDependencies": { "@playwright/test": "^1.56.1", "@wordpress/env": "^10.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" }, "optionalPeers": ["@wordpress/env"], "bin": { "wp-scripts": "./bin/wp-scripts.js" } }, "sha512-gXGptazCxAaR7g8kcN5joj7B5fCm0VeBHOmnDBs2dbQ4W4F3tfzdg6CTEj8LonF9bWQXlSy3ku8EqWCdkSG9Xw=="], @@ -753,7 +873,9 @@ "@wordpress/theme": ["@wordpress/theme@0.8.0", "", { "dependencies": { "@wordpress/element": "^6.41.0", "@wordpress/private-apis": "^1.41.0", "colorjs.io": "^0.6.0", "memize": "^2.1.0" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0", "stylelint": "^16.8.2" }, "optionalPeers": ["stylelint"] }, "sha512-xXGjWNFHICBuMNfjCjTui5ChkiKmmPTJtsF5tPXnUBXJaw43xxGlL0y7lpCNPJQxz+NPMJ01KlGfxhRsHXjKrQ=="], - "@wordpress/warning": ["@wordpress/warning@3.41.0", "", {}, "sha512-WhyGL1y6y18cZwOQeCOI9K+kWc8F9KAni9YQKZVYSriazbSPNOQGWpUdeKZVGbimBEjEspK7FQBE4pUW3q+D8w=="], + "@wordpress/undo-manager": ["@wordpress/undo-manager@1.44.0", "", { "dependencies": { "@wordpress/is-shallow-equal": "^5.44.0" } }, "sha512-NVMR35nMQc7DkCjQvkt13sd+cYtNsmwyaXJ0H2ENe23ndzRXoNKKLSgN03FzFQ73IlePbAHyasyEyLCc1hDRsw=="], + + "@wordpress/warning": ["@wordpress/warning@3.44.0", "", {}, "sha512-avxdbIYhDuUh2qi2oiq7KeqYOVv2RubqV8UI/Q7bctZSFSXJE8RQGSR/W2YjABeyWBIjlyX/U5lOxVs2PIfy/w=="], "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], @@ -863,6 +985,8 @@ "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.16", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.1", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.7", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw=="], @@ -969,6 +1093,8 @@ "clone-deep": ["clone-deep@0.2.4", "", { "dependencies": { "for-own": "^0.1.3", "is-plain-object": "^2.0.1", "kind-of": "^3.0.2", "lazy-cache": "^1.0.3", "shallow-clone": "^0.1.2" } }, "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="], "collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="], @@ -1073,6 +1199,10 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -1153,6 +1283,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], @@ -1165,6 +1297,8 @@ "envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="], + "equivalent-key-map": ["equivalent-key-map@0.2.2", "", {}, "sha512-xvHeyCDbZzkpN4VHQj/n+j2lOwL0VWszG30X4cOrc9Y7Tuo2qCdZK/0AMod23Z5dCtNUbaju6p0rwOhHUk05ew=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], @@ -1307,6 +1441,8 @@ "find-process": ["find-process@1.4.11", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^12.1.0", "loglevel": "^1.9.2" }, "bin": { "find-process": "bin/find-process.js" } }, "sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA=="], + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], @@ -1331,6 +1467,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fs-exists-sync": ["fs-exists-sync@0.1.0", "", {}, "sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg=="], @@ -1371,6 +1509,8 @@ "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "gettext-parser": ["gettext-parser@1.4.0", "", { "dependencies": { "encoding": "^0.1.12", "safe-buffer": "^5.1.1" } }, "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA=="], + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1393,6 +1533,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gradient-parser": ["gradient-parser@1.1.1", "", {}, "sha512-Hu0YfNU+38EsTmnUfLXUKFMXq9yz7htGYpF4x+dlbBhUCvIvzLt0yVLT/gJRmvLKFJdqNFrz4eKkIUjIXSr7Tw=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], @@ -1419,6 +1561,10 @@ "header-case": ["header-case@2.0.4", "", { "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" } }, "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q=="], + "highlight-words-core": ["highlight-words-core@1.2.3", "", {}, "sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + "homedir-polyfill": ["homedir-polyfill@1.0.3", "", { "dependencies": { "parse-passwd": "^1.0.0" } }, "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA=="], "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], @@ -1553,6 +1699,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -1827,6 +1975,16 @@ "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + + "moment-timezone": ["moment-timezone@0.5.48", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="], + + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + + "mousetrap": ["mousetrap@1.6.5", "", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1951,7 +2109,7 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], @@ -2117,8 +2275,14 @@ "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "re-resizable": ["re-resizable@6.11.2", "", { "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], + + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -2139,6 +2303,8 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -2153,6 +2319,12 @@ "regjsparser": ["regjsparser@0.13.0", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q=="], + "rememo": ["rememo@4.0.2", "", {}, "sha512-NVfSP9NstE3QPNs/TnegQY0vnJnstKQSpcrsI2kBTB3dB2PkdfKdTa+abbjMIDqpc63fE5LfjLgfMst0ULMFxQ=="], + + "remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="], + + "requestidlecallback": ["requestidlecallback@0.3.0", "", {}, "sha512-TWHFkT7S9p7IxLC5A1hYmAYQx2Eb9w1skrXmQ+dS1URyvR8tenMLl4lHbqEOUnpEYxNKpkVMXUgknVpBZWXXfQ=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -2191,6 +2363,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rungen": ["rungen@0.3.2", "", {}, "sha512-zWl10xu2D7zoR8zSC2U6bg5bYF6T/Wk7rxwp8IPaJH7f0Ge21G03kNHVgHR7tyVkSSfAOG0Rqf/Cl38JftSmtw=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], @@ -2361,6 +2535,8 @@ "stylelint-scss": ["stylelint-scss@6.14.0", "", { "dependencies": { "css-tree": "^3.0.1", "is-plain-object": "^5.0.0", "known-css-properties": "^0.37.0", "mdn-data": "^2.25.0", "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.6", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "stylelint": "^16.8.2" } }, "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA=="], + "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], @@ -2379,6 +2555,8 @@ "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], + "tannin": ["tannin@1.2.0", "", { "dependencies": { "@tannin/plural-forms": "^1.1.0" } }, "sha512-U7GgX/RcSeUETbV7gYgoz8PD7Ni4y95pgIP/Z6ayI3CfhSujwKEBlGFTCRN+Aqnuyf4AN2yHL+L8x+TCGjb9uA=="], + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], @@ -2489,11 +2667,15 @@ "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + "use-memo-one": ["use-memo-one@1.1.3", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], @@ -2607,6 +2789,10 @@ "@cacheable/utils/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -2665,8 +2851,30 @@ "@wordpress/babel-preset-default/@babel/preset-typescript": ["@babel/preset-typescript@7.25.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.7", "@babel/helper-validator-option": "^7.25.7", "@babel/plugin-syntax-jsx": "^7.25.7", "@babel/plugin-transform-modules-commonjs": "^7.25.7", "@babel/plugin-transform-typescript": "^7.25.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-rkkpaXJZOFN45Fb+Gki0c+KMIglk4+zZXOoMJuyEK8y8Kkc8Jd3BDmP7qPsz0zQMJj+UD7EprF+AqAXcILnexw=="], + "@wordpress/babel-preset-default/@wordpress/warning": ["@wordpress/warning@3.41.0", "", {}, "sha512-WhyGL1y6y18cZwOQeCOI9K+kWc8F9KAni9YQKZVYSriazbSPNOQGWpUdeKZVGbimBEjEspK7FQBE4pUW3q+D8w=="], + + "@wordpress/components/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + + "@wordpress/compose/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + + "@wordpress/data/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + "@wordpress/dependency-extraction-webpack-plugin/json2php": ["json2php@0.0.7", "", {}, "sha512-dnSoUiLAoVaMXxFsVi4CrPVYMKOuDBXTghXSmMINX44RZ8WM9cXlY7UqrQnlAcODCVO7FV3+8t/5nDKAjimLfg=="], + "@wordpress/element/@wordpress/escape-html": ["@wordpress/escape-html@3.41.0", "", {}, "sha512-xkif63w2OwcOZiJ+YKOmbuUnXAH0PM7YExA6UdQwxDGunED8L/4BY9f0nTEnDN9wFiUCYnqF4MIvMnWEnPwzUA=="], + + "@wordpress/icons/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + + "@wordpress/plugins/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + + "@wordpress/postcss-plugins-preset/@wordpress/base-styles": ["@wordpress/base-styles@6.17.0", "", {}, "sha512-6o4Tp9rrGaa2ExnkCjvBZl9CVETFptb6NWtpikrkhGC2HtCSFhXWMzYheK0t+4xSJcssrpm6BMSAQGGGFm6+Tg=="], + + "@wordpress/primitives/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + + "@wordpress/rich-text/@wordpress/element": ["@wordpress/element@6.44.0", "", { "dependencies": { "@types/react": "^18.3.27", "@types/react-dom": "^18.3.1", "@wordpress/escape-html": "^3.44.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } }, "sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig=="], + + "@wordpress/theme/@wordpress/private-apis": ["@wordpress/private-apis@1.41.0", "", {}, "sha512-5bVOGCJGJyD+5IhGtdLxLBsbJd+B1Ohx93COLbqMY4pGAnhNifNgt1rgsYWUjqHVLh80EnvL4HQeMzEncydNLA=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -2675,6 +2883,8 @@ "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + "babel-plugin-macros/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + "babel-preset-current-node-syntax/@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2727,6 +2937,8 @@ "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2843,6 +3055,8 @@ "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "read-pkg/normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], "read-pkg/type-fest": ["type-fest@0.6.0", "", {}, "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg=="], @@ -2871,6 +3085,8 @@ "shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="], + "sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/docs/extending-the-login-ui.md b/docs/extending-the-login-ui.md new file mode 100644 index 0000000..b791485 --- /dev/null +++ b/docs/extending-the-login-ui.md @@ -0,0 +1,186 @@ +# Extending the Login UI + +The `integration-workos` plugin renders its login screen as a single-page React application (the **AuthKit shell**). This document describes the three public extension surfaces: + +1. **WordPress SlotFill** — inject React elements into named slots in the login UI. +2. **`workos_authkit_enqueue_assets` action** — enqueue per-profile CSS and/or JavaScript files alongside the AuthKit bundle. +3. **PHP filters** — last-mile mutation of the data the shell renders. + +The same surfaces are available everywhere the AuthKit shell appears: the wp-login.php takeover, the `[workos_login_v2]` shortcode, the `workos/login-form` block, and the `/workos/login/{slug}` rewrite. + +--- + +## 1. SlotFill: injecting elements into the React UI + +The AuthKit shell ships its app inside a `SlotFillProvider` and a `PluginArea` scoped to `workos-authkit`. To register a UI extension, build a small React bundle that calls `registerPlugin` with a `Fill` targeting one of the named slots below. + +### Available slots + +Every slot's `fillProps` carries the active step and profile context so a Fill can render conditionally: + +```ts +{ + step: Step; // 'pick' | 'password' | 'magic_send' | 'magic_verify' | 'mfa' | 'signup' | … + profileSlug: string; // 'default', 'members-area', etc. + methods: AuthMethod[]; // enabled sign-in methods on this profile + flow?: string; // present on flow-specific slots, e.g. 'password', 'magic_send' +} +``` + +| Slot name | Where it renders | +| --- | --- | +| `workos.authkit.beforeHeader` | Top of every flow card, above the logo | +| `workos.authkit.afterHeader` | Below the heading/subheading, above the form | +| `workos.authkit.beforeForm` | Inside the form, above the first field | +| `workos.authkit.afterForm` | Inside the form, below the last field, above the primary button | +| `workos.authkit.afterPrimaryAction` | Inside the form, below the primary submit button | +| `workos.authkit.beforeFooter` | Above secondary links (back, forgot password, etc.) | +| `workos.authkit.afterFooter` | At the very bottom of every flow card | +| `workos.authkit.methodPicker.beforeMethods` | MethodPicker only — above the method buttons | +| `workos.authkit.methodPicker.afterMethods` | MethodPicker only — below the method buttons | + +### Example + +Two files — a PHP loader plus a small JS bundle. + +**`my-extension/my-extension.php`** + +```php + ( + + { ( { step, profileSlug } ) => + step === 'password' && profileSlug === 'members-area' ? ( +

+ Need help signing in? Contact us. +

+ ) : null + } +
+ ), +} ); +``` + +`registerPlugin` must be called with `scope: 'workos-authkit'` — only plugins registered to that scope are mounted by the AuthKit `PluginArea`, which keeps block-editor extensions from accidentally activating on the login screen. + +--- + +## 2. `workos_authkit_enqueue_assets`: per-profile CSS and JS + +Use this WordPress action to ship CSS and/or JavaScript files alongside the AuthKit shell. The active `Profile` instance is passed as the only argument so you can gate by profile slug, organization, methods, or any other field. + +### CSS-only example + +```php +add_action( 'workos_authkit_enqueue_assets', function ( $profile ) { + if ( $profile->get_slug() !== 'members-area' ) { + return; + } + wp_enqueue_style( + 'members-area-login', + plugins_url( 'login.css', __FILE__ ), + [ 'workos-authkit' ], + '1.0' + ); +} ); +``` + +### JS example (analytics / tracking pixel) + +```php +add_action( 'workos_authkit_enqueue_assets', function ( $profile ) { + wp_enqueue_script( + 'login-analytics', + plugins_url( 'analytics.js', __FILE__ ), + [], + '1.0', + true + ); +} ); +``` + +In `analytics.js`, wait for the AuthKit shell to mount before measuring: + +```js +document.addEventListener( 'workos-authkit:mounted', ( event ) => { + const { profileSlug } = event.detail; + // your analytics call here +} ); +``` + +The `workos-authkit:mounted` `CustomEvent` fires once, on `document`, after `createRoot().render()` completes. It carries `{ profileSlug }` in `event.detail`. + +### Scoping CSS + +Two stable selectors are available: + +- **`#workos-authkit-root`** — the React mount point. Present in every entry point. +- **`body.workos-authkit-body`** — present on the full-page renderer (wp-login takeover, `/workos/login/{slug}` rewrite). Each profile also gets a `workos-profile-{slug}` body class. Add more via the `workos_authkit_body_classes` filter. + +```css +body.workos-profile-members-area #workos-authkit-root .wa-card { + background: #f7f3ff; +} +``` + +--- + +## 3. PHP filter reference + +| Filter | Signature | Purpose | +| --- | --- | --- | +| `workos_authkit_branding` | `( array $branding, Profile $profile ) : array` | Override the resolved branding. Keys: `logo_url`, `primary_color`, `heading`, `subheading`. Useful for plugging in alternative logo fallbacks. | +| `workos_authkit_profile_data` | `( array $data, Profile $profile ) : array` | Mutate the JSON payload sent to the React shell. Use when a SlotFill plugin needs additional client-side config. | +| `workos_authkit_body_classes` | `( string[] $classes, Profile $profile ) : string[] ` | Add/remove CSS classes on the full-page renderer's ``. | + +| Action | Signature | Purpose | +| --- | --- | --- | +| `workos_authkit_enqueue_assets` | `( Profile $profile )` | Enqueue per-profile CSS/JS (see §2). | +| `workos_login_profile_saved` | `( Profile $profile )` | Fires after a profile is created or updated through the admin REST API. | +| `workos_user_authenticated` | `( int $user_id, array $workos_response )` | Fires after a successful AuthKit sign-in completes a WordPress login. | + +--- + +## Logo fallback chain + +The AuthKit shell renders a logo at the top of every flow card. The URL is resolved at render time in this order: + +1. **Per-profile logo** — set under WP Admin → WorkOS → Login Profiles → (profile) → Branding → Logo. Stored as a WordPress media attachment ID. +2. **WordPress Site Icon** — set under WP Admin → Settings → General → Site Icon. Used when no per-profile logo is set. +3. **No logo** — if neither is set, the logo `` is omitted entirely. + +To override the chain, hook `workos_authkit_branding`: + +```php +add_filter( 'workos_authkit_branding', function ( $branding, $profile ) { + if ( $profile->get_slug() === 'members-area' && empty( $branding['logo_url'] ) ) { + $branding['logo_url'] = plugins_url( 'members-logo.svg', __FILE__ ); + } + return $branding; +}, 10, 2 ); +``` diff --git a/package.json b/package.json index b37df6c..2b6ee1c 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,20 @@ "scripts": { "build": "wp-scripts build", "start": "wp-scripts start", + "lint:ts": "tsc --noEmit", "lint:php": "composer lint", "lint:php:fix": "composer lint:fix" }, "devDependencies": { - "@wordpress/scripts": "^30.0" + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/wordpress__element": "^2.14.0", + "@types/wordpress__i18n": "^3.11.0", + "@wordpress/components": "^32.6.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/plugins": "^7.44.0", + "@wordpress/scripts": "^30.0", + "typescript": "^5.5.0" }, "engines": { "node": ">=20" diff --git a/readme.txt b/readme.txt index 3588093..b3ea1d5 100644 --- a/readme.txt +++ b/readme.txt @@ -21,10 +21,21 @@ Integration with WorkOS connects your WordPress site with [WorkOS](https://worko * PHP 7.4 or higher * A [WorkOS](https://workos.com) account with API credentials += Custom AuthKit = + +* **WordPress-hosted React login** — no redirect to WorkOS for password, magic code, signup, invitation, or MFA. Mounts on wp-login.php, a shortcode (`[workos_login_v2]`), a Gutenberg block, and a dedicated `/workos/login/{profile}` route. +* **Login Profiles** — admin-defined presets (enabled sign-in methods, pinned organization, signup/invite toggles, MFA policy, branding) edited from **WorkOS → Login Profiles**. The organization picker loads live from WorkOS so admins pick an org by name instead of pasting raw IDs. +* **Sign-in methods** — email + password, magic code, social OAuth (Google, Microsoft, GitHub, Apple), and passkey. Each profile chooses its own subset. +* **MFA** — TOTP, SMS, and WebAuthn/passkey with in-app enrollment + challenge. Profile-level `mfa.enforce` (`never`/`if_required`/`always`) and factor allowlist are applied at login time. +* **Self-serve sign-up + invitation acceptance + in-app password reset** — all handled by the React shell; no third-party pages. +* **WorkOS Radar** anti-fraud integration optional via `WORKOS_RADAR_SITE_KEY`. +* **Profile routing rules** — send incoming logins to a specific profile based on `redirect_to`, referrer host, or user role. + = Authentication = -* **Single Sign-On (SSO)** — AuthKit redirect and headless API authentication flows. -* **Login Button** — Shortcode (`[workos_login]`), Gutenberg block, and classic widget for embedding a login button anywhere. +* **Single Sign-On (SSO)** — legacy AuthKit redirect mode, per-profile selectable for SAML/OIDC connections. +* **Headless mode** — intercept WordPress's `authenticate` filter for custom login forms. +* **Legacy Login Button** — Shortcode (`[workos_login]`), Gutenberg block, and classic widget (AuthKit-redirect flow). * **Login Bypass** — Access the native WordPress login form via `?fallback=1` when WorkOS is unavailable. * **Password Reset Integration** — Redirect password reset to WorkOS or fall back to WordPress. * **Registration Redirect** — Redirect registration to WorkOS AuthKit. @@ -77,6 +88,14 @@ Yes, if "Password Fallback" is enabled in settings. Users can access the standar Use the `[workos_login]` shortcode, add the "WorkOS Login" Gutenberg block, or use the "WorkOS Login" classic widget. All three render a styled login button that redirects to WorkOS AuthKit. += How do I show the new WordPress-hosted login (Custom AuthKit) on a page? = + +Use `[workos_login_v2 profile="your-profile-slug"]`, add the "WorkOS Login Form" block, or link to `/workos/login/{profile}`. All three mount the same React shell. The reserved `default` Login Profile automatically takes over wp-login.php. + += Can different login pages offer different sign-in methods? = + +Yes. Each Login Profile (WorkOS → Login Profiles) picks its own set of enabled methods (password, magic code, any subset of social providers, passkey), pins an organization, and sets its own MFA policy and branding. Reference a profile by slug in the shortcode or URL. + = What happens if WorkOS is down? = Users can bypass the WorkOS redirect by appending `?fallback=1` to the login URL (e.g., `wp-login.php?fallback=1`). This loads the standard WordPress login form with native password authentication. @@ -147,15 +166,30 @@ WorkOS is provided by WorkOS, Inc. == Changelog == -= 1.0.0 - 2026-04-14 = -* SSO login via WorkOS AuthKit (redirect and headless modes). += 1.0.0 - 2026-04-23 = + +Custom AuthKit (WordPress-hosted login): +* React login shell on wp-login.php, `[workos_login_v2]` shortcode, `workos/login-form` block, and `/workos/login/{profile}` route. +* Login Profiles — admin-defined presets for enabled methods, pinned organization, signup/invite/reset flows, MFA policy, and branding, managed at WorkOS → Login Profiles. +* Pinned-organization picker in the Profile editor reads live from WorkOS (with a "Custom ID…" fallback for legacy or unlisted orgs), and the Profiles list renders organization names instead of raw IDs. +* Sign-in methods: email + password, magic code, social OAuth (Google, Microsoft, GitHub, Apple), passkey. +* Full MFA support — TOTP, SMS, WebAuthn/passkey with in-app enrollment + challenge. +* Self-serve sign-up, invitation acceptance, and in-app password reset. +* Profile routing rules (redirect_to glob / referrer host / user role). +* WorkOS Radar anti-fraud integration (set `WORKOS_RADAR_SITE_KEY`). +* Public REST at `/wp-json/workos/v1/auth/*` with profile-scoped nonces, per-IP/per-email rate limits, and signature-verified tokens. +* Full browser internationalization — every user-facing React/TS/JS string ships through `@wordpress/i18n` with the `integration-workos` text domain and `wp_set_script_translations()` wiring. + +Base platform: +* SSO login via WorkOS AuthKit (legacy redirect mode, per-profile selectable). +* Headless authentication via WorkOS API. * Directory Sync (SCIM) for automatic user provisioning and deprovisioning. * Role mapping between WorkOS organization roles and WordPress roles. * Organization management with local caching and multisite support. * Entitlement gate — require organization membership to log in. * Webhook processing for user, organization, directory, membership, and connection events. * REST API Bearer token authentication using WorkOS access tokens. -* Login button shortcode (`[workos_login]`), Gutenberg block, and classic widget. +* Legacy login button shortcode (`[workos_login]`), Gutenberg block, and classic widget. * Login bypass via `?fallback=1` for native WordPress login when WorkOS is unavailable. * Activity logging with local database table and admin viewer. * Audit logging — forward WordPress events to WorkOS Audit Logs. @@ -171,4 +205,4 @@ WorkOS is provided by WorkOS, Inc. == Upgrade Notice == = 1.0.0 = -Initial stable release with SSO, Directory Sync, role mapping, and full admin tooling. +Initial stable release: WordPress-hosted Custom AuthKit (React login with Login Profiles, MFA, and passkeys), plus SSO, Directory Sync, role mapping, organization management, and full admin tooling. diff --git a/src/WorkOS/Admin/LoginProfiles/AdminPage.php b/src/WorkOS/Admin/LoginProfiles/AdminPage.php new file mode 100644 index 0000000..17b3cdb --- /dev/null +++ b/src/WorkOS/Admin/LoginProfiles/AdminPage.php @@ -0,0 +1,174 @@ +` from the request URL to deep-link into a profile. + * All CRUD still happens against /wp-json/workos/v1/admin/profiles. + */ +class AdminPage { + + public const MENU_SLUG = 'workos-login-profiles'; + public const SCRIPT_HANDLE = 'workos-admin-profiles'; + public const STYLE_HANDLE = 'workos-admin-profiles'; + + /** + * Profile repository used to preload the editor. + * + * @var ProfileRepository + */ + private ProfileRepository $repository; + + /** + * Constructor. + * + * @param ProfileRepository $repository Repository for the SSR preload. + */ + public function __construct( ProfileRepository $repository ) { + $this->repository = $repository; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'admin_menu', [ $this, 'register_menu' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_assets' ] ); + } + + /** + * Register the submenu under the main WorkOS settings menu. + * + * @return void + */ + public function register_menu(): void { + add_submenu_page( + 'workos', + __( 'Login Profiles', 'integration-workos' ), + __( 'Login Profiles', 'integration-workos' ), + 'manage_options', + self::MENU_SLUG, + [ $this, 'render' ] + ); + } + + /** + * Render the React mount shell. + * + * @return void + */ + public function render(): void { + ?> +
+

+

+ +

+
+
+ [ 'wp-element' ], + 'version' => WORKOS_VERSION, + ]; + + wp_enqueue_script( + self::SCRIPT_HANDLE, + $assets_url . 'admin-profiles.js', + $asset['dependencies'] ?? [ 'wp-element' ], + $asset['version'] ?? WORKOS_VERSION, + true + ); + + wp_set_script_translations( self::SCRIPT_HANDLE, 'integration-workos' ); + + wp_enqueue_style( + self::STYLE_HANDLE, + $assets_url . 'admin-profiles.css', + [], + $asset['version'] ?? WORKOS_VERSION + ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'workosProfileAdmin', + [ + 'restUrl' => esc_url_raw( rest_url( 'workos/v1/admin/profiles' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'pageUrl' => esc_url_raw( admin_url( 'admin.php?page=' . self::MENU_SLUG ) ), + 'profiles' => $this->preloaded_profiles(), + 'activeProfileSlug' => $this->active_profile_slug(), + ] + ); + } + + /** + * SSR preload — every profile shaped exactly like the REST list endpoint. + * + * @return array + */ + private function preloaded_profiles(): array { + $profiles = []; + foreach ( $this->repository->all() as $profile ) { + $profiles[] = $profile->to_editor_array(); + } + return $profiles; + } + + /** + * Resolve the deep-link profile slug from the current request. + * + * Special value `'new'` opens an empty editor. Anything else is treated + * as a profile slug; the React app validates membership against the + * preloaded list and falls back to the index view if unknown. + * + * @return string Slug or empty string when not present. + */ + private function active_profile_slug(): string { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['profile'] ) ) { + return ''; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return sanitize_title( wp_unslash( (string) $_GET['profile'] ) ); + } +} diff --git a/src/WorkOS/Admin/LoginProfiles/Controller.php b/src/WorkOS/Admin/LoginProfiles/Controller.php new file mode 100644 index 0000000..0aaf5d7 --- /dev/null +++ b/src/WorkOS/Admin/LoginProfiles/Controller.php @@ -0,0 +1,44 @@ +container->singleton( RestApi::class ); + $this->container->get( RestApi::class ); + + // AdminPage is a no-op outside wp-admin, but the React bundle it + // enqueues is served from admin_enqueue_scripts so registering + // unconditionally is fine. + $this->container->singleton( AdminPage::class ); + $this->container->get( AdminPage::class )->register(); + } + + /** + * Unregister. + * + * @return void + */ + protected function doUnregister(): void { + } +} diff --git a/src/WorkOS/Admin/LoginProfiles/RestApi.php b/src/WorkOS/Admin/LoginProfiles/RestApi.php new file mode 100644 index 0000000..0ce31db --- /dev/null +++ b/src/WorkOS/Admin/LoginProfiles/RestApi.php @@ -0,0 +1,425 @@ +repository = $repository; + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + + /** + * Register REST routes. + * + * @return void + */ + public function register_routes(): void { + register_rest_route( + self::NAMESPACE, + self::BASE, + [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'list_profiles' ], + 'permission_callback' => [ $this, 'permission_check' ], + ], + [ + 'methods' => 'POST', + 'callback' => [ $this, 'create_profile' ], + 'permission_callback' => [ $this, 'permission_check' ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/organizations', + [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'list_organizations' ], + 'permission_callback' => [ $this, 'permission_check' ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/(?P\d+)', + [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_profile' ], + 'permission_callback' => [ $this, 'permission_check' ], + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + ], + ], + [ + 'methods' => 'PUT', + 'callback' => [ $this, 'update_profile' ], + 'permission_callback' => [ $this, 'permission_check' ], + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + ], + ], + [ + 'methods' => 'DELETE', + 'callback' => [ $this, 'delete_profile' ], + 'permission_callback' => [ $this, 'permission_check' ], + 'args' => [ + 'id' => [ 'sanitize_callback' => 'absint' ], + ], + ], + ] + ); + } + + /** + * Permission check — admin capability required. + * + * @return true|WP_Error + */ + public function permission_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'workos_forbidden', + __( 'You do not have permission to manage Login Profiles.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + return true; + } + + /** + * GET /admin/profiles — list all profiles. + * + * @return WP_REST_Response + */ + public function list_profiles(): WP_REST_Response { + $profiles = array_map( + [ $this, 'shape_profile' ], + $this->repository->all() + ); + + return new WP_REST_Response( [ 'profiles' => $profiles ], 200 ); + } + + /** + * GET /admin/profiles/{id} — fetch a single profile. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function get_profile( WP_REST_Request $request ) { + $profile = $this->repository->find_by_id( (int) $request['id'] ); + if ( ! $profile ) { + return new WP_Error( + 'workos_profile_not_found', + __( 'Profile not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + return new WP_REST_Response( $this->shape_profile( $profile ), 200 ); + } + + /** + * POST /admin/profiles — create a new profile. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function create_profile( WP_REST_Request $request ) { + $params = (array) $request->get_json_params(); + // Force a clean insert — ignore any client-supplied ID. + $params['id'] = 0; + + $logo_error = $this->validate_logo_attachment( $params ); + if ( is_wp_error( $logo_error ) ) { + return $this->error_with_status( $logo_error, 400 ); + } + + $profile = Profile::from_array( $params ); + + $saved = $this->repository->save( $profile ); + if ( is_wp_error( $saved ) ) { + return $this->error_with_status( $saved, 400 ); + } + + return new WP_REST_Response( $this->shape_profile( $saved ), 201 ); + } + + /** + * PUT /admin/profiles/{id} — update an existing profile. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function update_profile( WP_REST_Request $request ) { + $id = (int) $request['id']; + $existing = $this->repository->find_by_id( $id ); + if ( ! $existing ) { + return new WP_Error( + 'workos_profile_not_found', + __( 'Profile not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + $params = (array) $request->get_json_params(); + + $logo_error = $this->validate_logo_attachment( $params ); + if ( is_wp_error( $logo_error ) ) { + return $this->error_with_status( $logo_error, 400 ); + } + + // Merge into the existing payload so partial updates are safe — the + // React editor only sends fields the user touched. + $merged = array_replace_recursive( $existing->to_array(), $params ); + $merged['id'] = $id; + + // Protect the reserved default slug from being renamed out from under + // the wp-login.php takeover. + if ( Profile::DEFAULT_SLUG === $existing->get_slug() ) { + $merged['slug'] = Profile::DEFAULT_SLUG; + } + + $profile = Profile::from_array( $merged ); + + $saved = $this->repository->save( $profile ); + if ( is_wp_error( $saved ) ) { + return $this->error_with_status( $saved, 400 ); + } + + return new WP_REST_Response( $this->shape_profile( $saved ), 200 ); + } + + /** + * Shape a Profile for REST output. + * + * Thin wrapper around `Profile::to_editor_array()` so REST responses + * stay consistent with the SSR preload that hydrates the editor on + * page load. + * + * @param Profile $profile Profile to shape. + * + * @return array + */ + private function shape_profile( Profile $profile ): array { + return $profile->to_editor_array(); + } + + /** + * GET /admin/profiles/organizations — list WorkOS organizations for the + * profile editor's pinned-org picker. + * + * Returns `{ organizations: [{ id, name }], error?: string }`. Uses the + * same transient cache key as the main settings org picker so a single + * lookup serves both UIs. Always returns 200 — the editor degrades to a + * free-text input when `organizations` is empty and surfaces `error` + * inline. + * + * @return WP_REST_Response + */ + public function list_organizations(): WP_REST_Response { + if ( ! workos()->is_enabled() ) { + return new WP_REST_Response( + [ + 'organizations' => [], + 'error' => __( 'WorkOS is not configured. Save API credentials to enable org selection.', 'integration-workos' ), + ], + 200 + ); + } + + $cache_key = 'workos_organizations_cache_' . Config::get_active_environment(); + $cached = get_transient( $cache_key ); + if ( is_array( $cached ) ) { + return new WP_REST_Response( + [ 'organizations' => $this->shape_organizations( $cached ) ], + 200 + ); + } + + $result = workos()->api()->list_organizations( [ 'limit' => 100 ] ); + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( + [ + 'organizations' => [], + 'error' => $result->get_error_message(), + ], + 200 + ); + } + + $organizations = isset( $result['data'] ) && is_array( $result['data'] ) + ? $result['data'] + : []; + + set_transient( $cache_key, $organizations, 5 * MINUTE_IN_SECONDS ); + + return new WP_REST_Response( + [ 'organizations' => $this->shape_organizations( $organizations ) ], + 200 + ); + } + + /** + * Trim the WorkOS organization payload down to the two fields the + * editor needs. + * + * @param array $organizations Raw organizations from the WorkOS API. + * + * @return array + */ + private function shape_organizations( array $organizations ): array { + $shaped = []; + foreach ( $organizations as $org ) { + if ( ! is_array( $org ) ) { + continue; + } + $id = isset( $org['id'] ) ? (string) $org['id'] : ''; + if ( '' === $id ) { + continue; + } + $shaped[] = [ + 'id' => $id, + 'name' => isset( $org['name'] ) ? (string) $org['name'] : $id, + ]; + } + + usort( + $shaped, + static function ( array $a, array $b ): int { + return strcasecmp( $a['name'], $b['name'] ); + } + ); + + return $shaped; + } + + /** + * DELETE /admin/profiles/{id} — delete a profile. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function delete_profile( WP_REST_Request $request ) { + $id = (int) $request['id']; + $result = $this->repository->delete( $id ); + if ( is_wp_error( $result ) ) { + $status = 'workos_profile_not_found' === $result->get_error_code() ? 404 : 400; + return $this->error_with_status( $result, $status ); + } + + return new WP_REST_Response( + [ + 'deleted' => true, + 'id' => $id, + ], + 200 + ); + } + + /** + * Confirm a supplied `branding.logo_attachment_id` points at an image. + * + * A non-image attachment (PDF, mp3, etc.) would render a broken + * `` tag in the login shell. Validate the stored MIME type on + * save so the bad reference never lands in the Profile payload. + * + * @param array $params Incoming JSON body. + * + * @return WP_Error|null WP_Error when the attachment is not a usable + * image; null when absent, zero, or valid. + */ + private function validate_logo_attachment( array $params ): ?WP_Error { + $branding = isset( $params['branding'] ) && is_array( $params['branding'] ) + ? $params['branding'] + : []; + $attachment_id = isset( $branding['logo_attachment_id'] ) + ? (int) $branding['logo_attachment_id'] + : 0; + + if ( $attachment_id <= 0 ) { + return null; + } + + $post = get_post( $attachment_id ); + if ( ! $post || 'attachment' !== $post->post_type ) { + return new WP_Error( + 'workos_profile_logo_not_found', + __( 'The selected logo attachment does not exist.', 'integration-workos' ) + ); + } + + if ( 0 !== strpos( (string) $post->post_mime_type, 'image/' ) ) { + return new WP_Error( + 'workos_profile_logo_not_image', + __( 'The selected logo must be an image file.', 'integration-workos' ) + ); + } + + return null; + } + + /** + * Attach an HTTP status to a WP_Error returned by the repository. + * + * @param WP_Error $error Error from the repository. + * @param int $default_status HTTP status to set when the error has no status data. + * + * @return WP_Error + */ + private function error_with_status( WP_Error $error, int $default_status ): WP_Error { + $data = $error->get_error_data(); + if ( ! is_array( $data ) || ! isset( $data['status'] ) ) { + $error->add_data( [ 'status' => $default_status ] ); + } + + return $error; + } +} diff --git a/src/WorkOS/Admin/OnboardingPage.php b/src/WorkOS/Admin/OnboardingPage.php index 75fa634..814ff6b 100644 --- a/src/WorkOS/Admin/OnboardingPage.php +++ b/src/WorkOS/Admin/OnboardingPage.php @@ -61,6 +61,8 @@ public function enqueue_assets( string $hook_suffix ): void { true ); + wp_set_script_translations( 'workos-onboarding', 'integration-workos' ); + wp_localize_script( 'workos-onboarding', 'workosOnboarding', diff --git a/src/WorkOS/Admin/Settings.php b/src/WorkOS/Admin/Settings.php index c9fddad..6dc0780 100644 --- a/src/WorkOS/Admin/Settings.php +++ b/src/WorkOS/Admin/Settings.php @@ -260,6 +260,8 @@ private function enqueue_role_mapping_assets(): void { true ); + wp_set_script_translations( 'workos-role-mapping', 'integration-workos' ); + wp_enqueue_style( 'workos-role-mapping', WORKOS_URL . 'build/role-mapping.css', @@ -301,6 +303,8 @@ private function enqueue_redirect_urls_assets(): void { true ); + wp_set_script_translations( 'workos-redirect-urls', 'integration-workos' ); + wp_enqueue_style( 'workos-redirect-urls', WORKOS_URL . 'build/redirect-urls.css', @@ -344,6 +348,8 @@ private function enqueue_logout_redirect_urls_assets(): void { true ); + wp_set_script_translations( 'workos-logout-redirect-urls', 'integration-workos' ); + wp_enqueue_style( 'workos-logout-redirect-urls', WORKOS_URL . 'build/logout-redirect-urls.css', @@ -555,8 +561,16 @@ function () { add_settings_section( 'workos_auth', __( 'Authentication', 'integration-workos' ), - function () { + static function (): void { + $profiles_url = admin_url( 'admin.php?page=workos-login-profiles' ); echo '

' . esc_html__( 'Configure how users authenticate with your site.', 'integration-workos' ) . '

'; + echo '

'; + printf( + /* translators: %s: link to Login Profiles admin page */ + esc_html__( 'Login Mode picks the default experience on wp-login.php. For per-page variants (different auth methods, pinned organizations, branding), manage %s.', 'integration-workos' ), + '' . esc_html__( 'Login Profiles', 'integration-workos' ) . '' + ); + echo '

'; }, 'workos' ); @@ -570,8 +584,9 @@ function () { [ 'name' => $this->env_option( 'login_mode' ), 'options' => [ - 'redirect' => __( 'AuthKit Redirect (Recommended)', 'integration-workos' ), - 'headless' => __( 'Headless API (Custom Form)', 'integration-workos' ), + 'custom' => __( 'Custom — React UI on wp-login.php (Recommended)', 'integration-workos' ), + 'redirect' => __( 'AuthKit Redirect — WorkOS-hosted login page', 'integration-workos' ), + 'headless' => __( 'Headless API — keep the native WP form', 'integration-workos' ), ], ] ); @@ -1899,7 +1914,7 @@ public function sanitize_environment_options( $input ): array { // Enumerated choice fields. $choices = [ - 'login_mode' => [ 'redirect', 'headless' ], + 'login_mode' => [ 'custom', 'redirect', 'headless' ], 'deprovision_action' => [ 'deactivate', 'demote', 'delete' ], ]; foreach ( $choices as $key => $allowed ) { diff --git a/src/WorkOS/Api/Client.php b/src/WorkOS/Api/Client.php index b11fa20..ee54ef5 100644 --- a/src/WorkOS/Api/Client.php +++ b/src/WorkOS/Api/Client.php @@ -86,6 +86,42 @@ public static function safe_redirect( string $url ): void { exit; } + /** + * Perform an outbound HTTP request to the WorkOS API through + * `wp_safe_remote_request()`. + * + * Mirrors {@see self::safe_redirect()}: temporarily allows the WorkOS + * API host in WordPress's outbound-URL allowlist so the "safe" variant + * never rejects our own API when `BASE_URL` points at a private host + * (local mock server, staging tunnel, IP literal, etc.). The filter is + * a no-op for public production hosts since `wp_http_validate_url()` + * only consults it for reserved / private addresses — but it keeps + * every outbound call symmetric with `safe_redirect()` so switching + * `BASE_URL` for tests never silently 400s the request layer. + * + * @param string $url Absolute URL on the WorkOS API host. + * @param array $args wp_remote_request-style args. + * + * @return array|\WP_Error + */ + public static function safe_remote_request( string $url, array $args = [] ) { + $allowed_host = wp_parse_url( self::BASE_URL, PHP_URL_HOST ); + + $filter = static function ( bool $external, string $host ) use ( $allowed_host ): bool { + if ( $host === $allowed_host ) { + return true; + } + + return $external; + }; + + add_filter( 'http_request_host_is_external', $filter, 10, 2 ); + $response = wp_safe_remote_request( $url, $args ); + remove_filter( 'http_request_host_is_external', $filter, 10 ); + + return $response; + } + // ------------------------------------------------------------------------- // Authentication // ------------------------------------------------------------------------- @@ -151,12 +187,13 @@ public function authenticate_with_code( string $code, string $redirect_uri ) { /** * Authenticate with email + password (headless mode). * - * @param string $email User email. - * @param string $password User password. + * @param string $email User email. + * @param string $password User password. + * @param string|null $radar_action_token Optional Radar action token from the browser SDK. * * @return array|\WP_Error User data and tokens, or error. */ - public function authenticate_with_password( string $email, string $password ) { + public function authenticate_with_password( string $email, string $password, ?string $radar_action_token = null ) { return $this->post( '/user_management/authenticate', [ @@ -165,10 +202,372 @@ public function authenticate_with_password( string $email, string $password ) { 'client_id' => $this->client_id, 'client_secret' => $this->api_key, 'grant_type' => 'password', + ], + $this->radar_headers( $radar_action_token ) + ); + } + + /** + * Send a magic authentication code to the given email. + * + * Enumeration-safe — WorkOS returns success even for unknown emails. + * + * @param string $email Email address to send the code to. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function send_magic_auth_code( string $email, ?string $radar_action_token = null ) { + return $this->post( + '/user_management/magic_auth/send', + [ 'email' => $email ], + $this->radar_headers( $radar_action_token ) + ); + } + + /** + * Authenticate a user with a magic auth code. + * + * @param string $email Email address. + * @param string $code One-time code delivered via email. + * @param string|null $pending_auth_token Pending authentication token from a prior incomplete auth. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function authenticate_with_magic_auth( + string $email, + string $code, + ?string $pending_auth_token = null, + ?string $radar_action_token = null + ) { + $body = [ + 'email' => $email, + 'code' => $code, + 'client_id' => $this->client_id, + 'client_secret' => $this->api_key, + 'grant_type' => 'urn:workos:oauth:grant-type:magic-auth:code', + ]; + + if ( null !== $pending_auth_token && '' !== $pending_auth_token ) { + $body['pending_authentication_token'] = $pending_auth_token; + } + + return $this->post( + '/user_management/authenticate', + $body, + $this->radar_headers( $radar_action_token ) + ); + } + + /** + * Complete a pending authentication with a TOTP code. + * + * @param string $pending_auth_token Pending authentication token returned from prior auth. + * @param string $authentication_challenge_id Challenge ID. + * @param string $code TOTP code. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function authenticate_with_totp( + string $pending_auth_token, + string $authentication_challenge_id, + string $code, + ?string $radar_action_token = null + ) { + return $this->post( + '/user_management/authenticate', + [ + 'client_id' => $this->client_id, + 'client_secret' => $this->api_key, + 'grant_type' => 'urn:workos:oauth:grant-type:mfa-totp', + 'pending_authentication_token' => $pending_auth_token, + 'authentication_challenge_id' => $authentication_challenge_id, + 'code' => $code, + ], + $this->radar_headers( $radar_action_token ) + ); + } + + /** + * Exchange a refresh token for a new access token. + * + * Used by both the server-side token refresh in REST\TokenAuth and by the + * React shell's proactive session refresh. + * + * @param string $refresh_token Refresh token. + * + * @return array|\WP_Error New tokens and user, or error. + */ + public function authenticate_with_refresh_token( string $refresh_token ) { + return $this->post( + '/user_management/authenticate', + [ + 'client_id' => $this->client_id, + 'client_secret' => $this->api_key, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refresh_token, ] ); } + /** + * Alias for {@see self::authenticate_with_refresh_token()}. + * + * @param string $refresh_token Refresh token. + * + * @return array|\WP_Error + */ + public function refresh_session( string $refresh_token ) { + return $this->authenticate_with_refresh_token( $refresh_token ); + } + + /** + * Accept a WorkOS invitation and authenticate the invited user in one + * atomic call. + * + * WorkOS validates the invitation token, identifies or creates the user + * on the invitation's original email (the caller CANNOT substitute an + * arbitrary email), sets their password, and returns a session. This + * is the only supported path for accepting invitations — it replaces + * a prior create_user + authenticate_with_password pair that let the + * caller override the email and force `email_verified = true`. + * + * @param string $invitation_token Invitation token from the email link. + * @param string $password Password the new user is setting. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error Session + user, or error. + */ + public function authenticate_with_invitation( + string $invitation_token, + string $password, + ?string $radar_action_token = null + ) { + return $this->post( + '/user_management/authenticate', + [ + 'client_id' => $this->client_id, + 'client_secret' => $this->api_key, + 'grant_type' => 'urn:workos:oauth:grant-type:invitation_token', + 'invitation_token' => $invitation_token, + 'password' => $password, + ], + $this->radar_headers( $radar_action_token ) + ); + } + + // ------------------------------------------------------------------------- + // Password reset + // ------------------------------------------------------------------------- + + /** + * Send a password reset email. + * + * Enumeration-safe — WorkOS returns success even for unknown emails. + * + * @param string $email Email address. + * @param string $password_reset_url URL the reset email should link to. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function send_password_reset( + string $email, + string $password_reset_url, + ?string $radar_action_token = null + ) { + return $this->post( + '/user_management/password_reset/send', + [ + 'email' => $email, + 'password_reset_url' => $password_reset_url, + ], + $this->radar_headers( $radar_action_token ) + ); + } + + /** + * Complete a password reset with a token and new password. + * + * @param string $token Password reset token from the reset email. + * @param string $new_password New password. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function reset_password( + string $token, + string $new_password, + ?string $radar_action_token = null + ) { + return $this->post( + '/user_management/password_reset/confirm', + [ + 'token' => $token, + 'new_password' => $new_password, + ], + $this->radar_headers( $radar_action_token ) + ); + } + + // ------------------------------------------------------------------------- + // Email verification + // ------------------------------------------------------------------------- + + /** + * Send a verification email to a user. + * + * @param string $user_id WorkOS user ID. + * + * @return array|\WP_Error + */ + public function send_verification_email( string $user_id ) { + return $this->post( "/user_management/users/{$user_id}/email_verification/send" ); + } + + /** + * Verify a user's email with a code. + * + * @param string $user_id WorkOS user ID. + * @param string $code Verification code from the email. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function verify_email( + string $user_id, + string $code, + ?string $radar_action_token = null + ) { + return $this->post( + "/user_management/users/{$user_id}/email_verification/confirm", + [ 'code' => $code ], + $this->radar_headers( $radar_action_token ) + ); + } + + // ------------------------------------------------------------------------- + // Invitations + // ------------------------------------------------------------------------- + + /** + * Look up an invitation by its token. + * + * @param string $token Invitation token. + * + * @return array|\WP_Error + */ + public function get_invitation_by_token( string $token ) { + return $this->get( "/user_management/invitations/by_token/{$token}" ); + } + + // ------------------------------------------------------------------------- + // Authentication factors (MFA) + // ------------------------------------------------------------------------- + + /** + * List a user's enrolled authentication factors. + * + * @param string $user_id WorkOS user ID. + * + * @return array|\WP_Error + */ + public function list_auth_factors( string $user_id ) { + return $this->get( "/user_management/users/{$user_id}/auth_factors" ); + } + + /** + * Enroll a TOTP authentication factor. + * + * @param string $user_id WorkOS user ID. + * @param string $totp_issuer Human-readable issuer displayed by the authenticator (e.g. site name). + * @param string $totp_user Human-readable account label (usually the user's email). + * + * @return array|\WP_Error Factor data including `totp.qr_code` and `totp.secret`. + */ + public function enroll_totp_factor( string $user_id, string $totp_issuer, string $totp_user ) { + return $this->post( + "/user_management/users/{$user_id}/auth_factors", + [ + 'type' => 'totp', + 'totp_issuer' => $totp_issuer, + 'totp_user' => $totp_user, + ] + ); + } + + /** + * Enroll an SMS authentication factor. + * + * @param string $user_id WorkOS user ID. + * @param string $phone_number E.164-formatted phone number. + * + * @return array|\WP_Error + */ + public function enroll_sms_factor( string $user_id, string $phone_number ) { + return $this->post( + "/user_management/users/{$user_id}/auth_factors", + [ + 'type' => 'sms', + 'phone_number' => $phone_number, + ] + ); + } + + /** + * Delete (un-enroll) an authentication factor. + * + * @param string $factor_id Factor ID. + * + * @return array|\WP_Error + */ + public function delete_auth_factor( string $factor_id ) { + return $this->delete( "/user_management/auth_factors/{$factor_id}" ); + } + + /** + * Issue a challenge for an enrolled authentication factor. + * + * For SMS factors, this triggers the text message; for TOTP, it just + * returns a challenge record to verify against. + * + * @param string $factor_id Factor ID. + * @param string|null $sms_template Optional SMS template override. + * + * @return array|\WP_Error Challenge data. + */ + public function challenge_auth_factor( string $factor_id, ?string $sms_template = null ) { + $body = []; + if ( null !== $sms_template && '' !== $sms_template ) { + $body['sms_template'] = $sms_template; + } + + return $this->post( "/user_management/auth_factors/{$factor_id}/challenge", $body ); + } + + /** + * Verify an authentication challenge with a user-provided code. + * + * @param string $challenge_id Challenge ID. + * @param string $code Code entered by the user. + * @param string|null $radar_action_token Optional Radar action token. + * + * @return array|\WP_Error + */ + public function verify_auth_challenge( + string $challenge_id, + string $code, + ?string $radar_action_token = null + ) { + return $this->post( + "/user_management/auth_challenges/{$challenge_id}/verify", + [ 'code' => $code ], + $this->radar_headers( $radar_action_token ) + ); + } + // ------------------------------------------------------------------------- // User Management // ------------------------------------------------------------------------- @@ -199,12 +598,17 @@ public function update_user( string $user_id, array $data ) { /** * Create a WorkOS user. * - * @param array $data User data (email required; first_name, last_name, email_verified optional). + * @param array $data User data (email required; first_name, last_name, email_verified, password optional). + * @param string|null $radar_action_token Optional Radar action token for signup-form submissions. * * @return array|\WP_Error */ - public function create_user( array $data ) { - return $this->post( '/user_management/users', $data ); + public function create_user( array $data, ?string $radar_action_token = null ) { + return $this->post( + '/user_management/users', + $data, + $this->radar_headers( $radar_action_token ) + ); } /** @@ -545,12 +949,13 @@ private static function jwk_to_pem( array $jwk ) { /** * Send a GET request. * - * @param string $path API path. - * @param array $params Query parameters. + * @param string $path API path. + * @param array $params Query parameters. + * @param array $extra_headers Additional headers to merge in. * * @return array|\WP_Error */ - private function get( string $path, array $params = [] ) { + private function get( string $path, array $params = [], array $extra_headers = [] ) { $url = self::BASE_URL . $path; if ( $params ) { $url .= '?' . http_build_query( $params ); @@ -559,7 +964,7 @@ private function get( string $path, array $params = [] ) { $response = wp_remote_get( $url, [ - 'headers' => $this->headers(), + 'headers' => $this->headers( false, $extra_headers ), 'timeout' => 15, ] ); @@ -570,16 +975,17 @@ private function get( string $path, array $params = [] ) { /** * Send a POST request. * - * @param string $path API path. - * @param array $data Request body. + * @param string $path API path. + * @param array $data Request body. + * @param array $extra_headers Additional headers to merge in. * * @return array|\WP_Error */ - private function post( string $path, array $data = [] ) { + private function post( string $path, array $data = [], array $extra_headers = [] ) { $response = wp_remote_post( self::BASE_URL . $path, [ - 'headers' => $this->headers( true ), + 'headers' => $this->headers( true, $extra_headers ), 'body' => wp_json_encode( $data ), 'timeout' => 15, ] @@ -591,17 +997,18 @@ private function post( string $path, array $data = [] ) { /** * Send a PUT request. * - * @param string $path API path. - * @param array $data Request body. + * @param string $path API path. + * @param array $data Request body. + * @param array $extra_headers Additional headers to merge in. * * @return array|\WP_Error */ - private function put( string $path, array $data = [] ) { + private function put( string $path, array $data = [], array $extra_headers = [] ) { $response = wp_remote_request( self::BASE_URL . $path, [ 'method' => 'PUT', - 'headers' => $this->headers( true ), + 'headers' => $this->headers( true, $extra_headers ), 'body' => wp_json_encode( $data ), 'timeout' => 15, ] @@ -610,14 +1017,36 @@ private function put( string $path, array $data = [] ) { return $this->parse_response( $response ); } + /** + * Send a DELETE request. + * + * @param string $path API path. + * @param array $extra_headers Additional headers to merge in. + * + * @return array|\WP_Error + */ + private function delete( string $path, array $extra_headers = [] ) { + $response = self::safe_remote_request( + self::BASE_URL . $path, + [ + 'method' => 'DELETE', + 'headers' => $this->headers( false, $extra_headers ), + 'timeout' => 15, + ] + ); + + return $this->parse_response( $response ); + } + /** * Build default request headers. * - * @param bool $json Whether to include JSON content type. + * @param bool $json Whether to include JSON content type. + * @param array $extra_headers Additional headers to merge in. Extra headers override defaults. * * @return array */ - private function headers( bool $json = false ): array { + private function headers( bool $json = false, array $extra_headers = [] ): array { $headers = [ 'Authorization' => 'Bearer ' . $this->api_key, 'User-Agent' => 'workos-wordpress/' . WORKOS_VERSION, @@ -627,9 +1056,34 @@ private function headers( bool $json = false ): array { $headers['Content-Type'] = 'application/json'; } + foreach ( $extra_headers as $name => $value ) { + if ( '' === (string) $value ) { + continue; + } + $headers[ $name ] = $value; + } + return $headers; } + /** + * Build the `x-workos-radar-action-token` header array from a token. + * + * Returns an empty array when the token is empty so callers can always + * pass the result as the `extra_headers` argument. + * + * @param string|null $radar_action_token Radar action token from the browser SDK. + * + * @return array + */ + private function radar_headers( ?string $radar_action_token ): array { + if ( null === $radar_action_token || '' === $radar_action_token ) { + return []; + } + + return [ 'x-workos-radar-action-token' => $radar_action_token ]; + } + /** * Parse a wp_remote_* response. * diff --git a/src/WorkOS/Auth/AuthKit/Controller.php b/src/WorkOS/Auth/AuthKit/Controller.php new file mode 100644 index 0000000..3651998 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/Controller.php @@ -0,0 +1,73 @@ +container->singleton( ProfileRepository::class ); + $this->container->singleton( ProfileRouter::class ); + $this->container->singleton( RateLimiter::class ); + $this->container->singleton( Nonce::class ); + $this->container->singleton( Radar::class ); + $this->container->singleton( Renderer::class ); + $this->container->singleton( LoginTakeover::class ); + $this->container->singleton( Shortcode::class ); + $this->container->singleton( FrontendRoute::class ); + $this->container->singleton( ModeSyncer::class ); + + // Register the CPT early — `init` priority 5 so other components that + // query the type on `init` (priority 10+) see a registered post type. + add_action( 'init', [ $this, 'register_post_type' ], 5 ); + + // Activate the wp-login.php takeover, the shortcode, and the + // dedicated /workos/login/{profile} route. Each no-ops when the + // resolved profile is in legacy AuthKit-redirect mode. + $this->container->get( LoginTakeover::class )->register(); + $this->container->get( Shortcode::class )->register(); + $this->container->get( FrontendRoute::class )->register(); + $this->container->get( ModeSyncer::class )->register(); + } + + /** + * Unregister the AuthKit module. + * + * @return void + */ + protected function doUnregister(): void { + remove_action( 'init', [ $this, 'register_post_type' ], 5 ); + + $this->container->get( LoginTakeover::class )->unregister(); + } + + /** + * Register the Login Profile custom post type. + * + * Public wrapper so the hook target resolves cleanly. + * + * @return void + */ + public function register_post_type(): void { + $this->container->get( ProfileRepository::class )->register_post_type(); + } +} diff --git a/src/WorkOS/Auth/AuthKit/FrontendRoute.php b/src/WorkOS/Auth/AuthKit/FrontendRoute.php new file mode 100644 index 0000000..1e00036 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/FrontendRoute.php @@ -0,0 +1,131 @@ +profiles = $profiles; + $this->renderer = $renderer; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'init', [ self::class, 'register_rewrite' ] ); + add_filter( 'query_vars', [ $this, 'register_query_var' ] ); + add_action( 'template_redirect', [ $this, 'maybe_render' ] ); + } + + /** + * Register the /workos/login/{profile} rewrite rule. + * + * Static so Plugin::activate() can call it before the DI container is + * built, matching the convention used by {@see \WorkOS\Auth\Login::register_rewrite()}. + * Called both from activation (so the rule persists after the + * flush_rewrite_rules() call below) and from the `init` hook on every + * request (so the rule is present in the rewrite table for matching). + * + * @return void + */ + public static function register_rewrite(): void { + add_rewrite_rule( + self::REWRITE, + 'index.php?' . self::QUERY_VAR . '=$matches[1]', + 'top' + ); + } + + /** + * Expose our query var to WP_Query. + * + * @param array $vars Existing query vars. + * + * @return array + */ + public function register_query_var( array $vars ): array { + $vars[] = self::QUERY_VAR; + return $vars; + } + + /** + * Render the AuthKit shell when our query var is populated. + * + * @return void + */ + public function maybe_render(): void { + $slug = (string) get_query_var( self::QUERY_VAR ); + if ( '' === $slug ) { + return; + } + + $profile = $this->profiles->find_by_slug( $slug ); + if ( ! $profile ) { + status_header( 404 ); + nocache_headers(); + wp_die( + esc_html__( 'Login profile not found.', 'integration-workos' ), + esc_html__( 'Not Found', 'integration-workos' ), + [ 'response' => 404 ] + ); + } + + if ( ! $profile->is_custom_mode() ) { + // Redirect-mode profiles hand off to the legacy callback flow. + status_header( 301 ); + wp_safe_redirect( wp_login_url( (string) ( SuperGlobals::get_get_var( 'redirect_to' ) ?? '' ) ) ); + exit; + } + + $context = [ + 'redirect_to' => (string) ( SuperGlobals::get_get_var( 'redirect_to' ) ?? '' ), + 'invitation_token' => (string) ( SuperGlobals::get_get_var( 'invitation_token' ) ?? '' ), + 'reset_token' => (string) ( SuperGlobals::get_get_var( 'token' ) ?? '' ), + ]; + + $this->renderer->render_full_page( $profile, $context ); + } +} diff --git a/src/WorkOS/Auth/AuthKit/LoginCompleter.php b/src/WorkOS/Auth/AuthKit/LoginCompleter.php new file mode 100644 index 0000000..dc7af50 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/LoginCompleter.php @@ -0,0 +1,206 @@ + { id, email, display_name }, + * 'redirect_to' => string, + * } + * When MFA is required: { + * 'mfa_required' => true, + * 'pending_authentication_token' => string, + * 'factors' => array, + * } + */ + public function complete( array $workos_response, Profile $profile, string $redirect_to = '' ) { + // WorkOS signals a required second factor by returning a pending + // authentication token and/or an authentication_factor. Hand that + // state back to the React shell so it can run the MFA challenge + // step and then POST /auth/mfa/verify. + if ( ! empty( $workos_response['pending_authentication_token'] ) ) { + $factors = $this->extract_factors( $workos_response ); + + // Enforce the profile's factor allowlist. If WorkOS surfaces a + // challenge for a factor type the admin has disabled on this + // profile, reject rather than completing via an unauthorized + // factor. + $allowed_types = $profile->get_mfa()['factors'] ?? []; + if ( ! empty( $allowed_types ) ) { + foreach ( $factors as $factor ) { + $type = (string) ( $factor['type'] ?? '' ); + if ( '' !== $type && ! in_array( $type, $allowed_types, true ) ) { + return new WP_Error( + 'workos_authkit_factor_not_allowed', + __( 'The multi-factor method returned is not permitted for this login.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + } + } + + return [ + 'mfa_required' => true, + 'pending_authentication_token' => (string) $workos_response['pending_authentication_token'], + 'factors' => $factors, + ]; + } + + // When the profile insists MFA is always required and WorkOS did + // not return a pending factor (i.e., the user has nothing enrolled + // or WorkOS is not enforcing org-level MFA), we must refuse to + // complete the login. Silently letting the user through would + // quietly break the admin's "MFA required" guarantee. + if ( Profile::MFA_ENFORCE_ALWAYS === ( $profile->get_mfa()['enforce'] ?? '' ) ) { + return new WP_Error( + 'workos_authkit_mfa_required', + __( 'This login requires multi-factor authentication, but no factor is enrolled on your account. Please enroll a factor and try again.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + $workos_user = $workos_response['user'] ?? null; + if ( ! is_array( $workos_user ) || empty( $workos_user['id'] ) ) { + return new WP_Error( + 'workos_authkit_invalid_response', + __( 'Unexpected response from the authentication provider.', 'integration-workos' ), + [ 'status' => 502 ] + ); + } + + $wp_user = UserSync::find_or_create_wp_user( $workos_user ); + if ( is_wp_error( $wp_user ) ) { + return $wp_user; + } + + $entitlement = EntitlementGate::evaluate( $wp_user->ID, $workos_response ); + if ( is_wp_error( $entitlement ) ) { + return $entitlement; + } + + Login::store_tokens( $wp_user->ID, $workos_response ); + + wp_set_auth_cookie( $wp_user->ID, true ); + wp_set_current_user( $wp_user->ID ); + + /** This action is documented in Auth/Login.php */ + do_action( 'workos_user_authenticated', $wp_user->ID, $workos_response ); + + /** + * Fires on WP login. + * + * @param string $user_login Username. + * @param WP_User $wp_user User object. + */ + do_action( 'wp_login', $wp_user->user_login, $wp_user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + + return [ + 'user' => [ + 'id' => (int) $wp_user->ID, + 'email' => (string) $wp_user->user_email, + 'display_name' => (string) $wp_user->display_name, + ], + 'redirect_to' => $this->resolve_redirect( $profile, $redirect_to, $wp_user ), + ]; + } + + /** + * Resolve the post-login redirect. + * + * Profile-level `post_login_redirect` wins over the client's + * `redirect_to` — this is intentional: admins use profile redirects to + * enforce where each login page lands users. Falls back through the + * existing Redirect::resolve() to honor role-based rules. + * + * @param Profile $profile Active profile. + * @param string $redirect_to Client-provided redirect URL. + * @param WP_User $wp_user Authenticated user. + * + * @return string + */ + private function resolve_redirect( Profile $profile, string $redirect_to, WP_User $wp_user ): string { + $profile_redirect = $profile->get_post_login_redirect(); + $requested = '' !== $profile_redirect ? $profile_redirect : $redirect_to; + + if ( '' === $requested ) { + $requested = admin_url(); + } + + // Redirect::resolve applies role-based rules AND wp_validate_redirect. + return Redirect::resolve( $requested, $wp_user ); + } + + /** + * Normalize factor metadata returned alongside a pending auth token. + * + * WorkOS returns either a single `authentication_factor` object or an + * array under `authentication_factors` depending on context. Always + * return a list so the React shell has a predictable shape. + * + * @param array $workos_response WorkOS authenticate response. + * + * @return array + */ + private function extract_factors( array $workos_response ): array { + if ( isset( $workos_response['authentication_factors'] ) && is_array( $workos_response['authentication_factors'] ) ) { + return $workos_response['authentication_factors']; + } + + if ( isset( $workos_response['authentication_factor'] ) && is_array( $workos_response['authentication_factor'] ) ) { + return [ $workos_response['authentication_factor'] ]; + } + + return []; + } +} diff --git a/src/WorkOS/Auth/AuthKit/LoginTakeover.php b/src/WorkOS/Auth/AuthKit/LoginTakeover.php new file mode 100644 index 0000000..5816e86 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/LoginTakeover.php @@ -0,0 +1,181 @@ +router = $router; + $this->renderer = $renderer; + $this->profiles = $profiles; + } + + /** + * Register the takeover on `login_init`. + * + * @return void + */ + public function register(): void { + add_action( 'login_init', [ $this, 'maybe_takeover' ], 5 ); + } + + /** + * Unregister (used by tests). + * + * @return void + */ + public function unregister(): void { + remove_action( 'login_init', [ $this, 'maybe_takeover' ], 5 ); + } + + /** + * Hook target. Decides whether to hand the page over to React. + * + * @return void + */ + public function maybe_takeover(): void { + if ( ! $this->should_takeover() ) { + return; + } + + $profile = $this->resolve_profile(); + + // AuthKit-redirect profiles still use the legacy Login::maybe_redirect_to_authkit() + // path — we deliberately pass them through rather than duplicating behavior. + if ( ! $profile->is_custom_mode() ) { + return; + } + + $context = $this->build_context(); + + $this->renderer->render_full_page( $profile, $context ); + } + + /** + * Whether this request is a candidate for takeover. + * + * @return bool + */ + private function should_takeover(): bool { + if ( ! workos()->is_enabled() ) { + return false; + } + + // Don't fight the "you've been logged out" screen — users need to see it. + if ( ! empty( SuperGlobals::get_get_var( 'loggedout' ) ) ) { + return false; + } + + // Plugin-level escape hatch. + if ( class_exists( LoginBypass::class ) && LoginBypass::is_active() ) { + return false; + } + + // Classic-fallback opt-in (`?fallback=1`). + if ( ! empty( SuperGlobals::get_get_var( 'fallback' ) ) + && workos()->option( 'allow_password_fallback', true ) ) { + return false; + } + + $action = SuperGlobals::get_var( 'action' ) ?? 'login'; + if ( 'login' !== $action ) { + return false; + } + + return true; + } + + /** + * Resolve the profile for this request. + * + * @return Profile + */ + private function resolve_profile(): Profile { + $explicit_slug = sanitize_title( + (string) ( SuperGlobals::get_get_var( 'workos_profile' ) ?? '' ) + ); + + $context = [ + 'explicit_slug' => $explicit_slug, + 'redirect_to' => (string) ( SuperGlobals::get_get_var( 'redirect_to' ) ?? '' ), + 'referrer' => (string) ( SuperGlobals::get_server_var( 'HTTP_REFERER' ) ?? '' ), + 'user_id' => get_current_user_id(), + ]; + + return $this->router->resolve( $context ); + } + + /** + * Build the renderer context from current-request state. + * + * @return array + */ + private function build_context(): array { + $redirect_to = (string) ( SuperGlobals::get_get_var( 'redirect_to' ) ?? '' ); + + $context = [ + 'redirect_to' => $redirect_to, + 'invitation_token' => (string) ( SuperGlobals::get_get_var( 'invitation_token' ) ?? '' ), + 'reset_token' => (string) ( SuperGlobals::get_get_var( 'token' ) ?? '' ), + ]; + + // When the reset-password email links back here, boot straight into + // the reset-confirm flow. + $workos_action = (string) ( SuperGlobals::get_get_var( 'workos_action' ) ?? '' ); + if ( 'reset-password' === $workos_action && '' !== $context['reset_token'] ) { + $context['initial_step'] = 'reset_confirm'; + } + + return $context; + } +} diff --git a/src/WorkOS/Auth/AuthKit/ModeSyncer.php b/src/WorkOS/Auth/AuthKit/ModeSyncer.php new file mode 100644 index 0000000..633dc94 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/ModeSyncer.php @@ -0,0 +1,254 @@ +repository = $repository; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + // Env option → profile sync (Production + Staging serialized rows). + add_action( 'update_option_workos_production', [ $this, 'on_option_update' ], 10, 2 ); + add_action( 'update_option_workos_staging', [ $this, 'on_option_update' ], 10, 2 ); + add_action( 'add_option_workos_production', [ $this, 'on_option_add' ], 10, 2 ); + add_action( 'add_option_workos_staging', [ $this, 'on_option_add' ], 10, 2 ); + + // Profile → env option sync. + add_action( 'workos_login_profile_saved', [ $this, 'on_profile_saved' ] ); + } + + /** + * Map a `login_mode` env value to a profile-mode enum value. + * + * Returns null for `headless` (no corresponding profile mode) and for + * unknown values. + * + * @param string $login_mode Raw value from the env option. + * + * @return string|null + */ + public static function login_mode_to_profile_mode( string $login_mode ): ?string { + switch ( $login_mode ) { + case self::LOGIN_MODE_CUSTOM: + return Profile::MODE_CUSTOM; + case self::LOGIN_MODE_REDIRECT: + return Profile::MODE_AUTHKIT_REDIRECT; + default: + return null; + } + } + + /** + * Map a profile-mode enum value back to a `login_mode` env value. + * + * @param string $profile_mode Profile mode enum. + * + * @return string|null + */ + public static function profile_mode_to_login_mode( string $profile_mode ): ?string { + switch ( $profile_mode ) { + case Profile::MODE_CUSTOM: + return self::LOGIN_MODE_CUSTOM; + case Profile::MODE_AUTHKIT_REDIRECT: + return self::LOGIN_MODE_REDIRECT; + default: + return null; + } + } + + /** + * Hook target for `update_option_workos_{env}`. + * + * @param mixed $old_value Previous option value (serialized array). + * @param mixed $value New option value. + * + * @return void + */ + public function on_option_update( $old_value, $value ): void { + if ( $this->syncing ) { + return; + } + + $old_mode = is_array( $old_value ) ? (string) ( $old_value['login_mode'] ?? '' ) : ''; + $new_mode = is_array( $value ) ? (string) ( $value['login_mode'] ?? '' ) : ''; + + if ( $old_mode === $new_mode ) { + return; + } + + $this->maybe_push_to_profile( $new_mode ); + } + + /** + * Hook target for `add_option_workos_{env}` (first-ever write). + * + * @param string $option Option name. + * @param mixed $value Option value. + * + * @return void + */ + public function on_option_add( $option, $value ): void { + if ( $this->syncing ) { + return; + } + + $new_mode = is_array( $value ) ? (string) ( $value['login_mode'] ?? '' ) : ''; + if ( '' === $new_mode ) { + return; + } + + $this->maybe_push_to_profile( $new_mode ); + } + + /** + * Hook target for `workos_login_profile_saved`. + * + * @param Profile $profile Saved profile. + * + * @return void + */ + public function on_profile_saved( Profile $profile ): void { + if ( $this->syncing ) { + return; + } + + if ( Profile::DEFAULT_SLUG !== $profile->get_slug() ) { + return; + } + + $next_login_mode = self::profile_mode_to_login_mode( $profile->get_mode() ); + if ( null === $next_login_mode ) { + return; + } + + $this->write_login_mode( $next_login_mode ); + } + + /** + * Update the default profile's mode to match the given login_mode value. + * + * @param string $login_mode Login mode value from the env option. + * + * @return void + */ + private function maybe_push_to_profile( string $login_mode ): void { + $next_profile_mode = self::login_mode_to_profile_mode( $login_mode ); + if ( null === $next_profile_mode ) { + // `headless` or unknown — do not touch the profile. + return; + } + + $default = $this->repository->ensure_default(); + if ( $default->get_mode() === $next_profile_mode ) { + return; + } + + $updated = $default->to_array(); + $updated['mode'] = $next_profile_mode; + + $this->syncing = true; + $this->repository->save( Profile::from_array( $updated ) ); + $this->syncing = false; + } + + /** + * Write `login_mode` back into the active environment's option row. + * + * @param string $login_mode Target value. + * + * @return void + */ + private function write_login_mode( string $login_mode ): void { + $env = Config::get_active_environment(); + $option_name = 'staging' === $env ? 'workos_staging' : 'workos_production'; + + $current = get_option( $option_name, [] ); + if ( ! is_array( $current ) ) { + $current = []; + } + + if ( ( $current['login_mode'] ?? '' ) === $login_mode ) { + return; + } + + $current['login_mode'] = $login_mode; + + $this->syncing = true; + update_option( $option_name, $current ); + $this->syncing = false; + } +} diff --git a/src/WorkOS/Auth/AuthKit/Nonce.php b/src/WorkOS/Auth/AuthKit/Nonce.php new file mode 100644 index 0000000..eff2885 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/Nonce.php @@ -0,0 +1,65 @@ +action( $profile_slug ) ); + } + + /** + * Verify a nonce against a profile. + * + * @param string $nonce Nonce provided by the caller. + * @param string $profile_slug Slug expected to match. + * + * @return bool True when the nonce matches the action and is not expired. + */ + public function verify( string $nonce, string $profile_slug ): bool { + if ( '' === $nonce || '' === $profile_slug ) { + return false; + } + + return (bool) wp_verify_nonce( $nonce, $this->action( $profile_slug ) ); + } + + /** + * Build the action string for a profile slug. + * + * @param string $profile_slug Slug of the target profile. + * + * @return string + */ + private function action( string $profile_slug ): string { + return self::ACTION_PREFIX . sanitize_title( $profile_slug ); + } +} diff --git a/src/WorkOS/Auth/AuthKit/Profile.php b/src/WorkOS/Auth/AuthKit/Profile.php new file mode 100644 index 0000000..ba7f30d --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/Profile.php @@ -0,0 +1,517 @@ +id = isset( $data['id'] ) ? (int) $data['id'] : 0; + $this->slug = (string) ( $data['slug'] ?? '' ); + $this->title = (string) ( $data['title'] ?? '' ); + $this->methods = array_values( array_filter( (array) ( $data['methods'] ?? [] ), 'is_string' ) ); + $this->organization_id = (string) ( $data['organization_id'] ?? '' ); + $this->signup = [ + 'enabled' => (bool) ( $data['signup']['enabled'] ?? false ), + 'require_invite' => (bool) ( $data['signup']['require_invite'] ?? false ), + ]; + $this->invite_flow = (bool) ( $data['invite_flow'] ?? true ); + $this->password_reset_flow = (bool) ( $data['password_reset_flow'] ?? true ); + $this->mfa = [ + 'enforce' => (string) ( $data['mfa']['enforce'] ?? self::MFA_ENFORCE_IF_REQUIRED ), + 'factors' => array_values( array_filter( (array) ( $data['mfa']['factors'] ?? [] ), 'is_string' ) ), + ]; + $this->branding = [ + 'logo_attachment_id' => (int) ( $data['branding']['logo_attachment_id'] ?? 0 ), + 'primary_color' => (string) ( $data['branding']['primary_color'] ?? '' ), + 'heading' => (string) ( $data['branding']['heading'] ?? '' ), + 'subheading' => (string) ( $data['branding']['subheading'] ?? '' ), + ]; + $this->post_login_redirect = (string) ( $data['post_login_redirect'] ?? '' ); + $this->mode = (string) ( $data['mode'] ?? self::MODE_CUSTOM ); + } + + /** + * Build a Profile from a raw array, applying defaults and validation. + * + * Unknown enum values are dropped; unknown keys are ignored. + * + * @param array $data Raw data, typically from $_POST or a stored JSON blob. + * + * @return self + */ + public static function from_array( array $data ): self { + $allowed_methods = [ + self::METHOD_PASSWORD, + self::METHOD_MAGIC_CODE, + self::METHOD_OAUTH_GOOGLE, + self::METHOD_OAUTH_MICROSOFT, + self::METHOD_OAUTH_GITHUB, + self::METHOD_OAUTH_APPLE, + self::METHOD_PASSKEY, + ]; + + $allowed_factors = [ + self::FACTOR_TOTP, + self::FACTOR_SMS, + self::FACTOR_WEBAUTHN, + ]; + + $allowed_enforce = [ + self::MFA_ENFORCE_NEVER, + self::MFA_ENFORCE_IF_REQUIRED, + self::MFA_ENFORCE_ALWAYS, + ]; + + $allowed_modes = [ + self::MODE_CUSTOM, + self::MODE_AUTHKIT_REDIRECT, + ]; + + // Slug is normalized to WP sanitize_title conventions. + $slug = sanitize_title( (string) ( $data['slug'] ?? '' ) ); + + $title = isset( $data['title'] ) ? (string) $data['title'] : ''; + if ( '' === $title ) { + $title = '' === $slug ? __( 'Untitled', 'integration-workos' ) : ucwords( str_replace( '-', ' ', $slug ) ); + } + + $methods = array_values( + array_intersect( + $allowed_methods, + array_map( 'strval', (array) ( $data['methods'] ?? [] ) ) + ) + ); + if ( empty( $methods ) ) { + $methods = [ self::METHOD_PASSWORD, self::METHOD_MAGIC_CODE ]; + } + + $mfa_enforce = (string) ( $data['mfa']['enforce'] ?? self::MFA_ENFORCE_IF_REQUIRED ); + if ( ! in_array( $mfa_enforce, $allowed_enforce, true ) ) { + $mfa_enforce = self::MFA_ENFORCE_IF_REQUIRED; + } + + $mfa_factors = array_values( + array_intersect( + $allowed_factors, + array_map( 'strval', (array) ( $data['mfa']['factors'] ?? [] ) ) + ) + ); + if ( empty( $mfa_factors ) ) { + $mfa_factors = [ self::FACTOR_TOTP ]; + } + + $mode = (string) ( $data['mode'] ?? self::MODE_CUSTOM ); + if ( ! in_array( $mode, $allowed_modes, true ) ) { + $mode = self::MODE_CUSTOM; + } + + // WorkOS organization ids follow `org_` + Crockford-style base32. + // Drop anything that doesn't match the shape rather than letting + // it reach the API layer and surface as a confusing remote error. + $organization_id = sanitize_text_field( (string) ( $data['organization_id'] ?? '' ) ); + if ( '' !== $organization_id && ! preg_match( '/^org_[A-Za-z0-9]+$/', $organization_id ) ) { + $organization_id = ''; + } + + $primary_color = (string) ( $data['branding']['primary_color'] ?? '' ); + if ( '' !== $primary_color && ! preg_match( '/^#[0-9a-fA-F]{3,8}$/', $primary_color ) ) { + $primary_color = ''; + } + + return new self( + [ + 'id' => (int) ( $data['id'] ?? 0 ), + 'slug' => $slug, + 'title' => $title, + 'methods' => $methods, + 'organization_id' => $organization_id, + 'signup' => [ + 'enabled' => (bool) ( $data['signup']['enabled'] ?? false ), + 'require_invite' => (bool) ( $data['signup']['require_invite'] ?? false ), + ], + 'invite_flow' => (bool) ( $data['invite_flow'] ?? true ), + 'password_reset_flow' => (bool) ( $data['password_reset_flow'] ?? true ), + 'mfa' => [ + 'enforce' => $mfa_enforce, + 'factors' => $mfa_factors, + ], + 'branding' => [ + 'logo_attachment_id' => (int) ( $data['branding']['logo_attachment_id'] ?? 0 ), + 'primary_color' => $primary_color, + 'heading' => sanitize_text_field( (string) ( $data['branding']['heading'] ?? '' ) ), + 'subheading' => sanitize_text_field( (string) ( $data['branding']['subheading'] ?? '' ) ), + ], + 'post_login_redirect' => sanitize_text_field( (string) ( $data['post_login_redirect'] ?? '' ) ), + 'mode' => $mode, + ] + ); + } + + /** + * Build the reserved "default" profile used for wp-login.php takeover. + * + * @return self + */ + public static function defaults(): self { + return self::from_array( + [ + 'slug' => self::DEFAULT_SLUG, + 'title' => __( 'Default Login', 'integration-workos' ), + 'methods' => [ + self::METHOD_PASSWORD, + self::METHOD_MAGIC_CODE, + self::METHOD_OAUTH_GOOGLE, + ], + 'organization_id' => '', + 'signup' => [ + 'enabled' => false, + 'require_invite' => false, + ], + 'invite_flow' => true, + 'password_reset_flow' => true, + 'mfa' => [ + 'enforce' => self::MFA_ENFORCE_IF_REQUIRED, + 'factors' => [ self::FACTOR_TOTP ], + ], + 'branding' => [ + 'logo_attachment_id' => 0, + 'primary_color' => '', + 'heading' => __( 'Sign in', 'integration-workos' ), + 'subheading' => '', + ], + 'post_login_redirect' => '', + 'mode' => self::MODE_CUSTOM, + ] + ); + } + + /** + * Return a new Profile with the given post ID. + * + * Used by the repository after persisting to capture the assigned CPT ID. + * + * @param int $id Post ID. + * + * @return self + */ + public function with_id( int $id ): self { + $clone = clone $this; + $clone->id = $id; + return $clone; + } + + /** + * Serialize to array (stable shape for REST + storage). + * + * @return array + */ + public function to_array(): array { + return [ + 'id' => $this->id, + 'slug' => $this->slug, + 'title' => $this->title, + 'methods' => $this->methods, + 'organization_id' => $this->organization_id, + 'signup' => $this->signup, + 'invite_flow' => $this->invite_flow, + 'password_reset_flow' => $this->password_reset_flow, + 'mfa' => $this->mfa, + 'branding' => $this->branding, + 'post_login_redirect' => $this->post_login_redirect, + 'mode' => $this->mode, + ]; + } + + /** + * Serialize for the React profile editor. + * + * Same shape as `to_array()` plus `branding.logo_url` resolved from + * the attachment ID — used both by the REST API responses and by the + * server-side preload that hydrates the editor on page load (saves a + * round-trip). + * + * @return array + */ + public function to_editor_array(): array { + $data = $this->to_array(); + $branding = $data['branding']; + + $branding['logo_url'] = ! empty( $branding['logo_attachment_id'] ) + ? (string) wp_get_attachment_url( (int) $branding['logo_attachment_id'] ) + : ''; + + $data['branding'] = $branding; + + return $data; + } + + /** + * Check whether a given first-factor method is enabled. + * + * @param string $method One of the METHOD_* constants. + * + * @return bool + */ + public function has_method( string $method ): bool { + return in_array( $method, $this->methods, true ); + } + + /** + * Check whether a given MFA factor is allowed. + * + * @param string $factor One of the FACTOR_* constants. + * + * @return bool + */ + public function allows_factor( string $factor ): bool { + return in_array( $factor, $this->mfa['factors'], true ); + } + + /** + * Whether this profile uses the custom React UI (vs legacy AuthKit redirect). + * + * @return bool + */ + public function is_custom_mode(): bool { + return self::MODE_CUSTOM === $this->mode; + } + + /** + * Post ID backing this profile, or 0 for unsaved profiles. + * + * @return int + */ + public function get_id(): int { + return $this->id; + } + + /** + * Profile slug (URL-safe identifier). + * + * @return string + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Display title. + * + * @return string + */ + public function get_title(): string { + return $this->title; + } + + /** + * Enabled first-factor methods. + * + * @return string[] + */ + public function get_methods(): array { + return $this->methods; + } + + /** + * Pinned WorkOS organization ID, or empty string when unpinned. + * + * @return string + */ + public function get_organization_id(): string { + return $this->organization_id; + } + + /** + * Signup configuration. + * + * @return array{enabled: bool, require_invite: bool} + */ + public function get_signup(): array { + return $this->signup; + } + + /** + * Whether the invitation acceptance flow is enabled. + * + * @return bool + */ + public function is_invite_flow_enabled(): bool { + return $this->invite_flow; + } + + /** + * Whether the in-app password-reset flow is enabled. + * + * @return bool + */ + public function is_password_reset_flow_enabled(): bool { + return $this->password_reset_flow; + } + + /** + * MFA configuration. + * + * @return array{enforce: string, factors: string[]} + */ + public function get_mfa(): array { + return $this->mfa; + } + + /** + * Branding configuration. + * + * @return array{logo_attachment_id: int, primary_color: string, heading: string, subheading: string} + */ + public function get_branding(): array { + return $this->branding; + } + + /** + * Default post-login redirect URL, or empty string for the WP default. + * + * @return string + */ + public function get_post_login_redirect(): string { + return $this->post_login_redirect; + } + + /** + * Profile mode (`custom` or `authkit_redirect`). + * + * @return string + */ + public function get_mode(): string { + return $this->mode; + } +} diff --git a/src/WorkOS/Auth/AuthKit/ProfileRepository.php b/src/WorkOS/Auth/AuthKit/ProfileRepository.php new file mode 100644 index 0000000..40f76dd --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/ProfileRepository.php @@ -0,0 +1,293 @@ + [ + 'name' => __( 'Login Profiles', 'integration-workos' ), + 'singular_name' => __( 'Login Profile', 'integration-workos' ), + ], + 'public' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'show_in_admin_bar' => false, + 'show_in_nav_menus' => false, + 'exclude_from_search' => true, + 'publicly_queryable' => false, + 'has_archive' => false, + 'rewrite' => false, + 'query_var' => false, + 'supports' => [ 'title' ], + 'capability_type' => 'post', + 'map_meta_cap' => true, + ] + ); + } + + /** + * Fetch a profile by slug. + * + * @param string $slug Profile slug. + * + * @return Profile|null + */ + public function find_by_slug( string $slug ): ?Profile { + $slug = sanitize_title( $slug ); + if ( '' === $slug ) { + return null; + } + + $posts = get_posts( + [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'name' => $slug, + 'numberposts' => 1, + 'suppress_filters' => true, + ] + ); + + if ( empty( $posts ) ) { + return null; + } + + return $this->hydrate( $posts[0] ); + } + + /** + * Fetch a profile by post ID. + * + * @param int $id Post ID. + * + * @return Profile|null + */ + public function find_by_id( int $id ): ?Profile { + if ( $id <= 0 ) { + return null; + } + + $post = get_post( $id ); + if ( ! $post || self::POST_TYPE !== $post->post_type ) { + return null; + } + + return $this->hydrate( $post ); + } + + /** + * List all profiles. + * + * @return Profile[] + */ + public function all(): array { + $posts = get_posts( + [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'numberposts' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + 'suppress_filters' => true, + ] + ); + + $profiles = []; + foreach ( $posts as $post ) { + $profile = $this->hydrate( $post ); + if ( $profile ) { + $profiles[] = $profile; + } + } + + return $profiles; + } + + /** + * Create or update a profile. + * + * The profile's slug must be unique; passing a slug that already exists + * and differs from the profile's post ID returns a WP_Error. + * + * @param Profile $profile Profile to persist. + * + * @return Profile|WP_Error The saved profile (with its post ID), or WP_Error on failure. + */ + public function save( Profile $profile ) { + $slug = $profile->get_slug(); + if ( '' === $slug ) { + return new WP_Error( + 'workos_profile_invalid_slug', + __( 'Profile slug cannot be empty.', 'integration-workos' ) + ); + } + + // Enforce slug uniqueness against any other profile. + $existing = $this->find_by_slug( $slug ); + if ( $existing && $existing->get_id() !== $profile->get_id() ) { + return new WP_Error( + 'workos_profile_slug_taken', + sprintf( + /* translators: %s: slug */ + __( 'A Login Profile with the slug "%s" already exists.', 'integration-workos' ), + $slug + ) + ); + } + + $post_args = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $profile->get_title(), + 'post_name' => $slug, + ]; + + if ( $profile->get_id() > 0 ) { + $post_args['ID'] = $profile->get_id(); + $post_id = wp_update_post( $post_args, true ); + } else { + $post_id = wp_insert_post( $post_args, true ); + } + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + update_post_meta( $post_id, self::META_KEY, wp_json_encode( $profile->to_array() ) ); + + $saved = $profile->with_id( (int) $post_id ); + + /** + * Fires after a Login Profile is saved. + * + * Lets other subsystems react (e.g. keep the global `login_mode` + * env option aligned with the default profile's mode field). + * + * @param Profile $saved The freshly-saved profile (with its post ID). + */ + do_action( 'workos_login_profile_saved', $saved ); + + return $saved; + } + + /** + * Delete a profile. + * + * The reserved `default` profile cannot be deleted. + * + * @param int $id Post ID. + * + * @return true|WP_Error + */ + public function delete( int $id ) { + $profile = $this->find_by_id( $id ); + if ( ! $profile ) { + return new WP_Error( + 'workos_profile_not_found', + __( 'Profile not found.', 'integration-workos' ) + ); + } + + if ( Profile::DEFAULT_SLUG === $profile->get_slug() ) { + return new WP_Error( + 'workos_profile_default_locked', + __( 'The default Login Profile cannot be deleted.', 'integration-workos' ) + ); + } + + $result = wp_delete_post( $id, true ); + if ( ! $result ) { + return new WP_Error( + 'workos_profile_delete_failed', + __( 'Failed to delete the Login Profile.', 'integration-workos' ) + ); + } + + return true; + } + + /** + * Ensure the reserved `default` profile exists; create it if not. + * + * Idempotent — safe to call on every activation and on plugin boot. + * + * @return Profile The default profile. + */ + public function ensure_default(): Profile { + $existing = $this->find_by_slug( Profile::DEFAULT_SLUG ); + if ( $existing ) { + return $existing; + } + + $saved = $this->save( Profile::defaults() ); + if ( is_wp_error( $saved ) ) { + // This should not happen under normal conditions; fall back to an + // in-memory default so callers can still render something sane. + return Profile::defaults(); + } + + return $saved; + } + + /** + * Hydrate a Profile from a WP_Post. + * + * @param WP_Post $post Post object. + * + * @return Profile|null Returns null if the meta payload is corrupt beyond recovery. + */ + private function hydrate( WP_Post $post ): ?Profile { + $raw = get_post_meta( $post->ID, self::META_KEY, true ); + + $data = []; + if ( is_string( $raw ) && '' !== $raw ) { + $decoded = json_decode( $raw, true ); + if ( is_array( $decoded ) ) { + $data = $decoded; + } + } + + // Title / slug always come from the post record itself; we don't + // trust the meta payload to hold authoritative copies. + $data['id'] = (int) $post->ID; + $data['slug'] = (string) $post->post_name; + $data['title'] = (string) $post->post_title; + + return Profile::from_array( $data ); + } +} diff --git a/src/WorkOS/Auth/AuthKit/ProfileRouter.php b/src/WorkOS/Auth/AuthKit/ProfileRouter.php new file mode 100644 index 0000000..c60595d --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/ProfileRouter.php @@ -0,0 +1,232 @@ +repository = $repository; + } + + /** + * Resolve a profile for the current request. + * + * The context array accepts: + * - `explicit_slug` (string) — forces a specific profile by slug. + * - `redirect_to` (string) — used by the glob matcher. + * - `referrer` (string) — used by the host matcher. + * - `user_id` (int) — used by the role matcher. + * + * @param array $context Request context (see above). + * + * @return Profile The matched profile, or the default profile (creating + * it when absent so callers can render something sane). + */ + public function resolve( array $context ): Profile { + // 1. Explicit slug wins. + if ( ! empty( $context['explicit_slug'] ) ) { + $profile = $this->repository->find_by_slug( (string) $context['explicit_slug'] ); + if ( $profile ) { + return $profile; + } + } + + // 2. Rules. + $rule = $this->match_rule( $context ); + if ( null !== $rule ) { + $profile = $this->repository->find_by_slug( (string) ( $rule['profile'] ?? '' ) ); + if ( $profile ) { + return $profile; + } + } + + // 3. Default — ensure it exists before returning. + return $this->repository->ensure_default(); + } + + /** + * Get raw routing rules. + * + * @return array + */ + public function get_rules(): array { + $raw = get_option( self::OPTION, [] ); + return is_array( $raw ) ? array_values( array_filter( $raw, 'is_array' ) ) : []; + } + + /** + * Replace the routing rules. + * + * Validates each rule and drops malformed entries. + * + * @param array $rules New rules list. + * + * @return array The validated rules that were persisted. + */ + public function set_rules( array $rules ): array { + $valid_types = [ 'redirect_to', 'referrer_host', 'user_role' ]; + $validated = []; + + foreach ( $rules as $rule ) { + if ( ! is_array( $rule ) ) { + continue; + } + + $profile = sanitize_title( (string) ( $rule['profile'] ?? '' ) ); + if ( '' === $profile ) { + continue; + } + + $matcher = is_array( $rule['matcher'] ?? null ) ? $rule['matcher'] : []; + $type = (string) ( $matcher['type'] ?? '' ); + $value = (string) ( $matcher['value'] ?? '' ); + + if ( ! in_array( $type, $valid_types, true ) || '' === $value ) { + continue; + } + + $validated[] = [ + 'profile' => $profile, + 'matcher' => [ + 'type' => $type, + 'value' => sanitize_text_field( $value ), + ], + ]; + } + + update_option( self::OPTION, $validated ); + + return $validated; + } + + /** + * Find the first rule that matches the given context. + * + * @param array $context Request context. + * + * @return array|null Matched rule, or null when nothing matches. + */ + private function match_rule( array $context ): ?array { + foreach ( $this->get_rules() as $rule ) { + $matcher = $rule['matcher'] ?? []; + $type = (string) ( $matcher['type'] ?? '' ); + $value = (string) ( $matcher['value'] ?? '' ); + + if ( $this->matches( $type, $value, $context ) ) { + return $rule; + } + } + + return null; + } + + /** + * Evaluate a single matcher. + * + * @param string $type Matcher type. + * @param string $value Matcher value. + * @param array $context Request context. + * + * @return bool + */ + private function matches( string $type, string $value, array $context ): bool { + switch ( $type ) { + case 'redirect_to': + $redirect = (string) ( $context['redirect_to'] ?? '' ); + if ( '' === $redirect ) { + return false; + } + return $this->path_matches_glob( $redirect, $value ); + + case 'referrer_host': + $referrer = (string) ( $context['referrer'] ?? '' ); + if ( '' === $referrer ) { + return false; + } + $host = wp_parse_url( $referrer, PHP_URL_HOST ); + return is_string( $host ) && strtolower( $host ) === strtolower( $value ); + + case 'user_role': + $user_id = (int) ( $context['user_id'] ?? 0 ); + if ( $user_id <= 0 ) { + return false; + } + $user = get_userdata( $user_id ); + if ( ! $user ) { + return false; + } + return in_array( $value, (array) $user->roles, true ); + } + + return false; + } + + /** + * Glob-match a request path against a pattern. + * + * Supports `*` as a wildcard; treats everything else as a literal. + * + * @param string $target Path or URL from the request. + * @param string $pattern Rule value. + * + * @return bool + */ + private function path_matches_glob( string $target, string $pattern ): bool { + $path = wp_parse_url( $target, PHP_URL_PATH ); + if ( ! is_string( $path ) ) { + $path = $target; + } + + $regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#i'; + + return (bool) preg_match( $regex, $path ); + } +} diff --git a/src/WorkOS/Auth/AuthKit/Radar.php b/src/WorkOS/Auth/AuthKit/Radar.php new file mode 100644 index 0000000..ddf6557 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/Radar.php @@ -0,0 +1,123 @@ +get_site_key(); + } + + /** + * Get the public Radar site key. + * + * Constant beats option, matching the rest of the plugin's Config + * resolution order. Returns an empty string when unset. + * + * @return string + */ + public function get_site_key(): string { + if ( defined( self::SITE_KEY_CONSTANT ) ) { + $value = constant( self::SITE_KEY_CONSTANT ); + if ( is_string( $value ) && '' !== $value ) { + return $value; + } + } + + $value = workos()->option( self::SITE_KEY_OPTION, '' ); + + return is_string( $value ) ? $value : ''; + } + + /** + * Extract the Radar action token from a REST request. + * + * Returns null when absent so callers can pass it through to the API + * client, which already treats null as "omit the header". + * + * @param WP_REST_Request $request REST request object. + * + * @return string|null + */ + public function extract_from_request( WP_REST_Request $request ): ?string { + $token = $request->get_header( self::REQUEST_HEADER ); + + if ( ! is_string( $token ) ) { + return null; + } + + $trimmed = trim( $token ); + if ( '' === $trimmed ) { + return null; + } + + // Silently drop absurdly large values rather than forwarding them + // to WorkOS. A hostile client cannot inflate every outbound auth + // call by stuffing megabytes into the header. + if ( strlen( $trimmed ) > self::MAX_TOKEN_LENGTH ) { + return null; + } + + return $trimmed; + } +} diff --git a/src/WorkOS/Auth/AuthKit/RateLimiter.php b/src/WorkOS/Auth/AuthKit/RateLimiter.php new file mode 100644 index 0000000..8081af4 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/RateLimiter.php @@ -0,0 +1,270 @@ +attempt_atomic( $bucket, $subject, $limit, $window_seconds, $now ); + } + + return $this->attempt_transient( $bucket, $subject, $limit, $window_seconds, $now ); + } + + /** + * Atomic attempt path backed by wp_cache_add + wp_cache_incr. + * + * `wp_cache_add` is CAS-safe when backed by a real object cache: the + * init of `first_seen` and `count` happens exactly once per window + * across concurrent callers. `wp_cache_incr` is an atomic in-place + * counter increment — no read-modify-write race. Two requests that + * both pass the init check get distinct `count` values, so the limit + * can never be exceeded under concurrency. + * + * @param string $bucket Operation identifier. + * @param string $subject Caller identity. + * @param int $limit Limit. + * @param int $window_seconds Window length. + * @param int $now Unix timestamp for this attempt. + * + * @return true|WP_Error + */ + private function attempt_atomic( string $bucket, string $subject, int $limit, int $window_seconds, int $now ) { + $cache_key = $this->cache_key( $bucket, $subject ); + $seen_key = $cache_key . ':first_seen'; + $count_key = $cache_key . ':count'; + + // CAS init: only the first caller in a fresh window writes these. + wp_cache_add( $seen_key, $now, self::CACHE_GROUP, $window_seconds ); + wp_cache_add( $count_key, 0, self::CACHE_GROUP, $window_seconds ); + + $first_seen = (int) wp_cache_get( $seen_key, self::CACHE_GROUP ); + if ( $first_seen <= 0 ) { + // Bucket evicted between add + get (rare). Recover. + wp_cache_set( $seen_key, $now, self::CACHE_GROUP, $window_seconds ); + wp_cache_set( $count_key, 0, self::CACHE_GROUP, $window_seconds ); + $first_seen = $now; + } + + $count = wp_cache_incr( $count_key, 1, self::CACHE_GROUP ); + if ( false === $count ) { + // Backend that doesn't support incr — degrade to the non-atomic path. + return $this->attempt_transient( $bucket, $subject, $limit, $window_seconds, $now ); + } + + if ( $count > $limit ) { + $retry_after = max( 1, ( $first_seen + $window_seconds ) - $now ); + return $this->limit_exceeded( $retry_after ); + } + + return true; + } + + /** + * Non-atomic fallback for sites without a persistent object cache. + * + * Under true concurrency a single over-limit attempt can slip past + * (two requests both read count=N-1 and both write N). WorkOS's own + * back-end rate limits (3-10/min on the user-facing endpoints we + * proxy) back-stop this; the plugin-level limiter is best-effort on + * non-object-cache installs. + * + * @param string $bucket Operation identifier. + * @param string $subject Caller identity. + * @param int $limit Limit. + * @param int $window_seconds Window length. + * @param int $now Unix timestamp for this attempt. + * + * @return true|WP_Error + */ + private function attempt_transient( string $bucket, string $subject, int $limit, int $window_seconds, int $now ) { + $key = $this->transient_key( $bucket, $subject ); + + $state = get_transient( $key ); + if ( ! is_array( $state ) || ! isset( $state['first_seen'], $state['count'] ) ) { + $state = [ + 'first_seen' => $now, + 'count' => 0, + ]; + } + + // Reset if the window has elapsed. + if ( ( $now - (int) $state['first_seen'] ) >= $window_seconds ) { + $state = [ + 'first_seen' => $now, + 'count' => 0, + ]; + } + + ++$state['count']; + + // Persist before the limit check so a concurrent caller also sees the increment. + // Transient TTL matches the remaining window so stale buckets drop on their own. + $ttl = max( 1, $window_seconds - ( $now - (int) $state['first_seen'] ) ); + set_transient( $key, $state, $ttl ); + + if ( $state['count'] > $limit ) { + $retry_after = max( 1, ( (int) $state['first_seen'] + $window_seconds ) - $now ); + return $this->limit_exceeded( $retry_after ); + } + + return true; + } + + /** + * Build the 429 WP_Error returned when a bucket is exhausted. + * + * @param int $retry_after Seconds the caller should wait. + * + * @return WP_Error + */ + private function limit_exceeded( int $retry_after ): WP_Error { + return new WP_Error( + 'workos_rate_limited', + __( 'Too many requests. Please try again shortly.', 'integration-workos' ), + [ + 'status' => 429, + 'retry_after' => $retry_after, + ] + ); + } + + /** + * Reset a bucket — intended for tests and administrative flushes. + * + * @param string $bucket Operation identifier. + * @param string $subject Caller identity. + * + * @return void + */ + public function reset( string $bucket, string $subject ): void { + delete_transient( $this->transient_key( $bucket, $subject ) ); + + if ( wp_using_ext_object_cache() ) { + $cache_key = $this->cache_key( $bucket, $subject ); + wp_cache_delete( $cache_key . ':first_seen', self::CACHE_GROUP ); + wp_cache_delete( $cache_key . ':count', self::CACHE_GROUP ); + } + } + + /** + * Build the object-cache key for a bucket + subject pair. + * + * Uses the same hashing approach as the transient path so personal + * data never sits in cache keys in plaintext. + * + * @param string $bucket Operation identifier. + * @param string $subject Subject (IP, email, etc.). + * + * @return string + */ + private function cache_key( string $bucket, string $subject ): string { + $safe_bucket = preg_replace( '/[^a-z0-9_]/i', '', $bucket ); + $hash = substr( hash( 'sha256', $subject ), 0, 32 ); + return $safe_bucket . '_' . $hash; + } + + /** + * Best-effort client IP extraction. + * + * Respects the standard WP proxy hint hierarchy without blindly trusting + * X-Forwarded-For (a caller can spoof the header otherwise). When no + * trusted IP can be determined, returns '0.0.0.0' so the caller still has + * *something* stable to rate-limit against. + * + * @return string IPv4 or IPv6 address string. + */ + public function client_ip(): string { + // REMOTE_ADDR is set by the webserver and cannot be spoofed by callers. + $remote = isset( $_SERVER['REMOTE_ADDR'] ) + ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) + : false; + + return is_string( $remote ) && '' !== $remote ? $remote : '0.0.0.0'; + } + + /** + * Normalize an email for consistent per-email bucketing. + * + * @param string $email Raw email string. + * + * @return string Lowercased, trimmed email; empty string if not an email. + */ + public function normalize_email( string $email ): string { + $email = strtolower( trim( $email ) ); + return is_email( $email ) ? $email : ''; + } + + /** + * Build the transient key for a bucket + subject pair. + * + * @param string $bucket Operation identifier. + * @param string $subject Subject (IP, email, etc.). + * + * @return string + */ + private function transient_key( string $bucket, string $subject ): string { + $safe_bucket = preg_replace( '/[^a-z0-9_]/i', '', $bucket ); + $hash = substr( hash( 'sha256', $subject ), 0, 32 ); + return self::TRANSIENT_PREFIX . $safe_bucket . '_' . $hash; + } +} diff --git a/src/WorkOS/Auth/AuthKit/Renderer.php b/src/WorkOS/Auth/AuthKit/Renderer.php new file mode 100644 index 0000000..0e30d8f --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/Renderer.php @@ -0,0 +1,328 @@ +assets_url = trailingslashit( WORKOS_URL . 'build' ); + $this->assets_dir = trailingslashit( WORKOS_DIR . 'build' ); + } + + /** + * Enqueue the AuthKit bundle, idempotent. + * + * Fires `workos_authkit_enqueue_assets` after the bundle + style are + * enqueued so extenders can register profile-aware companion assets. + * + * @param Profile|null $profile Active profile (null on the very first + * call before render_mount() resolves it + * — passed to the action when available). + * + * @return void + */ + public function enqueue( ?Profile $profile = null ): void { + if ( ! wp_script_is( self::SCRIPT_HANDLE, 'enqueued' ) ) { + $asset_file = $this->assets_dir . 'authkit.asset.php'; + $asset = file_exists( $asset_file ) + ? require $asset_file + : [ + 'dependencies' => [ 'wp-element' ], + 'version' => WORKOS_VERSION, + ]; + + wp_enqueue_script( + self::SCRIPT_HANDLE, + $this->assets_url . 'authkit.js', + $asset['dependencies'] ?? [ 'wp-element' ], + $asset['version'] ?? WORKOS_VERSION, + true + ); + + wp_set_script_translations( self::SCRIPT_HANDLE, 'integration-workos' ); + + wp_enqueue_style( + self::STYLE_HANDLE, + $this->assets_url . 'authkit.css', + [], + $asset['version'] ?? WORKOS_VERSION + ); + } + + if ( $profile instanceof Profile ) { + /** + * Fires after the AuthKit bundle is enqueued for a profile. + * + * Fires on every render — when multiple AuthKit instances + * appear on one page, extenders see each profile in turn. + * Use `wp_enqueue_style()` / `wp_enqueue_script()` here to + * ship per-profile CSS/JS. Depend on the `workos-authkit` + * handle for ordering, plus `wp-plugins` / `wp-components` + * if registering SlotFill plugins. + * + * @param Profile $profile The active Login Profile. + */ + do_action( 'workos_authkit_enqueue_assets', $profile ); + } + } + + /** + * Render the mount markup for a profile. + * + * @param Profile $profile Active Login Profile. + * @param array $context { + * Optional render context. + * + * @type string $redirect_to Post-login redirect URL. + * @type string $invitation_token If present, shell boots into invite flow. + * @type string $reset_token If present, shell boots into reset-confirm flow. + * @type string $initial_step Initial step slug (defaults to 'pick'). + * } + * + * @return string Safe HTML for direct output. + */ + public function render_mount( Profile $profile, array $context = [] ): string { + $this->enqueue( $profile ); + + $profile_data = $profile->to_array(); + + // Trim the payload before handing it to the browser: the nonce + org + // id live on the server; the client sees only what it needs to + // render. + unset( $profile_data['id'] ); + $profile_data['methods'] = array_values( $profile_data['methods'] ); + $profile_data['mfa'] = $profile_data['mfa'] ?? []; + $profile_data['branding'] = $this->resolve_branding( $profile ); + + /** + * Filters the profile data sent to the React shell. + * + * Use to inject extra config keys a SlotFill plugin needs at the + * client. The shell will receive whatever is returned here as the + * `data-profile` JSON. + * + * @param array $profile_data Trimmed profile data. + * @param Profile $profile The active profile. + */ + $profile_data = (array) apply_filters( 'workos_authkit_profile_data', $profile_data, $profile ); + + $attrs = [ + 'id' => 'workos-authkit-root', + 'data-profile' => wp_json_encode( $profile_data ), + 'data-rest-base' => esc_url_raw( rest_url( 'workos/v1/auth' ) ), + 'data-redirect-to' => esc_url_raw( $context['redirect_to'] ?? '' ), + 'data-invitation-token' => sanitize_text_field( $context['invitation_token'] ?? '' ), + 'data-reset-token' => sanitize_text_field( $context['reset_token'] ?? '' ), + 'data-initial-step' => sanitize_key( $context['initial_step'] ?? 'pick' ), + ]; + + $style_tag = $this->branding_style_tag( $profile_data['branding'] ); + + return $style_tag . '
stringify_attrs( $attrs ) . '>
'; + } + + /** + * Render a full-page login document (for wp-login.php takeover and the + * `/workos/login/{profile}` rewrite). + * + * @param Profile $profile Active profile. + * @param array $context Render context (see render_mount()). + * + * @return void Sends a full HTML response and exits. + */ + public function render_full_page( Profile $profile, array $context = [] ): void { + nocache_headers(); + header( 'Content-Type: text/html; charset=utf-8' ); + + $site_name = wp_strip_all_tags( (string) get_bloginfo( 'name' ) ); + $language = get_bloginfo( 'language' ); + $mount = $this->render_mount( $profile, $context ); + + /** + * Filters the CSS classes applied to the AuthKit full-page . + * + * Use to give external stylesheets a per-profile hook (e.g. add + * `workos-profile-{slug}` for slug-scoped rules). + * + * @param string[] $classes Default body classes. + * @param Profile $profile The active profile. + */ + $body_classes = (array) apply_filters( + 'workos_authkit_body_classes', + [ 'workos-authkit-body', 'workos-profile-' . $profile->get_slug() ], + $profile + ); + $body_class = trim( implode( ' ', array_map( 'sanitize_html_class', $body_classes ) ) ); + + /* + * We intentionally do not call wp_head()/wp_footer(): those pull in + * admin and theme chrome we do not want. Instead we call + * wp_print_scripts + wp_print_styles after our enqueue so only our + * own bundle ships. + */ + ?> + +> + + + + + <?php echo esc_html( sprintf( /* translators: %s: site name */ __( 'Sign in — %s', 'integration-workos' ), $site_name ) ); ?> + queue ); + ?> + + +
+ tag (allowed here) and our root + // div. Safe to echo. + echo $mount; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ?> +
+ queue ); ?> + + + get_branding(); + + $logo_url = ''; + if ( ! empty( $branding['logo_attachment_id'] ) ) { + $logo_url = (string) wp_get_attachment_url( (int) $branding['logo_attachment_id'] ); + } + + if ( '' === $logo_url ) { + $logo_url = (string) get_site_icon_url( 192 ); + } + + $resolved = [ + 'logo_url' => $logo_url, + 'primary_color' => (string) ( $branding['primary_color'] ?? '' ), + 'heading' => (string) ( $branding['heading'] ?? '' ), + 'subheading' => (string) ( $branding['subheading'] ?? '' ), + ]; + + /** + * Filters the resolved branding for an AuthKit render. + * + * Use to override the logo fallback chain or rewrite any branding + * field at the last mile before it lands in the data-profile JSON. + * + * @param array $resolved Resolved branding. + * @param Profile $profile The active profile. + */ + return (array) apply_filters( 'workos_authkit_branding', $resolved, $profile ); + } + + /** + * Build an inline ` sequence. + $primary = (string) ( $branding['primary_color'] ?? '' ); + if ( '' !== $primary && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $primary ) ) { + $rules[] = '--wa-primary: ' . $primary . ';'; + } + + if ( empty( $rules ) ) { + return ''; + } + + return ''; + } + + /** + * Render an attributes array as an HTML attribute string. + * + * @param array $attrs Key-value attributes. + * + * @return string + */ + private function stringify_attrs( array $attrs ): string { + $pieces = []; + foreach ( $attrs as $name => $value ) { + $pieces[] = sprintf( '%s="%s"', esc_attr( $name ), esc_attr( (string) $value ) ); + } + return implode( ' ', $pieces ); + } +} diff --git a/src/WorkOS/Auth/AuthKit/Shortcode.php b/src/WorkOS/Auth/AuthKit/Shortcode.php new file mode 100644 index 0000000..e226017 --- /dev/null +++ b/src/WorkOS/Auth/AuthKit/Shortcode.php @@ -0,0 +1,93 @@ +router = $router; + $this->renderer = $renderer; + } + + /** + * Register the shortcode. + * + * @return void + */ + public function register(): void { + add_shortcode( self::TAG, [ $this, 'render' ] ); + } + + /** + * Shortcode callback. + * + * @param array $atts Shortcode attributes. + * + * @return string + */ + public function render( $atts ): string { + $atts = shortcode_atts( + [ + 'profile' => '', + 'redirect_to' => '', + ], + is_array( $atts ) ? $atts : [], + self::TAG + ); + + $profile = $this->router->resolve( + [ + 'explicit_slug' => (string) $atts['profile'], + 'redirect_to' => (string) $atts['redirect_to'], + 'user_id' => get_current_user_id(), + ] + ); + + if ( ! $profile->is_custom_mode() ) { + // Legacy redirect-mode profiles aren't served from the shortcode — + // render an inert placeholder so authors notice. + return ''; + } + + return $this->renderer->render_mount( + $profile, + [ 'redirect_to' => (string) $atts['redirect_to'] ] + ); + } +} diff --git a/src/WorkOS/Auth/Login.php b/src/WorkOS/Auth/Login.php index eda0c3d..579d034 100644 --- a/src/WorkOS/Auth/Login.php +++ b/src/WorkOS/Auth/Login.php @@ -212,13 +212,27 @@ public function handle_callback(): void { ); } - // Parse state. - $state = SuperGlobals::get_get_var( 'state' ) ?? ''; - $state_parts = explode( '|', $state, 2 ); - $nonce = $state_parts[0] ?? ''; - $redirect_to = $state_parts[1] ?? admin_url(); + // Parse state. Two formats are accepted: + // 1. Legacy AuthKit-redirect mode: `nonce|redirect_to`, nonce minted + // with the generic `workos_auth` action by `maybe_redirect_to_authkit`. + // 2. Custom AuthKit OAuth: `nonce|redirect_to|profile_slug`, nonce + // minted profile-scoped by `WorkOS\Auth\AuthKit\Nonce::mint()`. + $state = SuperGlobals::get_get_var( 'state' ) ?? ''; + $state_parts = explode( '|', $state, 3 ); + $nonce = $state_parts[0] ?? ''; + $redirect_to = $state_parts[1] ?? admin_url(); + $profile_slug = isset( $state_parts[2] ) ? sanitize_title( $state_parts[2] ) : ''; + + $nonce_ok = false; + if ( '' !== $profile_slug ) { + $nonce_ok = ( new \WorkOS\Auth\AuthKit\Nonce() )->verify( $nonce, $profile_slug ); + } + if ( ! $nonce_ok ) { + // Back-compat: legacy AuthKit-redirect callbacks carry the generic nonce. + $nonce_ok = (bool) wp_verify_nonce( $nonce, 'workos_auth' ); + } - if ( ! wp_verify_nonce( $nonce, 'workos_auth' ) ) { + if ( ! $nonce_ok ) { wp_die( esc_html__( 'Security check failed. Please try logging in again.', 'integration-workos' ), esc_html__( 'Authentication Error', 'integration-workos' ), diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index 94a0f61..b622524 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -10,6 +10,8 @@ use WorkOS\Contracts\Controller as BaseController; use WorkOS\Admin\AdminBar; use WorkOS\Admin\Controller as AdminController; +use WorkOS\Admin\LoginProfiles\Controller as LoginProfilesAdminController; +use WorkOS\Auth\AuthKit\Controller as AuthKitController; use WorkOS\Auth\Controller as AuthController; use WorkOS\REST\Controller as RESTController; use WorkOS\Webhook\Controller as WebhookController; @@ -31,6 +33,8 @@ class Controller extends BaseController { */ protected function doRegister(): void { $this->container->register( AdminController::class ); + $this->container->register( AuthKitController::class ); + $this->container->register( LoginProfilesAdminController::class ); $this->container->register( AuthController::class ); $this->container->register( RESTController::class ); $this->container->register( WebhookController::class ); diff --git a/src/WorkOS/Options/Production.php b/src/WorkOS/Options/Production.php index 726ba8c..38fbb6b 100644 --- a/src/WorkOS/Options/Production.php +++ b/src/WorkOS/Options/Production.php @@ -31,7 +31,7 @@ protected function defaults(): array { 'webhook_secret' => '', 'organization_id' => '', 'environment_id' => '', - 'login_mode' => 'redirect', + 'login_mode' => 'custom', 'allow_password_fallback' => true, 'deprovision_action' => 'deactivate', 'reassign_user' => 0, diff --git a/src/WorkOS/Options/Staging.php b/src/WorkOS/Options/Staging.php index a55a183..65a85c2 100644 --- a/src/WorkOS/Options/Staging.php +++ b/src/WorkOS/Options/Staging.php @@ -31,7 +31,7 @@ protected function defaults(): array { 'webhook_secret' => '', 'organization_id' => '', 'environment_id' => '', - 'login_mode' => 'redirect', + 'login_mode' => 'custom', 'allow_password_fallback' => true, 'deprovision_action' => 'deactivate', 'reassign_user' => 0, diff --git a/src/WorkOS/Organization/EntitlementGate.php b/src/WorkOS/Organization/EntitlementGate.php index 260f84f..8998636 100644 --- a/src/WorkOS/Organization/EntitlementGate.php +++ b/src/WorkOS/Organization/EntitlementGate.php @@ -9,6 +9,7 @@ use WorkOS\ActivityLog\EventLogger; use WorkOS\Config; +use WP_Error; defined( 'ABSPATH' ) || exit; @@ -40,22 +41,43 @@ public static function is_enabled(): bool { /** * Check entitlements for a user after WorkOS authentication. * - * Call this before setting the auth cookie. Returns true if allowed, - * or calls wp_die() if denied. + * Call this before setting the auth cookie. Returns when the user is + * allowed, or calls wp_die() when denied. Use {@see self::evaluate()} + * from REST contexts where a wp_die() page would be inappropriate. * * @param int $user_id WP user ID. * @param array $workos_data WorkOS auth response data. */ public static function check( int $user_id, array $workos_data ): void { + $result = self::evaluate( $user_id, $workos_data ); + if ( is_wp_error( $result ) ) { + self::deny_with_wp_die( $user_id, (string) $result->get_error_code(), $workos_data ); + } + } + + /** + * Non-terminating entitlement check — returns true or WP_Error. + * + * Fires the same activity log and `workos_login_denied` action as + * {@see self::check()} but leaves the HTTP response to the caller. + * Used by the AuthKit REST endpoints so denials surface as JSON + * instead of the wp_die() 403 page. + * + * @param int $user_id WP user ID. + * @param array $workos_data WorkOS auth response data. + * + * @return true|WP_Error True when allowed; WP_Error(403) when denied. + */ + public static function evaluate( int $user_id, array $workos_data ) { if ( ! self::is_enabled() ) { - return; + return true; } $workos_user_id = $workos_data['user']['id'] ?? ''; $org_id = $workos_data['organization_id'] ?? Config::get_organization_id(); if ( empty( $workos_user_id ) || empty( $org_id ) ) { - return; + return true; } /** @@ -70,9 +92,10 @@ public static function check( int $user_id, array $workos_data ): void { // If a filter explicitly set a value, use it. if ( null !== $allowed ) { if ( $allowed ) { - return; + return true; } - self::deny( $user_id, 'filter_denied', $workos_data ); + self::log_denial( $user_id, 'filter_denied', $workos_data ); + return self::denial_error( 'filter_denied' ); } // Check organization membership via API. @@ -85,7 +108,7 @@ public static function check( int $user_id, array $workos_data ): void { if ( is_wp_error( $result ) ) { // On API error, don't block login — fail open. - return; + return true; } $memberships = $result['data'] ?? []; @@ -99,18 +122,41 @@ public static function check( int $user_id, array $workos_data ): void { } if ( ! $has_active ) { - self::deny( $user_id, 'no_active_membership', $workos_data ); + self::log_denial( $user_id, 'no_active_membership', $workos_data ); + return self::denial_error( 'no_active_membership' ); } + + return true; } /** - * Deny login and terminate. + * Build the WP_Error returned from evaluate() on denial. + * + * @param string $reason Denial reason. + * + * @return WP_Error + */ + private static function denial_error( string $reason ): WP_Error { + return new WP_Error( + 'workos_entitlement_denied', + __( 'Access denied. You do not have an active membership in the required organization.', 'integration-workos' ), + [ + 'status' => 403, + 'reason' => $reason, + ] + ); + } + + /** + * Fire activity log + `workos_login_denied` action for a denial. * * @param int $user_id WP user ID. * @param string $reason Denial reason. * @param array $workos_data WorkOS auth response. + * + * @return void */ - private static function deny( int $user_id, string $reason, array $workos_data ): void { + private static function log_denial( int $user_id, string $reason, array $workos_data ): void { EventLogger::log( 'login_denied', [ @@ -128,6 +174,28 @@ private static function deny( int $user_id, string $reason, array $workos_data ) * @param array $workos_data WorkOS auth response. */ do_action( 'workos_login_denied', $user_id, $reason, $workos_data ); + } + + /** + * Terminal denial via wp_die — used by the legacy redirect flow. + * + * Preserves the original `check()` behavior: activity-log the denial + * and short-circuit the request with a 403 page. + * + * @param int $user_id WP user ID. + * @param string $reason_or_error_code Original reason or error code string. + * @param array $workos_data WorkOS auth response. + */ + private static function deny_with_wp_die( int $user_id, string $reason_or_error_code, array $workos_data ): void { + // evaluate() already logged; re-derive the reason from the error code + // so this path can also be used directly. + $reason = 'workos_entitlement_denied' === $reason_or_error_code + ? ( $workos_data['__denial_reason'] ?? 'no_active_membership' ) + : $reason_or_error_code; + + if ( 'workos_entitlement_denied' !== $reason_or_error_code ) { + self::log_denial( $user_id, $reason, $workos_data ); + } wp_die( esc_html__( 'Access denied. You do not have an active membership in the required organization. Please contact your administrator.', 'integration-workos' ), diff --git a/src/WorkOS/Plugin.php b/src/WorkOS/Plugin.php index 43156f2..6a81205 100644 --- a/src/WorkOS/Plugin.php +++ b/src/WorkOS/Plugin.php @@ -175,9 +175,23 @@ public static function activate(): void { Database\Schema::activate(); - // Register the rewrite rule before flushing so it gets persisted. + /* + * Register rewrite rules before flushing so they all get persisted: + * - /workos/callback (OAuth callback for both AuthKit-redirect and custom modes). + * - /workos/login/{profile} (dedicated frontend login page). + */ Auth\Login::register_rewrite(); + Auth\AuthKit\FrontendRoute::register_rewrite(); flush_rewrite_rules(); + + // Seed the reserved default Login Profile so wp-login.php takeover + // has a profile to render against on first run. Registering the CPT + // here is safe because the post type is non-public and purely a + // storage vehicle — WordPress does not need to know about it to + // persist posts. + $profile_repository = new Auth\AuthKit\ProfileRepository(); + $profile_repository->register_post_type(); + $profile_repository->ensure_default(); } /** diff --git a/src/WorkOS/REST/Auth/BaseEndpoint.php b/src/WorkOS/REST/Auth/BaseEndpoint.php new file mode 100644 index 0000000..7c1dea7 --- /dev/null +++ b/src/WorkOS/REST/Auth/BaseEndpoint.php @@ -0,0 +1,225 @@ +profiles = $profiles; + $this->nonce = $nonce; + $this->radar = $radar; + $this->rate_limiter = $rate_limiter; + $this->login_completer = $login_completer; + } + + /** + * Register this endpoint's REST routes. + * + * @return void + */ + abstract public function register_routes(): void; + + /** + * Public permission check used for anonymous auth routes. + * + * Always returns true — access control is enforced per-request via + * {@see self::verify_nonce()} and {@see self::rate_limit()}. The + * method exists so we can pass `[ $this, 'public_permission' ]` to + * `register_rest_route()` without magic-method complexity. + * + * @return true + */ + public function public_permission(): bool { + return true; + } + + /** + * Resolve the Login Profile for a request. + * + * @param WP_REST_Request $request REST request. + * + * @return Profile|WP_Error + */ + protected function resolve_profile( WP_REST_Request $request ) { + $slug = sanitize_title( (string) $request->get_param( 'profile' ) ); + if ( '' === $slug ) { + return new WP_Error( + 'workos_authkit_missing_profile', + __( 'A profile is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $profile = $this->profiles->find_by_slug( $slug ); + if ( ! $profile ) { + return new WP_Error( + 'workos_authkit_profile_not_found', + __( 'Login Profile not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + return $profile; + } + + /** + * Verify the profile-scoped nonce on a mutation request. + * + * Accepts either the `X-WP-Nonce` header (preferred) or a `_nonce` + * body/query param for simple form submissions. + * + * @param WP_REST_Request $request REST request. + * @param Profile $profile Resolved profile. + * + * @return true|WP_Error + */ + protected function verify_nonce( WP_REST_Request $request, Profile $profile ) { + $nonce = (string) $request->get_header( 'X-WP-Nonce' ); + if ( '' === $nonce ) { + $nonce = (string) $request->get_param( '_nonce' ); + } + + if ( ! $this->nonce->verify( $nonce, $profile->get_slug() ) ) { + return new WP_Error( + 'workos_authkit_invalid_nonce', + __( 'Your login session has expired. Please refresh and try again.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + return true; + } + + /** + * Apply rate limits for a request. + * + * Provide one entry per subject that should be throttled (e.g. IP and + * email). Each entry is `[bucket, subject, limit, window_seconds]`. + * First bucket to exceed wins and returns a 429. + * + * @param array $rules Rate-limit rules. + * + * @return true|WP_Error + */ + protected function rate_limit( array $rules ) { + foreach ( $rules as $rule ) { + [ $bucket, $subject, $limit, $window ] = $rule; + if ( '' === $subject ) { + continue; + } + $result = $this->rate_limiter->attempt( $bucket, $subject, $limit, $window ); + if ( is_wp_error( $result ) ) { + return $result; + } + } + + return true; + } + + /** + * Extract Radar action token from a request. + * + * @param WP_REST_Request $request REST request. + * + * @return string|null + */ + protected function get_radar_token( WP_REST_Request $request ): ?string { + return $this->radar->extract_from_request( $request ); + } + + /** + * Validate a redirect_to URL against the site host. + * + * @param string $redirect_to Raw value from the client. + * + * @return string Empty string when invalid or not on-site. + */ + protected function sanitize_redirect( string $redirect_to ): string { + if ( '' === $redirect_to ) { + return ''; + } + + $validated = wp_validate_redirect( $redirect_to, '' ); + + return is_string( $validated ) ? $validated : ''; + } +} diff --git a/src/WorkOS/REST/Auth/Controller.php b/src/WorkOS/REST/Auth/Controller.php new file mode 100644 index 0000000..3c1e042 --- /dev/null +++ b/src/WorkOS/REST/Auth/Controller.php @@ -0,0 +1,90 @@ +container->singleton( LoginCompleter::class ); + + $this->container->singleton( Password::class ); + $this->container->singleton( MagicCode::class ); + $this->container->singleton( Session::class ); + $this->container->singleton( Signup::class ); + $this->container->singleton( Invitation::class ); + $this->container->singleton( OAuth::class ); + $this->container->singleton( Mfa::class ); + + // Resolve each once so the container instantiates them now; the + // actual route registration happens below on `rest_api_init`. + $this->container->get( Password::class ); + $this->container->get( MagicCode::class ); + $this->container->get( Session::class ); + $this->container->get( Signup::class ); + $this->container->get( Invitation::class ); + $this->container->get( OAuth::class ); + $this->container->get( Mfa::class ); + + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + + /** + * Unregister. + * + * @return void + */ + protected function doUnregister(): void { + remove_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + + /** + * Register all AuthKit REST routes. + * + * @return void + */ + public function register_routes(): void { + foreach ( $this->endpoints() as $endpoint ) { + $endpoint->register_routes(); + } + } + + /** + * The set of endpoints that should register routes. + * + * Separated into a method so future phases can extend it (Signup, + * Invitation, OAuth, MFA, Passkey) without reworking this controller. + * + * @return BaseEndpoint[] + */ + private function endpoints(): array { + return [ + $this->container->get( Password::class ), + $this->container->get( MagicCode::class ), + $this->container->get( Session::class ), + $this->container->get( Signup::class ), + $this->container->get( Invitation::class ), + $this->container->get( OAuth::class ), + $this->container->get( Mfa::class ), + ]; + } +} diff --git a/src/WorkOS/REST/Auth/Invitation.php b/src/WorkOS/REST/Auth/Invitation.php new file mode 100644 index 0000000..dfbbdcd --- /dev/null +++ b/src/WorkOS/REST/Auth/Invitation.php @@ -0,0 +1,186 @@ +[A-Za-z0-9_\-]+)', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'lookup' ], + 'permission_callback' => [ $this, 'public_permission' ], + 'args' => [ + 'token' => [ 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/invitation/accept', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'accept' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + } + + /** + * GET /auth/invitation/{token} + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function lookup( WP_REST_Request $request ) { + $token = (string) $request['token']; + if ( '' === $token ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Invitation token is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $rate_ok = $this->rate_limit( + [ + [ 'invitation_lookup_ip', $this->rate_limiter->client_ip(), self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $invitation = workos()->api()->get_invitation_by_token( $token ); + if ( is_wp_error( $invitation ) ) { + return $invitation; + } + + return new WP_REST_Response( + [ + 'id' => (string) ( $invitation['id'] ?? '' ), + 'email' => (string) ( $invitation['email'] ?? '' ), + 'organization_id' => (string) ( $invitation['organization_id'] ?? '' ), + 'state' => (string) ( $invitation['state'] ?? 'pending' ), + 'expires_at' => (string) ( $invitation['expires_at'] ?? '' ), + ], + 200 + ); + } + + /** + * POST /auth/invitation/accept + * + * Hands the invitation token + new password to WorkOS, which: + * - Validates the token (rejects consumed, expired, or unknown tokens) + * - Creates or identifies the invited user on the invitation's + * authoritative email (the caller cannot substitute an arbitrary email) + * - Sets the password and marks the email verified per WorkOS policy + * - Returns a session + * + * Single atomic call — we intentionally do NOT call create_user or + * authenticate_with_password from the public endpoint. Doing so + * previously let an anonymous caller force `email_verified=true` on an + * arbitrary email and then authenticate with a chosen password, which + * combined with email-based auto-linking allowed account takeover of + * existing WP admins who happened to receive an invitation to their + * address. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function accept( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->is_invite_flow_enabled() ) { + return new WP_Error( + 'workos_authkit_invitation_disabled', + __( 'Invitation acceptance is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $token = (string) $request->get_param( 'invitation_token' ); + $password = (string) $request->get_param( 'password' ); + + if ( '' === $token || '' === $password ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Invitation token and password are required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $rate_ok = $this->rate_limit( + [ + [ 'invitation_accept_ip', $this->rate_limiter->client_ip(), self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $workos_response = workos()->api()->authenticate_with_invitation( + $token, + $password, + $this->get_radar_token( $request ) + ); + + if ( is_wp_error( $workos_response ) ) { + return $workos_response; + } + + $result = $this->login_completer->complete( + $workos_response, + $profile, + $this->sanitize_redirect( (string) $request->get_param( 'redirect_to' ) ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } +} diff --git a/src/WorkOS/REST/Auth/MagicCode.php b/src/WorkOS/REST/Auth/MagicCode.php new file mode 100644 index 0000000..9555b32 --- /dev/null +++ b/src/WorkOS/REST/Auth/MagicCode.php @@ -0,0 +1,188 @@ + 'POST', + 'callback' => [ $this, 'send' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/magic/verify', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'verify' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + } + + /** + * POST /auth/magic/send + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function send( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->has_method( Profile::METHOD_MAGIC_CODE ) ) { + return new WP_Error( + 'workos_authkit_method_disabled', + __( 'Magic-code sign-in is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $email = strtolower( trim( (string) $request->get_param( 'email' ) ) ); + if ( '' === $email || ! is_email( $email ) ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Enter a valid email.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'magic_send_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + [ 'magic_send_email', $email, self::RATE_LIMIT_EMAIL_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + // Fire-and-forget: return 200 regardless so the client cannot enumerate + // registered accounts. Real errors land in the plugin log. + workos()->api()->send_magic_auth_code( + $email, + $this->get_radar_token( $request ) + ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'message' => __( 'If an account exists for this email, a code is on its way.', 'integration-workos' ), + ], + 200 + ); + } + + /** + * POST /auth/magic/verify + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function verify( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->has_method( Profile::METHOD_MAGIC_CODE ) ) { + return new WP_Error( + 'workos_authkit_method_disabled', + __( 'Magic-code sign-in is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $email = strtolower( trim( (string) $request->get_param( 'email' ) ) ); + $code = (string) $request->get_param( 'code' ); + $pending_auth_token = (string) $request->get_param( 'pending_authentication_token' ); + + if ( '' === $email || '' === $code ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'An email and code are required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'magic_verify_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + [ 'magic_verify_email', $email, self::RATE_LIMIT_EMAIL_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $workos_response = workos()->api()->authenticate_with_magic_auth( + $email, + $code, + '' !== $pending_auth_token ? $pending_auth_token : null, + $this->get_radar_token( $request ) + ); + + if ( is_wp_error( $workos_response ) ) { + return $workos_response; + } + + $result = $this->login_completer->complete( + $workos_response, + $profile, + $this->sanitize_redirect( (string) $request->get_param( 'redirect_to' ) ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } +} diff --git a/src/WorkOS/REST/Auth/Mfa.php b/src/WorkOS/REST/Auth/Mfa.php new file mode 100644 index 0000000..8764723 --- /dev/null +++ b/src/WorkOS/REST/Auth/Mfa.php @@ -0,0 +1,468 @@ + 'POST', + 'callback' => [ $this, 'challenge' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/mfa/verify', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'verify' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/mfa/factors', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'list_factors' ], + 'permission_callback' => [ $this, 'authenticated_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/mfa/totp/enroll', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'enroll_totp' ], + 'permission_callback' => [ $this, 'authenticated_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/mfa/sms/enroll', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'enroll_sms' ], + 'permission_callback' => [ $this, 'authenticated_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/mfa/factor/delete', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'delete_factor' ], + 'permission_callback' => [ $this, 'authenticated_permission' ], + ] + ); + } + + /** + * Permission check for the settings-style endpoints. + * + * @return true|WP_Error + */ + public function authenticated_permission() { + if ( get_current_user_id() <= 0 ) { + return new WP_Error( + 'workos_authkit_not_logged_in', + __( 'You must be signed in to manage MFA factors.', 'integration-workos' ), + [ 'status' => 401 ] + ); + } + return true; + } + + /** + * POST /auth/mfa/challenge + * + * Starts a challenge on a chosen factor. For SMS this triggers the + * text message; for TOTP it just returns a challenge id. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function challenge( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + $factor_id = (string) $request->get_param( 'factor_id' ); + if ( '' === $factor_id ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'A factor id is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + // Second bucket keyed on the factor id closes a gap in the + // IP-only limit: a user with multiple enrolled factors could + // otherwise be targeted by rotating `factor_id` values from a + // single IP and collecting a fresh OTP on each one. + $rate_ok = $this->rate_limit( + [ + [ 'mfa_challenge_ip', $this->rate_limiter->client_ip(), self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + [ 'mfa_challenge_factor', $factor_id, self::RATE_LIMIT_FACTOR_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $result = workos()->api()->challenge_auth_factor( $factor_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( + [ + 'challenge_id' => (string) ( $result['id'] ?? '' ), + 'expires_at' => (string) ( $result['expires_at'] ?? '' ), + ], + 200 + ); + } + + /** + * POST /auth/mfa/verify + * + * Completes the previously-pending authentication with a TOTP/SMS code. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function verify( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + $pending_auth_token = (string) $request->get_param( 'pending_authentication_token' ); + $challenge_id = (string) $request->get_param( 'authentication_challenge_id' ); + $code = (string) $request->get_param( 'code' ); + + if ( '' === $pending_auth_token || '' === $challenge_id || '' === $code ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'A pending authentication token, challenge id, and code are required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $rate_ok = $this->rate_limit( + [ + [ 'mfa_verify_ip', $this->rate_limiter->client_ip(), self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $workos_response = workos()->api()->authenticate_with_totp( + $pending_auth_token, + $challenge_id, + $code, + $this->get_radar_token( $request ) + ); + + if ( is_wp_error( $workos_response ) ) { + return $workos_response; + } + + $result = $this->login_completer->complete( + $workos_response, + $profile, + $this->sanitize_redirect( (string) $request->get_param( 'redirect_to' ) ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } + + /** + * GET /auth/mfa/factors + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function list_factors( WP_REST_Request $request ) { + $workos_user_id = (string) get_user_meta( get_current_user_id(), '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return new WP_Error( + 'workos_authkit_no_workos_user', + __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $result = workos()->api()->list_auth_factors( $workos_user_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $factors = array_map( + static function ( array $factor ): array { + return [ + 'id' => (string) ( $factor['id'] ?? '' ), + 'type' => (string) ( $factor['type'] ?? '' ), + 'created' => (string) ( $factor['created_at'] ?? '' ), + ]; + }, + (array) ( $result['data'] ?? $result ) + ); + + return new WP_REST_Response( [ 'factors' => $factors ], 200 ); + } + + /** + * POST /auth/mfa/totp/enroll + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function enroll_totp( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->allows_factor( Profile::FACTOR_TOTP ) ) { + return new WP_Error( + 'workos_authkit_factor_disabled', + __( 'TOTP is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $user = wp_get_current_user(); + $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return new WP_Error( + 'workos_authkit_no_workos_user', + __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $issuer = get_bloginfo( 'name' ); + $label = $user->user_email; + + $result = workos()->api()->enroll_totp_factor( $workos_user_id, $issuer, $label ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $totp = $result['totp'] ?? []; + + return new WP_REST_Response( + [ + 'factor_id' => (string) ( $result['id'] ?? '' ), + 'qr_code' => (string) ( $totp['qr_code'] ?? '' ), + 'secret' => (string) ( $totp['secret'] ?? '' ), + 'otpauth_uri' => (string) ( $totp['uri'] ?? '' ), + ], + 201 + ); + } + + /** + * POST /auth/mfa/sms/enroll + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function enroll_sms( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->allows_factor( Profile::FACTOR_SMS ) ) { + return new WP_Error( + 'workos_authkit_factor_disabled', + __( 'SMS is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $phone_number = preg_replace( '/[^\+0-9]/', '', (string) $request->get_param( 'phone_number' ) ); + if ( '' === $phone_number ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'A phone number is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $workos_user_id = (string) get_user_meta( get_current_user_id(), '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return new WP_Error( + 'workos_authkit_no_workos_user', + __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $result = workos()->api()->enroll_sms_factor( $workos_user_id, $phone_number ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( + [ + 'factor_id' => (string) ( $result['id'] ?? '' ), + 'type' => 'sms', + ], + 201 + ); + } + + /** + * POST /auth/mfa/factor/delete + * + * Removes an MFA factor the *current* user owns. The ownership check + * is mandatory: because the plugin authenticates to WorkOS with a + * tenant-wide API key, WorkOS cannot distinguish which user is + * requesting the delete. Without this check, any authenticated WP + * user could strip MFA from any other user's account by submitting + * that user's factor_id. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function delete_factor( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + $factor_id = (string) $request->get_param( 'factor_id' ); + if ( '' === $factor_id ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'A factor id is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $workos_user_id = (string) get_user_meta( get_current_user_id(), '_workos_user_id', true ); + if ( '' === $workos_user_id ) { + return new WP_Error( + 'workos_authkit_no_workos_user', + __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $factors = workos()->api()->list_auth_factors( $workos_user_id ); + if ( is_wp_error( $factors ) ) { + return $factors; + } + + $owned_ids = array_map( + static function ( array $factor ): string { + return (string) ( $factor['id'] ?? '' ); + }, + (array) ( $factors['data'] ?? $factors ) + ); + + if ( ! in_array( $factor_id, $owned_ids, true ) ) { + // Return 404 rather than 403 so we don't leak the existence of + // factor IDs that belong to other users. + return new WP_Error( + 'workos_authkit_factor_not_found', + __( 'Factor not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + $result = workos()->api()->delete_auth_factor( $factor_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( [ 'deleted' => true ], 200 ); + } +} diff --git a/src/WorkOS/REST/Auth/OAuth.php b/src/WorkOS/REST/Auth/OAuth.php new file mode 100644 index 0000000..fd9f982 --- /dev/null +++ b/src/WorkOS/REST/Auth/OAuth.php @@ -0,0 +1,154 @@ + 'GoogleOAuth', + Profile::METHOD_OAUTH_MICROSOFT => 'MicrosoftOAuth', + Profile::METHOD_OAUTH_GITHUB => 'GitHubOAuth', + Profile::METHOD_OAUTH_APPLE => 'AppleOAuth', + ]; + + /** + * Register routes. + * + * @return void + */ + public function register_routes(): void { + register_rest_route( + self::NAMESPACE, + self::BASE . '/oauth/authorize-url', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'authorize_url' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + } + + /** + * GET /auth/oauth/authorize-url?profile=slug&provider=oauth_google + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function authorize_url( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $method_param = sanitize_text_field( (string) $request->get_param( 'provider' ) ); + if ( '' === $method_param ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Provider is required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + if ( ! isset( self::METHOD_TO_PROVIDER[ $method_param ] ) ) { + return new WP_Error( + 'workos_authkit_unknown_provider', + __( 'Unknown OAuth provider.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + if ( ! $profile->has_method( $method_param ) ) { + return new WP_Error( + 'workos_authkit_method_disabled', + __( 'This social provider is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $rate_ok = $this->rate_limit( + [ + [ 'oauth_url_ip', $this->rate_limiter->client_ip(), self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $redirect_to = $this->sanitize_redirect( (string) $request->get_param( 'redirect_to' ) ); + if ( '' === $redirect_to ) { + $redirect_to = admin_url(); + } + + // State threads a *profile-scoped* nonce + the final redirect + the + // profile slug. Nonce::mint uses an action tied to the profile slug + // (`workos_authkit_`) — the same primitive every other REST + // endpoint uses — so a nonce minted for profile A cannot be + // consumed by a callback that claims profile B. Login::handle_callback + // verifies this nonce against whichever profile the state carries. + $state = implode( + '|', + [ + $this->nonce->mint( $profile->get_slug() ), + $redirect_to, + $profile->get_slug(), + ] + ); + + $args = [ + 'redirect_uri' => Login::get_callback_url(), + 'state' => $state, + 'provider' => self::METHOD_TO_PROVIDER[ $method_param ], + ]; + + /* + * Intentionally no organization_id. This endpoint only handles + * social OAuth providers (Google / Microsoft / GitHub / Apple), + * which are environment-level apps in WorkOS. Passing + * organization_id forces WorkOS to resolve an org-scoped + * connection and returns "missing_code — No Connection associated + * with Organization" when one is not configured. WorkOS scopes + * the user by the OAuth grant's verified email, same as password + * and magic-code flows. + */ + + $url = workos()->api()->get_authorization_url( $args ); + + return new WP_REST_Response( + [ 'authorize_url' => (string) $url ], + 200 + ); + } +} diff --git a/src/WorkOS/REST/Auth/Password.php b/src/WorkOS/REST/Auth/Password.php new file mode 100644 index 0000000..ee1eb7a --- /dev/null +++ b/src/WorkOS/REST/Auth/Password.php @@ -0,0 +1,317 @@ + 'POST', + 'callback' => [ $this, 'authenticate' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/password/reset/start', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'reset_start' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/password/reset/confirm', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'reset_confirm' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + } + + /** + * POST /auth/password/authenticate + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function authenticate( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->has_method( Profile::METHOD_PASSWORD ) ) { + return new WP_Error( + 'workos_authkit_method_disabled', + __( 'Password sign-in is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $email = strtolower( trim( (string) $request->get_param( 'email' ) ) ); + $password = (string) $request->get_param( 'password' ); + + if ( '' === $email || ! is_email( $email ) || '' === $password ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Enter a valid email and password.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'password_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + [ 'password_email', $email, self::RATE_LIMIT_EMAIL_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $workos_response = workos()->api()->authenticate_with_password( + $email, + $password, + $this->get_radar_token( $request ) + ); + + if ( is_wp_error( $workos_response ) ) { + return $workos_response; + } + + $result = $this->login_completer->complete( + $workos_response, + $profile, + $this->sanitize_redirect( (string) $request->get_param( 'redirect_to' ) ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } + + /** + * POST /auth/password/reset/start + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function reset_start( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->is_password_reset_flow_enabled() ) { + return new WP_Error( + 'workos_authkit_reset_disabled', + __( 'Password reset is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $email = strtolower( trim( (string) $request->get_param( 'email' ) ) ); + if ( '' === $email || ! is_email( $email ) ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Enter a valid email.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'pw_reset_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + [ 'pw_reset_email', $email, self::RATE_LIMIT_EMAIL_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + // Fire the WorkOS send call but *always* return 200 to prevent email + // enumeration (an attacker cannot tell whether an account exists). + // Also normalize response time: WorkOS answers valid + unknown + // emails at visibly different latencies, which leaks existence via + // side-channel timing despite the identical body. A fixed floor + // flattens both paths to the same observable wall-clock. + $start_ns = hrtime( true ); + + workos()->api()->send_password_reset( + $email, + $this->build_password_reset_url( $profile ), + $this->get_radar_token( $request ) + ); + + $this->sleep_until_floor( $start_ns, self::RESPONSE_TIME_FLOOR_US ); + + return new WP_REST_Response( + [ + 'ok' => true, + 'message' => __( 'If an account exists for this email, a password reset link is on its way.', 'integration-workos' ), + ], + 200 + ); + } + + /** + * POST /auth/password/reset/confirm + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function reset_confirm( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + if ( ! $profile->is_password_reset_flow_enabled() ) { + return new WP_Error( + 'workos_authkit_reset_disabled', + __( 'Password reset is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $token = (string) $request->get_param( 'token' ); + $new_password = (string) $request->get_param( 'new_password' ); + + if ( '' === $token || '' === $new_password ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'A reset token and new password are required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'pw_reset_confirm_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $workos_response = workos()->api()->reset_password( + $token, + $new_password, + $this->get_radar_token( $request ) + ); + + if ( is_wp_error( $workos_response ) ) { + return $workos_response; + } + + return new WP_REST_Response( + [ 'ok' => true ], + 200 + ); + } + + /** + * Build the URL emailed to users for completing a password reset. + * + * @param Profile $profile Active profile. + * + * @return string + */ + private function build_password_reset_url( Profile $profile ): string { + return add_query_arg( + [ + 'workos_action' => 'reset-password', + 'profile' => $profile->get_slug(), + ], + wp_login_url() + ); + } + + /** + * Sleep the caller's thread until at least $floor_us microseconds + * have elapsed since $start_ns. + * + * Used to flatten response-time differences that would otherwise + * leak whether a submitted email corresponds to a real account. + * + * @param float $start_ns hrtime(true) timestamp taken at the start. + * @param int $floor_us Minimum observable elapsed microseconds. + * + * @return void + */ + private function sleep_until_floor( float $start_ns, int $floor_us ): void { + $elapsed_us = (int) ( ( hrtime( true ) - $start_ns ) / 1000 ); + $remaining = $floor_us - $elapsed_us; + if ( $remaining > 0 ) { + usleep( $remaining ); + } + } +} diff --git a/src/WorkOS/REST/Auth/Session.php b/src/WorkOS/REST/Auth/Session.php new file mode 100644 index 0000000..8e3d4da --- /dev/null +++ b/src/WorkOS/REST/Auth/Session.php @@ -0,0 +1,193 @@ + 'GET', + 'callback' => [ $this, 'nonce' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/session/refresh', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'refresh' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/session/logout', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'logout' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + } + + /** + * GET /auth/nonce?profile=slug + * + * Returns the nonce the React shell attaches to every mutation, plus + * the Radar public site key when configured so the browser SDK can + * bootstrap without a second round-trip. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function nonce( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + return new WP_REST_Response( + [ + 'nonce' => $this->nonce->mint( $profile->get_slug() ), + 'radar_site_key' => $this->radar->get_site_key(), + ], + 200 + ); + } + + /** + * POST /auth/session/refresh + * + * Exchanges the currently-logged-in user's stored refresh token for a + * new access/refresh pair. Requires a logged-in WP session — the + * refresh token never leaves PHP. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function refresh( WP_REST_Request $request ) { + $user_id = get_current_user_id(); + if ( $user_id <= 0 ) { + return new WP_Error( + 'workos_authkit_not_logged_in', + __( 'You are not currently signed in.', 'integration-workos' ), + [ 'status' => 401 ] + ); + } + + $refresh_token = (string) get_user_meta( $user_id, '_workos_refresh_token', true ); + if ( '' === $refresh_token ) { + return new WP_Error( + 'workos_authkit_no_refresh_token', + __( 'No refresh token is available for this session.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + $workos_response = workos()->api()->refresh_session( $refresh_token ); + if ( is_wp_error( $workos_response ) ) { + return $workos_response; + } + + Login::store_tokens( $user_id, $workos_response ); + + return new WP_REST_Response( + [ + 'ok' => true, + // Expose the access-token `exp` so the React shell can + // schedule its own pre-emptive refresh ~60s before expiry. + 'expires_at' => $this->extract_exp( $workos_response['access_token'] ?? '' ), + ], + 200 + ); + } + + /** + * POST /auth/session/logout + * + * Clears the WP auth cookie and revokes the WorkOS session so neither + * side holds stale credentials. Safe to call while logged out. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response + */ + public function logout( WP_REST_Request $request ) { + $user_id = get_current_user_id(); + + if ( $user_id > 0 ) { + // wp_logout() will fire handle_logout() which revokes the + // WorkOS session and clears our usermeta tokens. + wp_logout(); + } + + return new WP_REST_Response( [ 'ok' => true ], 200 ); + } + + /** + * Decode the `exp` claim from a JWT payload without verifying. + * + * IMPORTANT — this is intentionally signature-unchecked. + * + * The returned timestamp drives ONLY the browser's pre-emptive + * session-refresh timer (the React shell schedules a refresh call + * ~60s before the access token expires). No authorization decision + * is made against this value; if an attacker forges an unsigned JWT + * with a bogus `exp`, the only effect is that the client schedules + * a refresh at the wrong time, and the refresh call itself is + * authenticated via the WP auth cookie + the signed refresh token + * held server-side. WorkOS's JWKS-verified `verify_access_token()` + * is the canonical check everywhere else in the plugin that needs + * authoritative session state. + * + * @param string $token JWT. + * + * @return int|null Unix timestamp, or null when absent / malformed. + */ + private function extract_exp( string $token ): ?int { + $parts = explode( '.', $token ); + if ( count( $parts ) !== 3 ) { + return null; + } + + $payload = json_decode( + // JWT payloads are base64url-encoded by RFC 7519; decoding is not + // obfuscation. We only read `exp` here, never execute the result. + base64_decode( strtr( $parts[1], '-_', '+/' ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + true + ); + + return is_array( $payload ) && isset( $payload['exp'] ) ? (int) $payload['exp'] : null; + } +} diff --git a/src/WorkOS/REST/Auth/Signup.php b/src/WorkOS/REST/Auth/Signup.php new file mode 100644 index 0000000..b59ff9b --- /dev/null +++ b/src/WorkOS/REST/Auth/Signup.php @@ -0,0 +1,229 @@ + 'POST', + 'callback' => [ $this, 'create' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + + register_rest_route( + self::NAMESPACE, + self::BASE . '/signup/verify', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'verify' ], + 'permission_callback' => [ $this, 'public_permission' ], + ] + ); + } + + /** + * POST /auth/signup/create + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function create( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + $signup = $profile->get_signup(); + if ( empty( $signup['enabled'] ) ) { + return new WP_Error( + 'workos_authkit_signup_disabled', + __( 'Sign-up is not enabled for this login.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + if ( ! empty( $signup['require_invite'] ) ) { + return new WP_Error( + 'workos_authkit_invitation_required', + __( 'This login requires an invitation to sign up.', 'integration-workos' ), + [ 'status' => 403 ] + ); + } + + $email = strtolower( trim( (string) $request->get_param( 'email' ) ) ); + $password = (string) $request->get_param( 'password' ); + $first_name = sanitize_text_field( (string) $request->get_param( 'first_name' ) ); + $last_name = sanitize_text_field( (string) $request->get_param( 'last_name' ) ); + + if ( '' === $email || ! is_email( $email ) ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'Enter a valid email.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'signup_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + [ 'signup_email', $email, self::RATE_LIMIT_EMAIL_ATTEMPTS, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $payload = array_filter( + [ + 'email' => $email, + 'password' => '' !== $password ? $password : null, + 'first_name' => '' !== $first_name ? $first_name : null, + 'last_name' => '' !== $last_name ? $last_name : null, + ], + static function ( $value ): bool { + return null !== $value; + } + ); + + $user = workos()->api()->create_user( $payload, $this->get_radar_token( $request ) ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + // If the user still needs to verify their email, kick off the code + // email so the React shell can transition straight to the verify step. + if ( empty( $user['email_verified'] ) && ! empty( $user['id'] ) ) { + workos()->api()->send_verification_email( $user['id'] ); + } + + return new WP_REST_Response( + [ + 'user' => [ + 'id' => (string) ( $user['id'] ?? '' ), + 'email' => (string) ( $user['email'] ?? $email ), + 'email_verified' => (bool) ( $user['email_verified'] ?? false ), + ], + 'verification_needed' => empty( $user['email_verified'] ), + ], + 201 + ); + } + + /** + * POST /auth/signup/verify + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function verify( WP_REST_Request $request ) { + $profile = $this->resolve_profile( $request ); + if ( is_wp_error( $profile ) ) { + return $profile; + } + + $nonce_ok = $this->verify_nonce( $request, $profile ); + if ( is_wp_error( $nonce_ok ) ) { + return $nonce_ok; + } + + $user_id = (string) $request->get_param( 'user_id' ); + $code = (string) $request->get_param( 'code' ); + + if ( '' === $user_id || '' === $code ) { + return new WP_Error( + 'workos_authkit_invalid_input', + __( 'A user and verification code are required.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + $ip = $this->rate_limiter->client_ip(); + $rate_ok = $this->rate_limit( + [ + [ 'signup_verify_ip', $ip, self::RATE_LIMIT_IP_ATTEMPTS * 2, self::RATE_LIMIT_WINDOW ], + ] + ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; + } + + $verify_response = workos()->api()->verify_email( + $user_id, + $code, + $this->get_radar_token( $request ) + ); + + if ( is_wp_error( $verify_response ) ) { + // Return a clean, generic error. Do NOT echo back the WorkOS + // response body (which may include the user_id we looked up) + // or the caller-supplied user_id — the caller already knows + // their input, and echoing it on a failed verification adds no + // value and risks leaking internal identifiers. + return new WP_Error( + 'workos_authkit_verify_failed', + __( 'That code is not valid. Request a new one and try again.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } + + // After email verification, WorkOS returns the user record. We do + // *not* log the user in automatically — the React shell transitions + // to the sign-in step with the email pre-filled. This keeps signup + // and login flows isolated (each one only has one way to succeed). + return new WP_REST_Response( + [ + 'ok' => true, + 'user' => [ + 'id' => (string) ( $verify_response['user']['id'] ?? $user_id ), + 'email' => (string) ( $verify_response['user']['email'] ?? '' ), + 'email_verified' => true, + ], + ], + 200 + ); + } +} diff --git a/src/WorkOS/REST/Controller.php b/src/WorkOS/REST/Controller.php index 8e51bc6..f24a49b 100644 --- a/src/WorkOS/REST/Controller.php +++ b/src/WorkOS/REST/Controller.php @@ -22,6 +22,8 @@ class Controller extends BaseController { protected function doRegister(): void { $this->container->singleton( TokenAuth::class ); $this->container->get( TokenAuth::class ); + + $this->container->register( Auth\Controller::class ); } /** diff --git a/src/WorkOS/REST/TokenAuth.php b/src/WorkOS/REST/TokenAuth.php index 0dc178d..b5ce011 100644 --- a/src/WorkOS/REST/TokenAuth.php +++ b/src/WorkOS/REST/TokenAuth.php @@ -8,6 +8,7 @@ namespace WorkOS\REST; use WorkOS\Vendor\StellarWP\SuperGlobals\SuperGlobals; +use WP_Error; defined( 'ABSPATH' ) || exit; @@ -53,10 +54,18 @@ public function authenticate( $user_id ) { return $user_id; } - // Verify the token. + // Verify the token — signature AND expiration both must hold. + // + // We intentionally do NOT attempt a "lazy refresh" off a Bearer + // token that fails verification. Doing so would mean trusting the + // `sub` claim of an unsigned or expired JWT to pick whose stored + // refresh token to exchange — an attacker-controlled identity + // lookup. Clients that need to renew an expired access token + // should hit POST /wp-json/workos/v1/auth/session/refresh, which + // is authenticated via the WP auth cookie (independently-verified + // session) rather than the Bearer token itself. $payload = workos()->api()->verify_access_token( $token ); if ( is_wp_error( $payload ) ) { - // Store error for rest_authentication_errors filter. $this->auth_error = $payload; return $user_id; } @@ -75,9 +84,9 @@ public function authenticate( $user_id ) { /** * Report authentication errors to the REST API. * - * @param \WP_Error|null|true $error Current error state. + * @param WP_Error|null|true $error Current error state. * - * @return \WP_Error|null|true + * @return WP_Error|null|true */ public function check_errors( $error ) { if ( ! empty( $error ) ) { @@ -137,7 +146,7 @@ private function is_rest_request(): bool { /** * Stored auth error from token verification. * - * @var \WP_Error|null + * @var WP_Error|null */ - private ?\WP_Error $auth_error = null; + private ?WP_Error $auth_error = null; } diff --git a/src/WorkOS/UI/Controller.php b/src/WorkOS/UI/Controller.php index be2e23a..8e4ff4b 100644 --- a/src/WorkOS/UI/Controller.php +++ b/src/WorkOS/UI/Controller.php @@ -84,23 +84,33 @@ private function enqueue_frontend_assets(): void { WORKOS_VERSION ); + $asset_file = WORKOS_DIR . 'build/login-button-frontend.asset.php'; + $asset = file_exists( $asset_file ) + ? include $asset_file + : [ + 'dependencies' => [], + 'version' => WORKOS_VERSION, + ]; + wp_enqueue_script( 'workos-login-button-frontend', - WORKOS_URL . 'src/js/login-button-frontend.js', - [], - WORKOS_VERSION, + WORKOS_URL . 'build/login-button-frontend.js', + $asset['dependencies'], + $asset['version'], true ); + wp_set_script_translations( + 'workos-login-button-frontend', + 'integration-workos' + ); + wp_localize_script( 'workos-login-button-frontend', 'workosLoginButton', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'workos_login_button' ), - 'i18n' => [ - 'error' => __( 'An error occurred. Please try again.', 'integration-workos' ), - ], ] ); } diff --git a/src/WorkOS/User.php b/src/WorkOS/User.php new file mode 100644 index 0000000..fe6ddda --- /dev/null +++ b/src/WorkOS/User.php @@ -0,0 +1,240 @@ + $resolved, + 'is_sso' => self::is_sso( $resolved ), + 'has_active_session' => self::has_active_session( $resolved ), + 'workos_user_id' => self::get_workos_id( $resolved ), + 'organization_id' => self::get_organization_id( $resolved ), + 'session_id' => self::get_session_id( $resolved ), + ]; + } + + /** + * Read a string-typed meta value, normalized to '' when absent. + * + * @param int $user_id WP user ID (0 = current). + * @param string $key Meta key. + * + * @return string + */ + private static function meta_string( int $user_id, string $key ): string { + $resolved = self::resolve_user_id( $user_id ); + if ( 0 === $resolved ) { + return ''; + } + + $value = get_user_meta( $resolved, $key, true ); + + return is_string( $value ) ? $value : ''; + } + + /** + * Normalize a user id argument: 0 / negative becomes the current user. + * + * @param int $user_id Incoming id. + * + * @return int Positive WP user id, or 0 when no user is available. + */ + private static function resolve_user_id( int $user_id ): int { + if ( $user_id > 0 ) { + return $user_id; + } + + $current = get_current_user_id(); + + return $current > 0 ? $current : 0; + } +} diff --git a/src/includes/functions-helpers.php b/src/includes/functions-helpers.php index 62dabde..b914a5d 100644 --- a/src/includes/functions-helpers.php +++ b/src/includes/functions-helpers.php @@ -7,6 +7,8 @@ * @package WorkOS */ +use WorkOS\User; + defined( 'ABSPATH' ) || exit; /** @@ -38,3 +40,65 @@ function workos_log( string $message, string $level = 'debug' ): void { error_log( "{$prefix} {$message}" ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } + +/** + * Whether a user is linked to a WorkOS identity. + * + * Thin shortcut for {@see \WorkOS\User::is_sso()}. Remains true after + * logout — checks the persistent link, not active session state. Pass + * 0 (or omit) to target the currently-authenticated user. + * + * ```php + * if ( workos_is_sso_user() ) { + * // Customize response for a user with a WorkOS identity. + * } + * ``` + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return bool + */ +function workos_is_sso_user( int $user_id = 0 ): bool { + return User::is_sso( $user_id ); +} + +/** + * Whether the user is currently signed in via WorkOS (access token stored). + * + * This matches the original `! empty( $workos_token )` check — the right + * signal for "is this request running under a live WorkOS session?". + * Use {@see workos_is_sso_user()} if you want to know whether the + * account is linked regardless of login state. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return bool + */ +function workos_has_active_session( int $user_id = 0 ): bool { + return User::has_active_session( $user_id ); +} + +/** + * WorkOS user identifier for a WP user, or empty string when absent. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return string + */ +function workos_get_user_id( int $user_id = 0 ): string { + return User::get_workos_id( $user_id ); +} + +/** + * 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. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return string + */ +function workos_get_access_token( int $user_id = 0 ): string { + return User::get_access_token( $user_id ); +} diff --git a/src/js/admin-profiles/index.tsx b/src/js/admin-profiles/index.tsx new file mode 100644 index 0000000..e762e05 --- /dev/null +++ b/src/js/admin-profiles/index.tsx @@ -0,0 +1,971 @@ +/** + * Admin Login Profiles editor. + * + * Mounts onto #workos-profiles-admin-root. Talks to the admin REST + * namespace at /wp-json/workos/v1/admin/profiles. Intentionally kept in + * a single file — the editor is a small CRUD form, not a full app. + */ + +import { createRoot, useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import type { ChangeEvent, MouseEvent, ReactNode } from 'react'; +import './styles.css'; + +declare global { + interface Window { + workosProfileAdmin?: { + restUrl: string; + nonce: string; + pageUrl: string; + profiles: Profile[]; + activeProfileSlug: string; + }; + wp?: { + media?: ( config: WpMediaConfig ) => WpMediaFrame; + }; + } +} + +const NEW_PROFILE_SENTINEL = 'new'; + +interface WpMediaConfig { + title?: string; + button?: { text?: string }; + multiple?: boolean; + library?: { type?: string }; +} + +interface WpMediaAttachment { + id: number; + url: string; + alt?: string; + filename?: string; +} + +/** + * `wp.media` selections return Backbone Models. `.first()` is the Model + * itself — accessing `.url` on it gives Backbone's URL **method**, not + * the attachment URL. Always call `.toJSON()` to get the plain + * attachment data. + */ +interface WpMediaModel { + toJSON(): WpMediaAttachment; +} + +interface WpMediaSelection { + first(): WpMediaModel; +} + +interface WpMediaState { + get( key: string ): WpMediaSelection; +} + +interface WpMediaFrame { + on( event: 'select', cb: () => void ): void; + open(): void; + state(): WpMediaState; +} + +type AuthMethod = + | 'password' + | 'magic_code' + | 'oauth_google' + | 'oauth_microsoft' + | 'oauth_github' + | 'oauth_apple' + | 'passkey'; + +type MfaFactor = 'totp' | 'sms' | 'webauthn'; +type MfaEnforce = 'never' | 'if_required' | 'always'; +type ProfileMode = 'custom' | 'authkit_redirect'; + +interface Profile { + id: number; + slug: string; + title: string; + methods: AuthMethod[]; + organization_id: string; + signup: { enabled: boolean; require_invite: boolean }; + invite_flow: boolean; + password_reset_flow: boolean; + mfa: { enforce: MfaEnforce; factors: MfaFactor[] }; + branding: { + logo_attachment_id: number; + logo_url?: string; + primary_color: string; + heading: string; + subheading: string; + }; + post_login_redirect: string; + mode: ProfileMode; +} + +interface ApiResult< T > { + ok: boolean; + status: number; + data: T; +} + +interface Organization { + id: string; + name: string; +} + +interface OrganizationsResponse { + organizations?: Organization[]; + error?: string; + message?: string; +} + +const CUSTOM_ORG_SENTINEL = '__workos_custom_org__'; + +interface OptionItem< V extends string > { + value: V; + label: string; +} + +const methodOptions = (): OptionItem< AuthMethod >[] => [ + { value: 'password', label: __( 'Email + password', 'integration-workos' ) }, + { value: 'magic_code', label: __( 'Magic link / email code', 'integration-workos' ) }, + { value: 'oauth_google', label: __( 'Google', 'integration-workos' ) }, + { value: 'oauth_microsoft', label: __( 'Microsoft', 'integration-workos' ) }, + { value: 'oauth_github', label: __( 'GitHub', 'integration-workos' ) }, + { value: 'oauth_apple', label: __( 'Apple', 'integration-workos' ) }, + { value: 'passkey', label: __( 'Passkey', 'integration-workos' ) }, +]; + +const factorOptions = (): OptionItem< MfaFactor >[] => [ + { value: 'totp', label: __( 'Authenticator app (TOTP)', 'integration-workos' ) }, + { value: 'sms', label: __( 'SMS', 'integration-workos' ) }, + { value: 'webauthn', label: __( 'WebAuthn / passkey', 'integration-workos' ) }, +]; + +const enforceOptions = (): OptionItem< MfaEnforce >[] => [ + { value: 'never', label: __( 'Never', 'integration-workos' ) }, + { value: 'if_required', label: __( 'When WorkOS requires it', 'integration-workos' ) }, + { value: 'always', label: __( 'Always', 'integration-workos' ) }, +]; + +const modeOptions = (): OptionItem< ProfileMode >[] => [ + { value: 'custom', label: __( 'Custom (React UI)', 'integration-workos' ) }, + { value: 'authkit_redirect', label: __( 'Legacy AuthKit redirect', 'integration-workos' ) }, +]; + +function apiUrl( path = '' ): string { + const base = window.workosProfileAdmin?.restUrl || '/wp-json/workos/v1/admin/profiles'; + return `${ base }${ path }`; +} + +async function apiCall< T >( + method: string, + path: string, + body?: unknown +): Promise< ApiResult< T > > { + const res = await fetch( apiUrl( path ), { + method, + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': window.workosProfileAdmin?.nonce || '', + }, + body: body ? JSON.stringify( body ) : undefined, + } ); + const data = await res.json().catch( () => ( {} ) ); + return { ok: res.ok, status: res.status, data: data as T }; +} + +interface CheckboxesProps< V extends string > { + label: string; + options: OptionItem< V >[]; + values: V[]; + onChange: ( next: V[] ) => void; +} + +function Checkboxes< V extends string >( { + label, + options, + values, + onChange, +}: CheckboxesProps< V > ) { + const toggle = ( value: V ): void => { + onChange( + values.includes( value ) + ? values.filter( ( v ) => v !== value ) + : [ ...values, value ] + ); + }; + return ( +
+ { label } + { options.map( ( opt ) => ( + + ) ) } +
+ ); +} + +interface SelectProps< V extends string > { + label: string; + value: V; + onChange: ( next: V ) => void; + options: OptionItem< V >[]; +} + +function Select< V extends string >( { + label, + value, + onChange, + options, +}: SelectProps< V > ) { + return ( + + ); +} + +interface TextFieldProps { + label: string; + value?: string; + onChange: ( next: string ) => void; + type?: string; + placeholder?: string; + disabled?: boolean; +} + +function TextField( { + label, + value, + onChange, + type = 'text', + placeholder, + disabled, +}: TextFieldProps ) { + return ( + + ); +} + +interface OrganizationFieldProps { + value: string; + organizations: Organization[]; + loading: boolean; + error: string; + onChange: ( next: string ) => void; +} + +/** + * Pinned-organization picker. Renders a select when we have a list from + * WorkOS, with a "Custom ID…" escape hatch so an org that isn't in the + * first page of results can still be pinned by pasting its ID. + */ +function OrganizationField( { + value, + organizations, + loading, + error, + onChange, +}: OrganizationFieldProps ) { + const inList = organizations.some( ( o ) => o.id === value ); + const [ customMode, setCustomMode ] = useState< boolean >( + () => value !== '' && ! inList + ); + + // If the orgs list arrives after mount and contains the current value, + // collapse back out of custom mode so the select reflects the choice. + useEffect( () => { + if ( value !== '' && organizations.some( ( o ) => o.id === value ) ) { + setCustomMode( false ); + } + }, [ value, organizations ] ); + + const selectValue = customMode ? CUSTOM_ORG_SENTINEL : value; + + const handleSelect = ( next: string ): void => { + if ( next === CUSTOM_ORG_SENTINEL ) { + setCustomMode( true ); + return; + } + setCustomMode( false ); + onChange( next ); + }; + + // No orgs to pick from — fall back to plain text entry so the field + // remains usable when WorkOS is unconfigured or the API is down. + if ( ! loading && organizations.length === 0 ) { + return ( + + ); + } + + return ( + + ); +} + +interface LogoFieldProps { + attachmentId: number; + url?: string; + onChange: ( id: number, url: string ) => void; +} + +/** + * Logo picker for the Branding fieldset. Opens the WordPress media modal + * (`wp.media`) — available because AdminPage.php calls `wp_enqueue_media()` + * on this page. Stores the chosen attachment ID; the preview thumbnail + * uses the URL the modal returns (or the URL the REST API resolved on + * load for an existing profile). + */ +function LogoField( { attachmentId, url, onChange }: LogoFieldProps ) { + const open = (): void => { + if ( ! window.wp?.media ) { + return; + } + const frame = window.wp.media( { + title: __( 'Select login logo', 'integration-workos' ), + button: { text: __( 'Use this image', 'integration-workos' ) }, + multiple: false, + library: { type: 'image' }, + } ); + frame.on( 'select', () => { + const attachment = frame + .state() + .get( 'selection' ) + .first() + .toJSON(); + onChange( attachment.id, attachment.url ); + } ); + frame.open(); + }; + + return ( +
+ { __( 'Logo', 'integration-workos' ) } + { attachmentId > 0 && url && ( + { + ) } +
+ + { attachmentId > 0 && ( + + ) } +
+ + { __( + 'Defaults to the WordPress Site Icon when no per-profile logo is set.', + 'integration-workos' + ) } + +
+ ); +} + +function emptyProfile(): Profile { + return { + id: 0, + slug: '', + title: '', + methods: [ 'password', 'magic_code' ], + organization_id: '', + signup: { enabled: false, require_invite: false }, + invite_flow: true, + password_reset_flow: true, + mfa: { enforce: 'if_required', factors: [ 'totp' ] }, + branding: { + logo_attachment_id: 0, + logo_url: '', + primary_color: '', + heading: '', + subheading: '', + }, + post_login_redirect: '', + mode: 'custom', + }; +} + +interface EditorProps { + profile: Profile; + organizations: Organization[]; + organizationsLoading: boolean; + organizationsError: string; + onSave: ( profile: Profile ) => void; + onCancel: () => void; + onDelete: ( profile: Profile ) => void; + saving: boolean; +} + +function Editor( { + profile, + organizations, + organizationsLoading, + organizationsError, + onSave, + onCancel, + onDelete, + saving, +}: EditorProps ) { + const [ data, setData ] = useState< Profile >( profile ); + + useEffect( () => setData( profile ), [ profile.id, profile.slug ] ); + + const set = ( patch: Partial< Profile > ): void => + setData( ( prev ) => ( { ...prev, ...patch } ) ); + const setSignup = ( patch: Partial< Profile[ 'signup' ] > ): void => + set( { signup: { ...data.signup, ...patch } } ); + const setMfa = ( patch: Partial< Profile[ 'mfa' ] > ): void => + set( { mfa: { ...data.mfa, ...patch } } ); + const setBranding = ( patch: Partial< Profile[ 'branding' ] > ): void => + set( { branding: { ...data.branding, ...patch } } ); + + const isDefault = data.slug === 'default'; + + return ( +
+

+ { data.id + ? sprintf( + /* translators: %s: profile title. */ + __( 'Edit: %s', 'integration-workos' ), + data.title + ) + : __( 'New Login Profile', 'integration-workos' ) } +

+ + set( { title: v } ) } + /> + + set( { slug: v } ) } + disabled={ isDefault } + placeholder={ __( 'members-area', 'integration-workos' ) } + /> + + + label={ __( 'Mode', 'integration-workos' ) } + value={ data.mode } + onChange={ ( v ) => set( { mode: v } ) } + options={ modeOptions() } + /> + + + label={ __( 'Enabled sign-in methods', 'integration-workos' ) } + options={ methodOptions() } + values={ data.methods } + onChange={ ( v ) => set( { methods: v } ) } + /> + + set( { organization_id: v } ) } + /> + + set( { post_login_redirect: v } ) } + placeholder={ __( '/dashboard', 'integration-workos' ) } + /> + +
+ { __( 'Sign-up', 'integration-workos' ) } + + +
+ +
+ { __( 'Flows', 'integration-workos' ) } + + +
+ + + label={ __( 'MFA enforcement', 'integration-workos' ) } + value={ data.mfa.enforce } + onChange={ ( v ) => setMfa( { enforce: v } ) } + options={ enforceOptions() } + /> + + + label={ __( 'MFA factors', 'integration-workos' ) } + options={ factorOptions() } + values={ data.mfa.factors } + onChange={ ( v ) => setMfa( { factors: v } ) } + /> + +
+ { __( 'Branding', 'integration-workos' ) } + + setBranding( { logo_attachment_id: id, logo_url: url } ) + } + /> + setBranding( { heading: v } ) } + /> + setBranding( { subheading: v } ) } + /> + setBranding( { primary_color: v } ) } + placeholder={ __( '#0057ff', 'integration-workos' ) } + /> +
+ +
+ + + { data.id > 0 && ! isDefault && ( + + ) } +
+
+ ); +} + +interface ListProps { + profiles: Profile[]; + organizations: Organization[]; + onSelect: ( profile: Profile ) => void; + onCreate: () => void; +} + +function profileUrl( slug: string ): string { + const base = window.workosProfileAdmin?.pageUrl || ''; + return '' === base + ? '#' + : `${ base }&profile=${ encodeURIComponent( slug ) }`; +} + +function List( { profiles, organizations, onSelect, onCreate }: ListProps ) { + const orgName = ( id: string ): string => { + if ( '' === id ) { + return '—'; + } + const match = organizations.find( ( o ) => o.id === id ); + return match ? match.name : id; + }; + + // Real anchors so middle-click + copy-link work; left-click is + // intercepted by `onSelect` to keep navigation client-side. + const handleClick = ( + event: MouseEvent< HTMLAnchorElement >, + profile: Profile + ): void => { + // Honor modifier keys / non-left-click so the browser opens the link + // in a new tab/window when the user asks for it. + if ( event.defaultPrevented || event.button !== 0 ) { + return; + } + if ( event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { + return; + } + event.preventDefault(); + onSelect( profile ); + }; + + return ( +
+
+ +
+ + + + + + + + + + + + { profiles.map( ( p ) => ( + + + + + + + + ) ) } + +
{ __( 'Title', 'integration-workos' ) }{ __( 'Slug', 'integration-workos' ) }{ __( 'Mode', 'integration-workos' ) }{ __( 'Methods', 'integration-workos' ) }{ __( 'Organization', 'integration-workos' ) }
+ + handleClick( e, p ) } + > + { p.title } + + + + { p.slug } + { p.mode }{ ( p.methods || [] ).join( ', ' ) }{ orgName( p.organization_id ) }
+
+ ); +} + +interface ListResponse { + profiles?: Profile[]; + message?: string; +} + +/** + * Read the current `?profile=…` slug from the URL. Empty string means + * the index view; `'new'` means the empty-profile editor; anything else + * is treated as a slug and resolved against the in-memory list. + */ +function readProfileSlugFromUrl(): string { + const params = new URLSearchParams( window.location.search ); + return ( params.get( 'profile' ) || '' ).trim(); +} + +/** + * Resolve a slug against the loaded list and return the matching record, + * an empty editor for `'new'`, or `null` for the index view / unknown + * slugs. + */ +function resolveSelected( + slug: string, + profiles: Profile[] +): Profile | null { + if ( '' === slug ) { + return null; + } + if ( NEW_PROFILE_SENTINEL === slug ) { + return emptyProfile(); + } + return profiles.find( ( p ) => p.slug === slug ) ?? null; +} + +/** + * Push or replace the URL so the current view is bookmarkable. We use + * the page URL provided by PHP (escaped via esc_url_raw) as the base so + * we never rely on string-stitching `?page=` ourselves. + */ +function navigateTo( slug: string, replace = false ): void { + const base = window.workosProfileAdmin?.pageUrl || window.location.pathname + window.location.search.replace( /([?&])profile=[^&]*/g, '' ); + const target = '' === slug ? base : `${ base }&profile=${ encodeURIComponent( slug ) }`; + const method = replace ? 'replaceState' : 'pushState'; + window.history[ method ]( {}, '', target ); +} + +function App(): ReactNode { + const initialProfiles = window.workosProfileAdmin?.profiles ?? []; + const initialSlug = window.workosProfileAdmin?.activeProfileSlug ?? ''; + + const [ profiles, setProfiles ] = useState< Profile[] >( initialProfiles ); + const [ selected, setSelected ] = useState< Profile | null >( () => + resolveSelected( initialSlug, initialProfiles ) + ); + const [ error, setError ] = useState( '' ); + const [ saving, setSaving ] = useState( false ); + const [ organizations, setOrganizations ] = useState< Organization[] >( [] ); + const [ organizationsLoading, setOrganizationsLoading ] = useState( true ); + const [ organizationsError, setOrganizationsError ] = useState( '' ); + + const refreshProfiles = async (): Promise< Profile[] > => { + const { ok, data } = await apiCall< ListResponse >( 'GET', '' ); + if ( ok ) { + const next = data.profiles || []; + setProfiles( next ); + return next; + } + setError( + data.message || __( 'Failed to load profiles.', 'integration-workos' ) + ); + return profiles; + }; + + const loadOrganizations = async (): Promise< void > => { + setOrganizationsLoading( true ); + const { ok, data } = await apiCall< OrganizationsResponse >( + 'GET', + '/organizations' + ); + setOrganizationsLoading( false ); + if ( ok ) { + setOrganizations( data.organizations || [] ); + setOrganizationsError( data.error || '' ); + return; + } + setOrganizationsError( + data.message || + data.error || + __( 'Failed to load organizations.', 'integration-workos' ) + ); + }; + + useEffect( () => { + loadOrganizations(); + }, [] ); + + // Browser back/forward — re-derive the selection from the current URL. + useEffect( () => { + const onPop = (): void => { + setSelected( resolveSelected( readProfileSlugFromUrl(), profiles ) ); + }; + window.addEventListener( 'popstate', onPop ); + return () => window.removeEventListener( 'popstate', onPop ); + }, [ profiles ] ); + + const openProfile = ( profile: Profile ): void => { + setSelected( profile ); + navigateTo( profile.slug ); + }; + + const openNewProfile = (): void => { + setSelected( emptyProfile() ); + navigateTo( NEW_PROFILE_SENTINEL ); + }; + + const closeEditor = (): void => { + setSelected( null ); + navigateTo( '' ); + }; + + const handleSave = async ( profile: Profile ): Promise< void > => { + setSaving( true ); + setError( '' ); + const isNew = ! profile.id; + const { ok, data } = await apiCall< Profile & { message?: string } >( + isNew ? 'POST' : 'PUT', + isNew ? '' : `/${ profile.id }`, + profile + ); + setSaving( false ); + if ( ! ok ) { + setError( + data.message || __( 'Failed to save.', 'integration-workos' ) + ); + return; + } + await refreshProfiles(); + setSelected( data ); + // Slug normalization on the server may rename — keep the URL in sync. + navigateTo( data.slug, true ); + }; + + const handleDelete = async ( profile: Profile ): Promise< void > => { + const { ok, data } = await apiCall< { message?: string } >( + 'DELETE', + `/${ profile.id }` + ); + if ( ! ok ) { + setError( + data.message || __( 'Failed to delete.', 'integration-workos' ) + ); + return; + } + setSelected( null ); + navigateTo( '' ); + await refreshProfiles(); + }; + + return ( + <> + { error && ( +
+

{ error }

+
+ ) } + { selected ? ( + + ) : ( + + ) } + + ); +} + +function mount(): void { + const root = document.getElementById( 'workos-profiles-admin-root' ); + if ( ! root ) { + return; + } + createRoot( root ).render( ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', mount ); +} else { + mount(); +} diff --git a/src/js/admin-profiles/styles.css b/src/js/admin-profiles/styles.css new file mode 100644 index 0000000..dd0f14d --- /dev/null +++ b/src/js/admin-profiles/styles.css @@ -0,0 +1,98 @@ +/* + * Admin Login Profiles editor styles. + * + * Scoped under #workos-profiles-admin-root so the stylesheet ships safely + * with the admin bundle without leaking into other wp-admin screens. + */ + +#workos-profiles-admin-root { + --wpa-gap: 16px; + max-width: 820px; +} + +#workos-profiles-admin-root .wpa-list-header { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: var(--wpa-gap); +} + +#workos-profiles-admin-root .wpa-row { + cursor: pointer; +} + +#workos-profiles-admin-root .wpa-row:hover { + background: #f6f7f7; +} + +#workos-profiles-admin-root .wpa-editor { + display: flex; + flex-direction: column; + gap: var(--wpa-gap); +} + +#workos-profiles-admin-root .wpa-field { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 420px; +} + +#workos-profiles-admin-root .wpa-field > span { + font-weight: 500; +} + +#workos-profiles-admin-root .wpa-field > input, +#workos-profiles-admin-root .wpa-field > select { + padding: 6px 8px; + font-size: 14px; +} + +#workos-profiles-admin-root .wpa-fieldset { + border: 1px solid #dcdcde; + padding: 12px 16px; + border-radius: 4px; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +#workos-profiles-admin-root .wpa-fieldset > legend { + font-weight: 600; + padding: 0 6px; +} + +#workos-profiles-admin-root .wpa-check { + display: flex; + align-items: center; + gap: 8px; +} + +#workos-profiles-admin-root .wpa-actions { + display: flex; + gap: 10px; + margin-top: var(--wpa-gap); +} + +#workos-profiles-admin-root .wpa-logo-field .wpa-logo-preview { + max-width: 200px; + max-height: 80px; + width: auto; + height: auto; + border: 1px solid #dcdcde; + border-radius: 4px; + padding: 8px; + background: #fff; +} + +#workos-profiles-admin-root .wpa-logo-field .wpa-logo-actions { + display: flex; + gap: 8px; + align-items: center; +} + +#workos-profiles-admin-root .wpa-logo-field .description { + font-style: italic; + color: #646970; +} diff --git a/src/js/authkit/App.tsx b/src/js/authkit/App.tsx new file mode 100644 index 0000000..13e0a6d --- /dev/null +++ b/src/js/authkit/App.tsx @@ -0,0 +1,264 @@ +/** + * Top-level AuthKit React app — drives the step machine. + * + * Profile + runtime config arrive through `props` (hydrated from data-* + * attributes on the root div by index.tsx). Every step renders a flow + * component from flows.tsx; transitions are plain setStep() calls. + */ + +import { useEffect, useRef, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { createClient } from './api'; +import type { AuthKitClient } from './api'; +import * as Radar from './radar'; +import type { + LoginSuccess, + MfaRequired, + Profile, + Step, +} from './types'; +import { + Complete, + InvitationAccept, + MagicSend, + MagicVerify, + MethodPicker, + MfaChallenge, + Password, + ResetConfirm, + ResetRequest, + Signup, + SignupVerify, +} from './flows'; + +export interface AppProps { + profile: Profile; + initialStep?: Step; + invitationToken?: string; + resetToken?: string; +} + +interface MfaState { + pending: string; + factors: MfaRequired[ 'factors' ]; +} + +interface SignupContext { + userId: string; + email: string; +} + +export function App( props: AppProps ) { + const { profile, initialStep = 'pick', invitationToken, resetToken } = props; + + const initial: Step = invitationToken + ? 'invitation' + : resetToken + ? 'reset_confirm' + : initialStep; + + const [ step, setStep ] = useState< Step >( initial ); + const [ redirectTo, setRedirectTo ] = useState( '' ); + const [ mfaState, setMfaState ] = useState< MfaState | null >( null ); + const [ magicEmail, setMagicEmail ] = useState( '' ); + const [ signupContext, setSignupContext ] = useState< SignupContext | null >( + null + ); + const [ booted, setBooted ] = useState( false ); + const radarTokenRef = useRef< string | null >( null ); + const [ client ] = useState< AuthKitClient >( () => + createClient( { + profile: profile.slug, + baseUrl: profile.restBaseUrl, + radarToken: () => radarTokenRef.current, + } ) + ); + + useEffect( () => { + let cancelled = false; + + ( async () => { + const bootstrap = await client.bootstrap().catch( () => null ); + + // Start Radar once we know the site key. Fire-and-forget; tokens + // become available whenever the SDK finishes loading. + const siteKey = bootstrap?.radar_site_key || client.radarSiteKey; + if ( siteKey ) { + Radar.load( siteKey ).then( async () => { + if ( cancelled ) { + return; + } + const token = await Radar.getActionToken( 'authkit_signin' ); + radarTokenRef.current = token; + } ); + } + + if ( ! cancelled ) { + setBooted( true ); + } + } )(); + + return () => { + cancelled = true; + }; + }, [ client ] ); + + if ( ! booted ) { + return null; + } + + const handleSuccess = ( data: LoginSuccess ): void => { + setRedirectTo( data.redirect_to || '/' ); + setStep( 'complete' ); + }; + + const handleMfa = ( data: MfaRequired ): void => { + setMfaState( { + pending: data.pending_authentication_token, + factors: data.factors || [], + } ); + setStep( 'mfa' ); + }; + + switch ( step ) { + case 'pick': + return ( + setStep( next ) } + onError={ () => setStep( 'pick' ) } + /> + ); + + case 'password': + return ( + setStep( target || 'pick' ) } + /> + ); + + case 'magic_send': + return ( + { + setMagicEmail( email ); + setStep( 'magic_verify' ); + } } + onBack={ () => setStep( 'pick' ) } + /> + ); + + case 'magic_verify': + return ( + setStep( 'magic_send' ) } + /> + ); + + case 'mfa': + return ( + + ); + + case 'signup': + return ( + { + setSignupContext( ctx ); + setStep( 'signup_verify' ); + } } + onBack={ () => setStep( 'pick' ) } + /> + ); + + case 'signup_verify': + return ( + setStep( 'pick' ) } + /> + ); + + case 'reset': + return ( + setStep( 'reset_sent' ) } + onBack={ () => setStep( 'password' ) } + /> + ); + + case 'reset_sent': + return ( +
+

+ { __( 'Check your email', 'integration-workos' ) } +

+

+ { __( + 'If an account exists for that email, we’ve sent a reset link.', + 'integration-workos' + ) } +

+ +
+ ); + + case 'reset_confirm': + return ( + setStep( 'pick' ) } + /> + ); + + case 'invitation': + return ( + + ); + + case 'complete': + return ; + + default: + return ( + setStep( next ) } + onError={ () => {} } + /> + ); + } +} diff --git a/src/js/authkit/api.ts b/src/js/authkit/api.ts new file mode 100644 index 0000000..20bb39c --- /dev/null +++ b/src/js/authkit/api.ts @@ -0,0 +1,163 @@ +/** + * REST client for the AuthKit React shell. + * + * Every call goes to /wp-json/workos/v1/auth/*. We handle three things + * the browser should never deal with directly: + * - the profile-scoped CSRF nonce (re-fetched automatically on 403), + * - the Radar action token (injected into every mutation when present), + * - 401 responses (trigger one silent /session/refresh before retrying). + * + * Not a Radix. Not axios. Just fetch + retry. + */ + +import { __ } from '@wordpress/i18n'; +import type { ApiResponse, NonceResponse } from './types'; + +type RadarTokenGetter = () => string | null; + +interface AuthKitClientOptions { + profile: string; + baseUrl: string; + radarToken?: RadarTokenGetter; +} + +interface CallOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + retry?: boolean; +} + +export const NONCE_CACHE_KEY = '__workos_authkit_nonce__'; + +class AuthKitClient { + public profile: string; + public baseUrl: string; + public nonce: string | null = null; + public radarSiteKey = ''; + private getRadarToken: RadarTokenGetter; + + constructor( { profile, baseUrl, radarToken }: AuthKitClientOptions ) { + this.profile = profile; + this.baseUrl = baseUrl.replace( /\/$/, '' ); + this.getRadarToken = radarToken ?? ( () => null ); + } + + /** + * Fetch a fresh nonce + Radar site key. Called once on mount. + */ + async bootstrap(): Promise< NonceResponse > { + const resp = await fetch( + `${ this.baseUrl }/nonce?profile=${ encodeURIComponent( this.profile ) }`, + { credentials: 'same-origin' } + ); + if ( ! resp.ok ) { + throw new Error( + __( 'Failed to bootstrap authentication.', 'integration-workos' ) + ); + } + const data = ( await resp.json() ) as NonceResponse; + this.nonce = data.nonce; + this.radarSiteKey = data.radar_site_key || ''; + return data; + } + + /** + * POST to an AuthKit endpoint with automatic nonce + Radar handling. + */ + async call( + path: string, + body: Record< string, unknown > = {}, + opts: CallOptions = {} + ): Promise< Response > { + const method = opts.method || 'POST'; + const retry = opts.retry !== false; + + if ( ! this.nonce && method !== 'GET' ) { + await this.bootstrap(); + } + + const payload = method === 'GET' ? null : { profile: this.profile, ...body }; + + const url = + method === 'GET' && Object.keys( body ).length + ? `${ this.baseUrl }${ path }?${ new URLSearchParams( + Object.fromEntries( + Object.entries( { profile: this.profile, ...body } ).map( + ( [ k, v ] ) => [ k, String( v ) ] + ) + ) + ).toString() }` + : method === 'GET' + ? `${ this.baseUrl }${ path }?profile=${ encodeURIComponent( + this.profile + ) }` + : `${ this.baseUrl }${ path }`; + + const headers: Record< string, string > = { + 'Content-Type': 'application/json', + }; + if ( method !== 'GET' && this.nonce ) { + headers[ 'X-WP-Nonce' ] = this.nonce; + } + const radar = this.getRadarToken(); + if ( radar ) { + headers[ 'X-WorkOS-Radar-Action-Token' ] = radar; + } + + const response = await fetch( url, { + method, + headers, + credentials: 'same-origin', + body: payload ? JSON.stringify( payload ) : undefined, + } ); + + // 403 with invalid_nonce — refresh once and retry. + if ( response.status === 403 && retry ) { + const data = await response + .clone() + .json() + .catch( () => ( {} as { code?: string } ) ); + if ( data?.code === 'workos_authkit_invalid_nonce' ) { + await this.bootstrap(); + return this.call( path, body, { ...opts, retry: false } ); + } + } + + // 401 on an authenticated endpoint — attempt a silent refresh and retry. + if ( response.status === 401 && retry && path !== '/session/refresh' ) { + const refreshHeaders: Record< string, string > = { + 'Content-Type': 'application/json', + }; + if ( this.nonce ) { + refreshHeaders[ 'X-WP-Nonce' ] = this.nonce; + } + const refreshed = await fetch( `${ this.baseUrl }/session/refresh`, { + method: 'POST', + headers: refreshHeaders, + credentials: 'same-origin', + } ); + if ( refreshed.ok ) { + return this.call( path, body, { ...opts, retry: false } ); + } + } + + return response; + } + + async json< T >( + path: string, + body: Record< string, unknown > = {}, + opts: CallOptions = {} + ): Promise< ApiResponse< T > > { + const response = await this.call( path, body, opts ); + const data = ( await response.json().catch( () => ( {} ) ) ) as T; + return { status: response.status, data, ok: response.ok }; + } +} + +export function createClient( + options: AuthKitClientOptions +): AuthKitClient { + return new AuthKitClient( options ); +} + +export type { AuthKitClient, AuthKitClientOptions }; diff --git a/src/js/authkit/flows.tsx b/src/js/authkit/flows.tsx new file mode 100644 index 0000000..a3e9165 --- /dev/null +++ b/src/js/authkit/flows.tsx @@ -0,0 +1,1118 @@ +/** + * AuthKit flow components. + * + * Each flow is a small state machine that renders a card of inputs and + * reports its outcome to the parent App via `onDone`. Flows never hold + * global state — App.tsx owns step transitions + resulting session. + */ + +import { useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import type { FormEvent } from 'react'; +import type { AuthKitClient } from './api'; +import type { + ApiError, + AuthFactor, + AuthMethod, + AuthResult, + InvitationLookup, + LoginSuccess, + MfaRequired, + Profile, + Step, +} from './types'; +import { isMfaRequired } from './types'; +import { + Alert, + Button, + Card, + Divider, + Field, + FlowCard, + Heading, + Input, + LinkButton, + Spinner, + Subheading, +} from './ui'; +import { + AuthKitSlot, + SLOT_AFTER_FORM, + SLOT_AFTER_PRIMARY_ACTION, + SLOT_BEFORE_FORM, + SLOT_PICKER_AFTER_METHODS, + SLOT_PICKER_BEFORE_METHODS, +} from './slots'; +import type { AuthKitSlotFillProps } from './slots'; + +function fillPropsFor( + profile: Profile, + step: Step, + flow?: string +): AuthKitSlotFillProps { + return { + step, + profileSlug: profile.slug, + methods: profile.methods || [], + flow, + }; +} + +function methodLabel( method: AuthMethod ): string { + switch ( method ) { + case 'oauth_google': + return __( 'Continue with Google', 'integration-workos' ); + case 'oauth_microsoft': + return __( 'Continue with Microsoft', 'integration-workos' ); + case 'oauth_github': + return __( 'Continue with GitHub', 'integration-workos' ); + case 'oauth_apple': + return __( 'Continue with Apple', 'integration-workos' ); + default: + return method; + } +} + +function errorMessage( data: unknown ): string { + if ( ! data || typeof data !== 'object' ) { + return __( 'Something went wrong. Please try again.', 'integration-workos' ); + } + const err = data as ApiError; + return err.message || err.code || __( 'Unexpected error.', 'integration-workos' ); +} + +// ---------------------------------------------------------------- MethodPicker + +interface MethodPickerProps { + profile: Profile; + onChoose: ( step: Step ) => void; + onError: ( message: string ) => void; +} + +export function MethodPicker( { profile, onChoose, onError }: MethodPickerProps ) { + const methods = profile.methods || []; + const hasPassword = methods.includes( 'password' ); + const hasMagic = methods.includes( 'magic_code' ); + const oauthMethods = methods.filter( ( m ) => m.startsWith( 'oauth_' ) ); + + const handleOAuth = async ( method: AuthMethod ): Promise< void > => { + try { + const response = await fetch( + `${ profile.restBaseUrl }/oauth/authorize-url?profile=${ encodeURIComponent( + profile.slug + ) }&provider=${ encodeURIComponent( method ) }&redirect_to=${ encodeURIComponent( + profile.redirectTo || '' + ) }`, + { credentials: 'same-origin' } + ); + const data = await response.json(); + if ( response.ok && data.authorize_url ) { + window.location.assign( data.authorize_url ); + } else { + onError( errorMessage( data ) ); + } + } catch ( _ ) { + onError( __( 'Unable to start social sign-in.', 'integration-workos' ) ); + } + }; + + const fillProps = fillPropsFor( profile, 'pick' ); + const logoAlt = + profile.branding.heading || __( 'Sign in', 'integration-workos' ); + + return ( + + + { profile.branding.heading || + __( 'Sign in', 'integration-workos' ) } + + { profile.branding.subheading && ( + { profile.branding.subheading } + ) } + + } + footer={ + profile.signup?.enabled && ! profile.signup?.require_invite ? ( +

+ { __( 'New here?', 'integration-workos' ) }{ ' ' } + onChoose( 'signup' ) }> + { __( 'Create an account', 'integration-workos' ) } + +

+ ) : null + } + > + + + { oauthMethods.map( ( method ) => ( + + ) ) } + + { oauthMethods.length > 0 && ( hasPassword || hasMagic ) && ( + { __( 'or', 'integration-workos' ) } + ) } + + { hasMagic && ( + + ) } + + { hasPassword && ( + + ) } + + +
+ ); +} + +// -------------------------------------------------------------------- Password + +interface PasswordProps { + client: AuthKitClient; + profile: Profile; + onMfa: ( data: MfaRequired ) => void; + onSuccess: ( data: LoginSuccess ) => void; + onBack: ( target?: Step ) => void; +} + +export function Password( { client, onMfa, onSuccess, onBack, profile }: PasswordProps ) { + const [ email, setEmail ] = useState( '' ); + const [ password, setPassword ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json< AuthResult >( + '/password/authenticate', + { email, password, redirect_to: profile.redirectTo } + ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + const result = data as AuthResult; + if ( isMfaRequired( result ) ) { + onMfa( result ); + return; + } + onSuccess( result ); + }; + + const fillProps = fillPropsFor( profile, 'password', 'password' ); + + return ( + { __( 'Sign in', 'integration-workos' ) } } + footer={ + <> + { profile.password_reset_flow && ( +

+ onBack( 'reset' ) }> + { __( + 'Forgot your password?', + 'integration-workos' + ) } + +

+ ) } +

+ onBack() }> + { __( '← Back', 'integration-workos' ) } + +

+ + } + > + { error && { error } } +
+ + + + + + + + + + + +
+ ); +} + +// ------------------------------------------------------------------ MagicSend + +interface MagicSendProps { + client: AuthKitClient; + profile: Profile; + onCodeSent: ( email: string ) => void; + onBack: () => void; +} + +export function MagicSend( { client, profile, onCodeSent, onBack }: MagicSendProps ) { + const [ email, setEmail ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json( '/magic/send', { email } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + onCodeSent( email ); + }; + + const fillProps = fillPropsFor( profile, 'magic_send', 'magic_send' ); + + return ( + + + { __( 'Sign in with a code', 'integration-workos' ) } + + + { __( + 'We’ll email you a short code to sign in.', + 'integration-workos' + ) } + + + } + footer={ +

+ + { __( '← Back', 'integration-workos' ) } + +

+ } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +// ---------------------------------------------------------------- MagicVerify + +interface MagicVerifyProps { + client: AuthKitClient; + profile: Profile; + email: string; + onMfa: ( data: MfaRequired ) => void; + onSuccess: ( data: LoginSuccess ) => void; + onBack: () => void; +} + +export function MagicVerify( { + client, + email, + onMfa, + onSuccess, + onBack, + profile, +}: MagicVerifyProps ) { + const [ code, setCode ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json< AuthResult >( '/magic/verify', { + email, + code, + redirect_to: profile.redirectTo, + } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + const result = data as AuthResult; + if ( isMfaRequired( result ) ) { + onMfa( result ); + return; + } + onSuccess( result ); + }; + + const fillProps = fillPropsFor( profile, 'magic_verify', 'magic_verify' ); + + return ( + + + { __( 'Enter your code', 'integration-workos' ) } + + + { sprintf( + /* translators: %s: email address. */ + __( 'We sent a code to %s.', 'integration-workos' ), + email + ) } + + + } + footer={ +

+ + { __( '← Back', 'integration-workos' ) } + +

+ } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +// ---------------------------------------------------------------------- Mfa + +interface MfaChallengeProps { + client: AuthKitClient; + profile: Profile; + pendingAuthToken: string; + factors: AuthFactor[]; + onSuccess: ( data: LoginSuccess ) => void; +} + +export function MfaChallenge( { + client, + pendingAuthToken, + factors, + onSuccess, + profile, +}: MfaChallengeProps ) { + const firstFactor = factors && factors.length > 0 ? factors[ 0 ] : null; + const [ challengeId, setChallengeId ] = useState( '' ); + const [ code, setCode ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + useEffect( () => { + if ( ! firstFactor ) { + return; + } + ( async () => { + const { ok, data } = await client.json< { challenge_id: string } >( + '/mfa/challenge', + { factor_id: firstFactor.id } + ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + setChallengeId( ( data as { challenge_id: string } ).challenge_id ); + } )(); + }, [ firstFactor, client ] ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json< LoginSuccess >( '/mfa/verify', { + pending_authentication_token: pendingAuthToken, + authentication_challenge_id: challengeId, + code, + redirect_to: profile.redirectTo, + } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + onSuccess( data as LoginSuccess ); + }; + + const fillProps = fillPropsFor( profile, 'mfa', 'mfa' ); + + return ( + + + { __( 'Verify your identity', 'integration-workos' ) } + + + { firstFactor?.type === 'sms' + ? __( + 'Enter the code we just sent to your phone.', + 'integration-workos' + ) + : __( + 'Enter the 6-digit code from your authenticator app.', + 'integration-workos' + ) } + + + } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +// -------------------------------------------------------------------- Signup + +interface SignupContext { + userId: string; + email: string; +} + +interface SignupProps { + client: AuthKitClient; + profile: Profile; + onVerify: ( ctx: SignupContext ) => void; + onBack: () => void; +} + +export function Signup( { client, profile, onVerify, onBack }: SignupProps ) { + const [ email, setEmail ] = useState( '' ); + const [ password, setPassword ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json< { + verification_needed: boolean; + user: { id: string }; + } >( '/signup/create', { email, password } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + const payload = data as { verification_needed: boolean; user: { id: string } }; + if ( payload.verification_needed ) { + onVerify( { userId: payload.user.id, email } ); + } else { + // Email already verified — send them to sign-in. + onBack(); + } + }; + + const fillProps = fillPropsFor( profile, 'signup', 'signup' ); + + return ( + + { __( 'Create your account', 'integration-workos' ) } + + } + footer={ +

+ + { __( + 'Already have an account? Sign in', + 'integration-workos' + ) } + +

+ } + > + { error && { error } } +
+ + + + + + + + + + + +
+ ); +} + +// ---------------------------------------------------------------- SignupVerify + +interface SignupVerifyProps { + client: AuthKitClient; + profile: Profile; + userId: string; + email: string; + onDone: ( data: unknown ) => void; +} + +export function SignupVerify( { + client, + profile, + userId, + email, + onDone, +}: SignupVerifyProps ) { + const [ code, setCode ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json( '/signup/verify', { + user_id: userId, + code, + } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + onDone( data ); + }; + + const fillProps = fillPropsFor( profile, 'signup_verify', 'signup_verify' ); + + return ( + + + { __( 'Verify your email', 'integration-workos' ) } + + + { sprintf( + /* translators: %s: email address. */ + __( 'Enter the code we sent to %s.', 'integration-workos' ), + email + ) } + + + } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +// --------------------------------------------------------------------- Reset + +interface ResetRequestProps { + client: AuthKitClient; + profile: Profile; + onSent: ( email: string ) => void; + onBack: () => void; +} + +export function ResetRequest( { client, profile, onSent, onBack }: ResetRequestProps ) { + const [ email, setEmail ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json( '/password/reset/start', { + email, + } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + onSent( email ); + }; + + const fillProps = fillPropsFor( profile, 'reset', 'reset' ); + + return ( + + + { __( 'Reset your password', 'integration-workos' ) } + + + { __( + 'Enter your email and we’ll send a reset link.', + 'integration-workos' + ) } + + + } + footer={ +

+ + { __( '← Back', 'integration-workos' ) } + +

+ } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +interface ResetConfirmProps { + client: AuthKitClient; + profile: Profile; + token: string; + onDone: () => void; +} + +export function ResetConfirm( { client, profile, token, onDone }: ResetConfirmProps ) { + const [ password, setPassword ] = useState( '' ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + const [ success, setSuccess ] = useState( false ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json( '/password/reset/confirm', { + token, + new_password: password, + } ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + setSuccess( true ); + }; + + const fillProps = fillPropsFor( profile, 'reset_confirm', 'reset_confirm' ); + const logoAlt = + profile.branding.heading || __( 'Sign in', 'integration-workos' ); + + if ( success ) { + return ( + + + { __( 'Password reset', 'integration-workos' ) } + + + { __( + 'You can now sign in with your new password.', + 'integration-workos' + ) } + + + } + > + + + + ); + } + + return ( + + { __( 'Set a new password', 'integration-workos' ) } + + } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +// ----------------------------------------------------------- InvitationAccept + +interface InvitationAcceptProps { + client: AuthKitClient; + invitationToken: string; + profile: Profile; + onSuccess: ( data: LoginSuccess ) => void; +} + +export function InvitationAccept( { + client, + invitationToken, + profile, + onSuccess, +}: InvitationAcceptProps ) { + const [ password, setPassword ] = useState( '' ); + const [ invitation, setInvitation ] = useState< InvitationLookup | null >( null ); + const [ loading, setLoading ] = useState( false ); + const [ error, setError ] = useState( '' ); + + // The invitation email is authoritative and comes from WorkOS — we show + // it read-only so the user sees who they're being invited as, but we + // never send it back on accept. The server forwards only the token + + // password, and WorkOS matches the invitation to its bound email. + useEffect( () => { + ( async () => { + const resp = await fetch( + `${ profile.restBaseUrl }/invitation/${ encodeURIComponent( invitationToken ) }`, + { credentials: 'same-origin' } + ); + const data = await resp.json(); + if ( resp.ok ) { + setInvitation( data as InvitationLookup ); + } else { + setError( errorMessage( data ) ); + } + } )(); + }, [ invitationToken, profile.restBaseUrl ] ); + + const submit = async ( event: FormEvent ) => { + event.preventDefault(); + setLoading( true ); + setError( '' ); + const { ok, data } = await client.json< LoginSuccess >( + '/invitation/accept', + { + invitation_token: invitationToken, + password, + redirect_to: profile.redirectTo, + } + ); + setLoading( false ); + if ( ! ok ) { + setError( errorMessage( data ) ); + return; + } + onSuccess( data as LoginSuccess ); + }; + + const fillProps = fillPropsFor( profile, 'invitation', 'invitation' ); + + return ( + + + { __( 'Accept your invitation', 'integration-workos' ) } + + { invitation && ( + + { sprintf( + /* translators: %s: email address. */ + __( 'Welcome, %s.', 'integration-workos' ), + invitation.email + ) } + + ) } + + } + > + { error && { error } } +
+ + + + + + + + +
+ ); +} + +// ------------------------------------------------------------------ Complete + +export function Complete( { redirectTo }: { redirectTo: string } ) { + useEffect( () => { + if ( redirectTo ) { + window.location.assign( redirectTo ); + } + }, [ redirectTo ] ); + + return ( + + { __( 'Signed in', 'integration-workos' ) } + { __( 'Redirecting…', 'integration-workos' ) } + + + ); +} diff --git a/src/js/authkit/index.tsx b/src/js/authkit/index.tsx new file mode 100644 index 0000000..bbba1c7 --- /dev/null +++ b/src/js/authkit/index.tsx @@ -0,0 +1,91 @@ +/** + * Entry point for the AuthKit React shell. + * + * Looks for `
` and + * hydrates the App into it. Every piece of configuration arrives via + * data-* attributes on that root div; we never fetch config after mount. + */ + +import { SlotFillProvider } from '@wordpress/components'; +import { createRoot } from '@wordpress/element'; +import { PluginArea } from '@wordpress/plugins'; +import { App } from './App'; +import type { AppProps } from './App'; +import type { Profile, Step } from './types'; +import './styles.css'; + +function parseConfig( root: HTMLElement ): AppProps { + const profileJson = root.getAttribute( 'data-profile' ); + const rawProfile: Partial< Profile > = profileJson ? JSON.parse( profileJson ) : {}; + + // Compose a few derived fields the React tree uses. + const profile: Profile = { + id: rawProfile.id, + slug: rawProfile.slug ?? '', + title: rawProfile.title ?? '', + methods: rawProfile.methods ?? [], + organization_id: rawProfile.organization_id ?? '', + signup: rawProfile.signup ?? { enabled: false, require_invite: false }, + invite_flow: rawProfile.invite_flow ?? true, + password_reset_flow: rawProfile.password_reset_flow ?? true, + mfa: rawProfile.mfa ?? { enforce: 'if_required', factors: [ 'totp' ] }, + branding: rawProfile.branding ?? { + logo_attachment_id: 0, + primary_color: '', + heading: '', + subheading: '', + }, + post_login_redirect: rawProfile.post_login_redirect ?? '', + mode: rawProfile.mode ?? 'custom', + restBaseUrl: + root.getAttribute( 'data-rest-base' ) || '/wp-json/workos/v1/auth', + redirectTo: root.getAttribute( 'data-redirect-to' ) || '', + }; + + const initialStep = ( root.getAttribute( 'data-initial-step' ) || + 'pick' ) as Step; + + return { + profile, + invitationToken: root.getAttribute( 'data-invitation-token' ) || '', + resetToken: root.getAttribute( 'data-reset-token' ) || '', + initialStep, + }; +} + +function mount(): void { + const root = document.getElementById( 'workos-authkit-root' ); + if ( ! root ) { + return; + } + + let config: AppProps; + try { + config = parseConfig( root ); + } catch ( _ ) { + // Malformed data-profile — leave the markup as-is so the caller can + // inspect what shipped. + return; + } + + createRoot( root ).render( + + + + + ); + + // Signal to non-React extenders that the shell is mounted and slots are + // live. Carries the active profile slug so listeners can gate behavior. + document.dispatchEvent( + new CustomEvent( 'workos-authkit:mounted', { + detail: { profileSlug: config.profile.slug }, + } ) + ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', mount ); +} else { + mount(); +} diff --git a/src/js/authkit/radar.ts b/src/js/authkit/radar.ts new file mode 100644 index 0000000..6298ef8 --- /dev/null +++ b/src/js/authkit/radar.ts @@ -0,0 +1,110 @@ +/** + * WorkOS Radar SDK loader. + * + * Because we replace AuthKit's hosted page with our own React shell, we + * lose AuthKit's built-in anti-fraud layer. Radar fills that gap: a + * fingerprint script runs in the browser, produces an *action token* per + * sensitive action, and WorkOS scores risk on the server side when we + * forward that token. + * + * Integration points: + * - load( siteKey ) starts the SDK load but does not block rendering. + * - getActionToken( action ) returns a string or null. api.ts threads + * that into the X-WorkOS-Radar-Action-Token header on every mutation. + */ + +interface WorkOSRadarSdk { + init( options: { siteKey: string } ): void; + getActionToken( options: { action: string } ): Promise< string >; +} + +declare global { + interface Window { + WorkOSRadar?: WorkOSRadarSdk; + } +} + +const SDK_URL = 'https://radar.workos.com/v1/radar.js'; +const SDK_ID = 'workos-radar-sdk'; +const READY_TIMEOUT_MS = 4000; + +let readyPromise: Promise< WorkOSRadarSdk | null > | null = null; + +/** + * Load the Radar SDK, idempotent. Resolves when `window.WorkOSRadar` is + * available, resolves to null on timeout / load failure so callers can + * fall through cleanly. + */ +export function load( siteKey: string ): Promise< WorkOSRadarSdk | null > { + if ( ! siteKey || typeof window === 'undefined' ) { + return Promise.resolve( null ); + } + + if ( readyPromise ) { + return readyPromise; + } + + readyPromise = new Promise( ( resolve ) => { + // Avoid double-insertion if the SDK script tag was added elsewhere. + if ( document.getElementById( SDK_ID ) ) { + waitForGlobal( siteKey, resolve ); + return; + } + + const script = document.createElement( 'script' ); + script.id = SDK_ID; + script.src = SDK_URL; + script.async = true; + script.addEventListener( 'load', () => waitForGlobal( siteKey, resolve ) ); + script.addEventListener( 'error', () => resolve( null ) ); + document.head.appendChild( script ); + } ); + + return readyPromise; +} + +function waitForGlobal( + siteKey: string, + resolve: ( sdk: WorkOSRadarSdk | null ) => void +): void { + const start = Date.now(); + const tick = (): void => { + const radar = window.WorkOSRadar; + if ( radar && typeof radar.init === 'function' ) { + try { + radar.init( { siteKey } ); + } catch ( _ ) { + // init may be idempotent; swallow duplicate-init errors. + } + resolve( radar ); + return; + } + if ( Date.now() - start > READY_TIMEOUT_MS ) { + resolve( null ); + return; + } + setTimeout( tick, 100 ); + }; + tick(); +} + +/** + * Ask the loaded SDK for an action token. + */ +export async function getActionToken( + action: string +): Promise< string | null > { + if ( ! readyPromise ) { + return null; + } + const sdk = await readyPromise; + if ( ! sdk || typeof sdk.getActionToken !== 'function' ) { + return null; + } + try { + const token = await sdk.getActionToken( { action } ); + return typeof token === 'string' ? token : null; + } catch ( _ ) { + return null; + } +} diff --git a/src/js/authkit/slots.tsx b/src/js/authkit/slots.tsx new file mode 100644 index 0000000..e45641d --- /dev/null +++ b/src/js/authkit/slots.tsx @@ -0,0 +1,60 @@ +/** + * Public SlotFill insertion points for the AuthKit React shell. + * + * Centralized so the slot vocabulary stays in one file. Extender plugins + * register `` elements at any of these names; each Slot + * receives `fillProps` carrying the active step + profile context so a + * Fill can render conditionally. + * + * Documented in docs/extending-the-login-ui.md. + */ + +import { Slot } from '@wordpress/components'; +import type { AuthMethod, Step } from './types'; + +export const SLOT_BEFORE_HEADER = 'workos.authkit.beforeHeader'; +export const SLOT_AFTER_HEADER = 'workos.authkit.afterHeader'; +export const SLOT_BEFORE_FORM = 'workos.authkit.beforeForm'; +export const SLOT_AFTER_FORM = 'workos.authkit.afterForm'; +export const SLOT_AFTER_PRIMARY_ACTION = 'workos.authkit.afterPrimaryAction'; +export const SLOT_BEFORE_FOOTER = 'workos.authkit.beforeFooter'; +export const SLOT_AFTER_FOOTER = 'workos.authkit.afterFooter'; +export const SLOT_PICKER_BEFORE_METHODS = 'workos.authkit.methodPicker.beforeMethods'; +export const SLOT_PICKER_AFTER_METHODS = 'workos.authkit.methodPicker.afterMethods'; + +export interface AuthKitSlotFillProps { + step: Step; + profileSlug: string; + methods: AuthMethod[]; + flow?: string; + [ key: string ]: unknown; +} + +interface AuthKitSlotProps { + name: string; + fillProps: AuthKitSlotFillProps; +} + +/** + * Renders a named insertion point. Uses `bubblesVirtually` so a Fill + * registered anywhere in the React tree (including PluginArea) can target + * the slot — that is the standard WP block-editor pattern. No children + * accepted: with `bubblesVirtually`, fills render directly into the slot + * via portal. + * + * The `wa-slot` className tags the underlying `
` the slot renders — + * even with no fills registered, `bubblesVirtually` still emits a div to + * serve as a portal target, and without the `:empty { display: none }` + * rule in styles.css it would show up as invisible extra flex-gap space + * inside every card. + */ +export function AuthKitSlot( { name, fillProps }: AuthKitSlotProps ) { + return ( + + ); +} diff --git a/src/js/authkit/styles.css b/src/js/authkit/styles.css new file mode 100644 index 0000000..ecf9940 --- /dev/null +++ b/src/js/authkit/styles.css @@ -0,0 +1,221 @@ +/* + * AuthKit React shell styles. + * + * All selectors scoped under #workos-authkit-root so the stylesheet can + * safely ship on the frontend, wp-login.php, and inside shortcode/block + * contexts without leaking into theme or admin styles. + * + * Custom properties are overridable per profile — the Renderer emits a + *