Skip to content

feat(authkit): custom WordPress-hosted AuthKit login experience - #1

Merged
bordoni merged 34 commits into
mainfrom
feature/custom-authkit
Apr 24, 2026
Merged

bordoni merged 34 commits into
mainfrom
feature/custom-authkit

Conversation

@bordoni

@bordoni bordoni commented Apr 22, 2026 •

Copy link
Copy Markdown
Owner

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.

  • Custom AuthKit: React shell mounts on wp-login.php (takeover for action=login only — logout/register/lostpassword/confirmaction/postpass pass through), on the [workos_login_v2] shortcode, on a workos/login-form block, and on a dedicated /workos/login/{profile} rewrite. All WorkOS calls stay server-side via wp_remote_request + the admin API key. No WorkOS PHP SDK.
  • Login Profiles: new non-public CPT + admin React editor (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 reserved default profile drives wp-login.php takeover.
  • Public REST surface at /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.
  • Profile routing rules: ordered redirect_to glob / referrer_host / user_role matchers pick which profile applies to a request when no explicit slug is passed. First match wins, falls back to default.
  • Legacy AuthKit-redirect mode stays selectable per profile so SSO (SAML/OIDC) keeps working until the React shell gains a dedicated SSO launcher in 1.1.

TypeScript migration

All browser code is now TypeScript + TSX. @wordpress/scripts v30 transpiles .ts/.tsx natively via its default babel preset; tsc --noEmit (exposed as bun run lint:ts) provides strict type checking. Shared interfaces in src/js/authkit/types.ts mirror Profile::to_array() on the PHP side.

Security

Ran /security-review end-to-end on the branch. Surfaced four findings, all fixed in 82bbb63:

# Severity Fix
1 HIGH — JWT signature-verification bypass in TokenAuth lazy-refresh Lazy-refresh removed; clients use /session/refresh (WP-cookie-authenticated)
2 HIGH — Invitation acceptance allowed account takeover Switched to atomic grant_type=urn:workos:oauth:grant-type:invitation_token call; caller can no longer substitute email or force email_verified=true
3 HIGH — IDOR on MFA factor delete Ownership check against list_auth_factors before delete; 404 on mismatch
4 MEDIUM — Profile mfa.enforce=always + factor allowlist unenforced LoginCompleter denies single-step success on enforce=always profiles and rejects pending factors whose type is not in the profile's allowlist

Scope

  • 62 files changed (+11,820 / −100)
  • 9 commits on feature/custom-authkit
  • 142 new WPUnit tests; 356 tests green (was 214 on main)
  • bun run lint:ts clean under strict mode; bun run build emits 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 fixes
  • README.md — expanded Features, rewritten Authentication section, new Login Profiles section, updated Architecture tree, bun + lint:ts in Development
  • readme.txt — user-facing Custom AuthKit section, new FAQ entries, rewritten Changelog
  • AGENTS.md — file inventory updated for AuthKit + Admin\LoginProfiles + REST\Auth trees, new TypeScript Browser section, full WPUnit list

Release 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_redirect mode), per-profile rich theme editor.

Test plan

Automated (all green on this branch):

  • slic run wpunit — 356 / 356 pass
  • /security-review on branch — 4 findings fixed, confirmed via new regression tests
  • bun run lint:ts — clean under strict mode
  • bun run build — authkit (17.5 KiB) + admin-profiles (7.3 KiB) bundles build clean

Manual E2E (pending reviewer verification on a live slic WP environment):

  • wp-login.php renders the React shell; password login succeeds
  • Magic-code login succeeds (WorkOS test-mode email)
  • Google OAuth button redirects out, returns to /workos/callback, React shows success
  • Signup → email-verification code → sign in
  • Invitation email link → accept → signed in (verify the invitation is consumed in the WorkOS dashboard)
  • TOTP enrollment: QR scan with an authenticator app; challenge verifies
  • Logout revokes the WorkOS session (confirm via WorkOS dashboard sessions tab)
  • Switching default profile mode to authkit_redirect restores the legacy hosted AuthKit flow
  • EntitlementGate: user not in profile org gets a clean rejection screen
  • Scripted 20 rapid /magic/send calls → 429 with Retry-After
  • Test each of the four security-fix regressions manually: forged unsigned JWT → rejected; invitation accept with mismatched email → rejected; delete-other-users-factor → 404; mfa.enforce=always user without factors → rejected

bordoni added 26 commits April 22, 2026 08:59
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.
Comment thread src/includes/functions-helpers.php Outdated
Comment thread src/js/logout-redirect-urls.js Outdated
Comment thread src/js/logout-redirect-urls.js Outdated
Comment thread src/js/redirect-urls.js Outdated
Comment thread src/js/redirect-urls.js Outdated
Comment thread src/js/role-mapping.js Outdated
Comment thread src/WorkOS/Admin/LoginProfiles/RestApi.php Outdated
Comment thread src/WorkOS/Admin/Settings.php Outdated
@@ -556,7 +562,15 @@ function () {
'workos_auth',
__( 'Authentication', 'integration-workos' ),
function () {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static function (): void {

Comment thread src/WorkOS/Api/Client.php Outdated
Comment thread src/WorkOS/Auth/AuthKit/LoginTakeover.php
Comment thread src/WorkOS/Auth/AuthKit/Renderer.php
}

return '<style>#workos-authkit-root{' . implode( ' ', $rules ) . '}</style>';
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>';
}

Comment thread src/WorkOS/Auth/Login.php

$nonce_ok = false;
if ( '' !== $profile_slug ) {
$nonce_ok = ( new \WorkOS\Auth\AuthKit\Nonce() )->verify( $nonce, $profile_slug );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would add to top of file use WorkOS\Auth\AuthKit\Nonce;

*/
protected function doRegister(): void {
$this->container->singleton( LoginCompleter::class );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove seemingly random line break.

@mauteri

mauteri commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Claude's Code Review: integration-workos PR #1 — WorkOS AuthKit takeover

Summary

This 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 wp-login.php takeover.

The architecture is clean: an abstract BaseEndpoint centralizes profile resolution, nonce verification, rate limiting, and redirect sanitization; Profile is a validated immutable value object; Nonce is profile-scoped; LoginCompleter enforces a factor allowlist and MFA_ENFORCE_ALWAYS; Renderer does defense-in-depth regex validation before emitting inline CSS; Radar caps forwarded header size. Security was clearly a first-class concern — rate limits, CSRF nonces, a timing-floor on password/reset_start, enumeration-safe error messages, and atomic invitation acceptance are all present.

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

# File Line Issue Severity
1 src/WorkOS/Api/Client.php list_auth_factors, get_invitation_by_token, delete_auth_factor, and other /{$id} interpolations User- and token-supplied identifiers are interpolated into WorkOS API URL paths with string concatenation and no rawurlencode(). A user_id, factor_id, or invitation token containing .., /, ?, #, or spaces lets a caller pivot the request to a different WorkOS endpoint (e.g. GET /user_management/users/..%2Fauth_factors%2F<id> style path traversal, or ?foo=bar# query smuggling). The Invitation GET route regex [A-Za-z0-9_\-]+ defends one call site by coincidence; the MFA and signup paths have no such guard. Wrap every interpolated segment in rawurlencode(...) at the point of URL construction. Critical
2 src/WorkOS/Auth/AuthKit/RateLimiter.php client_ip() The docstring says "Respects the standard WP proxy hint hierarchy" but the implementation only reads REMOTE_ADDR. Behind any reverse proxy (which is the typical production topology), every request will hash to the load-balancer's IP and the per-IP buckets (signup, password reset start, magic code send, MFA verify) collapse into a single shared counter that trips globally on the first attacker. Either implement the hierarchy (checking HTTP_X_FORWARDED_FOR/HTTP_X_REAL_IP behind a configurable trusted-proxy allowlist) or correct the docstring and add a README note — but the current mismatch between doc and behavior will silently ship a broken control. High
3 src/WorkOS/Auth/Login.php (handle_callback) state parsing handle_callback splits the OAuth state into [nonce, redirect_to, profile_slug] via `explode(' ', $state, 3), verifies the nonce against the slug, then discards the slug — downstream session creation and post-login redirect behavior run against the global default, not the profile that initiated the flow. The user sees profile A on the login page, clicks "Sign in with Google", and lands back under profile B's rules (redirects, branding on error, factor allowlist). Thread the resolved profile through to LoginCompleter. Also:

Suggestions

Security

# File Line Suggestion
S1 src/WorkOS/REST/Auth/Mfa.php verify() The verify path is rate-limited per-IP at 10/min but has no per-pending-token bucket. An attacker with a valid pending-auth token can brute-force TOTP codes from a botnet (rotating IPs) at essentially unbounded rate. Add a per-pending-token counter (e.g. 5 attempts before the token is burned server-side) that's consumed atomically with the verify call.
S2 src/WorkOS/REST/Auth/MagicCode.php send() Password::reset_start() uses hrtime() + usleep_until_floor() to mask whether an email exists. MagicCode::send() takes the same email/no-email branch but ships without the floor, so the response-time side-channel that reset_start closes is reopened here. Apply the same RESPONSE_TIME_FLOOR_US pattern.
S3 src/WorkOS/REST/Auth/Signup.php create() No server-side password strength validation. Add a minimum-length check (12+ chars is reasonable) and reject obvious weak patterns before sending to WorkOS — the React shell's client-side check is not a security control.
S4 src/WorkOS/User.php META_ACCESS_TOKEN, META_REFRESH_TOKEN Access and refresh tokens are stored in usermeta as plaintext. usermeta is world-readable to anyone with a SQL-capable plugin compromise or a database backup dump. Consider encrypting at rest with SECURE_AUTH_KEY (or a dedicated WorkOS key), or document the threat model explicitly in the constant's docblock.
S5 src/WorkOS/Auth/Login.php nonce verify Legacy fallback wp_verify_nonce( $nonce, 'workos_auth' ) defeats the profile scoping — a nonce minted under profile A is accepted for a callback under profile B if the fallback path is taken. If this is transition-only, add a date/version comment and a deletion ticket. If it's permanent, drop it now and force a single path.
S6 src/WorkOS/REST/Auth/BaseEndpoint.php sanitize_redirect() Good use of wp_validate_redirect. Consider also rejecting redirects whose scheme is not http/https (e.g. javascript:, data:) as a belt-and-braces step — wp_validate_redirect generally handles this but the behavior has shifted across WP versions.

Correctness

# File Line Suggestion
C1 src/WorkOS/Auth/AuthKit/LoginCompleter.php wp_set_auth_cookie($user_id, true) "Remember me" is hardcoded to true, which extends the session cookie to 14 days regardless of user preference. Plumb the user's actual "remember me" selection from the React shell through the REST payload.
C2 src/WorkOS/Admin/LoginProfiles/RestApi.php update array_replace_recursive is used for partial updates. For lists (e.g. methods, mfa.allowed_factors) this merges by numeric index rather than replacing — a PATCH that sends methods: ["password"] over an existing ["password","magic_code","sso"] returns ["password","magic_code","sso"] instead of ["password"]. Either document that lists are full-replace and special-case them, or switch to a field-by-field merge.
C3 src/WorkOS/Auth/AuthKit/ProfileRouter.php path_matches_glob The glob replaces \* with .* which is greedy and unanchored-internally. The pattern /wp-admin/* matches /wp-admin/edit.php as expected, but /admin/* also matches /public/admin/anything. Anchor more tightly (you already wrap in ^…$, which is correct), but note that * matching / may surprise admins who expect shell-style segment globs. Document the semantics in the admin UI helper text.
C4 src/WorkOS/REST/Auth/Signup.php create() response On success the endpoint calls send_verification_email but does not surface a failure from that call. If the email fails to send, the shell advances to the verify step with no code on the way. Either propagate the failure or schedule a retry.

Maintainability / Style

# File Line Suggestion
M1 src/WorkOS/REST/Auth/Session.php extract_exp The intentional "decode without verifying signature" is correct for UI timing, but the method name doesn't convey that. Rename to extract_exp_unverified (or add a big warning block at the top of the method) so it can't be mistaken for an authorization primitive by a later contributor.
M2 src/WorkOS/REST/Auth/BaseEndpoint.php constants RATE_LIMIT_* constants are duplicated across Signup/Password/Mfa/MagicCode/OAuth endpoints with slightly different values. Consider hoisting them into a single config array on BaseEndpoint so a policy change is one edit.
M3 src/WorkOS/Auth/AuthKit/Renderer.php render_full_page echo $mount with a phpcs:ignore. The comment justifying the ignore is accurate, but the escape contract now lives implicitly across two methods. Consider a SafeHtml wrapper class (even a single-property struct) returned from render_mount() so the type system — not a comment — enforces "this has been escaped".
M4 src/WorkOS/Auth/AuthKit/Profile.php immutability to_array() returns the internal branding array by value, which is fine for scalars but methods / mfa.allowed_factors are lists; callers that mutate the returned array in place will not affect the profile, but readers don't know that without reading source. Worth a brief @return docblock note.
M5 src/WorkOS/Auth/AuthKit/ProfileRepository.php CPT The default profile is guarded against deletion at the repo layer. Also add a capability check at the admin delete endpoint to reject the request before it reaches the repo, so the admin UI can show a clean 403 rather than a generic error.

What Looks Good

  • Layered security-by-default. Every state-changing REST endpoint goes through BaseEndpoint::resolve_profile → verify_nonce → rate_limit in the same order. It's easy to audit and hard to skip a step by accident.
  • Profile-scoped nonces. workos_authkit_<slug> actions mean a nonce minted for profile A cannot be replayed against profile B, which is the right move for multi-tenant login surfaces on the same site.
  • Timing-safe password/reset_start. The hrtime() + usleep_until_floor() pattern is the correct mitigation for email-enumeration via response time, and the 900ms floor is a reasonable balance between UX and side-channel coverage.
  • Defense-in-depth in Renderer::branding_style_tag. Re-validating the primary_color hex regex at the emission site (where esc_attr is semantically wrong for CSS context) is exactly the right call — this is the kind of layered check that stops future refactors from silently reintroducing CSS injection.
  • Atomic invitation acceptance. The authenticate_with_invitation atomic flow, with a comment calling out the account-takeover vuln in the legacy two-call path, is a great example of "fix the class of bug, don't just patch the instance" — plus the comment helps a future reader understand why the atomic path exists.
  • Radar::extract_from_request header size cap. 2 KB upper bound on a forwarded fingerprint header is a small, cheap mitigation that prevents header-stuffing attacks from amplifying every outbound WorkOS call.
  • Profile as an immutable value object with strict allowlist validation on construction. Modeling the config as a parsed-once, validated value rather than repeatedly get_option()'d arrays eliminates a whole class of "what if the admin wrote garbage" bugs at the boundaries.
  • Escape hatches on LoginTakeover. Preserving ?workos=0, ?fallback=1, ?loggedout=1 means a misconfigured WorkOS tenant doesn't brick admin access — exactly the kind of operational foresight that prevents 3 a.m. pages.
  • wp-login.php rendering without wp_head/wp_footer. The comment explaining why (no admin/theme chrome bleed) saves the next maintainer a head-scratch.

Verdict

Request changes. The critical rawurlencode omission in Api\Client is a must-fix before merge — it affects identifier paths that flow from user input through MFA/invitation/signup. The two high-severity findings (proxy-aware rate limiting, profile-slug threading through OAuth callback) are also ship-blockers in my view: both silently break intended security behavior in production topologies without any log or error to notice. The medium/minor items in the Suggestions section are worth a follow-up pass but don't need to gate this merge once the critical/high items are addressed.

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.

bordoni added 8 commits April 23, 2026 13:46
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.
@bordoni
bordoni merged commit f5205e8 into main Apr 24, 2026
6 checks passed
@bordoni
bordoni deleted the feature/custom-authkit branch April 24, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants