Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.28% covered (warning)
85.28%
226 / 265
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tokens
85.28% covered (warning)
85.28%
226 / 265
50.00% covered (danger)
50.00%
8 / 16
136.82
0.00% covered (danger)
0.00%
0 / 1
 delete_all
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
9
 validate_blog_token
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 get
77.14% covered (warning)
77.14%
54 / 70
0.00% covered (danger)
0.00%
0 / 1
27.78
 update_user_token
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 sign_role
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 return_30
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_access_token
78.18% covered (warning)
78.18%
43 / 55
0.00% covered (danger)
0.00%
0 / 1
42.64
 update_blog_token
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 disconnect_user
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_connected_users
n/a
0 / 0
n/a
0 / 0
1
 get_signed_token
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
5
 get_user_tokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update_user_tokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_lock
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 remove_lock
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_locked
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
7.54
1<?php
2/**
3 * The Jetpack Connection Tokens class file.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\Constants;
11use Automattic\Jetpack\Roles;
12use DateInterval;
13use DateTime;
14use Exception;
15use Jetpack_Options;
16use WP_Error;
17
18/**
19 * The Jetpack Connection Tokens class that manages tokens.
20 */
21class Tokens {
22
23    const MAGIC_NORMAL_TOKEN_KEY = ';normal;';
24
25    /**
26     * Datetime format.
27     */
28    const DATE_FORMAT_ATOM = 'Y-m-d\TH:i:sP';
29
30    /**
31     * Deletes all connection tokens and transients from the local Jetpack site.
32     */
33    public function delete_all() {
34        Jetpack_Options::delete_option(
35            array(
36                'blog_token',
37                'user_token',
38                'user_tokens',
39            )
40        );
41
42        $this->remove_lock();
43
44        /**
45         * Fires after all connection tokens have been deleted from the local site.
46         *
47         * `Jetpack_Options::delete_option()` fires no action of its own, so this is the only
48         * signal that the tokens backing the connection are gone. Anything holding derived
49         * state — a memoized connection status, a cached credential — must recompute from here.
50         *
51         * @since 9.1.1
52         */
53        do_action( 'jetpack_connection_tokens_deleted' );
54    }
55
56    /**
57     * Perform the API request to validate the blog and user tokens.
58     *
59     * @param int|null $user_id ID of the user we need to validate token for. Current user's ID by default.
60     *
61     * @return array|false|WP_Error The API response: `array( 'blog_token_is_healthy' => true|false, 'user_token_is_healthy' => true|false )`.
62     */
63    public function validate( $user_id = null ) {
64        $blog_id = Jetpack_Options::get_option( 'id' );
65        if ( ! $blog_id ) {
66            return new WP_Error( 'site_not_registered', 'Site not registered.' );
67        }
68        $url = sprintf(
69            '%s/%s/v%s/%s',
70            Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
71            'wpcom',
72            '2',
73            'sites/' . $blog_id . '/jetpack-token-health'
74        );
75
76        $user_token = $this->get_access_token( $user_id ? $user_id : get_current_user_id() );
77        $blog_token = $this->get_access_token();
78
79        // Cannot validate non-existent tokens.
80        if ( false === $user_token || false === $blog_token ) {
81            return false;
82        }
83
84        $method   = 'POST';
85        $body     = array(
86            'user_token' => $this->get_signed_token( $user_token ),
87            'blog_token' => $this->get_signed_token( $blog_token ),
88        );
89        $response = Client::_wp_remote_request( $url, compact( 'body', 'method' ) );
90
91        if ( is_wp_error( $response ) || ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
92            return false;
93        }
94
95        $body = json_decode( wp_remote_retrieve_body( $response ), true );
96
97        return $body ? $body : false;
98    }
99
100    /**
101     * Perform the API request to validate only the blog.
102     *
103     * @since 9.8.0 Returns a WP_Error, not false, when the request fails.
104     *
105     * @return bool|WP_Error Boolean with the test result. WP_Error if test cannot be performed.
106     */
107    public function validate_blog_token() {
108        $blog_id = Jetpack_Options::get_option( 'id' );
109        if ( ! $blog_id ) {
110            return new WP_Error( 'site_not_registered', 'Site not registered.' );
111        }
112
113        // A missing blog token is broken, not unverifiable: the signed request would fail before it is sent.
114        if ( ! $this->get_access_token() ) {
115            return false;
116        }
117
118        $url = sprintf(
119            '%s/%s/v%s/%s',
120            Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
121            'wpcom',
122            '2',
123            'sites/' . $blog_id . '/jetpack-token-health/blog'
124        );
125
126        $method   = 'GET';
127        $response = Client::remote_request( compact( 'url', 'method' ) );
128
129        if ( is_wp_error( $response ) ) {
130            return $response;
131        }
132
133        if ( ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
134            return new WP_Error( 'blog_token_check_failed', 'The blog token health check could not be performed.' );
135        }
136
137        $body = json_decode( wp_remote_retrieve_body( $response ), true );
138
139        return is_array( $body ) && isset( $body['is_healthy'] ) && true === $body['is_healthy'];
140    }
141
142    /**
143     * Obtains the auth token.
144     *
145     * @param array  $data The request data.
146     * @param string $token_api_url The URL of the Jetpack "token" API.
147     * @return object|WP_Error Returns the auth token on success.
148     *                          Returns a WP_Error on failure.
149     */
150    public function get( $data, $token_api_url ) {
151        $roles = new Roles();
152        $role  = $roles->translate_current_user_to_role();
153
154        if ( ! $role ) {
155            return new WP_Error( 'role', __( 'An administrator for this blog must set up the Jetpack connection.', 'jetpack-connection' ) );
156        }
157
158        $client_secret = $this->get_access_token();
159        if ( ! $client_secret ) {
160            return new WP_Error( 'client_secret', __( 'You need to register your Jetpack before connecting it.', 'jetpack-connection' ) );
161        }
162
163        /**
164         * Filter the URL of the first time the user gets redirected back to your site for connection
165         * data processing.
166         *
167         * @since 1.7.0
168         * @since-jetpack 8.0.0
169         *
170         * @param string $redirect_url Defaults to the site admin URL.
171         */
172        $processing_url = apply_filters( 'jetpack_token_processing_url', admin_url( 'admin.php' ) );
173
174        $redirect = isset( $data['redirect'] ) ? esc_url_raw( (string) $data['redirect'] ) : '';
175
176        /**
177        * Filter the URL to redirect the user back to when the authentication process
178        * is complete.
179        *
180        * @since 1.7.0
181        * @since-jetpack 8.0.0
182        *
183        * @param string $redirect_url Defaults to the site URL.
184        */
185        $redirect = apply_filters( 'jetpack_token_redirect_url', $redirect );
186
187        $redirect_uri = ( 'calypso' === $data['auth_type'] )
188            ? $data['redirect_uri']
189            : add_query_arg(
190                array(
191                    'handler'  => 'jetpack-connection-webhooks',
192                    'action'   => 'authorize',
193                    '_wpnonce' => wp_create_nonce( "jetpack-authorize_{$role}_{$redirect}" ),
194                    'redirect' => $redirect ? rawurlencode( $redirect ) : false,
195                ),
196                esc_url( $processing_url )
197            );
198
199        /**
200         * Filters the token request data.
201         *
202         * @since 1.7.0
203         * @since-jetpack 8.0.0
204         *
205         * @param array $request_data request data.
206         */
207        $body = apply_filters(
208            'jetpack_token_request_body',
209            array(
210                'client_id'     => Jetpack_Options::get_option( 'id' ),
211                'client_secret' => $client_secret->secret,
212                'grant_type'    => 'authorization_code',
213                'code'          => $data['code'],
214                'redirect_uri'  => $redirect_uri,
215            )
216        );
217
218        $args = array(
219            'method'  => 'POST',
220            'body'    => $body,
221            'headers' => array(
222                'Accept' => 'application/json',
223            ),
224        );
225        add_filter( 'http_request_timeout', array( $this, 'return_30' ), PHP_INT_MAX - 1 );
226        $response = Client::_wp_remote_request( $token_api_url, $args );
227        remove_filter( 'http_request_timeout', array( $this, 'return_30' ), PHP_INT_MAX - 1 );
228
229        if ( is_wp_error( $response ) ) {
230            return new WP_Error( 'token_http_request_failed', $response->get_error_message() );
231        }
232
233        $code   = wp_remote_retrieve_response_code( $response );
234        $entity = wp_remote_retrieve_body( $response );
235
236        if ( $entity ) {
237            $json = json_decode( $entity );
238        } else {
239            $json = false;
240        }
241
242        if ( 200 !== $code || ! empty( $json->error ) ) {
243            if ( empty( $json->error ) ) {
244                return new WP_Error( 'unknown', '', $code );
245            }
246
247            /* translators: Error description string. */
248            $error_description = isset( $json->error_description ) ? sprintf( __( 'Error Details: %s', 'jetpack-connection' ), (string) $json->error_description ) : '';
249
250            return new WP_Error( (string) $json->error, $error_description, $code );
251        }
252
253        if ( empty( $json->access_token ) || ! is_scalar( $json->access_token ) ) {
254            return new WP_Error( 'access_token', '', $code );
255        }
256
257        if ( empty( $json->token_type ) || 'X_JETPACK' !== strtoupper( $json->token_type ) ) {
258            return new WP_Error( 'token_type', '', $code );
259        }
260
261        if ( empty( $json->scope ) ) {
262            return new WP_Error( 'scope', 'No Scope', $code );
263        }
264
265        // TODO: get rid of the error silencer.
266        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
267        @list( $role, $hmac ) = explode( ':', $json->scope );
268        if ( empty( $role ) || empty( $hmac ) ) {
269            return new WP_Error( 'scope', 'Malformed Scope', $code );
270        }
271
272        if ( $this->sign_role( $role ) !== $json->scope ) {
273            return new WP_Error( 'scope', 'Invalid Scope', $code );
274        }
275
276        $cap = $roles->translate_role_to_cap( $role );
277        if ( ! $cap ) {
278            return new WP_Error( 'scope', 'No Cap', $code );
279        }
280
281        if ( ! current_user_can( $cap ) ) {
282            return new WP_Error( 'scope', 'current_user_cannot', $code );
283        }
284
285        return (string) $json->access_token;
286    }
287
288    /**
289     * Enters a user token into the user_tokens option
290     *
291     * @param int    $user_id The user id.
292     * @param string $token The user token.
293     * @param bool   $is_master_user Whether the user is the master user.
294     * @return bool
295     */
296    public function update_user_token( $user_id, $token, $is_master_user ) {
297        // Not designed for concurrent updates.
298        $user_tokens = $this->get_user_tokens();
299        if ( ! is_array( $user_tokens ) ) {
300            $user_tokens = array();
301        }
302        $user_tokens[ $user_id ] = $token;
303        if ( $is_master_user ) {
304            $master_user = $user_id;
305            $options     = compact( 'user_tokens', 'master_user' );
306        } else {
307            $options = compact( 'user_tokens' );
308        }
309        $updated = Jetpack_Options::update_options( $options );
310
311        /**
312         * Fires when the user token gets replaced.
313         *
314         * @since 1.29.0
315         * @since 9.8.1 Fired from Tokens::update_user_token() so every write path (authorize, provisioning, CLI) clears stale connection errors, not just the REST endpoint.
316         *
317         * @param int    $user_id User ID.
318         * @param string $token   New user token.
319         */
320        do_action( 'jetpack_updated_user_token', $user_id, $token );
321
322        return $updated;
323    }
324
325    /**
326     * Sign a user role with the master access token.
327     * If not specified, will default to the current user.
328     *
329     * @access public
330     *
331     * @param string $role    User role.
332     * @param int    $user_id ID of the user.
333     * @return string Signed user role.
334     */
335    public function sign_role( $role, $user_id = null ) {
336        if ( empty( $user_id ) ) {
337            $user_id = (int) get_current_user_id();
338        }
339
340        if ( ! $user_id ) {
341            return false;
342        }
343
344        $token = $this->get_access_token();
345        if ( ! $token || is_wp_error( $token ) ) {
346            return false;
347        }
348
349        return $role . ':' . hash_hmac( 'md5', "{$role}|{$user_id}", $token->secret );
350    }
351
352    /**
353     * Increases the request timeout value to 30 seconds.
354     *
355     * @return int Returns 30.
356     */
357    public function return_30() {
358        return 30;
359    }
360
361    /**
362     * Gets the requested token.
363     *
364     * Tokens are one of two types:
365     * 1. Blog Tokens: These are the "main" tokens. Each site typically has one Blog Token,
366     *    though some sites can have multiple "Special" Blog Tokens (see below). These tokens
367     *    are not associated with a user account. They represent the site's connection with
368     *    the Jetpack servers.
369     * 2. User Tokens: These are "sub-"tokens. Each connected user account has one User Token.
370     *
371     * All tokens look like "{$token_key}.{$private}". $token_key is a public ID for the
372     * token, and $private is a secret that should never be displayed anywhere or sent
373     * over the network; it's used only for signing things.
374     *
375     * Blog Tokens can be "Normal" or "Special".
376     * * Normal: The result of a normal connection flow. They look like
377     *   "{$random_string_1}.{$random_string_2}"
378     *   That is, $token_key and $private are both random strings.
379     *   Sites only have one Normal Blog Token. Normal Tokens are found in either
380     *   Jetpack_Options::get_option( 'blog_token' ) (usual) or the JETPACK_BLOG_TOKEN
381     *   constant (rare).
382     * * Special: A connection token for sites that have gone through an alternative
383     *   connection flow. They look like:
384     *   ";{$special_id}{$special_version};{$wpcom_blog_id};.{$random_string}"
385     *   That is, $private is a random string and $token_key has a special structure with
386     *   lots of semicolons.
387     *   Most sites have zero Special Blog Tokens. Special tokens are only found in the
388     *   JETPACK_BLOG_TOKEN constant.
389     *
390     * In particular, note that Normal Blog Tokens never start with ";" and that
391     * Special Blog Tokens always do.
392     *
393     * When searching for a matching Blog Tokens, Blog Tokens are examined in the following
394     * order:
395     * 1. Defined Special Blog Tokens (via the JETPACK_BLOG_TOKEN constant)
396     * 2. Stored Normal Tokens (via Jetpack_Options::get_option( 'blog_token' ))
397     * 3. Defined Normal Tokens (via the JETPACK_BLOG_TOKEN constant)
398     *
399     * @param int|false    $user_id   false: Return the Blog Token. int: Return that user's User Token.
400     * @param string|false $token_key If provided, check that the token matches the provided input.
401     * @param bool|true    $suppress_errors If true, return a falsy value when the token isn't found; When false, return a descriptive WP_Error when the token isn't found.
402     *
403     * @return object|false|WP_Error
404     */
405    public function get_access_token( $user_id = false, $token_key = false, $suppress_errors = true ) {
406        if ( $this->is_locked() ) {
407            $this->delete_all();
408            return false;
409        }
410
411        $possible_special_tokens = array();
412        $possible_normal_tokens  = array();
413        $user_tokens             = $this->get_user_tokens();
414
415        if ( $user_id ) {
416            $resolved_user_id = true === $user_id ? (int) Jetpack_Options::get_option( 'master_user' ) : (int) $user_id;
417
418            if ( ! $user_tokens ) {
419                return $suppress_errors ? false : new WP_Error( 'no_user_tokens', __( 'No user tokens found', 'jetpack-connection' ), array( 'user_id' => $resolved_user_id ) );
420            }
421            if ( true === $user_id ) { // connection owner.
422                if ( ! $resolved_user_id ) {
423                    return $suppress_errors ? false : new WP_Error( 'empty_master_user_option', __( 'No primary user defined', 'jetpack-connection' ) );
424                }
425                $user_id = $resolved_user_id;
426            }
427            if ( ! isset( $user_tokens[ $user_id ] ) || ! $user_tokens[ $user_id ] ) {
428                // translators: %s is the user ID.
429                return $suppress_errors ? false : new WP_Error( 'no_token_for_user', sprintf( __( 'No token for user %d', 'jetpack-connection' ), $user_id ), array( 'user_id' => (int) $user_id ) );
430            }
431            $user_token_chunks = explode( '.', $user_tokens[ $user_id ] );
432            if ( empty( $user_token_chunks[1] ) || empty( $user_token_chunks[2] ) ) {
433                // translators: %s is the user ID.
434                return $suppress_errors ? false : new WP_Error( 'token_malformed', sprintf( __( 'Token for user %d is malformed', 'jetpack-connection' ), $user_id ), array( 'user_id' => (int) $user_id ) );
435            }
436            if ( $user_token_chunks[2] !== (string) $user_id ) {
437                // translators: %1$d is the ID of the requested user. %2$d is the user ID found in the token.
438                return $suppress_errors ? false : new WP_Error( 'user_id_mismatch', sprintf( __( 'Requesting user_id %1$d does not match token user_id %2$d', 'jetpack-connection' ), $user_id, $user_token_chunks[2] ), array( 'user_id' => (int) $user_id ) );
439            }
440            $possible_normal_tokens[] = "{$user_token_chunks[0]}.{$user_token_chunks[1]}";
441        } else {
442            $stored_blog_token = Jetpack_Options::get_option( 'blog_token' );
443            if ( $stored_blog_token ) {
444                $possible_normal_tokens[] = $stored_blog_token;
445            }
446
447            $defined_tokens_string = Constants::get_constant( 'JETPACK_BLOG_TOKEN' );
448
449            if ( $defined_tokens_string ) {
450                $defined_tokens = explode( ',', $defined_tokens_string );
451                foreach ( $defined_tokens as $defined_token ) {
452                    if ( ';' === $defined_token[0] ) {
453                        $possible_special_tokens[] = $defined_token;
454                    } else {
455                        $possible_normal_tokens[] = $defined_token;
456                    }
457                }
458            }
459        }
460
461        if ( self::MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
462            $possible_tokens = $possible_normal_tokens;
463        } else {
464            $possible_tokens = array_merge( $possible_special_tokens, $possible_normal_tokens );
465        }
466
467        if ( ! $possible_tokens ) {
468            // If no user tokens were found, it would have failed earlier, so this is about blog token.
469            return $suppress_errors ? false : new WP_Error( 'no_possible_tokens', __( 'No blog token found', 'jetpack-connection' ) );
470        }
471
472        $valid_token = false;
473
474        if ( false === $token_key ) {
475            // Use first token.
476            $valid_token = $possible_tokens[0];
477        } elseif ( self::MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
478            // Use first normal token.
479            $valid_token = $possible_tokens[0]; // $possible_tokens only contains normal tokens because of earlier check.
480        } else {
481            // Use the token matching $token_key or false if none.
482            // Ensure we check the full key.
483            $token_check = rtrim( $token_key, '.' ) . '.';
484
485            foreach ( $possible_tokens as $possible_token ) {
486                if ( hash_equals( substr( $possible_token, 0, strlen( $token_check ) ), $token_check ) ) {
487                    $valid_token = $possible_token;
488                    break;
489                }
490            }
491        }
492
493        if ( ! $valid_token ) {
494            if ( $user_id ) {
495                // translators: %d is the user ID.
496                return $suppress_errors ? false : new WP_Error( 'no_valid_user_token', sprintf( __( 'Invalid token for user %d', 'jetpack-connection' ), $user_id ), array( 'user_id' => (int) $user_id ) );
497            } else {
498                return $suppress_errors ? false : new WP_Error( 'no_valid_blog_token', __( 'Invalid blog token', 'jetpack-connection' ) );
499            }
500        }
501
502        return (object) array(
503            'secret'           => $valid_token,
504            'external_user_id' => (int) $user_id,
505        );
506    }
507
508    /**
509     * Updates the blog token to a new value.
510     *
511     * @access public
512     *
513     * @param string $token the new blog token value.
514     * @return Boolean Whether updating the blog token was successful.
515     */
516    public function update_blog_token( $token ) {
517        return Jetpack_Options::update_option( 'blog_token', $token );
518    }
519
520    /**
521     * Unlinks the current user from the linked WordPress.com user.
522     *
523     * @access public
524     * @static
525     *
526     * @todo Refactor to properly load the XMLRPC client independently.
527     *
528     * @param int $user_id The user identifier.
529     *
530     * @return bool Whether the disconnection of the user was successful.
531     */
532    public function disconnect_user( $user_id ) {
533        $tokens = $this->get_user_tokens();
534        if ( ! $tokens ) {
535            return false;
536        }
537
538        if ( ! isset( $tokens[ $user_id ] ) ) {
539            return false;
540        }
541
542        unset( $tokens[ $user_id ] );
543
544        $this->update_user_tokens( $tokens );
545
546        return true;
547    }
548
549    /**
550     * Returns an array of user_id's that have user tokens for communicating with wpcom.
551     * Able to select by specific capability.
552     *
553     * @deprecated 1.30.0
554     * @see Manager::get_connected_users
555     *
556     * @param string   $capability The capability of the user.
557     * @param int|null $limit How many connected users to get before returning.
558     * @return array Array of WP_User objects if found.
559     */
560    public function get_connected_users( $capability = 'any', $limit = null ) {
561        _deprecated_function( __METHOD__, '1.30.0' );
562        return ( new Manager( 'jetpack' ) )->get_connected_users( $capability, $limit );
563    }
564
565    /**
566     * Fetches a signed token.
567     *
568     * @param object $token the token.
569     * @return WP_Error|string a signed token
570     */
571    public function get_signed_token( $token ) {
572        if ( ! isset( $token->secret ) || empty( $token->secret ) ) {
573            return new WP_Error( 'invalid_token' );
574        }
575
576        list( $token_key, $token_secret ) = explode( '.', $token->secret );
577
578        $token_key = sprintf(
579            '%s:%d:%d',
580            $token_key,
581            Constants::get_constant( 'JETPACK__API_VERSION' ),
582            $token->external_user_id
583        );
584
585        $timestamp = time();
586
587        if ( function_exists( 'wp_generate_password' ) ) {
588            $nonce = wp_generate_password( 10, false );
589        } else {
590            $nonce = substr( sha1( (string) wp_rand( 0, 1000000 ) ), 0, 10 );
591        }
592
593        $normalized_request_string = implode(
594            "\n",
595            array(
596                $token_key,
597                $timestamp,
598                $nonce,
599            )
600        ) . "\n";
601
602        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
603        $signature = base64_encode( hash_hmac( 'sha1', $normalized_request_string, $token_secret, true ) );
604
605        $auth = array(
606            'token'     => $token_key,
607            'timestamp' => $timestamp,
608            'nonce'     => $nonce,
609            'signature' => $signature,
610        );
611
612        $header_pieces = array();
613        foreach ( $auth as $key => $value ) {
614            $header_pieces[] = sprintf( '%s="%s"', $key, $value );
615        }
616
617        return implode( ' ', $header_pieces );
618    }
619
620    /**
621     * Gets the list of user tokens
622     *
623     * @since 1.30.0
624     *
625     * @return bool|array An array of user tokens where keys are user IDs and values are the tokens. False if no user token is found.
626     */
627    public function get_user_tokens() {
628        return Jetpack_Options::get_option( 'user_tokens' );
629    }
630
631    /**
632     * Updates the option that stores the user tokens
633     *
634     * @since 1.30.0
635     *
636     * @param array $tokens An array of user tokens where keys are user IDs and values are the tokens.
637     * @return bool Was the option successfully updated?
638     *
639     * @todo add validate the input.
640     */
641    public function update_user_tokens( $tokens ) {
642        return Jetpack_Options::update_option( 'user_tokens', $tokens );
643    }
644
645    /**
646     * Lock the tokens to the current site URL.
647     *
648     * @param int $timespan How long the tokens should be locked, in seconds.
649     *
650     * @return bool
651     */
652    public function set_lock( $timespan = HOUR_IN_SECONDS ) {
653        try {
654            $expires = ( new DateTime() )->add( DateInterval::createFromDateString( (int) $timespan . ' seconds' ) );
655        } catch ( Exception $e ) {
656            return false;
657        }
658
659        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
660        return Jetpack_Options::update_option( 'token_lock', $expires->format( static::DATE_FORMAT_ATOM ) . '|||' . base64_encode( Urls::site_url() ) );
661    }
662
663    /**
664     * Remove the site lock from tokens.
665     *
666     * @return bool
667     */
668    public function remove_lock() {
669        Jetpack_Options::delete_option( 'token_lock' );
670
671        return true;
672    }
673
674    /**
675     * Check if the domain is locked, remove the lock if needed.
676     * Possible scenarios:
677     * - lock expired, site URL matches the lock URL: remove the lock, return false.
678     * - lock not expired, site URL matches the lock URL: return false.
679     * - site URL does not match the lock URL (expiration date is ignored): return true, do not remove the lock.
680     *
681     * @return bool
682     */
683    public function is_locked() {
684        $the_lock = Jetpack_Options::get_option( 'token_lock' );
685        if ( ! $the_lock ) {
686            // Not locked.
687            return false;
688        }
689
690        $the_lock = explode( '|||', $the_lock, 2 );
691        if ( count( $the_lock ) !== 2 ) {
692            // Something's wrong with the lock.
693            $this->remove_lock();
694            return false;
695        }
696
697        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
698        $locked_site_url = base64_decode( $the_lock[1] );
699        $expires         = $the_lock[0];
700
701        $expiration_date = DateTime::createFromFormat( static::DATE_FORMAT_ATOM, $expires );
702        if ( false === $expiration_date || ! $locked_site_url ) {
703            // Something's wrong with the lock.
704            $this->remove_lock();
705            return false;
706        }
707
708        if ( Urls::site_url() === $locked_site_url ) {
709            if ( new DateTime() > $expiration_date ) {
710                // Site lock expired.
711                // Site URL matches, removing the lock.
712                $this->remove_lock();
713            }
714
715            return false;
716        }
717
718        // Site URL doesn't match.
719        return true;
720    }
721}