Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.09% covered (danger)
18.09%
127 / 702
39.53% covered (danger)
39.53%
17 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
SSO
18.09% covered (danger)
18.09%
127 / 702
39.53% covered (danger)
39.53%
17 / 43
17198.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
 get_instance
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sync_sso_callables
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 sso_reminder_logout_wpcom
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 maybe_logout_user
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 xmlrpc_methods
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 xmlrpc_user_disconnect
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 login_enqueue_scripts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 login_body_class
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
12.33
 print_inline_admin_css
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 register_settings
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
2
 render_require_two_step
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 validate_jetpack_sso_require_two_step
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 render_match_by_email
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 validate_jetpack_sso_match_by_email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 wants_to_login
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 login_init
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
156
 display_sso_login_form
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 save_cookies
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 login_form
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
90
 clear_cookies_after_login
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
42
 disconnect
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 request_initial_nonce
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 validate_broker_url
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 is_broker_authorized
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 is_referrer_wpcom
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_live_referrer_wpcom
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 get_broker_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_broker_auth_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 handle_login
0.00% covered (danger)
0.00%
0 / 157
0.00% covered (danger)
0.00%
0 / 1
1056
 profile_page_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build_sso_button
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 build_sso_button_url
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_sso_url_or_die
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 get_sso_base_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 build_sso_url
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 build_reauth_and_sso_url
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 get_user_by_wpcom_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 maybe_authorize_user_after_sso
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 store_wpcom_profile_cookies_on_logout
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 is_user_connected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_two_factor_session_meta
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * SSO feature. Entry point.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Connection\SSO\Force_2FA;
12use Automattic\Jetpack\Connection\SSO\Helpers;
13use Automattic\Jetpack\Connection\SSO\Notices;
14use Automattic\Jetpack\Connection\SSO\User_Admin;
15use Automattic\Jetpack\Connection\Webhooks\Authorize_Redirect;
16use Automattic\Jetpack\Constants;
17use Automattic\Jetpack\Roles;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\Tracking;
21use Jetpack_IXR_Client;
22use WP_Error;
23use WP_User;
24use WP_User_Query;
25
26/**
27 * SSO feature main class.
28 */
29class SSO {
30    /**
31     * WordPress.com User information.
32     *
33     * @var false|object
34     */
35    private $user_data;
36
37    /**
38     * Automattic\Jetpack\Connection\SSO instance.
39     *
40     * @var \Automattic\Jetpack\Connection\SSO
41     */
42    public static $instance = null;
43
44    /**
45     * Stores the WP_User being authenticated via SSO so the
46     * attach_session_information callback can tag the session.
47     *
48     * @var WP_User|null
49     */
50    private static $sso_user_for_2fa = null;
51
52    /**
53     * Cookie name for the SSO broker authorization signal.
54     *
55     * Set when WP.com signals that a broker should be used for SSO. The cookie
56     * value is the SSO nonce, tying the signal to a specific authentication flow.
57     *
58     * @var string
59     */
60    const BROKER_COOKIE = 'jetpack_sso_broker';
61
62    /**
63     * Automattic\Jetpack\Connection\SSO constructor.
64     */
65    private function __construct() {
66
67        self::$instance = $this;
68
69        add_action( 'admin_init', array( $this, 'maybe_authorize_user_after_sso' ), 1 );
70        add_action( 'admin_init', array( $this, 'register_settings' ) );
71        add_action( 'login_init', array( $this, 'login_init' ) );
72        add_filter( 'jetpack_xmlrpc_methods', array( $this, 'xmlrpc_methods' ) );
73        add_action( 'init', array( $this, 'maybe_logout_user' ), 5 );
74        add_action( 'login_form_logout', array( $this, 'store_wpcom_profile_cookies_on_logout' ) );
75        add_action( 'jetpack_unlinked_user', array( Helpers::class, 'delete_connection_for_user' ) );
76
77        add_action( 'jetpack_site_before_disconnected', array( static::class, 'disconnect' ) );
78        add_action( 'wp_login', array( static::class, 'clear_cookies_after_login' ) );
79
80        // Adding this action so that on login_init, the action won't be sanitized out of the $action global.
81        add_action( 'login_form_jetpack-sso', '__return_true' );
82
83        add_filter( 'wp_login_errors', array( $this, 'sso_reminder_logout_wpcom' ) );
84
85        // Synchronize SSO options with WordPress.com.
86        add_filter( 'jetpack_sync_callable_whitelist', array( $this, 'sync_sso_callables' ), 10, 1 );
87
88        /**
89         * Filter to include Force 2FA feature.
90         *
91         * By default, `manage_options` users are forced when enable. The capability can be modified
92         * with the `jetpack_force_2fa_cap` filter.
93         *
94         * To enable the feature, add the following code:
95         * add_filter( 'jetpack_force_2fa', '__return_true' );
96         *
97         * @param bool $force_2fa Whether to force 2FA or not.
98         *
99         * @todo Provide a UI to enable/disable the feature.
100         *
101         * @since jetpack-12.7
102         * @module SSO
103         * @return bool
104         */
105        if (
106            ! class_exists( 'Automattic\Jetpack\Connection\SSO\Force_2FA', false )
107            && apply_filters( 'jetpack_force_2fa', false )
108        ) {
109            new Force_2FA();
110        }
111
112        /*
113         * Allow admins to invite new users to create a WordPress.com account
114         * as they are added to the site.
115         *
116         * This is a feature that is only available when the admin is connected to WordPress.com.
117         */
118        if (
119            ( new Manager() )->is_user_connected() &&
120            ! is_multisite() &&
121            /**
122             * Toggle the ability to invite new users to create a WordPress.com account.
123             *
124             * @module sso
125             *
126             * @since 2.7.2
127             *
128             * @param bool true Whether to allow admins to invite new users to create a WordPress.com account.
129             */
130            apply_filters( 'jetpack_sso_invite_new_users_wpcom', true )
131        ) {
132            new User_Admin();
133        }
134    }
135
136    /**
137     * Returns the single instance of the Automattic\Jetpack\Connection\SSO object
138     *
139     * @since jetpack-2.8
140     * @return \Automattic\Jetpack\Connection\SSO
141     */
142    public static function get_instance() {
143        if ( self::$instance !== null ) {
144            return self::$instance;
145        }
146
147        self::$instance = new SSO();
148        return self::$instance;
149    }
150
151    /**
152     * Add SSO callables to the sync whitelist.
153     *
154     * @since 2.8.1
155     *
156     * @param array $callables list of callables.
157     *
158     * @return array list of callables.
159     */
160    public function sync_sso_callables( $callables ) {
161        $sso_callables = array(
162            'sso_is_two_step_required'      => array( Helpers::class, 'is_two_step_required' ),
163            'sso_should_hide_login_form'    => array( Helpers::class, 'should_hide_login_form' ),
164            'sso_match_by_email'            => array( Helpers::class, 'match_by_email' ),
165            'sso_new_user_override'         => array( Helpers::class, 'new_user_override' ),
166            'sso_bypass_default_login_form' => array( Helpers::class, 'bypass_login_forward_wpcom' ),
167        );
168
169        return array_merge( $callables, $sso_callables );
170    }
171
172    /**
173     * Safety heads-up added to the logout messages when SSO is enabled.
174     * Some folks on a shared computer don't know that they need to log out of WordPress.com as well.
175     *
176     * @param WP_Error $errors WP_Error object.
177     */
178    public function sso_reminder_logout_wpcom( $errors ) {
179        if ( ( new Host() )->is_wpcom_platform() ) {
180            return $errors;
181        }
182
183        if ( ! empty( $errors->errors['loggedout'] ) ) {
184            $logout_message = wp_kses(
185                sprintf(
186                /* translators: %1$s is a link to the WordPress.com account settings page. */
187                    __( 'If you are on a shared computer, remember to also <a href="%1$s">log out of WordPress.com</a>.', 'jetpack-connection' ),
188                    'https://wordpress.com/me'
189                ),
190                array(
191                    'a' => array(
192                        'href' => array(),
193                    ),
194                )
195            );
196            $errors->add( 'jetpack-sso-show-logout', $logout_message, 'message' );
197        }
198        return $errors;
199    }
200
201    /**
202     * If jetpack_force_logout == 1 in current user meta the user will be forced
203     * to logout and reauthenticate with the site.
204     **/
205    public function maybe_logout_user() {
206        global $current_user;
207
208        if ( 1 === (int) $current_user->jetpack_force_logout ) {
209            delete_user_meta( $current_user->ID, 'jetpack_force_logout' );
210            Helpers::delete_connection_for_user( $current_user->ID );
211            wp_logout();
212            wp_safe_redirect( wp_login_url() );
213            exit( 0 );
214        }
215    }
216
217    /**
218     * Adds additional methods the WordPress xmlrpc API for handling SSO specific features
219     *
220     * @param array $methods API methods.
221     * @return array
222     **/
223    public function xmlrpc_methods( $methods ) {
224        $methods['jetpack.userDisconnect'] = array( $this, 'xmlrpc_user_disconnect' );
225        return $methods;
226    }
227
228    /**
229     * Marks a user's profile for disconnect from WordPress.com and forces a logout
230     * the next time the user visits the site.
231     *
232     * @param int $user_id User to disconnect from the site.
233     **/
234    public function xmlrpc_user_disconnect( $user_id ) {
235        $user_query = new WP_User_Query(
236            array(
237                'meta_key'   => 'wpcom_user_id',
238                'meta_value' => $user_id,
239            )
240        );
241        $user       = $user_query->get_results();
242        $user       = $user[0];
243
244        if ( $user instanceof WP_User ) {
245            $user = wp_set_current_user( $user->ID );
246            update_user_meta( $user->ID, 'jetpack_force_logout', '1' );
247            Helpers::delete_connection_for_user( $user->ID );
248            return true;
249        }
250        return false;
251    }
252
253    /**
254     * Enqueues scripts and styles necessary for SSO login.
255     */
256    public function login_enqueue_scripts() {
257        global $action;
258
259        if ( ! Helpers::display_sso_form_for_action( $action ) ) {
260            return;
261        }
262
263        Assets::register_script(
264            'jetpack-sso-login',
265            '../../dist/jetpack-sso-login.js',
266            __FILE__,
267            array(
268                'enqueue' => true,
269                'version' => Package_Version::PACKAGE_VERSION,
270            )
271        );
272    }
273
274    /**
275     * Adds Jetpack SSO classes to login body
276     *
277     * @param  array $classes Array of classes to add to body tag.
278     * @return array          Array of classes to add to body tag.
279     */
280    public function login_body_class( $classes ) {
281        global $action;
282
283        if ( ! Helpers::display_sso_form_for_action( $action ) ) {
284            return $classes;
285        }
286
287        // Always add the jetpack-sso class so that we can add SSO specific styling even when the SSO form isn't being displayed.
288        $classes[] = 'jetpack-sso';
289
290        if ( ! ( new Status() )->in_safe_mode() ) {
291            /**
292             * Should we show the SSO login form?
293             *
294             * $_GET['jetpack-sso-default-form'] is used to provide a fallback in case JavaScript is not enabled.
295             *
296             * The default_to_sso_login() method allows us to dynamically decide whether we show the SSO login form or not.
297             * The SSO module uses the method to display the default login form if we cannot find a user to log in via SSO.
298             * But, the method could be filtered by a site admin to always show the default login form if that is preferred.
299             */
300            $default_form_preference = isset( $_GET['jetpack-sso-show-default-form'] ) ? sanitize_text_field( wp_unslash( $_GET['jetpack-sso-show-default-form'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
301            $show_sso_form           = empty( $default_form_preference ) && Helpers::show_sso_login();
302
303            if ( 'entered_recovery_mode' === $action ) {
304                if ( '0' === $default_form_preference ) {
305                    // Explicit user opt-in via the no-JS toggle; honor it regardless of show_sso_login() so the toggle always works.
306                    $show_sso_form = true;
307                } elseif ( null === $default_form_preference && ! Helpers::should_hide_login_form() ) {
308                    // Recovery is the break-glass fallback, so default to the wp-admin password form. Skip when that form is hidden, otherwise no login path would work.
309                    $show_sso_form = false;
310                }
311            }
312
313            if ( $show_sso_form ) {
314                $classes[] = 'jetpack-sso-form-display';
315            }
316        }
317
318        return $classes;
319    }
320
321    /**
322     * Inlined admin styles for SSO.
323     */
324    public function print_inline_admin_css() {
325        ?>
326            <style>
327                .jetpack-sso .message {
328                    margin-top: 20px;
329                }
330
331                .jetpack-sso #login .message:first-child,
332                .jetpack-sso #login h1 + .message {
333                    margin-top: 0;
334                }
335            </style>
336        <?php
337    }
338
339    /**
340     * Adds settings fields to Settings > General > Secure Sign On that allows users to
341     * turn off the login form on wp-login.php
342     *
343     * @since jetpack-2.7
344     **/
345    public function register_settings() {
346
347        add_settings_section(
348            'jetpack_sso_settings',
349            __( 'Secure Sign On', 'jetpack-connection' ),
350            '__return_false',
351            'jetpack-sso'
352        );
353
354        /*
355         * Settings > General > Secure Sign On
356         * Require two step authentication
357         */
358        register_setting(
359            'jetpack-sso',
360            'jetpack_sso_require_two_step',
361            array( $this, 'validate_jetpack_sso_require_two_step' )
362        );
363
364        add_settings_field(
365            'jetpack_sso_require_two_step',
366            '', // Output done in render $callback: __( 'Require Two-Step Authentication' , 'jetpack-connection' ).
367            array( $this, 'render_require_two_step' ),
368            'jetpack-sso',
369            'jetpack_sso_settings'
370        );
371
372        /*
373         * Settings > General > Secure Sign On
374         */
375        register_setting(
376            'jetpack-sso',
377            'jetpack_sso_match_by_email',
378            array( $this, 'validate_jetpack_sso_match_by_email' )
379        );
380
381        add_settings_field(
382            'jetpack_sso_match_by_email',
383            '', // Output done in render $callback: __( 'Match by Email' , 'jetpack-connection' ).
384            array( $this, 'render_match_by_email' ),
385            'jetpack-sso',
386            'jetpack_sso_settings'
387        );
388    }
389
390    /**
391     * Builds the display for the checkbox allowing user to require two step
392     * auth be enabled on WordPress.com accounts before login. Displays in Settings > General
393     *
394     * @since jetpack-2.7
395     **/
396    public function render_require_two_step() {
397        ?>
398        <label>
399            <input
400                type="checkbox"
401                name="jetpack_sso_require_two_step"
402        <?php checked( Helpers::is_two_step_required() ); ?>
403        <?php disabled( Helpers::is_require_two_step_checkbox_disabled() ); ?>
404            >
405        <?php esc_html_e( 'Require Two-Step Authentication', 'jetpack-connection' ); ?>
406        </label>
407        <?php
408    }
409
410    /**
411     * Validate the require  two step checkbox in Settings > General.
412     *
413     * @param bool $input The jetpack_sso_require_two_step option setting.
414     *
415     * @since jetpack-2.7
416     * @return int
417     **/
418    public function validate_jetpack_sso_require_two_step( $input ) {
419        return ( ! empty( $input ) ) ? 1 : 0;
420    }
421
422    /**
423     * Builds the display for the checkbox allowing the user to allow matching logins by email
424     * Displays in Settings > General
425     *
426     * @since jetpack-2.9
427     **/
428    public function render_match_by_email() {
429        ?>
430            <label>
431                <input
432                    type="checkbox"
433                    name="jetpack_sso_match_by_email"
434            <?php checked( Helpers::match_by_email() ); ?>
435            <?php disabled( Helpers::is_match_by_email_checkbox_disabled() ); ?>
436                >
437        <?php esc_html_e( 'Match by Email', 'jetpack-connection' ); ?>
438            </label>
439        <?php
440    }
441
442    /**
443     * Validate the match by email check in Settings > General.
444     *
445     * @param bool $input The jetpack_sso_match_by_email option setting.
446     *
447     * @since jetpack-2.9
448     * @return int
449     **/
450    public function validate_jetpack_sso_match_by_email( $input ) {
451        return ( ! empty( $input ) ) ? 1 : 0;
452    }
453
454    /**
455     * Checks to determine if the user wants to login on wp-login
456     *
457     * This function mostly exists to cover the exceptions to login
458     * that may exist as other parameters to $_GET[action] as $_GET[action]
459     * does not have to exist. By default WordPress assumes login if an action
460     * is not set, however this may not be true, as in the case of logout
461     * where $_GET[loggedout] is instead set
462     *
463     * @return boolean
464     **/
465    private function wants_to_login() {
466        $wants_to_login = false;
467
468        // Cover default WordPress behavior.
469        $action = isset( $_REQUEST['action'] ) ? filter_var( wp_unslash( $_REQUEST['action'] ) ) : 'login'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
470
471        // And now the exceptions.
472        $action = isset( $_GET['loggedout'] ) ? 'loggedout' : $action; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
473
474        // Recovery mode must complete on the local site (token validation, cookie, recovery notice). Skip the bypass-redirect so SSO doesn't carry the user off-site mid-recovery.
475        if ( 'entered_recovery_mode' === $action ) {
476            return false;
477        }
478
479        if ( Helpers::display_sso_form_for_action( $action ) ) {
480            $wants_to_login = true;
481        }
482
483        return $wants_to_login;
484    }
485
486    /**
487     * Initialization for a SSO request.
488     */
489    public function login_init() {
490        global $action;
491
492        $tracking = new Tracking();
493
494        if ( Helpers::should_hide_login_form() ) {
495            /**
496             * Since the default authenticate filters fire at priority 20 for checking username and password,
497             * let's fire at priority 30. wp_authenticate_spam_check is fired at priority 99, but since we return a
498             * WP_Error in disable_default_login_form, then we won't trigger spam processing logic.
499             */
500            add_filter( 'authenticate', array( Notices::class, 'disable_default_login_form' ), 30 );
501
502            /**
503             * Filter the display of the disclaimer message appearing when default WordPress login form is disabled.
504             *
505             * @module sso
506             *
507             * @since jetpack-2.8.0
508             *
509             * @param bool true Should the disclaimer be displayed. Default to true.
510             */
511            $display_sso_disclaimer = apply_filters( 'jetpack_sso_display_disclaimer', true );
512            if ( $display_sso_disclaimer ) {
513                add_filter( 'login_message', array( Notices::class, 'msg_login_by_jetpack' ) );
514            }
515        }
516
517        if ( 'jetpack-sso' === $action ) {
518            if ( isset( $_GET['result'] ) && isset( $_GET['user_id'] ) && isset( $_GET['sso_nonce'] ) && 'success' === $_GET['result'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
519                $this->handle_login();
520                $this->display_sso_login_form();
521            } elseif ( ( new Status() )->in_safe_mode() ) {
522                add_filter( 'login_message', array( Notices::class, 'sso_not_allowed_in_safe_mode' ) );
523            } else {
524                // Is it wiser to just use wp_redirect than do this runaround to wp_safe_redirect?
525                add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
526                $reauth  = ! empty( $_GET['force_reauth'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
527                $sso_url = $this->get_sso_url_or_die( $reauth );
528
529                $tracking->record_user_event( 'sso_login_redirect_success' );
530                wp_safe_redirect( $sso_url );
531                exit( 0 );
532            }
533        } elseif ( Helpers::display_sso_form_for_action( $action ) ) {
534
535            // Save cookies so we can handle redirects after SSO.
536            static::save_cookies();
537
538            /**
539             * Check to see if the site admin wants to automagically forward the user
540             * to the WordPress.com login page AND  that the request to wp-login.php
541             * is not something other than login (Like logout!)
542             */
543            if ( Helpers::bypass_login_forward_wpcom() && $this->wants_to_login() ) {
544                add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
545                $reauth  = ! empty( $_GET['force_reauth'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
546                $sso_url = $this->get_sso_url_or_die( $reauth );
547                $tracking->record_user_event( 'sso_login_redirect_bypass_success' );
548                wp_safe_redirect( $sso_url );
549                exit( 0 );
550            }
551
552            $this->display_sso_login_form();
553        }
554    }
555
556    /**
557     * Ensures that we can get a nonce from WordPress.com via XML-RPC before setting
558     * up the hooks required to display the SSO form.
559     */
560    public function display_sso_login_form() {
561        add_filter( 'login_body_class', array( $this, 'login_body_class' ) );
562        add_action( 'login_head', array( $this, 'print_inline_admin_css' ) );
563
564        if ( ( new Status() )->in_safe_mode() ) {
565            add_filter( 'login_message', array( Notices::class, 'sso_not_allowed_in_safe_mode' ) );
566            return;
567        }
568
569        $sso_nonce = self::request_initial_nonce();
570        if ( is_wp_error( $sso_nonce ) ) {
571            return;
572        }
573
574        add_action( 'login_form', array( $this, 'login_form' ) );
575        add_action( 'login_enqueue_scripts', array( $this, 'login_enqueue_scripts' ) );
576    }
577
578    /**
579     * Conditionally save the redirect_to url as a cookie.
580     *
581     * @since jetpack-4.6.0 Renamed to save_cookies from maybe_save_redirect_cookies
582     */
583    public static function save_cookies() {
584        if ( headers_sent() ) {
585            return new WP_Error( 'headers_sent', __( 'Cannot deal with cookie redirects, as headers are already sent.', 'jetpack-connection' ) );
586        }
587
588        setcookie(
589            'jetpack_sso_original_request',
590        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff misses the wrapping esc_url_raw().
591            esc_url_raw( set_url_scheme( ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ) ) ),
592            time() + HOUR_IN_SECONDS,
593            COOKIEPATH,
594            COOKIE_DOMAIN,
595            is_ssl(),
596            true
597        );
598
599        // Persist the WordPress.com referrer signal so it survives the SSO button
600        // click, which changes the HTTP Referer to the site's own login page.
601        // Uses the live-only check to avoid a self-reinforcing cookie loop.
602        if ( self::is_live_referrer_wpcom() ) {
603            setcookie( 'jetpack_sso_wpcom_referrer', '1', time() + ( 10 * MINUTE_IN_SECONDS ), COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
604            $_COOKIE['jetpack_sso_wpcom_referrer'] = '1';
605        } elseif ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
606            setcookie( 'jetpack_sso_wpcom_referrer', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
607            unset( $_COOKIE['jetpack_sso_wpcom_referrer'] );
608        }
609
610        if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
611            // If we have something to redirect to.
612            $url = esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
613            setcookie( 'jetpack_sso_redirect_to', $url, time() + HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
614        } elseif ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
615            // Otherwise, if it's already set, purge it.
616            setcookie( 'jetpack_sso_redirect_to', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
617        }
618    }
619
620    /**
621     * Outputs the Jetpack SSO button and description as well as the toggle link
622     * for switching between Jetpack SSO and default login.
623     */
624    public function login_form() {
625        $site_name = get_bloginfo( 'name' );
626        if ( ! $site_name ) {
627            $site_name = get_bloginfo( 'url' );
628        }
629
630        $display_name = ! empty( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] )
631        ? sanitize_text_field( wp_unslash( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] ) )
632        : false;
633        $gravatar     = ! empty( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] )
634        ? esc_url_raw( wp_unslash( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] ) )
635        : false;
636
637        ?>
638        <div id="jetpack-sso-wrap">
639        <?php
640        /**
641         * Allow extension above Jetpack's SSO form.
642         *
643         * @module sso
644         *
645         * @since jetpack-8.6.0
646         */
647        do_action( 'jetpack_sso_login_form_above_wpcom' );
648
649        if ( $display_name && $gravatar ) :
650            ?>
651                <div id="jetpack-sso-wrap__user">
652                    <img width="72" height="72" src="<?php echo esc_html( $gravatar ); ?>" />
653
654                    <h2>
655                <?php
656                echo wp_kses(
657                    /* translators: %s a user display name. */
658                    sprintf( __( 'Log in as <span>%s</span>', 'jetpack-connection' ), esc_html( $display_name ) ),
659                    array( 'span' => true )
660                );
661                ?>
662                    </h2>
663                </div>
664
665                <?php endif; ?>
666
667
668            <div id="jetpack-sso-wrap__action">
669                    <?php echo $this->build_sso_button( array(), true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaping done in build_sso_button() ?>
670
671                    <?php if ( $display_name && $gravatar ) : ?>
672                    <a rel="nofollow" class="jetpack-sso-wrap__reauth" href="<?php echo esc_url( $this->build_sso_button_url( array( 'force_reauth' => '1' ) ) ); ?>">
673                        <?php esc_html_e( 'Log in with another WordPress.com account', 'jetpack-connection' ); ?>
674                    </a>
675                <?php else : ?>
676                    <p>
677                        <?php
678                            /**
679                             * Filter the messeage displayed below the SSO button.
680                             *
681                             * @module sso
682                             *
683                             * @since jetpack-10.3.0
684                             *
685                             * @param string $sso_explanation Message displayed below the SSO button.
686                             */
687                            $sso_explanation = apply_filters(
688                                'jetpack_sso_login_form_explanation_text',
689                                sprintf(
690                                    /* Translators: %s is the name of the site. */
691                                    __( 'You can now save time spent logging in by connecting your WordPress.com account to %s.', 'jetpack-connection' ),
692                                    esc_html( $site_name )
693                                )
694                            );
695                            echo esc_html( $sso_explanation );
696                        ?>
697                    </p>
698                <?php endif; ?>
699            </div>
700
701                    <?php
702                    /**
703                     * Allow extension below Jetpack's SSO form.
704                     *
705                     * @module sso
706                     *
707                     * @since jetpack-8.6.0
708                     */
709                    do_action( 'jetpack_sso_login_form_below_wpcom' );
710
711                    if ( ! Helpers::should_hide_login_form() ) :
712                        ?>
713                    <div class="jetpack-sso-or">
714                        <span><?php esc_html_e( 'Or', 'jetpack-connection' ); ?></span>
715                    </div>
716
717                    <a href="<?php echo esc_url( add_query_arg( 'jetpack-sso-show-default-form', '1' ) ); ?>" class="jetpack-sso-toggle wpcom">
718                        <?php
719                        esc_html_e( 'Log in with username and password', 'jetpack-connection' )
720                        ?>
721                    </a>
722
723                    <a href="<?php echo esc_url( add_query_arg( 'jetpack-sso-show-default-form', '0' ) ); ?>" class="jetpack-sso-toggle default">
724                        <?php
725                        esc_html_e( 'Log in with WordPress.com', 'jetpack-connection' )
726                        ?>
727                    </a>
728                    <?php endif; ?>
729        </div>
730                <?php
731    }
732
733    /**
734     * Clear cookies that are no longer needed once the user has logged in.
735     *
736     * @since jetpack-4.8.0
737     */
738    public static function clear_cookies_after_login() {
739        Helpers::clear_wpcom_profile_cookies();
740        if ( isset( $_COOKIE['jetpack_sso_nonce'] ) ) {
741            setcookie(
742                'jetpack_sso_nonce',
743                ' ',
744                time() - YEAR_IN_SECONDS,
745                COOKIEPATH,
746                COOKIE_DOMAIN,
747                is_ssl(),
748                true
749            );
750        }
751
752        if ( isset( $_COOKIE['jetpack_sso_original_request'] ) ) {
753            setcookie(
754                'jetpack_sso_original_request',
755                ' ',
756                time() - YEAR_IN_SECONDS,
757                COOKIEPATH,
758                COOKIE_DOMAIN,
759                is_ssl(),
760                true
761            );
762        }
763
764        if ( isset( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
765            setcookie(
766                'jetpack_sso_redirect_to',
767                ' ',
768                time() - YEAR_IN_SECONDS,
769                COOKIEPATH,
770                COOKIE_DOMAIN,
771                is_ssl(),
772                true
773            );
774        }
775
776        if ( isset( $_COOKIE[ self::BROKER_COOKIE ] ) ) {
777            setcookie(
778                self::BROKER_COOKIE,
779                ' ',
780                time() - YEAR_IN_SECONDS,
781                COOKIEPATH,
782                COOKIE_DOMAIN,
783                is_ssl(),
784                true
785            );
786        }
787
788        if ( isset( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
789            setcookie(
790                'jetpack_sso_wpcom_referrer',
791                ' ',
792                time() - YEAR_IN_SECONDS,
793                COOKIEPATH,
794                COOKIE_DOMAIN,
795                is_ssl(),
796                true
797            );
798        }
799    }
800
801    /**
802     * Clean up after Jetpack gets disconnected.
803     *
804     * @since jetpack-10.7
805     */
806    public static function disconnect() {
807        if ( ( new Manager() )->is_user_connected() ) {
808            Helpers::delete_connection_for_user( get_current_user_id() );
809        }
810    }
811
812    /**
813     * Retrieves nonce used for SSO form.
814     *
815     * @return string|WP_Error
816     */
817    public static function request_initial_nonce() {
818        $nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] )
819        ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) )
820        : false;
821
822        if ( ! $nonce ) {
823            $xml = new Jetpack_IXR_Client();
824            $xml->query( 'jetpack.sso.requestNonce' );
825
826            if ( $xml->isError() ) {
827                return new WP_Error( $xml->getErrorCode(), $xml->getErrorMessage() );
828            }
829
830            $response = $xml->getResponse();
831
832            // The response may be a plain nonce string (default) or an associative
833            // array containing 'nonce' and a 'use_sso_broker' signal for sites that
834            // use an external SSO broker (e.g. CIAB stores via the MSD).
835            if ( is_array( $response ) ) {
836                if ( empty( $response['nonce'] ) ) {
837                    return new WP_Error( 'invalid_response', __( 'Invalid nonce response from WordPress.com.', 'jetpack-connection' ) );
838                }
839
840                $nonce      = sanitize_key( $response['nonce'] );
841                $use_broker = ! empty( $response['use_sso_broker'] );
842            } else {
843                $nonce      = sanitize_key( $response );
844                $use_broker = false;
845            }
846
847            $cookie_expiry = time() + ( 10 * MINUTE_IN_SECONDS );
848
849            setcookie(
850                'jetpack_sso_nonce',
851                $nonce,
852                $cookie_expiry,
853                COOKIEPATH,
854                COOKIE_DOMAIN,
855                is_ssl(),
856                true
857            );
858            // Ensure this request can use the nonce immediately after setcookie().
859            $_COOKIE['jetpack_sso_nonce'] = $nonce;
860
861            if ( $use_broker ) {
862                setcookie(
863                    self::BROKER_COOKIE,
864                    $nonce,
865                    $cookie_expiry,
866                    COOKIEPATH,
867                    COOKIE_DOMAIN,
868                    is_ssl(),
869                    true
870                );
871                // Mirror the broker signal in-memory for this request.
872                $_COOKIE[ self::BROKER_COOKIE ] = $nonce;
873            } else {
874                setcookie( self::BROKER_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
875                unset( $_COOKIE[ self::BROKER_COOKIE ] );
876            }
877        }
878
879        return $nonce;
880    }
881
882    /**
883     * Validates a broker URL string.
884     *
885     * @param string $url The URL to validate.
886     * @return string|false The URL if valid HTTPS with a host, or false.
887     */
888    private static function validate_broker_url( $url ) {
889        if ( empty( $url ) || ! is_string( $url ) ) {
890            return false;
891        }
892
893        $sanitized = esc_url_raw( $url );
894        $url_parts = wp_parse_url( $sanitized );
895
896        if ( $url_parts && 'https' === ( $url_parts['scheme'] ?? '' ) && ! empty( $url_parts['host'] ) ) {
897            return $sanitized;
898        }
899
900        return false;
901    }
902
903    /**
904     * Checks whether WP.com has authorized broker mode for the current SSO flow.
905     *
906     * The broker cookie is set during the nonce request when WP.com signals
907     * that broker SSO should be used. Its value matches the SSO nonce to tie
908     * the authorization to a specific flow.
909     *
910     * @return bool True if WP.com authorized broker mode for this nonce.
911     */
912    private static function is_broker_authorized() {
913        $broker_signal = ! empty( $_COOKIE[ self::BROKER_COOKIE ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
914            ? sanitize_key( wp_unslash( $_COOKIE[ self::BROKER_COOKIE ] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
915            : false;
916        $current_nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
917            ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
918            : false;
919
920        return $broker_signal && $current_nonce && $broker_signal === $current_nonce;
921    }
922
923    /**
924     * Checks whether the current request's referrer is a WordPress.com domain.
925     *
926     * Used to skip the broker URL when the user navigated from Calypso or
927     * another WordPress.com interface, so they stay within the expected
928     * wordpress.com SSO flow.
929     *
930     * @return bool True if the referrer is a WordPress.com domain.
931     */
932    private static function is_referrer_wpcom() {
933        // Check the cookie persisted by save_cookies() on the initial login page
934        // load. The live HTTP Referer changes to the site's own wp-login.php when
935        // the user clicks the SSO button, so the cookie carries the original signal.
936        if ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
937            return true;
938        }
939
940        return self::is_live_referrer_wpcom();
941    }
942
943    /**
944     * Checks the live HTTP Referer header against WordPress.com domains.
945     *
946     * Unlike is_referrer_wpcom(), this does NOT consult the persisted cookie,
947     * so it is safe to call from save_cookies() without creating a
948     * self-reinforcing loop.
949     *
950     * @return bool True if the live referrer is a WordPress.com domain.
951     */
952    private static function is_live_referrer_wpcom() {
953        $referer = wp_get_raw_referer();
954        if ( ! $referer ) {
955            return false;
956        }
957
958        $wpcom_hosts = array(
959            'wordpress.com',
960            'horizon.wordpress.com',
961            'wpcalypso.wordpress.com',
962        );
963
964        $referer_host = wp_parse_url( $referer, PHP_URL_HOST );
965        return $referer_host && in_array( $referer_host, $wpcom_hosts, true );
966    }
967
968    /**
969     * Retrieves the SSO broker URL if authorized by WP.com and defined by the MU plugin.
970     *
971     * The broker URL is read from the JETPACK_SSO_BROKER_URL constant, which
972     * is expected to be defined by a garden MU plugin (e.g. for CIAB stores).
973     * It is only used when WP.com has signaled broker mode via the nonce response.
974     *
975     * @return string|false The broker URL, or false if not available.
976     */
977    public static function get_broker_url() {
978        if ( ! self::is_broker_authorized() ) {
979            return false;
980        }
981        $url = Constants::get_constant( 'JETPACK_SSO_BROKER_URL' );
982        return $url ? self::validate_broker_url( $url ) : false;
983    }
984
985    /**
986     * Retrieves the SSO broker authorization URL if authorized by WP.com.
987     *
988     * For broker sites, this URL replaces the Jetpack authorization endpoint
989     * for establishing user connections. Read from the JETPACK_SSO_BROKER_AUTH_URL
990     * constant defined by the garden MU plugin.
991     *
992     * @return string|false The broker authorization URL, or false if not available.
993     */
994    public static function get_broker_auth_url() {
995        if ( ! self::is_broker_authorized() ) {
996            return false;
997        }
998        $url = Constants::get_constant( 'JETPACK_SSO_BROKER_AUTH_URL' );
999        return $url ? self::validate_broker_url( $url ) : false;
1000    }
1001
1002    /**
1003     * The function that actually handles the login!
1004     */
1005    public function handle_login() {
1006        $wpcom_nonce   = isset( $_GET['sso_nonce'] ) ? sanitize_key( $_GET['sso_nonce'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1007        $wpcom_user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1008
1009        $xml = new Jetpack_IXR_Client();
1010        $xml->query( 'jetpack.sso.validateResult', $wpcom_nonce, $wpcom_user_id );
1011
1012        $user_data = $xml->isError() ? false : $xml->getResponse();
1013        if ( empty( $user_data ) ) {
1014            add_filter( 'jetpack_sso_default_to_sso_login', '__return_false' );
1015            add_filter( 'login_message', array( Notices::class, 'error_invalid_response_data' ) );
1016            return;
1017        }
1018
1019        $user_data = (object) $user_data;
1020        $user      = null;
1021
1022        /**
1023         * Fires before Jetpack's SSO modifies the log in form.
1024         *
1025         * @module sso
1026         *
1027         * @since jetpack-2.6.0
1028         *
1029         * @param object $user_data WordPress.com User information.
1030         */
1031        do_action( 'jetpack_sso_pre_handle_login', $user_data );
1032
1033        $tracking = new Tracking();
1034
1035        if ( Helpers::is_two_step_required() && 0 === (int) $user_data->two_step_enabled ) {
1036            $this->user_data = $user_data;
1037
1038            $tracking->record_user_event(
1039                'sso_login_failed',
1040                array(
1041                    'error_message' => 'error_msg_enable_two_step',
1042                )
1043            );
1044
1045            $error = new WP_Error( 'two_step_required', __( 'You must have Two-Step Authentication enabled on your WordPress.com account.', 'jetpack-connection' ) );
1046
1047            /** This filter is documented in core/src/wp-includes/pluggable.php */
1048            do_action( 'wp_login_failed', $user_data->login, $error );
1049            add_filter( 'login_message', array( Notices::class, 'error_msg_enable_two_step' ) );
1050            return;
1051        }
1052
1053        $user_found_with = '';
1054        if ( isset( $user_data->external_user_id ) ) {
1055            $user_found_with = 'external_user_id';
1056            $user            = get_user_by( 'id', (int) $user_data->external_user_id );
1057            if ( $user ) {
1058                $expected_id = get_user_meta( $user->ID, 'wpcom_user_id', true );
1059                if ( $expected_id && $expected_id != $user_data->ID ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison, Universal.Operators.StrictComparisons.LooseNotEqual
1060                    $error = new WP_Error( 'expected_wpcom_user', __( 'Something got a little mixed up and an unexpected WordPress.com user logged in.', 'jetpack-connection' ) );
1061
1062                    $tracking->record_user_event(
1063                        'sso_login_failed',
1064                        array(
1065                            'error_message' => 'error_unexpected_wpcom_user',
1066                        )
1067                    );
1068
1069                    /** This filter is documented in core/src/wp-includes/pluggable.php */
1070                    do_action( 'wp_login_failed', $user_data->login, $error );
1071                    add_filter( 'login_message', array( Notices::class, 'error_invalid_response_data' ) ); // @todo Need to have a better notice. This is only for the sake of testing the validation.
1072                    return;
1073                }
1074                update_user_meta( $user->ID, 'wpcom_user_id', $user_data->ID );
1075            }
1076        }
1077
1078        // If we don't have one by wpcom_user_id, try by the email?
1079        if ( empty( $user ) && Helpers::match_by_email() ) {
1080            $user_found_with = 'match_by_email';
1081            $user            = get_user_by( 'email', $user_data->email );
1082            if ( $user ) {
1083                update_user_meta( $user->ID, 'wpcom_user_id', $user_data->ID );
1084            }
1085        }
1086
1087        // If we've still got nothing, create the user.
1088        $new_user_override_role = Helpers::new_user_override( $user_data );
1089        if ( empty( $user ) && ( get_option( 'users_can_register' ) || $new_user_override_role ) ) {
1090            /**
1091             * If not matching by email we still need to verify the email does not exist
1092             * or this blows up
1093             *
1094             * If match_by_email is true, we know the email doesn't exist, as it would have
1095             * been found in the first pass.  If get_user_by( 'email' ) doesn't find the
1096             * user, then we know that email is unused, so it's safe to add.
1097             */
1098            if ( Helpers::match_by_email() || ! get_user_by( 'email', $user_data->email ) ) {
1099
1100                if ( $new_user_override_role ) {
1101                    $user_data->role = $new_user_override_role;
1102                }
1103
1104                $user = Utils::generate_user( $user_data );
1105                if ( ! $user ) {
1106                    $tracking->record_user_event(
1107                        'sso_login_failed',
1108                        array(
1109                            'error_message' => 'could_not_create_username',
1110                        )
1111                    );
1112                    add_filter( 'login_message', array( Notices::class, 'error_unable_to_create_user' ) );
1113                    return;
1114                }
1115
1116                $user_found_with = $new_user_override_role
1117                ? 'user_created_new_user_override'
1118                : 'user_created_users_can_register';
1119            } else {
1120                $tracking->record_user_event(
1121                    'sso_login_failed',
1122                    array(
1123                        'error_message' => 'error_msg_email_already_exists',
1124                    )
1125                );
1126
1127                $this->user_data = $user_data;
1128                add_action( 'login_message', array( Notices::class, 'error_msg_email_already_exists' ) );
1129                return;
1130            }
1131        }
1132
1133        /**
1134         * Fires after we got login information from WordPress.com.
1135         *
1136         * @module sso
1137         *
1138         * @since jetpack-2.6.0
1139         *
1140         * @param WP_User|false|null $user      Local User information.
1141         * @param object             $user_data WordPress.com User Login information.
1142         */
1143        do_action( 'jetpack_sso_handle_login', $user, $user_data );
1144
1145        if ( $user ) {
1146            // Cache the user's details, so we can present it back to them on their user screen.
1147            update_user_meta( $user->ID, 'wpcom_user_data', $user_data );
1148
1149            /*
1150             * Two-Factor plugin 0.15.0+ unconditionally hooks wp_login at PHP_INT_MAX,
1151             * which destroys the auth session and prompts for local 2FA â€” even for SSO
1152             * logins that already completed 2FA on WordPress.com.
1153             *
1154             * When WP.com confirms the user has 2FA active, remove Two-Factor's wp_login
1155             * hook so SSO can complete without a redundant local 2FA prompt.
1156             *
1157             * When WP.com 2FA is NOT active, the hook stays and Two-Factor can enforce
1158             * local 2FA as a safety net.
1159             *
1160             * @see https://github.com/WordPress/two-factor/issues/811
1161             */
1162            /**
1163             * Filter whether to accept WordPress.com 2FA in place of a local
1164             * Two-Factor prompt during SSO login.
1165             *
1166             * Return false to always require the local Two-Factor prompt,
1167             * even when the user has completed 2FA on WordPress.com.
1168             *
1169             * @since 8.1.0
1170             * @module sso
1171             *
1172             * @param bool    $accept    Whether to accept WP.com 2FA. Default true.
1173             * @param object  $user_data WordPress.com user data from SSO validation.
1174             * @param WP_User $user      The local WordPress user.
1175             */
1176            $accept_wpcom_2fa = apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user );
1177
1178            if (
1179                ! empty( $user_data->two_step_enabled )
1180                && class_exists( 'Two_Factor_Core' )
1181                && $accept_wpcom_2fa
1182            ) {
1183                self::$sso_user_for_2fa = $user;
1184                add_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10, 2 );
1185
1186                remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX );
1187            }
1188
1189            add_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
1190            wp_set_auth_cookie( $user->ID, true );
1191            remove_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
1192            remove_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10 );
1193
1194            /** This filter is documented in core/src/wp-includes/user.php */
1195            do_action( 'wp_login', $user->user_login, $user );
1196
1197            wp_set_current_user( $user->ID );
1198
1199            $json_api_auth_environment = Helpers::get_json_api_auth_environment();
1200
1201            $is_json_api_auth  = ! empty( $json_api_auth_environment );
1202            $is_user_connected = ( new Manager() )->is_user_connected( $user->ID );
1203            $roles             = new Roles();
1204            $tracking->record_user_event(
1205                'sso_user_logged_in',
1206                array(
1207                    'user_found_with'  => $user_found_with,
1208                    'user_connected'   => (bool) $is_user_connected,
1209                    'user_role'        => $roles->translate_current_user_to_role(),
1210                    'is_json_api_auth' => $is_json_api_auth,
1211                )
1212            );
1213
1214            $_request_redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1215            $redirect_to          = user_can( $user, 'edit_posts' ) ? admin_url() : self::profile_page_url();
1216
1217            // If we have a saved redirect to request in a cookie.
1218            if ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
1219                // Set that as the requested redirect to.
1220                $redirect_to          = esc_url_raw( wp_unslash( $_COOKIE['jetpack_sso_redirect_to'] ) );
1221                $_request_redirect_to = $redirect_to;
1222            }
1223
1224            if ( $is_json_api_auth ) {
1225                $authorize_json_api = new Authorize_Json_Api();
1226                $authorize_json_api->verify_json_api_authorization_request( $json_api_auth_environment );
1227                $authorize_json_api->store_json_api_authorization_token( $user->user_login, $user );
1228
1229            } elseif ( ! $is_user_connected ) {
1230                $broker_auth_url = self::get_broker_auth_url();
1231                if ( $broker_auth_url ) {
1232                    add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1233                    wp_safe_redirect(
1234                        add_query_arg(
1235                            array(
1236                                'action'                   => 'jetpack-sso',
1237                                'site_id'                  => Manager::get_site_id( true ),
1238                                'redirect_to'              => $redirect_to,
1239                                'request_redirect_to'      => $_request_redirect_to,
1240                                'broker-sso-auth-redirect' => '1',
1241                            ),
1242                            $broker_auth_url
1243                        )
1244                    );
1245                    exit( 0 );
1246                }
1247
1248                wp_safe_redirect(
1249                    add_query_arg(
1250                        array(
1251                            'redirect_to'               => $redirect_to,
1252                            'request_redirect_to'       => $_request_redirect_to,
1253                            'calypso_env'               => ( new Host() )->get_calypso_env(),
1254                            'jetpack-sso-auth-redirect' => '1',
1255                        ),
1256                        admin_url()
1257                    )
1258                );
1259                exit( 0 );
1260            }
1261
1262            add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1263            wp_safe_redirect(
1264            /** This filter is documented in core/src/wp-login.php */
1265                apply_filters( 'login_redirect', $redirect_to, $_request_redirect_to, $user )
1266            );
1267            exit( 0 );
1268        }
1269
1270        add_filter( 'jetpack_sso_default_to_sso_login', '__return_false' );
1271
1272        $tracking->record_user_event(
1273            'sso_login_failed',
1274            array(
1275                'error_message' => 'cant_find_user',
1276            )
1277        );
1278
1279        $this->user_data = $user_data;
1280
1281        $error = new WP_Error( 'account_not_found', __( 'Account not found. If you already have an account, make sure you have connected to WordPress.com.', 'jetpack-connection' ) );
1282
1283        /** This filter is documented in core/src/wp-includes/pluggable.php */
1284        do_action( 'wp_login_failed', $user_data->login, $error );
1285        add_filter( 'login_message', array( Notices::class, 'cant_find_user' ) );
1286    }
1287
1288    /**
1289     * Retrieve the admin profile page URL.
1290     */
1291    public static function profile_page_url() {
1292        return admin_url( 'profile.php' );
1293    }
1294
1295    /**
1296     * Builds the "Login to WordPress.com" button that is displayed on the login page as well as user profile page.
1297     *
1298     * @param  array   $args       An array of arguments to add to the SSO URL.
1299     * @param  boolean $is_primary If the button have the `button-primary` class.
1300     * @return string              Returns the HTML markup for the button.
1301     */
1302    public function build_sso_button( $args = array(), $is_primary = false ) {
1303        $url     = $this->build_sso_button_url( $args );
1304        $classes = $is_primary
1305        ? 'jetpack-sso button button-primary'
1306        : 'jetpack-sso button';
1307
1308        return sprintf(
1309            '<a rel="nofollow" href="%1$s" class="%2$s">%3$s %4$s</a>',
1310            esc_url( $url ),
1311            $classes,
1312            '<span class="genericon genericon-wordpress"></span>',
1313            esc_html__( 'Log in with WordPress.com', 'jetpack-connection' )
1314        );
1315    }
1316
1317    /**
1318     * Builds a URL with `jetpack-sso` action and option args which is used to setup SSO.
1319     *
1320     * @param  array $args An array of arguments to add to the SSO URL.
1321     * @return string       The URL used for SSO.
1322     */
1323    public function build_sso_button_url( $args = array() ) {
1324        $defaults = array(
1325            'action' => 'jetpack-sso',
1326        );
1327
1328        $args = wp_parse_args( $args, $defaults );
1329
1330        if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1331            $args['redirect_to'] = rawurlencode( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1332        }
1333
1334        return add_query_arg( $args, wp_login_url() );
1335    }
1336
1337    /**
1338     * Retrieves a WordPress.com SSO URL with appropriate query parameters or dies.
1339     *
1340     * @param  boolean $reauth  If the user be forced to reauthenticate on WordPress.com.
1341     * @param  array   $args    Optional query parameters.
1342     * @return string            The WordPress.com SSO URL.
1343     */
1344    public function get_sso_url_or_die( $reauth = false, $args = array() ) {
1345        $custom_login_url = Helpers::get_custom_login_url();
1346        if ( $custom_login_url ) {
1347            $args['login_url'] = rawurlencode( $custom_login_url );
1348        }
1349
1350        if ( empty( $reauth ) ) {
1351            $sso_redirect = $this->build_sso_url( $args );
1352        } else {
1353            Helpers::clear_wpcom_profile_cookies();
1354            $sso_redirect = $this->build_reauth_and_sso_url( $args );
1355        }
1356
1357        // If there was an error retrieving the SSO URL, then error.
1358        if ( is_wp_error( $sso_redirect ) ) {
1359            $error_message = sanitize_text_field(
1360                sprintf( '%s: %s', $sso_redirect->get_error_code(), $sso_redirect->get_error_message() )
1361            );
1362            $tracking      = new Tracking();
1363            $tracking->record_user_event(
1364                'sso_login_redirect_failed',
1365                array(
1366                    'error_message' => $error_message,
1367                )
1368            );
1369            wp_die( esc_html( $error_message ) );
1370        }
1371
1372        return $sso_redirect;
1373    }
1374
1375    /**
1376     * Returns the base URL for SSO authentication.
1377     *
1378     * If a broker URL is available (authorized by WP.com and defined by the
1379     * garden MU plugin), that URL is used unless the user navigated from a
1380     * WordPress.com domain. Otherwise falls back to the default WordPress.com
1381     * login URL.
1382     *
1383     * @return string The base SSO URL.
1384     */
1385    public static function get_sso_base_url() {
1386        $broker_url = self::get_broker_url();
1387        if ( $broker_url && ! self::is_referrer_wpcom() ) {
1388            return $broker_url;
1389        }
1390        return 'https://wordpress.com/wp-login.php';
1391    }
1392
1393    /**
1394     * Build SSO URL with appropriate query parameters.
1395     *
1396     * The base URL can be WordPress.com or an authorized broker URL.
1397     *
1398     * @param array $args Optional query parameters.
1399     * @return string|WP_Error Redirect URL for SSO authentication.
1400     */
1401    public function build_sso_url( $args = array() ) {
1402        $sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
1403        $defaults  = array(
1404            'action'       => 'jetpack-sso',
1405            'site_id'      => Manager::get_site_id( true ),
1406            'sso_nonce'    => $sso_nonce,
1407            'calypso_auth' => '1',
1408        );
1409
1410        $args = wp_parse_args( $args, $defaults );
1411
1412        if ( is_wp_error( $sso_nonce ) ) {
1413            return $sso_nonce;
1414        }
1415
1416        return add_query_arg( $args, self::get_sso_base_url() );
1417    }
1418
1419    /**
1420     * Build SSO URL with appropriate query parameters, including the
1421     * parameters necessary to force the user to reauthenticate.
1422     *
1423     * @param array $args Optional query parameters.
1424     * @return string|WP_Error Redirect URL for SSO authentication.
1425     */
1426    public function build_reauth_and_sso_url( $args = array() ) {
1427        $sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
1428        $redirect  = $this->build_sso_url(
1429            array(
1430                'force_auth' => '1',
1431                'sso_nonce'  => $sso_nonce,
1432            )
1433        );
1434
1435        if ( is_wp_error( $redirect ) ) {
1436            return $redirect;
1437        }
1438
1439        $defaults = array(
1440            'action'       => 'jetpack-sso',
1441            'site_id'      => Manager::get_site_id( true ),
1442            'sso_nonce'    => $sso_nonce,
1443            'reauth'       => '1',
1444            'redirect_to'  => rawurlencode( $redirect ),
1445            'calypso_auth' => '1',
1446        );
1447
1448        $args = wp_parse_args( $args, $defaults );
1449
1450        if ( is_wp_error( $args['sso_nonce'] ) ) {
1451            return $args['sso_nonce'];
1452        }
1453
1454        return add_query_arg( $args, self::get_sso_base_url() );
1455    }
1456
1457    /**
1458     * Determines local user associated with a given WordPress.com user ID.
1459     *
1460     * @since jetpack-2.6.0
1461     *
1462     * @param int $wpcom_user_id User ID from WordPress.com.
1463     * @return null|object Local user object if found, null if not.
1464     */
1465    public static function get_user_by_wpcom_id( $wpcom_user_id ) {
1466        $user_query = new WP_User_Query(
1467            array(
1468                'meta_key'   => 'wpcom_user_id',
1469                'meta_value' => (int) $wpcom_user_id,
1470                'number'     => 1,
1471            )
1472        );
1473
1474        $users = $user_query->get_results();
1475        return $users ? array_shift( $users ) : null;
1476    }
1477
1478    /**
1479     * When jetpack-sso-auth-redirect query parameter is set, will redirect user to
1480     * WordPress.com authorization flow.
1481     *
1482     * We redirect here instead of in handle_login() because Jetpack::init()->build_connect_url
1483     * calls menu_page_url() which doesn't work properly until admin menus are registered.
1484     */
1485    public function maybe_authorize_user_after_sso() {
1486        if ( empty( $_GET['jetpack-sso-auth-redirect'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1487            return;
1488        }
1489
1490        $redirect_to         = ! empty( $_GET['redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1491        $request_redirect_to = ! empty( $_GET['request_redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['request_redirect_to'] ) ) : $redirect_to; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1492
1493        /** This filter is documented in core/src/wp-login.php */
1494        $redirect_after_auth = apply_filters( 'login_redirect', $redirect_to, $request_redirect_to, wp_get_current_user() );
1495
1496        /**
1497         * Since we are passing this redirect to WordPress.com and therefore cannot use wp_safe_redirect(),
1498         * let's sanitize it here to make sure it's safe. If the redirect is not safe, then use admin_url().
1499         */
1500        $redirect_after_auth = wp_sanitize_redirect( $redirect_after_auth );
1501        $redirect_after_auth = wp_validate_redirect( $redirect_after_auth, admin_url() );
1502
1503        /**
1504         * Return the raw connect URL with our redirect and attribute connection to SSO.
1505         * We remove any other filters that may be turning on the in-place connection
1506         * since we will be redirecting the user as opposed to iFraming.
1507         */
1508        remove_all_filters( 'jetpack_use_iframe_authorization_flow' );
1509        add_filter( 'jetpack_use_iframe_authorization_flow', '__return_false' );
1510
1511        $connection  = new Manager( 'jetpack-connection' );
1512        $connect_url = ( new Authorize_Redirect( $connection ) )->build_authorize_url( $redirect_after_auth, 'sso', true );
1513
1514        add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1515        wp_safe_redirect( $connect_url );
1516        exit( 0 );
1517    }
1518
1519    /**
1520     * Cache user's display name and Gravatar so it can be displayed on the login screen. These cookies are
1521     * stored when the user logs out, and then deleted when the user logs in.
1522     */
1523    public function store_wpcom_profile_cookies_on_logout() {
1524        $user_id = get_current_user_id();
1525        if ( ! ( new Manager() )->is_user_connected( $user_id ) ) {
1526            return;
1527        }
1528
1529        $user_data = $this->get_user_data( $user_id );
1530        if ( ! $user_data ) {
1531            return;
1532        }
1533
1534        setcookie(
1535            'jetpack_sso_wpcom_name_' . COOKIEHASH,
1536            $user_data->display_name,
1537            time() + WEEK_IN_SECONDS,
1538            COOKIEPATH,
1539            COOKIE_DOMAIN,
1540            is_ssl(),
1541            true
1542        );
1543
1544        setcookie(
1545            'jetpack_sso_wpcom_gravatar_' . COOKIEHASH,
1546            get_avatar_url(
1547                $user_data->email,
1548                array(
1549                    'size'    => 144,
1550                    'default' => 'mystery',
1551                )
1552            ),
1553            time() + WEEK_IN_SECONDS,
1554            COOKIEPATH,
1555            COOKIE_DOMAIN,
1556            is_ssl(),
1557            true
1558        );
1559    }
1560
1561    /**
1562     * Determines if a local user is connected to WordPress.com
1563     *
1564     * @since jetpack-2.8
1565     * @param integer $user_id - Local user id.
1566     * @return boolean
1567     **/
1568    public function is_user_connected( $user_id ) {
1569        return $this->get_user_data( $user_id );
1570    }
1571
1572    /**
1573     * Retrieves a user's WordPress.com data
1574     *
1575     * @since jetpack-2.8
1576     * @param integer $user_id - Local user id.
1577     * @return mixed null or stdClass
1578     **/
1579    public function get_user_data( $user_id ) {
1580        return get_user_meta( $user_id, 'wpcom_user_data', true );
1581    }
1582
1583    /**
1584     * Marks a session as two-factor-authenticated when SSO handled 2FA via WP.com.
1585     *
1586     * @param array $session Session information array.
1587     * @param int   $user_id User ID for the session being created.
1588     * @return array Modified session information.
1589     */
1590    public static function add_two_factor_session_meta( $session, $user_id ) {
1591        if ( self::$sso_user_for_2fa && self::$sso_user_for_2fa->ID === $user_id ) {
1592            $session['two-factor-login'] = time();
1593            self::$sso_user_for_2fa      = null;
1594        }
1595        return $session;
1596    }
1597}