$verifier, 'user_id' => get_current_user_id(), 'started' => time(), ), self::STATE_TTL ); $query = array( 'response_type' => 'code', 'client_id' => $client['client_id'], 'redirect_uri' => self::return_url(), 'scope' => 'modules:read offline_access', 'state' => $state, 'code_challenge' => self::base64url( hash( 'sha256', $verifier, true ) ), 'code_challenge_method' => 'S256', 'resource' => self::resource(), 'environment' => wp_get_environment_type(), ); return Config::api_url() . '/api/oauth/authorize?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ); } /** * The return route: `GET /wp-json/sr-anywhere/v1/connect/return?code=…&state=…`. Public, because the consent may end in * another browser or on a phone (decision 0009); the state and the verifier are what secure it. * * @param \WP_REST_Request $request The request. */ public static function handle_return( \WP_REST_Request $request ) { $state = (string) $request->get_param( 'state' ); $code = (string) $request->get_param( 'code' ); $denied = (string) $request->get_param( 'error' ); if ( '' === $denied && ! empty( $_SERVER['QUERY_STRING'] ) ) { // WordPress takes `error` out of $_GET (a public query var of its own), so a denied consent is read from the query string. wp_parse_str( sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ), $raw ); $denied = isset( $raw['error'] ) ? sanitize_key( $raw['error'] ) : ''; } $kept = 1 === preg_match( '/^[A-Za-z0-9_-]{16,64}$/', $state ) ? get_transient( 'sranywhere_oauth_' . $state ) : false; if ( is_array( $kept ) ) { delete_transient( 'sranywhere_oauth_' . $state ); // single use, whatever happens next } if ( ! is_array( $kept ) || time() - (int) $kept['started'] > self::STATE_TTL ) { self::page( false, __( 'This connect attempt is unknown or has expired. Start again from Settings → SR Anywhere.', 'sr-anywhere' ) ); } if ( 'invalid_client' === $denied ) { delete_option( self::CLIENT_OPTION ); // the service no longer knows this site's registration: the next start registers anew self::page( false, __( 'SR Anywhere no longer knew this site. Start again from Settings → SR Anywhere; it registers afresh.', 'sr-anywhere' ) ); } if ( '' !== $denied || '' === $code ) { self::page( false, 'access_denied' === $denied ? __( 'You did not allow the connection. Nothing changed on this site.', 'sr-anywhere' ) : __( 'SR Anywhere did not return a code. Start again from Settings → SR Anywhere.', 'sr-anywhere' ) ); } $client = self::client(); if ( is_wp_error( $client ) ) { self::page( false, $client->get_error_message() ); } $tokens = self::token_request( array( 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => self::return_url(), 'client_id' => $client['client_id'], 'code_verifier' => $kept['verifier'], 'resource' => self::resource(), ) ); if ( is_wp_error( $tokens ) ) { if ( 'invalid_client' === $tokens->get_error_code() ) { delete_option( self::CLIENT_OPTION ); // the service forgot the registration: the next start registers anew } self::page( false, __( 'SR Anywhere did not accept the code. Start again from Settings → SR Anywhere.', 'sr-anywhere' ) ); } $connection = Connection::connect_with_tokens( $tokens, $client['client_id'] ); if ( is_wp_error( $connection ) ) { self::page( false, $connection->get_error_message() ); } self::schedule(); self::page( true, __( 'Connected. You can close this tab and place your modules with the SR Anywhere block.', 'sr-anywhere' ) ); } /** * A usable access token for the connection: the stored one while it lasts, else a refreshed one. * * @param Connection $connection The OAuth connection. * @param bool $force Refresh even when the stored token still lasts (after a 401). * @return string|\WP_Error */ public static function access_token( Connection $connection, $force = false ) { if ( ! $force && $connection->expires_at() > time() + self::EARLY ) { $token = $connection->token(); if ( null !== $token ) { return $token; } } return self::refresh( $connection ); } /** * Refreshes the tokens under a lock; a second request at the same time waits for the first and uses its result. * * @param Connection $connection The OAuth connection. * @return string|\WP_Error The new access token. */ public static function refresh( Connection $connection ) { $refresh = $connection->refresh_token(); if ( null === $refresh ) { return new \WP_Error( 'sranywhere_no_refresh', __( 'The connection cannot be refreshed.', 'sr-anywhere' ), array( 'status' => 401 ) ); } if ( ! self::lock() ) { // Someone else refreshes right now: wait briefly, then take what they stored. for ( $i = 0; $i < 10; $i++ ) { usleep( 300000 ); $again = Connection::get(); if ( $again && $again->expires_at() > $connection->expires_at() ) { $token = $again->token(); if ( null !== $token ) { return $token; } } } return new \WP_Error( 'sranywhere_busy', __( 'SR Anywhere is being reconnected. Please try again in a moment.', 'sr-anywhere' ), array( 'status' => 503 ) ); } try { $tokens = self::token_request( array( 'grant_type' => 'refresh_token', 'refresh_token' => $refresh, 'client_id' => $connection->client_id(), 'resource' => self::resource(), ) ); if ( is_wp_error( $tokens ) ) { if ( 'invalid_grant' === $tokens->get_error_code() ) { Connection::mark( Connection::EXPIRED ); // the grant is over: revoked, idle too long, or ended in Settings } return $tokens; } $stored = $connection->store_tokens( $tokens ); return is_wp_error( $stored ) ? $stored : (string) $tokens['access_token']; } finally { self::unlock(); } } /** * The weekly refresh (WP-cron): keeps the grant's idle clock (0019 §3) running on a site nobody edits. */ public static function cron() { $connection = Connection::get(); if ( $connection && $connection->is_oauth() && Connection::CONNECTED === $connection->state() ) { self::refresh( $connection ); } } /** * Ends the grant at SR Anywhere, best effort (decision 0008): token revocation (RFC 7009) with the refresh token, which * outlives the access token; a failure never blocks the disconnect. * * @param Connection $connection The OAuth connection. */ public static function revoke( Connection $connection ) { $token = $connection->refresh_token(); if ( null === $token ) { return; } wp_remote_post( Config::api_url() . '/api/oauth/revoke', array( 'timeout' => 5, 'redirection' => 0, 'body' => array( 'token' => $token, 'token_type_hint' => 'refresh_token', 'client_id' => $connection->client_id(), ), ) ); } /** * Schedules the weekly refresh. */ public static function schedule() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + WEEK_IN_SECONDS, 'weekly', self::CRON_HOOK ); } } /** * Takes the weekly refresh off the schedule. */ public static function unschedule() { wp_unschedule_hook( self::CRON_HOOK ); } /** * The client registration for this site's return address: registered once, again when the address changed or the * registration is older than 170 days (the server keeps one for 180, 0019 §2). * * @return array|\WP_Error `{ client_id, redirect_uri, api, registered_at }`. */ private static function client() { $kept = get_option( self::CLIENT_OPTION, array() ); if ( is_array( $kept ) && ! empty( $kept['client_id'] ) && ( $kept['redirect_uri'] ?? '' ) === self::return_url() && ( $kept['api'] ?? '' ) === Config::api_url() && time() - (int) ( $kept['registered_at'] ?? 0 ) < 170 * DAY_IN_SECONDS ) { return $kept; } $response = wp_remote_post( Config::api_url() . '/api/oauth/register', array( 'timeout' => 8, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( array( 'client_name' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), 'client_uri' => home_url( '/' ), 'redirect_uris' => array( self::return_url() ), 'token_endpoint_auth_method' => 'none', 'grant_types' => array( 'authorization_code', 'refresh_token' ), 'response_types' => array( 'code' ), 'scope' => 'modules:read offline_access', ) ), ) ); $body = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['client_id'] ) || ! is_string( $body['client_id'] ) ) { return new \WP_Error( 'sranywhere_register_failed', __( 'SR Anywhere could not be reached to start the connection. Please try again in a moment.', 'sr-anywhere' ) ); } $client = array( 'client_id' => sanitize_text_field( $body['client_id'] ), 'redirect_uri' => self::return_url(), 'api' => Config::api_url(), 'registered_at' => time(), ); update_option( self::CLIENT_OPTION, $client, false ); return $client; } /** * A request to the token endpoint. * * @param array $form The form fields. * @return array|\WP_Error The token answer (`access_token`, `refresh_token`, `expires_in`, `scope`), or the OAuth error * code as the error's code (`invalid_grant`, …), the HTTP status in its data. */ private static function token_request( array $form ) { $response = wp_remote_post( Config::api_url() . '/api/oauth/token', array( 'timeout' => 8, 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json' ), 'body' => $form, ) ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'sranywhere_unreachable', __( 'SR Anywhere could not be reached.', 'sr-anywhere' ), array( 'status' => 0 ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 === $status && is_array( $body ) && ! empty( $body['access_token'] ) && is_string( $body['access_token'] ) ) { return $body; } $code = is_array( $body ) && isset( $body['error'] ) && is_string( $body['error'] ) ? sanitize_key( $body['error'] ) : 'sranywhere_token_failed'; return new \WP_Error( $code, __( 'SR Anywhere did not issue a token.', 'sr-anywhere' ), array( 'status' => $status ) ); } /** * Takes the refresh lock: one row, which `add_option()` refuses while it exists; a stale one (a crashed request) is * taken over after LOCK_TTL seconds. * * @return bool */ private static function lock() { if ( add_option( self::LOCK_OPTION, (string) time(), '', false ) ) { return true; } $held = (int) get_option( self::LOCK_OPTION, 0 ); if ( $held && time() - $held > self::LOCK_TTL ) { delete_option( self::LOCK_OPTION ); return (bool) add_option( self::LOCK_OPTION, (string) time(), '', false ); } return false; } /** * Releases the refresh lock. */ private static function unlock() { delete_option( self::LOCK_OPTION ); } /** * The page the returning tab shows; it tells the editor's and the settings page's tabs (BroadcastChannel) and closes * itself when it can. Its style and script are files, registered and printed the WordPress way. Ends the request. * * @param bool $connected Whether the site is connected now. * @param string $text The sentence to show. */ private static function page( $connected, $text ) { wp_register_style( 'sranywhere-return', SRANYWHERE_URL . 'assets/return.css', array(), SRANYWHERE_VERSION ); wp_register_script( 'sranywhere-return', SRANYWHERE_URL . 'assets/return.js', array(), SRANYWHERE_VERSION, array( 'in_footer' => true ) ); $assets = self::origin( SRANYWHERE_URL ); nocache_headers(); status_header( 200 ); header( 'Content-Type: text/html; charset=utf-8' ); header( 'Referrer-Policy: no-referrer' ); header( "Content-Security-Policy: default-src 'none'; style-src 'self' {$assets}; script-src 'self' {$assets}; frame-ancestors 'none'" ); $title = $connected ? __( 'Connected', 'sr-anywhere' ) : __( 'Not connected', 'sr-anywhere' ); $settings = Settings::url(); ?> >