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' => [