diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb240da..3a7e4cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@
- **MFA at sign-in, decided by the plugin** — in `plugin` mode every AuthKit sign-in (password, magic code, OAuth, the hosted-AuthKit callback) asks the MFA policy before it sets a session: a user with a factor answers a code, a user who must use MFA and has none enrolls an authenticator app on the spot (QR code and setup key), and a profile that can't satisfy the user's MFA refuses with `403 workos_mfa_blocked`. The WorkOS response waits server-side (`WorkOS\Mfa\PendingLogin`: bound to the profile, single use, 5 wrong codes, 10 minutes) and the browser only holds an `mfa_token`. New `POST /wp-json/workos/v1/auth/mfa/enroll`; `/challenge` and `/verify` accept `mfa_token`. WorkOS's own `mfa_enrollment` now enrolls in the same step instead of being refused. A profile with `enforce: always` asks a user without a factor to enroll instead of refusing them. Hosted-AuthKit sign-ins finish the step on `/workos/login/{profile}/?workos_mfa=…`.
- **Two-step verification account panel** — a "Two-step verification" section on the profile screen and a `[workos:mfa]` shortcode let users turn MFA on (QR code and setup key, confirmed with the first code) and remove an authenticator app (with a current code from it). New routes `GET /wp-json/workos/v1/users/{id}/mfa`, `POST …/mfa/enroll`, `POST …/mfa/confirm` and `DELETE …/mfa/factors/{factor_id}`, cookie-authenticated with the `wp_rest` nonce. Filters `workos_mfa_can_manage` and `workos_mfa_can_manage_user`.
- **MFA admin tools** — **WorkOS → Settings → Authentication** sets the mode and the roles that must use MFA, per environment. The user-edit screen shows another user's MFA status with a **Require** checkbox and a **Lost authenticator** reset. New routes `PUT /wp-json/workos/v1/users/{id}/mfa/required` and `POST …/mfa/reset`, WP-CLI `wp workos mfa status|require|unrequire|reset`, and a "Two-Step Verification" check on **WorkOS → Diagnostics**.
+- **Passkey and SSO sign-ins count as MFA** — a passkey sign-in (WorkOS requires user verification) skips the plugin's code step, as WorkOS's own MFA does, and so does an SSO sign-in unless **WorkOS → Settings → Authentication → SSO and Two-Step Verification** is turned off (option `mfa_trust_sso`, on by default). Filters `workos_mfa_trust_sso` and `workos_mfa_satisfied_by_sign_in`, action `workos_mfa_step_skipped`, Activity Log event `mfa.step_skipped`.
+- **Passkey sign-in button** — a Login Profile with the Passkey method now shows **Sign in with a passkey**, which opens WorkOS's hosted sign-in page (WorkOS runs passkeys only there). The option did nothing before. The unsupported "WebAuthn / passkey" MFA factor is gone from the profile editor.
- **MFA helpers** — `workos_mfa_get_status()`, `workos_mfa_is_enabled()`, `workos_mfa_is_required()`, `workos_mfa_set_required()`, `workos_mfa_list_factors()`, `workos_mfa_reset()` and `workos_mfa_decision()`.
- **MFA hooks and events** — filters `workos_mfa_mode`, `workos_mfa_required_for_user`, `workos_mfa_decision`, `workos_mfa_totp_issuer`, `workos_mfa_block_message`, `workos_mfa_pending_ttl` and `workos_mfa_allow_password_fallback`; actions `workos_mfa_enrolled`, `workos_mfa_factor_removed`, `workos_mfa_reset`, `workos_mfa_required_changed`, `workos_mfa_challenge_succeeded`, `workos_mfa_challenge_failed` and `workos_mfa_blocked`; Activity Log events `mfa.enrolled`, `mfa.removed`, `mfa.reset`, `mfa.required_set`, `mfa.required_cleared`, `mfa.challenge_succeeded`, `mfa.challenge_failed` and `mfa.blocked`.
diff --git a/README.md b/README.md
index acd88f6..fd43a9e 100644
--- a/README.md
+++ b/README.md
@@ -165,7 +165,7 @@ Each profile stores:
| `invite_flow` | Allow invitation acceptance |
| `password_reset_flow` | Allow in-app password reset |
| `auto_login_after_reset` | When `true` (default), `reset_confirm` re-authenticates the user with the new password (still running MFA / org-selection / entitlement gates via `LoginCompleter`), sets the WP auth cookie, and lands them on the validated `redirect_url`. When `false`, the user lands on the "Password reset — Continue to sign in" card and signs in manually. |
-| `mfa` | `{enforce: never|if_required|always, factors: [totp,sms,webauthn]}` |
+| `mfa` | `{enforce: never|if_required|always, factors: [totp,sms]}` — passkey and SSO sign-ins count as MFA; see [`docs/mfa.md`](docs/mfa.md#passkey-and-sso-sign-ins) |
| `branding` | `{logo_mode, logo_attachment_id, primary_color, heading, subheading}` — `logo_mode` is `default` / `custom` / `none` (see "Logo modes" in [`docs/extending-the-login-ui.md`](docs/extending-the-login-ui.md)) |
| `post_login_redirect` | URL the React shell navigates to on success (beats `redirect_to`)|
| `forward_query_args` | When `true`, appends safe inbound query args (`utm_*`, `ref`, custom params — never `redirect_to`, `_wpnonce`, `loggedout`, `wp_lang`, `workos_*`, etc.) to the post-login destination |
diff --git a/docs/mfa.md b/docs/mfa.md
index 6b3e017..200e4bd 100644
--- a/docs/mfa.md
+++ b/docs/mfa.md
@@ -16,6 +16,7 @@ WorkOS users.
| Mode | `mfa_mode` option per environment, filter `workos_mfa_mode` | Site |
| Who must use MFA | Require flag per user, `mfa_required_roles` option, filter `workos_mfa_required_for_user` | Admin, code |
| What a sign-in needs | `WorkOS\Mfa\Policy::decide()`, filter `workos_mfa_decision`, helper `workos_mfa_decision()` | Code |
+| Passkey and SSO sign-ins | Count as MFA (`mfa_trust_sso` option for SSO, filter `workos_mfa_satisfied_by_sign_in`) | Site, code |
| Sign-in step | `POST /auth/mfa/enroll`, `/auth/mfa/challenge`, `/auth/mfa/verify` and the React shell | Visitors |
| Password-only sign-ins | Refused for users with MFA (`workos_mfa_allow_password_fallback`) | Site |
| Account panel | Profile screen, `[workos:mfa]` shortcode, `/users/{id}/mfa…` routes | Users |
@@ -156,6 +157,57 @@ check capabilities.
| `workos_mfa_invalid_user` | 404 | `workos_mfa_set_required()` got an unknown user |
| `workos_mfa_invalid_response` | 502 | WorkOS answered an enrollment without a factor |
+## Passkey and SSO sign-ins
+
+Some sign-ins already include a second factor, so the plugin's code step is
+skipped for them. WorkOS's authenticate response says how the user signed in
+(`authentication_method`), and the server reads it from WorkOS itself.
+
+| Method | Skips the code step | Why |
+|---|---|---|
+| `Passkey` | Always | WorkOS only accepts a passkey with user verification (fingerprint, face or PIN), so it is both factors. WorkOS's own MFA counts it the same way |
+| `SSO` | When **SSO and Two-Step Verification** is on (default, option `mfa_trust_sso`, filter `workos_mfa_trust_sso`) | The company's identity provider runs its own MFA. WorkOS's own MFA never applies to SSO users either |
+| Anything else (`Password`, `MagicAuth`, `GoogleOAuth`, …) | No | |
+
+It applies in every mode, so an `enforce: always` profile accepts a passkey
+sign-in too. A user who must use MFA and has no authenticator isn't asked to
+enroll after a passkey sign-in. When the policy would otherwise have asked for
+a code or an enrollment, the plugin logs `mfa.step_skipped` and fires
+`workos_mfa_step_skipped`.
+
+```php
+// Ask SSO users from one organization for a code anyway.
+add_filter( 'workos_mfa_satisfied_by_sign_in', function ( bool $satisfied, string $method, array $response ) {
+ if ( 'SSO' === $method && 'org_untrusted' === ( $response['organization_id'] ?? '' ) ) {
+ return false;
+ }
+ return $satisfied;
+}, 10, 3 );
+```
+
+### Offering passkeys
+
+WorkOS runs passkey sign-ins on its hosted page only; there is no API for the
+plugin's own form. A Login Profile with the **Passkey** method shows a
+**Sign in with a passkey** button that sends the visitor to the hosted page
+(`GET /auth/oauth/authorize-url?provider=passkey` returns a
+`provider=authkit` URL), and the callback finishes the sign-in like any
+other. To set it up:
+
+1. Turn on Passkeys in the WorkOS dashboard (**Authentication**). In
+ production, set up an AuthKit custom domain first: a passkey is bound to
+ the domain it was created on.
+2. Turn on WorkOS's progressive enrollment (**Authentication → Passkeys**). It
+ offers password users a passkey at their next hosted sign-in, and it's how
+ existing users get one: on staging, the hosted sign-up page didn't offer a
+ passkey without it.
+3. Tick **Passkey** on the Login Profile.
+
+The hosted page offers every sign-in method turned on in WorkOS, not only
+passkeys. A password sign-in there still gets the plugin's code step.
+Users manage their passkeys in WorkOS; an admin can delete one from the user's
+page in the WorkOS dashboard.
+
## Sign-in
Every AuthKit sign-in (password, magic code, OAuth, the hosted-AuthKit
@@ -333,6 +385,8 @@ const { enabled, required } = await res.json();
| Filter | Arguments | Default |
|---|---|---|
| `workos_mfa_mode` | `string $mode` | `mfa_mode` option, else `plugin` |
+| `workos_mfa_trust_sso` | `bool $trust` | `mfa_trust_sso` option, else `true` |
+| `workos_mfa_satisfied_by_sign_in` | `bool $satisfied, string $method, array $response, Profile $profile` | `true` for `Passkey`, and for `SSO` when trusted |
| `workos_mfa_required_for_user` | `bool $required, int $user_id, ?Profile $profile` | Flag or role |
| `workos_mfa_decision` | `string $decision, int $user_id, Profile $profile, array $context` | Policy table |
| `workos_mfa_totp_issuer` | `string $issuer, int $user_id` | Site name |
@@ -358,6 +412,7 @@ const { enabled, required } = await res.json();
| `workos_mfa_challenge_succeeded` | `int $user_id` | The plugin's sign-in challenge is answered |
| `workos_mfa_challenge_failed` | `int $user_id, string $error_code` | A wrong code at the plugin's sign-in challenge |
| `workos_mfa_blocked` | `int $user_id, Profile $profile` | A sign-in is refused by MFA policy |
+| `workos_mfa_step_skipped` | `int $user_id, string $method, Profile $profile` | A passkey or trusted SSO sign-in skips a code or enrollment the policy would have asked for |
## Activity log
@@ -370,6 +425,7 @@ const { enabled, required } = await res.json();
| `mfa.required_set`, `mfa.required_cleared` | `actor_id` |
| `mfa.challenge_succeeded`, `mfa.challenge_failed` | `factor_type` |
| `mfa.blocked` | `profile` |
+| `mfa.step_skipped` | `method`, `profile` |
## Security model
@@ -414,6 +470,9 @@ Known limits:
per-account daily cap is what keeps guessing out of reach.
- In `workos` mode WorkOS enforces MFA, which doesn't cover SSO users, and
Bearer tokens aren't gated.
+- SSO sign-ins trust the identity provider's MFA by default. Turn
+ **SSO and Two-Step Verification** off if an identity provider doesn't
+ enforce it.
## Don't do this
diff --git a/src/WorkOS/ActivityLog/AdminPage.php b/src/WorkOS/ActivityLog/AdminPage.php
index ed86d0b..7a044d7 100644
--- a/src/WorkOS/ActivityLog/AdminPage.php
+++ b/src/WorkOS/ActivityLog/AdminPage.php
@@ -91,7 +91,7 @@ public function render_page(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$cleared = ! empty( SuperGlobals::get_get_var( 'cleared' ) );
- $event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'mfa.enrolled', 'mfa.removed', 'mfa.reset', 'mfa.removed_in_workos', 'mfa.required_set', 'mfa.required_cleared', 'mfa.challenge_succeeded', 'mfa.challenge_failed', 'mfa.blocked' ];
+ $event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'mfa.enrolled', 'mfa.removed', 'mfa.reset', 'mfa.removed_in_workos', 'mfa.required_set', 'mfa.required_cleared', 'mfa.challenge_succeeded', 'mfa.challenge_failed', 'mfa.blocked', 'mfa.step_skipped' ];
?>
diff --git a/src/WorkOS/Admin/Settings.php b/src/WorkOS/Admin/Settings.php
index aa0b362..2990c42 100644
--- a/src/WorkOS/Admin/Settings.php
+++ b/src/WorkOS/Admin/Settings.php
@@ -751,6 +751,19 @@ static function (): void {
'workos_auth'
);
+ add_settings_field(
+ 'workos_env_mfa_trust_sso',
+ __( 'SSO and Two-Step Verification', 'integration-workos' ),
+ [ $this, 'render_checkbox' ],
+ 'workos',
+ 'workos_auth',
+ [
+ 'name' => $this->env_option( MfaSettings::OPTION_TRUST_SSO ),
+ 'label' => __( 'Don’t ask for a code after an SSO sign-in: the company’s identity provider runs its own MFA. Passkey sign-ins never ask, since a passkey counts as both steps.', 'integration-workos' ),
+ 'default' => true,
+ ]
+ );
+
// --- Audit Logging section ---
add_settings_section(
'workos_audit',
@@ -2389,7 +2402,7 @@ public function sanitize_environment_options( $input ): array {
}
// Boolean fields.
- $bool_keys = [ 'allow_password_fallback', 'audit_logging_enabled', 'wp_password_fallback_email_confirmation', 'allow_magic_code_registration', 'allow_legacy_magic_code_registration', 'relink_protection', 'require_linked_identity' ];
+ $bool_keys = [ 'allow_password_fallback', 'audit_logging_enabled', 'wp_password_fallback_email_confirmation', 'allow_magic_code_registration', 'allow_legacy_magic_code_registration', 'relink_protection', 'require_linked_identity', MfaSettings::OPTION_TRUST_SSO ];
foreach ( $bool_keys as $key ) {
if ( isset( $input[ $key ] ) ) {
$sanitized[ $key ] = rest_sanitize_boolean( $input[ $key ] );
diff --git a/src/WorkOS/Auth/AuthKit/LoginCompleter.php b/src/WorkOS/Auth/AuthKit/LoginCompleter.php
index 2ec1265..a6d7806 100644
--- a/src/WorkOS/Auth/AuthKit/LoginCompleter.php
+++ b/src/WorkOS/Auth/AuthKit/LoginCompleter.php
@@ -132,6 +132,13 @@ public function complete( $workos_response, Profile $profile, string $redirect_t
);
}
+ // A passkey, and by default an SSO sign-in, already counts as MFA.
+ $satisfied_by = '';
+ if ( ! $mfa_satisfied && is_array( $workos_response ) ) {
+ $satisfied_by = App::container()->get( Policy::class )->satisfied_by_sign_in( $workos_response, $profile );
+ $mfa_satisfied = '' !== $satisfied_by;
+ }
+
// When the profile insists MFA is always required and WorkOS did
// not return a pending factor (i.e., the user has nothing enrolled
// or WorkOS is not enforcing org-level MFA), we must refuse to
@@ -172,6 +179,10 @@ public function complete( $workos_response, Profile $profile, string $redirect_t
return $entitlement;
}
+ if ( '' !== $satisfied_by && $this->plugin_decides() ) {
+ $this->note_step_skipped( $wp_user, $satisfied_by, $profile );
+ }
+
if ( ! $mfa_satisfied && $this->plugin_decides() ) {
$step = $this->plugin_mfa_step( $wp_user, $workos_response, $profile, $redirect_to, $honor_profile_redirect );
if ( null !== $step ) {
@@ -396,6 +407,46 @@ private function plugin_mfa_step( WP_User $wp_user, array $workos_response, Prof
];
}
+ /**
+ * Record a sign-in whose method stood in for the plugin's code step.
+ *
+ * Only when the policy would otherwise have asked for a code or an
+ * enrollment, so ordinary SSO sign-ins don't fill the log.
+ *
+ * @param WP_User $wp_user Matching WP user.
+ * @param string $method WorkOS `authentication_method`.
+ * @param Profile $profile Active Login Profile.
+ *
+ * @return void
+ */
+ private function note_step_skipped( WP_User $wp_user, string $method, Profile $profile ): void {
+ $decision = App::container()->get( Policy::class )->decide( $wp_user->ID, $profile );
+ if ( Policy::CHALLENGE !== $decision && Policy::ENROLL !== $decision ) {
+ return;
+ }
+
+ EventLogger::log(
+ 'mfa.step_skipped',
+ [
+ 'user_id' => $wp_user->ID,
+ 'metadata' => [
+ 'method' => $method,
+ 'profile' => $profile->get_slug(),
+ ],
+ ]
+ );
+
+ /**
+ * Fires when a sign-in skips the plugin's code step because its
+ * method already counts as MFA (a passkey, or a trusted SSO).
+ *
+ * @param int $user_id WP user ID.
+ * @param string $method WorkOS `authentication_method`.
+ * @param Profile $profile Login Profile.
+ */
+ do_action( 'workos_mfa_step_skipped', $wp_user->ID, $method, $profile );
+ }
+
/**
* Factor fields the browser needs.
*
diff --git a/src/WorkOS/Mfa/Policy.php b/src/WorkOS/Mfa/Policy.php
index 4ce37e8..e8f95ad 100644
--- a/src/WorkOS/Mfa/Policy.php
+++ b/src/WorkOS/Mfa/Policy.php
@@ -56,6 +56,16 @@ class Policy {
*/
public const BLOCK = 'block';
+ /**
+ * WorkOS `authentication_method` for a passkey sign-in.
+ */
+ public const SIGN_IN_PASSKEY = 'Passkey';
+
+ /**
+ * WorkOS `authentication_method` for an SSO sign-in.
+ */
+ public const SIGN_IN_SSO = 'SSO';
+
/**
* MFA settings.
*
@@ -156,6 +166,41 @@ public function is_enrolled( int $user_id ): bool {
return '1' === (string) get_user_meta( $user_id, User::META_MFA_ENABLED, true );
}
+ /**
+ * The sign-in method that already counts as MFA, if this one does.
+ *
+ * - `Passkey`: WorkOS only accepts a passkey with user verification
+ * (fingerprint, face or PIN), so it is both factors at once. WorkOS's
+ * own MFA counts it the same way.
+ * - `SSO`: the identity provider runs its own MFA. Trusted unless the
+ * site turned that off (`Settings::trust_sso()`).
+ *
+ * The method comes from WorkOS's authenticate response, which the
+ * server fetched itself; nothing the browser sends can set it.
+ *
+ * @param array $workos_response WorkOS authenticate response.
+ * @param Profile $profile Login Profile.
+ *
+ * @return string The WorkOS method, or '' when the code step still applies.
+ */
+ public function satisfied_by_sign_in( array $workos_response, Profile $profile ): string {
+ $method = (string) ( $workos_response['authentication_method'] ?? '' );
+ $satisfied = self::SIGN_IN_PASSKEY === $method
+ || ( self::SIGN_IN_SSO === $method && $this->settings->trust_sso() );
+
+ /**
+ * Filters whether a sign-in method already counts as MFA.
+ *
+ * @param bool $satisfied Whether the plugin's code step is skipped.
+ * @param string $method WorkOS `authentication_method` (`Password`, `Passkey`, `SSO`, `MagicAuth`, `GoogleOAuth`, …).
+ * @param array $workos_response WorkOS authenticate response.
+ * @param Profile $profile Login Profile.
+ */
+ $satisfied = (bool) apply_filters( 'workos_mfa_satisfied_by_sign_in', $satisfied, $method, $workos_response, $profile );
+
+ return $satisfied && '' !== $method ? $method : '';
+ }
+
/**
* The decision table, before the filter.
*
diff --git a/src/WorkOS/Mfa/Settings.php b/src/WorkOS/Mfa/Settings.php
index d22342f..c8ebd5f 100644
--- a/src/WorkOS/Mfa/Settings.php
+++ b/src/WorkOS/Mfa/Settings.php
@@ -48,6 +48,11 @@ class Settings {
*/
public const OPTION_REQUIRED_ROLES = 'mfa_required_roles';
+ /**
+ * Option key for trusting the identity provider's MFA on SSO sign-ins.
+ */
+ public const OPTION_TRUST_SSO = 'mfa_trust_sso';
+
/**
* Every mode.
*
@@ -90,4 +95,23 @@ public function required_roles(): array {
return is_array( $roles ) ? array_values( array_filter( array_map( 'strval', $roles ) ) ) : [];
}
+
+ /**
+ * Whether an SSO sign-in skips the plugin's code step.
+ *
+ * On by default, like WorkOS's own MFA, which never applies to SSO
+ * users: their identity provider runs its own MFA.
+ *
+ * @return bool
+ */
+ public function trust_sso(): bool {
+ $trust = (bool) workos()->option( self::OPTION_TRUST_SSO, true );
+
+ /**
+ * Filters whether an SSO sign-in skips the plugin's code step.
+ *
+ * @param bool $trust Whether to trust the identity provider's MFA.
+ */
+ return (bool) apply_filters( 'workos_mfa_trust_sso', $trust );
+ }
}
diff --git a/src/WorkOS/Options/Production.php b/src/WorkOS/Options/Production.php
index ee05d09..8537ec2 100644
--- a/src/WorkOS/Options/Production.php
+++ b/src/WorkOS/Options/Production.php
@@ -54,6 +54,8 @@ protected function defaults(): array {
'relink_protection' => false,
'require_linked_identity' => false,
'identity_gate_profile' => 'default',
+ // Two-step verification (see src/WorkOS/Mfa).
+ 'mfa_trust_sso' => true,
];
}
}
diff --git a/src/WorkOS/Options/Staging.php b/src/WorkOS/Options/Staging.php
index 9a08125..3308adb 100644
--- a/src/WorkOS/Options/Staging.php
+++ b/src/WorkOS/Options/Staging.php
@@ -54,6 +54,8 @@ protected function defaults(): array {
'relink_protection' => false,
'require_linked_identity' => false,
'identity_gate_profile' => 'default',
+ // Two-step verification (see src/WorkOS/Mfa).
+ 'mfa_trust_sso' => true,
];
}
}
diff --git a/src/WorkOS/REST/Auth/OAuth.php b/src/WorkOS/REST/Auth/OAuth.php
index 4b8b9eb..e7268fe 100644
--- a/src/WorkOS/REST/Auth/OAuth.php
+++ b/src/WorkOS/REST/Auth/OAuth.php
@@ -19,7 +19,9 @@
* GET /auth/oauth/authorize-url
*
* Builds a WorkOS /user_management/authorize URL for a specific social
- * provider (Google, Microsoft, GitHub, Apple). The React shell hits this
+ * provider (Google, Microsoft, GitHub, Apple), or for WorkOS's hosted
+ * sign-in page when the profile offers passkeys: WorkOS only runs passkey
+ * sign-ins on that page, with no API to build one here. The React shell hits this
* endpoint when the user clicks a provider button, receives the URL, and
* does a `window.location` redirect. WorkOS hands the user back off to
* the existing `/workos/callback` handler which exchanges the code.
@@ -44,6 +46,7 @@ class OAuth extends BaseEndpoint {
Profile::METHOD_OAUTH_MICROSOFT => 'MicrosoftOAuth',
Profile::METHOD_OAUTH_GITHUB => 'GitHubOAuth',
Profile::METHOD_OAUTH_APPLE => 'AppleOAuth',
+ Profile::METHOD_PASSKEY => 'authkit',
];
/**
@@ -139,7 +142,8 @@ public function authorize_url( WP_REST_Request $request ) {
/*
* Intentionally no organization_id. This endpoint only handles
* social OAuth providers (Google / Microsoft / GitHub / Apple),
- * which are environment-level apps in WorkOS. Passing
+ * which are environment-level apps in WorkOS, and the hosted page
+ * for passkeys, which are bound to the user. Passing
* organization_id forces WorkOS to resolve an org-scoped
* connection and returns "missing_code — No Connection associated
* with Organization" when one is not configured. WorkOS scopes
diff --git a/src/js/admin-profiles/index.tsx b/src/js/admin-profiles/index.tsx
index e81b75f..1bafa1f 100644
--- a/src/js/admin-profiles/index.tsx
+++ b/src/js/admin-profiles/index.tsx
@@ -148,13 +148,12 @@ const methodOptions = (): OptionItem< AuthMethod >[] => [
{ value: 'oauth_microsoft', label: __( 'Microsoft', 'integration-workos' ) },
{ value: 'oauth_github', label: __( 'GitHub', 'integration-workos' ) },
{ value: 'oauth_apple', label: __( 'Apple', 'integration-workos' ) },
- { value: 'passkey', label: __( 'Passkey', 'integration-workos' ) },
+ { value: 'passkey', label: __( 'Passkey (on the WorkOS sign-in page)', 'integration-workos' ) },
];
const factorOptions = (): OptionItem< MfaFactor >[] => [
{ value: 'totp', label: __( 'Authenticator app (TOTP)', 'integration-workos' ) },
{ value: 'sms', label: __( 'SMS', 'integration-workos' ) },
- { value: 'webauthn', label: __( 'WebAuthn / passkey', 'integration-workos' ) },
];
const enforceOptions = (): OptionItem< MfaEnforce >[] => [
diff --git a/src/js/authkit/flows.tsx b/src/js/authkit/flows.tsx
index 9383d21..a76f45b 100644
--- a/src/js/authkit/flows.tsx
+++ b/src/js/authkit/flows.tsx
@@ -70,6 +70,8 @@ function methodLabel( method: AuthMethod ): string {
return __( 'Continue with GitHub', 'integration-workos' );
case 'oauth_apple':
return __( 'Continue with Apple', 'integration-workos' );
+ case 'passkey':
+ return __( 'Sign in with a passkey', 'integration-workos' );
default:
return method;
}
@@ -95,7 +97,11 @@ export function MethodPicker( { profile, onChoose, onError }: MethodPickerProps
const methods = profile.methods || [];
const hasPassword = methods.includes( 'password' );
const hasMagic = methods.includes( 'magic_code' );
- const oauthMethods = methods.filter( ( m ) => m.startsWith( 'oauth_' ) );
+ // Passkeys run on WorkOS's hosted sign-in page, so they redirect like
+ // the social providers do.
+ const oauthMethods = methods.filter(
+ ( m ) => m.startsWith( 'oauth_' ) || m === 'passkey'
+ );
const handleOAuth = async ( method: AuthMethod ): Promise< void > => {
try {
@@ -114,7 +120,11 @@ export function MethodPicker( { profile, onChoose, onError }: MethodPickerProps
onError( errorMessage( data ) );
}
} catch ( _ ) {
- onError( __( 'Unable to start social sign-in.', 'integration-workos' ) );
+ onError(
+ method === 'passkey'
+ ? __( 'Unable to start passkey sign-in.', 'integration-workos' )
+ : __( 'Unable to start social sign-in.', 'integration-workos' )
+ );
}
};
diff --git a/tests/wpunit/AuthKitRestSignupInvitationOAuthTest.php b/tests/wpunit/AuthKitRestSignupInvitationOAuthTest.php
index 1c93972..22b704e 100644
--- a/tests/wpunit/AuthKitRestSignupInvitationOAuthTest.php
+++ b/tests/wpunit/AuthKitRestSignupInvitationOAuthTest.php
@@ -369,6 +369,33 @@ public function test_oauth_authorize_url_builds_google_url(): void {
$this->assertStringContainsString( 'state=', $data['authorize_url'] );
}
+ public function test_passkey_sends_the_user_to_the_hosted_sign_in_page(): void {
+ $open = $this->repository->find_by_slug( 'open' );
+ $data = $open->to_array();
+ $data['methods'] = array_merge( $data['methods'], [ Profile::METHOD_PASSKEY ] );
+ $this->repository->save( Profile::from_array( $data ) );
+
+ $request = new WP_REST_Request( 'GET', '/workos/v1/auth/oauth/authorize-url' );
+ $request->set_param( 'profile', 'open' );
+ $request->set_param( 'provider', Profile::METHOD_PASSKEY );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertStringContainsString( 'provider=authkit', $response->get_data()['authorize_url'] );
+ }
+
+ public function test_passkey_is_refused_when_the_profile_does_not_offer_it(): void {
+ $request = new WP_REST_Request( 'GET', '/workos/v1/auth/oauth/authorize-url' );
+ $request->set_param( 'profile', 'open' );
+ $request->set_param( 'provider', Profile::METHOD_PASSKEY );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'workos_authkit_method_disabled', $response->get_data()['code'] );
+ }
+
public function test_oauth_authorize_url_rejects_disabled_provider(): void {
$request = new WP_REST_Request( 'GET', '/workos/v1/auth/oauth/authorize-url' );
$request->set_param( 'profile', 'open' );
diff --git a/tests/wpunit/MfaSecurityTest.php b/tests/wpunit/MfaSecurityTest.php
index d89c428..f36bc83 100644
--- a/tests/wpunit/MfaSecurityTest.php
+++ b/tests/wpunit/MfaSecurityTest.php
@@ -409,6 +409,78 @@ public function test_failed_factor_lookup_keeps_mfa_on(): void {
$this->assertSame( '1', get_user_meta( $this->user_id, User::META_MFA_ENABLED, true ) );
}
+ // ------------------------- Sign-in methods that count as MFA -------------------------
+
+ public function test_passkey_sign_in_skips_the_code_step(): void {
+ $this->enroll_member();
+ $skipped = [];
+ add_action(
+ 'workos_mfa_step_skipped',
+ static function ( $user_id, $method ) use ( &$skipped ) {
+ $skipped[] = [ $user_id, $method ];
+ },
+ 10,
+ 2
+ );
+
+ $result = $this->complete_with( Policy::SIGN_IN_PASSKEY );
+
+ $this->assertArrayNotHasKey( 'mfa_required', $result );
+ $this->assertSame( $this->user_id, get_current_user_id() );
+ $this->assertSame( [ [ $this->user_id, 'Passkey' ] ], $skipped );
+ }
+
+ public function test_passkey_sign_in_also_satisfies_a_required_user_without_factors(): void {
+ delete_user_meta( $this->user_id, User::META_MFA_ENABLED );
+ update_user_meta( $this->user_id, User::META_MFA_REQUIRED, '1' );
+ $this->factors( [] );
+
+ $result = $this->complete_with( Policy::SIGN_IN_PASSKEY );
+
+ $this->assertArrayNotHasKey( 'mfa_required', $result );
+ $this->assertSame( $this->user_id, get_current_user_id() );
+ }
+
+ public function test_sso_sign_in_trusts_the_identity_provider_by_default(): void {
+ $this->enroll_member();
+ $result = $this->complete_with( Policy::SIGN_IN_SSO );
+
+ $this->assertArrayNotHasKey( 'mfa_required', $result );
+ $this->assertSame( $this->user_id, get_current_user_id() );
+ }
+
+ public function test_sso_sign_in_asks_for_a_code_when_the_site_stops_trusting_it(): void {
+ $this->enroll_member();
+ $options = get_option( 'workos_production' );
+ $options[ Settings::OPTION_TRUST_SSO ] = false;
+ update_option( 'workos_production', $options );
+ App::container()->get( \WorkOS\Options\Production::class )->reset();
+
+ $result = $this->complete_with( Policy::SIGN_IN_SSO );
+
+ $this->assertTrue( $result['mfa_required'] );
+ $this->assertSame( 0, get_current_user_id() );
+ }
+
+ public function test_password_and_social_sign_ins_still_ask_for_a_code(): void {
+ $this->enroll_member();
+ foreach ( [ 'Password', 'GoogleOAuth', 'MagicAuth', '' ] as $method ) {
+ $result = $this->complete_with( $method );
+ $this->assertTrue( $result['mfa_required'], $method );
+ $this->assertSame( 0, get_current_user_id(), $method );
+ }
+ }
+
+ public function test_satisfied_by_sign_in_filter_can_require_a_code_after_a_passkey(): void {
+ $this->enroll_member();
+ add_filter( 'workos_mfa_satisfied_by_sign_in', '__return_false' );
+
+ $result = $this->complete_with( Policy::SIGN_IN_PASSKEY );
+
+ remove_filter( 'workos_mfa_satisfied_by_sign_in', '__return_false' );
+ $this->assertTrue( $result['mfa_required'] );
+ }
+
// ------------------------- PendingLogin -------------------------
public function test_touching_a_parked_sign_in_never_extends_it(): void {
@@ -639,6 +711,32 @@ private function sign_in( string $profile = 'members' ): WP_REST_Response {
);
}
+ private function enroll_member(): void {
+ update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' );
+ $this->factors( [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] );
+ }
+
+ private function complete_with( string $method ): array {
+ $response = [
+ 'access_token' => $this->jwt(
+ [
+ 'sub' => 'user_mfa',
+ 'sid' => 'sess_m',
+ ]
+ ),
+ 'refresh_token' => 'rt_mfa',
+ 'user' => [ 'id' => 'user_mfa', 'email' => 'member@example.com' ],
+ ];
+ if ( '' !== $method ) {
+ $response['authentication_method'] = $method;
+ }
+
+ $result = ( new LoginCompleter() )->complete( $response, $this->repository->find_by_slug( 'members' ) );
+ $this->assertIsArray( $result );
+
+ return $result;
+ }
+
private function enroll_response(): array {
return [
'authentication_factor' => [