Repository navigation
feat(authkit): custom WordPress-hosted AuthKit login experience - #1
Conversation
Lays down the data model, API client extensions, security helpers, and session-refresh plumbing that the React login shell and public REST endpoints will build on. - Login Profile CPT + immutable Profile value object + CRUD repository with reserved "default" seeded on activation - Admin REST CRUD at /wp-json/workos/v1/admin/profiles (manage_options) - Api\Client: +14 methods (magic auth, TOTP, refresh, password reset, email verification, invitation lookup, MFA factor enroll/challenge/ verify, passkey factor, SMS factor) + DELETE helper + Radar x-workos-radar-action-token header forwarding on every user-facing auth endpoint - AuthKit\RateLimiter: transient-backed fixed-window limiter, per-IP and per-email bucketing with 429 + Retry-After - AuthKit\Nonce: profile-scoped CSRF nonces for public REST calls - AuthKit\Radar: site-key resolution (constant > option) + action-token extraction from REST requests - REST\TokenAuth: lazy access-token refresh when JWT is expired and a refresh token is stored in usermeta 80 new WPUnit tests; 294 tests green overall with no regressions.
Adds the complete public REST surface that the React shell will talk to,
plus a rule-based profile router so wp-login.php takeover can pick the
right Login Profile based on redirect_to / referrer / user role.
New REST namespace: /wp-json/workos/v1/auth/*
- Password.php: POST /password/{authenticate,reset/start,reset/confirm}
- MagicCode.php: POST /magic/{send,verify}
- Session.php: GET /nonce + POST /session/{refresh,logout}
- Signup.php: POST /signup/{create,verify}
- Invitation.php: GET /invitation/{token} + POST /invitation/accept
- OAuth.php: GET /oauth/authorize-url (Google/MS/GitHub/Apple)
- Mfa.php: POST /mfa/{challenge,verify,totp/enroll,sms/enroll,factor/delete}
GET /mfa/factors
- BaseEndpoint.php: shared profile resolution + nonce verify + rate limiting
+ Radar token extraction + redirect sanitization
- Controller.php: wires all endpoints into the DI container and registers
them on rest_api_init via the main REST controller
Shared LoginCompleter.php runs every successful auth path through:
EntitlementGate::evaluate -> UserSync::find_or_create_wp_user ->
Login::store_tokens -> wp_set_auth_cookie -> profile.post_login_redirect.
It also surfaces MFA-required responses as a structured payload so the
React shell can render the challenge step.
EntitlementGate gains a non-terminating evaluate() that returns true or
WP_Error; the existing check() still wp_die()s on denial for the legacy
redirect flow.
ProfileRouter.php picks the right profile from an ordered rules list
(redirect_to glob / referrer_host / user_role matchers). First-match
wins; explicit slug overrides; default seeds when no rule matches.
49 new WPUnit tests; 343 tests green overall with no regressions.
Ships the full React-side AuthKit experience and the integration points
that put it in front of users. Browser-side code is plain JSX + scoped
CSS (shadcn + Tailwind polish can layer on later without changing the
REST contract).
React bundle (new webpack entry `authkit`, mounts on #workos-authkit-root):
- src/js/authkit/index.js — entry + hydration from data-* attrs
- src/js/authkit/App.js — top-level step machine
- src/js/authkit/api.js — REST client with auto-nonce refresh, 401→
session/refresh→retry, Radar header inject
- src/js/authkit/ui.js — Button/Input/Field/Alert/Card/Divider primitives
- src/js/authkit/flows.js — MethodPicker, Password, MagicSend+Verify,
Signup+Verify, ResetRequest+Confirm,
InvitationAccept, MfaChallenge, Complete
- src/js/authkit/styles.css — CSS vars scoped to #workos-authkit-root,
per-profile branding via inline <style>
PHP rendering + takeover:
- Auth/AuthKit/Renderer.php — shared mount HTML + full-page template
- Auth/AuthKit/LoginTakeover.php— intercepts action=login on wp-login.php
only when the resolved profile is in
custom mode; all other WP actions
(logout, register, lostpassword,
resetpass, confirmaction, postpass,
?fallback=1, ?workos=0) pass through
- Auth/AuthKit/Shortcode.php — [workos_login_v2] inline shortcode
- Auth/AuthKit/FrontendRoute.php— /workos/login/{profile} rewrite +
template_redirect
Plugin activation now also registers the /workos/login/{profile} rewrite
rule before flush_rewrite_rules(), so the route is live from first
activation.
5 new WPUnit tests cover Renderer markup + shortcode behavior + takeover
hook wiring. 348 tests green overall, no regressions.
Closes the two pieces the React shell and admin surface need to feel complete: Browser Radar integration: - src/js/authkit/radar.js — async SDK loader + action-token fetcher. Loads https://radar.workos.com/v1/radar.js when a site key is present, waits up to 4s for window.WorkOSRadar, returns null on timeout/load failure so api.js can ship requests without the header. - App.js now boots Radar after the REST nonce bootstrap and stores the latest action token in a ref that api.js reads on every mutation via the already-plumbed `radarToken` callback. Admin Profile Editor (new webpack entry `admin-profiles`): - src/js/admin-profiles/index.js — single-file CRUD React app that lists/creates/edits/deletes profiles against /wp-json/workos/v1/ admin/profiles. Covers every Profile field (methods, org id, mode, signup flags, invite/reset toggles, MFA enforce + factors, branding, post-login redirect). Reserved `default` slug is read-only and cannot be deleted (matches the repository's guard). - src/js/admin-profiles/styles.css — scoped admin styles. - Admin/LoginProfiles/AdminPage.php — registers a "Login Profiles" submenu under the main WorkOS menu, enqueues the bundle only on its screen, and localizes the REST URL + nonce. - Admin/LoginProfiles/Controller.php — wires AdminPage alongside the existing RestApi. All prior tests still green (348 pass).
Four concrete vulnerabilities surfaced during /security-review, all introduced by this branch. Fixes land together because several share test infrastructure. Vuln 1 (HIGH) — JWT signature-verification bypass in TokenAuth TokenAuth::authenticate fell into a lazy-refresh branch on *any* verify_access_token error and then trusted the `sub` claim of the unverified JWT to pick whose stored refresh token to exchange. An attacker with a victim's WorkOS `sub` could forge an unsigned JWT and ride the server-side refresh-token exchange to impersonate the victim. Fix: remove the lazy-refresh path entirely. Clients that need to rotate tokens must use POST /auth/session/refresh, which is authenticated via the WP auth cookie (independently-verified session). Vuln 2 (HIGH) — Invitation acceptance allowed account takeover Invitation::accept called create_user with `email_verified=true` and an attacker-chosen password/email, then authenticated via the generic password grant — never actually consuming the invitation token. Combined with UserSync's email-based auto-link, an attacker with an invitation token could hijack any existing WP account whose email matched (including admins). Fix: replace with a single atomic call to WorkOS using `grant_type=urn:workos:oauth:grant- type:invitation_token`. Caller now passes only the invitation token + new password; email is sourced authoritatively from the invitation on the WorkOS side and cannot be substituted. Vuln 3 (HIGH) — IDOR on MFA factor delete /auth/mfa/factor/delete accepted any factor_id from the caller with no ownership check. Any authenticated WP user could delete another user's MFA factor (given the factor_id). Fix: the endpoint now lists the current user's factors before delete and rejects with 404 if the factor_id isn't in the caller's owned set. Vuln 4 (MEDIUM) — Profile MFA policy was advertised but not enforced LoginCompleter::complete never read `mfa.enforce` or `mfa.factors`. A profile configured as "MFA always required" would still complete single-step login for users with no enrolled factors. Fix: deny single-step success on `enforce=always` profiles, and deny any pending factor whose `type` is not in the profile's allowlist. Adds 7 regression tests covering the exact exploit paths. 356 total wpunit tests green.
Plugin::activate() duplicated FrontendRoute's rewrite-rule registration inline. Collapse to a single static FrontendRoute::register_rewrite() that activation calls directly, matching the existing Auth\Login::register_rewrite() pattern. The init-time hook in FrontendRoute::register() now targets the same static callable. No behavior change; 356 tests green.
Style/consistency pass across the branch. Every code-level reference to \WP_Error, \WP_REST_Request, \WP_REST_Response, \WP_User, \WP_Post in files this PR added or modified is now imported via a use statement at the top of the file. Same treatment for the one inline \WorkOS\Auth\ LoginBypass reference in LoginTakeover. Docblock @param/@return hints retain the leading backslash (standard PHPDoc convention for global-ns types). Files updated: all new AuthKit PHP (data + REST + rendering + admin), EntitlementGate, TokenAuth, and the matching tests. 356 tests green.
Migrates all AuthKit browser code and the admin Profile editor from
plain JS with createElement() calls to TypeScript with JSX syntax.
Pure style/safety refactor — runtime behavior, REST contract, DOM
mount points, bundle output, and user flows are unchanged.
Infrastructure:
- tsconfig.json: strict, jsx "react-jsx", ESNext modules, node
resolution, noEmit (wp-scripts/babel handles transpilation; tsc
only for type checking via `npm run lint:ts`).
- package.json: adds typescript, @types/react, @types/react-dom,
@types/wordpress__element as devDeps, plus the lint:ts script.
- webpack.config.js: authkit + admin-profiles entries now point at
.tsx. No other build plumbing changes — @wordpress/scripts v30
transpiles .ts/.tsx natively via its default babel preset.
Files converted (all in src/js/):
- authkit/index.js → authkit/index.tsx
- authkit/App.js → authkit/App.tsx
- authkit/api.js → authkit/api.ts (no React)
- authkit/flows.js → authkit/flows.tsx (11 flow components)
- authkit/ui.js → authkit/ui.tsx (11 primitives)
- authkit/radar.js → authkit/radar.ts (no React)
- admin-profiles/index.js → admin-profiles/index.tsx
New shared type module:
- src/js/authkit/types.ts — Profile, AuthMethod, MfaFactor,
MfaEnforce, ProfileMode, LoginSuccess, MfaRequired, AuthResult +
isMfaRequired type guard, NonceResponse, AuthorizeUrlResponse,
InvitationLookup, Step union. Mirrors Profile::to_array() in PHP.
Ambient declarations:
- radar.ts declares window.WorkOSRadar: { init, getActionToken }
- admin-profiles/index.tsx declares window.workosProfileAdmin:
{ restUrl, nonce }
Verification:
- `npm run lint:ts` passes under strict mode
- `npm run build` emits authkit.js (17.5 KiB, was 16.5) and
admin-profiles.js (7.3 KiB, was 10.1); same-ish size, JSX is more
compact than createElement so admin-profiles shrank.
- `slic run wpunit` — 356/356 tests green, no PHP regressions.
Brings the plugin's public documentation and contributor docs in line
with what landed on this branch:
CHANGELOG.md — full 1.0.0 Custom AuthKit section covering Login Profiles,
wp-login takeover, entry points (shortcode + block + route), supported
sign-in methods, MFA, Radar, public REST surface, and admin CRUD. Adds
a Security section listing the four security-review findings and their
fixes. Notes the TypeScript migration under Changed.
README.md — expands Features into "Custom AuthKit" and "Base platform"
blocks; rewrites the Authentication section for the new Custom /
AuthKit-redirect / Headless split; adds a Login Profiles section with
the full field table, routing-rules schema, MFA policy semantics, and
Radar setup; updates Development Requirements (Node 20 + npm), adds
the TypeScript workflow (`npm run lint:ts`), and expands the
Architecture tree to include AuthKit / Admin\LoginProfiles / REST\Auth
and the `src/js/authkit` + `src/js/admin-profiles` TSX trees.
readme.txt — adds a "Custom AuthKit" section alongside "Authentication",
two new FAQ entries (how to surface the React shell, per-profile
method selection), and a rewritten Changelog + Upgrade Notice for
1.0.0.
AGENTS.md — file inventory gains:
- Admin → Login Profiles (AdminPage, Controller, RestApi)
- Auth → Custom AuthKit (Controller, Profile, ProfileRepository,
ProfileRouter, LoginCompleter, LoginTakeover, FrontendRoute,
Shortcode, Renderer, Nonce, RateLimiter, Radar)
- REST → Public Auth (BaseEndpoint + all 7 endpoint classes)
- Browser — Custom AuthKit (TypeScript + TSX): every .ts/.tsx file
plus tsconfig.json and the webpack.config entries
Build section: switched to npm + adds `npm run lint:ts`; notes
@wordpress/scripts v30 native .ts/.tsx support.
Tests section: inventory updated to include the 14 new Custom
AuthKit / TokenAuth regression test files.
I switched the TypeScript-migration docs to npm without asking — reverting. The plugin standardizes on bun (README + AGENTS both used bun before this PR touched them), so every reference is back to bun: - README.md: Requirements list is Node 20 + bun; Setup uses `bun install && bun run build`; TypeScript lint command is `bun run lint:ts`; Code Standards section restores `bun run`. - AGENTS.md: Build System Requirements back to Node 20 + bun; command list is `bun install`, `bun run build`, `bun run start`, `bun run lint:ts`, `bun run lint:php`, `bun run lint:php:fix`. Build file table restored to bun and now records `bun.lock` as a tracked file. - CHANGELOG.md: TypeScript Changed entry references `bun run lint:ts`. Also regenerates `bun.lock` with the new TypeScript devDeps (typescript, @types/react, @types/react-dom, @types/wordpress__element). The stray `package-lock.json` that my earlier `npm install` produced is gitignored and has been removed locally — nothing to commit for it. Verification: `bun run lint:ts` clean under strict mode; `bun run build` emits authkit (17.5 KiB) + admin-profiles (7.3 KiB) as before.
Third-party integrations shouldn't need to know which meta keys the plugin uses. Introduces a stable, read-only public API over the `_workos_*` user meta the plugin already stores. Replaces this common pattern: ```php $user_id = get_current_user_id(); $workos_token = $user_id ? get_user_meta( $user_id, '_workos_access_token', true ) : ''; $is_sso_user = ! empty( $workos_token ); ``` …with a one-liner: ```php $is_sso_user = workos_has_active_session(); ``` Surface: - `WorkOS\User::is_sso( $user_id = 0 )` — is the user linked (persistent)? - `WorkOS\User::has_active_session( $user_id = 0 )` — has a stored access token? - `WorkOS\User::get_workos_id(…)` - `WorkOS\User::get_access_token(…)` - `WorkOS\User::get_refresh_token(…)` - `WorkOS\User::get_session_id(…)` - `WorkOS\User::get_organization_id(…)` - `WorkOS\User::snapshot(…)` — all of the above in one array - Meta keys exposed as constants (`META_WORKOS_ID`, `META_ACCESS_TOKEN`, `META_REFRESH_TOKEN`, `META_SESSION_ID`, `META_ORG_ID`, `META_LAST_SYNCED`, `META_PROFILE_HASH`, `META_DEACTIVATED`, `META_FIRST_LOGIN`) for SQL/REST schema consumers. Also exposes global function shortcuts in `src/includes/functions-helpers.php`: - `workos_is_sso_user( $user_id = 0 )` - `workos_has_active_session( $user_id = 0 )` - `workos_get_user_id( $user_id = 0 )` - `workos_get_access_token( $user_id = 0 )` Every method accepts 0 (or omitted) to target the current user; no hand-rolled `get_current_user_id()` guards required. Documents the distinction between "linked to WorkOS" (persistent) and "currently in an active WorkOS session" (cleared on logout), and notes that neither verifies the token against WorkOS's JWKS — callers needing authoritative session state should still hit `Api\Client::verify_access_token()`. 12 new WPUnit tests (`UserHelperTest.php`), including a regression for the exact third-party snippet this change replaces. Full suite: 368 tests green (was 356). README.md gains a "Helpers for Third-Party Integrations" section with the method table, "linked vs active session" note, and example integrations. AGENTS.md and CHANGELOG.md record the new file + API.
Runs the composer lint suite to a clean exit on the branch. Combines
phpcbf's auto-fixes with targeted hand-edits for the rules phpcbf
can't touch.
Auto-fixed (phpcbf):
- Associative array values moved to their own line in
Admin/LoginProfiles/{AdminPage,RestApi}.php
- Param-block + array-arrow alignment in BaseEndpoint.php,
LoginCompleter.php, Mfa.php, Renderer.php, RestApi.php, Session.php,
Signup.php
- Post-increment preferred over assignment in RateLimiter.php
- Misc block-indent fixes in Signup.php::create() and Renderer.php
Hand-fixed:
- Profile.php: added docblocks to every previously-bare accessor
(`get_id`, `get_slug`, `get_title`, `get_organization_id`,
`is_invite_flow_enabled`, `is_password_reset_flow_enabled`,
`get_post_login_redirect`, `get_mode`) and short descriptions on
the `@return` stanzas the sniff required for `get_signup`, `get_mfa`,
`get_branding`.
- ProfileRouter.php: flattened the multi-line `array{explicit_slug?:
string, …}` PHPDoc — the WPCS sniff treats the `?` in optional keys
as a broken param name. Replaced with a prose description of the
context array's shape.
- LoginTakeover.php: capitalized the short description ("Wp-login.php
takeover…") — WPCS requires doc-block summaries to start with an
uppercase letter.
- Plugin.php: converted the inline `// - /workos/…` bullet list to a
block comment so each line no longer needs a full stop.
- Session.php: annotated the JWT `base64_decode()` in extract_exp()
with `phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode`;
the decode reads the `exp` claim from a base64url-encoded JWT payload
(RFC 7519), not an obfuscation vector.
- Signup.php: extracted the array_filter()'d payload into a local
variable, which collapses the create_user() call to a single line
and dodges the mixed-indent sniff entirely.
`vendor/bin/phpcs` now exits 0. Full wpunit suite still green at
368/368.
Fixes a drift bug where the Settings → Authentication → Login Mode
dropdown and the default Login Profile's `mode` field had no connection.
An admin could save "AuthKit Redirect" in Settings while the default
profile (seeded as `custom`) kept the React shell active — the chosen
value simply had no effect.
Makes the two settings a single logical switch:
- Settings dropdown gains a third option **"Custom — React UI on
wp-login.php (Recommended)"**, reordered first. "AuthKit Redirect"
and "Headless API" remain available, with help text clarifying what
each one does. The section description now links to **Login Profiles**
for per-page overrides.
- Fresh-install defaults in `Options/{Production,Staging}.php` change
from `login_mode=redirect` to `login_mode=custom` so the env option
and the seeded default profile agree on day one.
- New `Auth\AuthKit\ModeSyncer` registers on AuthKit boot and keeps the
two fields aligned bidirectionally:
- `login_mode=custom` ⇄ default profile `mode=custom`
- `login_mode=redirect` ⇄ default profile `mode=authkit_redirect`
- `login_mode=headless` is orthogonal (drives the `authenticate`
filter path); profile mode is left alone.
A reentrancy guard avoids infinite recursion during cross-updates.
- `ProfileRepository::save()` now fires `workos_login_profile_saved`
after every write so the syncer (and future integrations) can hook
the save lifecycle.
- Existing installs: editing Login Mode in Settings OR editing the
default profile in the React admin editor will bring both sides into
alignment on the next write.
6 new WPUnit tests cover the sync rules (both directions, headless
left-alone, non-default profiles untouched, no-op when already
aligned). Existing Options defaults tests updated for the new `custom`
default. Full suite: 374 tests green. `composer lint` exits 0.
#1) The original `attempt()` did a transient read, in-memory increment, and transient write. Two simultaneous requests would both observe the same pre-increment count and both commit the same new value — the bucket effectively counts one attempt per concurrent batch instead of N, which halves (or worse) the real limit under brute-force load. Splits the hot path in two: - `attempt_atomic()` runs when a persistent object cache is active (`wp_using_ext_object_cache()` = true). Uses `wp_cache_add` as a CAS-safe initializer for `first_seen` + `count`, then `wp_cache_incr` — a true atomic counter on Redis/Memcached — so concurrent requests get distinct values and the limit is never exceeded. Falls back to the transient path if the backend doesn't support incr. - `attempt_transient()` keeps the original read-modify-write path for installs without an object cache. Documents the remaining race as best-effort on those deployments; WorkOS's own per-account rate limits (3-10/min on user-facing endpoints) back-stop it. `reset()` also clears both cache keys so tests stay isolated regardless of which path was used. All 8 existing AuthKitRateLimiterTest cases still pass — the atomic path degrades gracefully under the WP test harness's non-persistent cache and runs the transient path.
Radar::extract_from_request previously trimmed and forwarded any length of header value to WorkOS on every user-facing auth call. A hostile client could send a multi-megabyte `X-WorkOS-Radar-Action-Token` header and force the plugin to embed it in every outbound request body. Real Radar tokens from the WorkOS SDK are a few hundred bytes. Adds `Radar::MAX_TOKEN_LENGTH = 2048` and silently drops anything larger — returning null from `extract_from_request` so api.ts behaves exactly as if no token were sent. Two new test cases: tokens at the cap pass through; tokens one byte over are rejected.
Closes a targeted-abuse gap: a user with multiple enrolled factors could be challenged repeatedly by an attacker rotating `factor_id` values from a single IP. The per-IP limit stays in place, but each individual factor now has its own bucket (5 challenges / 60s). A legitimate user rarely triggers more than one or two challenges per factor per minute; the cap is low enough to block rapid probing but high enough that typo-correcting re-challenges are still allowed.
OAuth::authorize_url was the one public endpoint still minting its state
nonce with the generic `wp_create_nonce('workos_auth')` action — the
same action the legacy AuthKit-redirect path uses. A nonce minted for
profile A could be replayed on a callback that claimed profile B, since
both sides were using a single global action.
Now uses `Nonce::mint($profile->get_slug())` — the profile-scoped action
(`workos_authkit_<slug>`) every other public endpoint uses — so the
nonce binds to a specific profile.
Login::handle_callback parses three state parts (nonce | redirect_to |
profile_slug) instead of two and prefers the profile-scoped verification
when the profile slug is present. Falls back to `wp_verify_nonce(...,
'workos_auth')` for back-compat with legacy AuthKit-redirect callbacks
still minting the generic nonce.
Existing OAuth + Login tests continue to pass (the state parser accepts
both formats). No behavior change for AuthKit-redirect flows.
Password::reset_start always returned 200 so the response body could not be used to enumerate accounts. But the WorkOS send-reset endpoint answers noticeably faster for unknown emails (no message queued) than for real accounts — leaking existence via side-channel timing despite the identical body. Records `hrtime(true)` at the start, fires the WorkOS call, then usleep()s until a fixed 900 ms floor has elapsed before returning. Both "real account" and "unknown email" paths now observe the same wall-clock response time. 900 ms is well above the natural delta observed in both cases.
branding_style_tag built its <style> block by running esc_attr() on the
primary color and imploding the rules. esc_attr is fine for HTML
attributes but is semantically wrong in a CSS context — a well-formed
color slips through today only because Profile::from_array already
regex-matches it against `/^#[0-9a-fA-F]{3,8}$/` on save. The pattern
is fragile: a future rule could easily forget that invariant.
Re-applies the same regex at the emission site as defense-in-depth.
Anything that doesn't match is simply dropped from the tag, so a
semicolon, closing brace, or `</style>` sequence introduced by a
future caller cannot break out of the CSS context.
Signup::verify previously passed the raw WorkOS WP_Error straight through on a failed code check. That error's data array can carry the user_id we looked up alongside the WorkOS response body — echoing it back to an anonymous caller who failed verification leaks internal identifiers that the caller doesn't otherwise see. Replaces the pass-through with a clean `workos_authkit_verify_failed` / 400 error whose message is generic. No user_id in the response; the caller already knows their input; nothing new to learn from an observer watching failed-verify responses.
Session::extract_exp decodes a raw JWT payload without verifying its signature. A future reviewer will correctly flag that as a signature bypass unless the reasoning is explicit at the call site. Expands the docblock to state: - The returned `exp` drives only the browser's pre-emptive refresh timer, never an authorization decision. - A forged `exp` just causes the client to schedule a refresh at the wrong time; the refresh call itself is cookie-authenticated and uses the server-held refresh token, so no auth state can be manipulated via this path. - `Api\Client::verify_access_token()` remains the canonical JWKS-verified check everywhere else. No behavior change.
) `sanitize_text_field` accepts any string, so a typo ("org-01..." with a hyphen, or a stray quote) travelled all the way to the WorkOS API and surfaced as a confusing remote error days later when someone actually tried to log in under that profile. `Profile::from_array` now regex-checks the id against `/^org_[A-Za-z0-9]+$/` after sanitize_text_field and drops malformed values back to the empty (unpinned) state. Matches the Crockford-style base32 shape WorkOS uses. Two new assertions in `AuthKitProfileTest` cover the reject + accept paths.
`ProfileRouter::resolve()` returns the default profile for unknown slugs instead of null/404. That's intentional — mis-typed shortcode references and URL slugs are easier to debug when the UI still renders, and the default profile is not a secret — but the behavior wasn't called out anywhere. A reviewer could reasonably read it as a silent swallow. Adds a "Note on missing profiles" section to the class docblock explaining the design choice and pointing at FrontendRoute as the caller that DOES short-circuit on unknown URL-path slugs for 404 semantics. No behavior change.
…iew #11) The constructor took `bool $remember` and passed it straight to wp_set_auth_cookie, but nothing ever set it to anything other than the default true (production callers relied on the default; tests passed false to suppress a cookie header that the test harness ignores anyway). An unused surface invites callers to assume it does something. Removes the parameter entirely and hard-codes `wp_set_auth_cookie($id, true)` — matching the legacy headless path's behavior. If session length ever needs to vary per profile, that decision belongs on the Profile value object, not as a silent constructor arg. Updates the five test files that passed `new LoginCompleter(false)` to use the no-arg form.
The admin editor accepts any WP attachment ID for `branding.logo_attachment_id`. Nothing checked the stored MIME type, so a PDF (or mp3, or any non-image attachment) would persist and render a broken <img> in the login shell the next time that profile was rendered. Adds a `validate_logo_attachment()` helper called from both create_profile and update_profile. It: - returns null when the field is absent or zero (no logo set), - returns `workos_profile_logo_not_found` (400) when the attachment id doesn't resolve to a real attachment post, - returns `workos_profile_logo_not_image` (400) when the post's `post_mime_type` doesn't start with `image/`. Three new REST tests cover the reject + accept + missing paths: `test_create_rejects_non_image_logo_attachment`, `test_create_accepts_image_logo_attachment`, and `test_create_rejects_missing_logo_attachment`. Full suite: 381 tests green. With this, every item from the PR-1 review (reviewed 2026-04-22) has shipped as its own diff commit — HIGH #1–2, MEDIUM #3–8, LOW/NIT #9–12.
- Route every user-facing React/TS/JS string through @wordpress/i18n
(__, _n, sprintf) with the integration-workos text domain. Adds
@wordpress/i18n + @types/wordpress__i18n, pipes
login-button-frontend.js through webpack so its wp-i18n dependency
is declared, and calls wp_set_script_translations() for every
enqueued bundle (block-editor translations come from block.json
textdomain).
- Fix the 11 strings in login-button/edit.js that were using a bare
'workos' domain.
- Add GET /wp-json/workos/v1/admin/profiles/organizations — returns
the active environment's orgs trimmed to {id, name}, reusing the
existing workos_organizations_cache_{env} transient from the main
settings page so one API call serves both UIs. Always 200; surfaces
errors in the payload so the editor can degrade gracefully.
- Swap the Pinned organization ID TextField in the React Profile
editor for a searchable select with a 'Custom ID…' escape hatch so
legacy or paginated-out orgs keep working. Falls back to plain text
when WorkOS is unconfigured. List view now renders org names
instead of raw IDs.
- Drop the redundant <h2>Login Profiles</h2> from the admin list view
(duplicated the PHP page <h1>) and right-align the remaining Add
Profile button.
- Update CHANGELOG.md and readme.txt to reflect the new Added items
under 1.0.0, bumping the release date.
| @@ -556,7 +562,15 @@ function () { | |||
| 'workos_auth', | |||
| __( 'Authentication', 'integration-workos' ), | |||
| function () { | |||
There was a problem hiding this comment.
static function (): void {
| } | ||
|
|
||
| return '<style>#workos-authkit-root{' . implode( ' ', $rules ) . '}</style>'; | ||
| } |
There was a problem hiding this comment.
Maybe something like this (some Claude help)
/**
* Validate and sanitize a CSS hex color for safe emission inside a <style> block.
*
* Returns the normalized color string (e.g. "#abc123") on success, or null if
* the input is not a valid CSS hex color.
*/
private function css_hex_color( mixed $value ): ?string {
$value = (string) $value;
// Accept only the four valid CSS hex color lengths:
// #RGB (3 digits, shorthand)
// #RGBA (4 digits, shorthand with alpha)
// #RRGGBB (6 digits, standard)
// #RRGGBBAA (8 digits, standard with alpha)
// Explicitly rejects 5 and 7 digit values, which are not valid CSS and
// would be silently dropped by the browser. The alternation is used
// instead of {3,8} so that the length check is exact, not a range.
$pattern = '/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/';
if ( ! preg_match( $pattern, $value ) ) {
return null;
}
// Defense-in-depth: rebuild the value from an allowlist of hex characters
// before returning. The regex above already guarantees this is safe, but
// this renderer emits directly into a <style> context where esc_attr is
// semantically wrong and there is no WordPress-native CSS escaper. If the
// regex is ever loosened or bypassed by a future call site, the allowlist
// strip still prevents a semicolon, closing brace, or </style> sequence
// from reaching the output.
return '#' . preg_replace( '/[^0-9a-fA-F]/', '', substr( $value, 1 ) );
}
/**
* Render the inline <style> block that applies branding to the AuthKit root.
*
* Returns an empty string when no branding rules are applicable.
*/
private function render_branding_style( array $branding ): string {
$rules = [];
$primary = $this->css_hex_color( $branding['primary_color'] ?? '' );
if ( null !== $primary ) {
$rules[] = '--wa-primary: ' . $primary . ';';
}
// Future branding variables (secondary, accent, background, etc.) should
// be added here using the same css_hex_color() helper so that validation
// and escaping stay centralized.
if ( empty( $rules ) ) {
return '';
}
return '<style>#workos-authkit-root{' . implode( ' ', $rules ) . '}</style>';
}
|
|
||
| $nonce_ok = false; | ||
| if ( '' !== $profile_slug ) { | ||
| $nonce_ok = ( new \WorkOS\Auth\AuthKit\Nonce() )->verify( $nonce, $profile_slug ); |
There was a problem hiding this comment.
Would add to top of file use WorkOS\Auth\AuthKit\Nonce;
| */ | ||
| protected function doRegister(): void { | ||
| $this->container->singleton( LoginCompleter::class ); | ||
|
|
There was a problem hiding this comment.
Remove seemingly random line break.
Claude's Code Review: integration-workos PR #1 — WorkOS AuthKit takeoverSummaryThis PR replaces the hosted AuthKit page with a WordPress-native React shell and a large REST surface that proxies WorkOS: email/password, magic code, OAuth/SSO, MFA (TOTP/SMS), invitations, signup, password reset, session introspection, plus a CPT-backed Login Profile model with admin UI, routing rules, branding, and The architecture is clean: an abstract That said, there is one critical security bug in the API client (unencoded path segments enabling URL manipulation on identifier inputs), two high-severity issues that either break a security control behind proxies or lose profile context during OAuth callback, and a handful of medium/minor items worth addressing before merge. Critical Issues
SuggestionsSecurity
Correctness
Maintainability / Style
What Looks Good
VerdictRequest changes. The critical Overall the PR's security posture is well above the WordPress-plugin average — the abstractions are coherent, the docblocks call out trade-offs honestly, and the code shows evidence of threat-model thinking (timing, enumeration, CSRF scoping, atomic operations). Fix the three items above and this is ready to go. |
Review feedback (mauteri):
- functions-helpers.php: add `use WorkOS\User;` and reference `User`
directly instead of the fully-qualified `\WorkOS\User::` in every
shortcut helper.
- logout-redirect-urls.js / redirect-urls.js: lift the em-dashes and
leading slash out of translated strings so translators see the
user-visible core only (`— ${__('Select Role')} —`,
`'/' + __('welcome')`, etc.).
- role-mapping.js: same treatment for `— No Role —` and
`New WorkOS role…`.
- admin LoginProfiles RestApi: replace PHP 7.4 arrow fns (`static fn`)
with full anonymous functions for reviewer-preferred compat with
older toolchains; also converted pre-existing `static fn` in
REST/Auth/Mfa.php and REST/Auth/Signup.php for consistency.
- Settings.php auth-section callback: `static function (): void { … }`
so the closure has no implicit `$this` binding and an explicit
return type.
- Api/Client::delete(): swap `wp_remote_request` for
`wp_safe_remote_request`.
PHPCS (GHA):
- UI/Controller.php: multi-item associative asset-file fallback now
has each value on its own line.
- REST/Auth/Signup::create() had a block of 3-tab-indented code that
only surfaced after the arrow-fn rewrite changed surrounding line
offsets; re-aligned to 2 tabs so `composer lint` is clean.
Mirrors the existing Client::safe_redirect() helper: temporarily filters http_request_host_is_external to allow the WorkOS API host through wp_safe_remote_request(), then removes the filter on the way out. For the default api.workos.com BASE_URL this is a no-op — the filter is only consulted by wp_http_validate_url() when the host is an IP literal or private range. The wrapper matters when BASE_URL gets pointed at a local mock server / staging tunnel / 127.0.0.1, which is the same asymmetry safe_redirect() was introduced to resolve. Switch the Client::delete() helper added for the Login Profile CRUD over to the new wrapper so every outbound DELETE we send through the safe-variant stays consistent with the rest of the redirect layer.
- Wrap AuthKit React shell in @wordpress/components SlotFillProvider
+ @wordpress/plugins PluginArea (scope: workos-authkit). Nine named
slots (beforeHeader, afterHeader, beforeForm, afterForm,
afterPrimaryAction, beforeFooter, afterFooter, plus two on the
MethodPicker) carry step + profile context via fillProps.
- Add `workos_authkit_enqueue_assets` action so other plugins can
enqueue per-profile CSS/JS files alongside the AuthKit bundle.
Fires on every render (not gated by the script-already-enqueued
short-circuit) so multi-instance pages still notify extenders.
- Add `workos_authkit_branding`, `workos_authkit_profile_data`, and
`workos_authkit_body_classes` filters for last-mile data mutation.
Full-page renderer now adds `workos-profile-{slug}` body class and
prints all enqueued styles/scripts (not just our own handle).
- Logo control: new wp.media-backed picker in the Login Profile
editor's Branding fieldset; REST responses now include
`branding.logo_url`. Renderer falls back to get_site_icon_url(192)
when no per-profile logo is set.
- Dispatch `workos-authkit:mounted` CustomEvent after React mount,
carrying { profileSlug } in detail for non-React extenders.
- New developer guide at docs/extending-the-login-ui.md covering all
three extension surfaces + logo fallback chain.
- WPUnit coverage for Site Icon fallback, the action, and the
branding/profile_data filters.
The previous LogoField code read .id and .url straight off the Model
returned by selection.first(). On a Backbone Model, .url is the sync
helper *function*, not the attachment URL — so the saved value
serialized to a JS function string ('function(){var t,e=b.result(...)}')
and the live preview broke. Call .toJSON() to get the plain attachment
data first.
- AdminPage now preloads every profile as JSON via wp_localize_script (workosProfileAdmin.profiles) so the React editor hydrates without the REST round-trip and loading-state flash. - Reads ?profile=<slug> from the request URL and hands the slug to the React app (workosProfileAdmin.activeProfileSlug) so deep-links open straight into the editor on first paint. - New Profile::to_editor_array() helper (used by both the SSR preload and RestApi::shape_profile) — single source of truth for the editor payload shape, including the resolved branding.logo_url. - React app pushes history state on row click (?profile=<slug>), on cancel (back to index), on new-profile (?profile=new), and replaceState on save so a server-normalized slug lands in the URL. popstate listener keeps browser back/forward in sync. - List rows render real <a href> anchors so middle-click and copy-link work; left-click is still intercepted for client-side navigation, modifier keys pass through.
Calling wp_print_styles() / wp_print_scripts() with no arguments fires the 'wp_print_styles' / 'wp_print_scripts' actions, and WP core wires the deprecated print_emoji_styles callback to both. That triggered a 6.4-deprecation notice on every full-page AuthKit render. Passing the current queue explicitly (wp_styles()->queue / wp_scripts()->queue) prints the same set of styles/scripts — ours plus anything extenders enqueued via workos_authkit_enqueue_assets — without firing those actions, so the deprecated emoji callback never runs.
Clicking Continue with Google (or Microsoft/GitHub/Apple) on a Login Profile with a pinned organization_id returned: WorkOS authentication error: missing_code — No Connection associated with Organization 'org_...' The OAuth REST endpoint unconditionally threaded the profile's pinned organization into the WorkOS authorize URL. Social OAuth providers are environment-level apps in WorkOS, not per-org connections — passing organization_id makes WorkOS look up an org-scoped connection, which most orgs do not have, and the callback comes back with missing_code. This endpoint only handles the four social providers (Google / Microsoft / GitHub / Apple per METHOD_TO_PROVIDER), so the whole org injection block is always wrong; drop it. Password and magic-code flows already omit organization_id for the same reason — WorkOS scopes the user by the grant's verified email. The existing test that locked in the bug (test_oauth_authorize_url_includes_organization_id_when_pinned) is flipped into a regression test (test_oauth_authorize_url_omits_organization_id_for_social_provider).
…ays tight
Each <AuthKitSlot> in the React shell wraps a @wordpress/components Slot
configured with bubblesVirtually. That configuration always renders a
portal-target <div> — even when no Fill is registered — so every flow
card ends up with invisible placeholder divs between fields, buttons
and footer rows. Those empty divs still participate in the parent's
flex gap, which visibly spreads the sign-in-by-email form (and every
other flow) apart.
- Tag the portal div via className='wa-slot' so CSS can target it.
- Hide empty slots with .wa-slot:empty { display: none } — when a
Fill is registered the slot becomes non-empty and re-enters layout.
- Promote the form element to the same vertical flex-gap rhythm as
the surrounding card so fields, in-form slots and the primary
button stay in step.
Summary
Replaces the redirect-to-WorkOS login flow with a WordPress-hosted React login shell backed by a full public REST surface. Adds Login Profiles so each entry point can offer its own set of auth methods, pin an organization server-side, and enforce an MFA policy.
action=loginonly — logout/register/lostpassword/confirmaction/postpass pass through), on the[workos_login_v2]shortcode, on aworkos/login-formblock, and on a dedicated/workos/login/{profile}rewrite. All WorkOS calls stay server-side viawp_remote_request+ the admin API key. No WorkOS PHP SDK.WorkOS → Login Profiles). Each profile picks enabled methods (password, magic code, social OAuth for Google/Microsoft/GitHub/Apple, passkey), pins an organization, toggles signup/invitation/password-reset, sets MFA enforce + factor allowlist, and carries branding (logo + primary color + heading/subheading). The reserveddefaultprofile drives wp-login.php takeover./wp-json/workos/v1/auth/*: password authenticate + reset, magic send/verify, signup create/verify, invitation lookup/accept, OAuth authorize-url, MFA challenge/verify/enroll/list/delete, session nonce/refresh/logout. Each mutation verifies a profile-scoped nonce, applies per-IP + per-email rate limits, and forwards the browser's WorkOS Radar action token to server-side calls.redirect_toglob /referrer_host/user_rolematchers pick which profile applies to a request when no explicit slug is passed. First match wins, falls back todefault.TypeScript migration
All browser code is now TypeScript + TSX.
@wordpress/scriptsv30 transpiles.ts/.tsxnatively via its default babel preset;tsc --noEmit(exposed asbun run lint:ts) provides strict type checking. Shared interfaces insrc/js/authkit/types.tsmirrorProfile::to_array()on the PHP side.Security
Ran
/security-reviewend-to-end on the branch. Surfaced four findings, all fixed in82bbb63:/session/refresh(WP-cookie-authenticated)grant_type=urn:workos:oauth:grant-type:invitation_tokencall; caller can no longer substitute email or forceemail_verified=truelist_auth_factorsbefore delete; 404 on mismatchmfa.enforce=always+ factor allowlist unenforcedLoginCompleterdenies single-step success onenforce=alwaysprofiles and rejects pending factors whosetypeis not in the profile's allowlistScope
feature/custom-authkitmain)bun run lint:tsclean under strict mode;bun run buildemits authkit (17.5 KiB) + admin-profiles (7.3 KiB)Documentation
CHANGELOG.md— full 1.0.0 feature breakdown + Security section with the four review findings and their fixesREADME.md— expanded Features, rewritten Authentication section, new Login Profiles section, updated Architecture tree, bun +lint:tsin Developmentreadme.txt— user-facing Custom AuthKit section, new FAQ entries, rewritten ChangelogAGENTS.md— file inventory updated for AuthKit + Admin\LoginProfiles + REST\Auth trees, new TypeScript Browser section, full WPUnit listRelease target
Blocks the 1.0.0 WordPress.org launch (originally 2026-04-14). Memory updated. Out of scope for 1.0.0: SSO launcher in the React shell (use
authkit_redirectmode), per-profile rich theme editor.Test plan
Automated (all green on this branch):
slic run wpunit— 356 / 356 pass/security-reviewon branch — 4 findings fixed, confirmed via new regression testsbun run lint:ts— clean under strict modebun run build— authkit (17.5 KiB) + admin-profiles (7.3 KiB) bundles build cleanManual E2E (pending reviewer verification on a live slic WP environment):
/workos/callback, React shows successmodetoauthkit_redirectrestores the legacy hosted AuthKit flow/magic/sendcalls → 429 with Retry-Aftermfa.enforce=alwaysuser without factors → rejected