'xspeed', // Bumped if the consent or redemption contract changes shape. 'hub_connect' => 1, 'site_url' => Mcp_Hub::site_url_canonical(), 'connect_url' => admin_url( 'admin.php?page=' . self::PAGE_SLUG ), ); } /** A Hub `state`: opaque base64url, long enough to be unguessable. */ public static function valid_state( string $state ): bool { return 1 === preg_match( '/^[A-Za-z0-9_-]{32,128}$/', $state ); } /** An S256 challenge: base64url of a SHA-256, so exactly 43 characters. */ public static function valid_challenge( string $challenge ): bool { return 1 === preg_match( '/^[A-Za-z0-9_-]{43}$/', $challenge ); } /** The S256 challenge a verifier answers to (RFC 7636 §4.2). */ public static function challenge_for( string $verifier ): string { return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url of a hash, per RFC 7636. } /** * Issue a single-use code for the admin who approved. * * @param string $challenge The Hub's S256 challenge. * @param int $user_id The approving admin, recorded on redemption. */ public static function issue_code( string $challenge, int $user_id ): string { $code = bin2hex( random_bytes( 32 ) ); set_transient( self::CODE_KEY . hash( 'sha256', $code ), array( 'challenge' => $challenge, 'user_id' => $user_id, 'expires' => time() + self::CODE_TTL, ), self::CODE_TTL ); return $code; } /** * Redeem a code with its verifier. Single use: the code is gone after the * first attempt, right or wrong, so it cannot be guessed at. * * @return array{site_url:string,site_token:string,user_id:int}|null */ public static function redeem_code( string $code, string $verifier ): ?array { if ( 1 !== preg_match( '/^[a-f0-9]{64}$/', $code ) ) { return null; } // RFC 7636 §4.1: 43–128 unreserved characters. if ( 1 !== preg_match( '/^[A-Za-z0-9._~-]{43,128}$/', $verifier ) ) { return null; } $key = self::CODE_KEY . hash( 'sha256', $code ); $entry = get_transient( $key ); delete_transient( $key ); if ( ! is_array( $entry ) || ! isset( $entry['challenge'], $entry['user_id'], $entry['expires'] ) ) { return null; } if ( (int) $entry['expires'] < time() ) { return null; } if ( ! hash_equals( (string) $entry['challenge'], self::challenge_for( $verifier ) ) ) { return null; } return Mcp_Hub::grant_attach_credential( (int) $entry['user_id'] ); } /** Where the browser goes back to on the Hub, with the outcome in the query. */ public static function return_url( array $args ): string { return Mcp_Hub::hub_url() . '/attach/return?' . http_build_query( $args ); } /** * Register the consent screen as a page with no menu entry. wp-admin * refuses an unregistered ?page= with a 403 before admin_init runs. */ public static function register_page(): void { add_submenu_page( '', __( 'Connect to xSpeed Hub', 'xspeed' ), '', 'manage_options', self::PAGE_SLUG, '__return_null' ); } /** * Serve the consent page on admin_init, before admin chrome is sent. * * admin_init runs after wp-admin's auth_redirect(), so a signed-out * visitor has already been sent to wp-login and back by the time we look. */ public static function maybe_handle_page(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the form below is nonce-checked. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( self::PAGE_SLUG !== $page ) { return; } if ( ! current_user_can( 'manage_options' ) ) { self::emit_page( __( 'Not allowed', 'xspeed' ), '
' . esc_html__( 'Only an administrator of this site can connect it to xSpeed Hub. Sign in as an administrator and start again from the Hub.', 'xspeed' ) . '
', 403 ); } $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ); // phpcs:disable WordPress.Security.NonceVerification -- GET renders; POST is verified below before anything is acted on. $source = $is_post ? $_POST : $_GET; $state = isset( $source['state'] ) ? sanitize_text_field( wp_unslash( $source['state'] ) ) : ''; $challenge = isset( $source['code_challenge'] ) ? sanitize_text_field( wp_unslash( $source['code_challenge'] ) ) : ''; $method = isset( $source['code_challenge_method'] ) ? sanitize_text_field( wp_unslash( $source['code_challenge_method'] ) ) : 'S256'; $account = isset( $source['account'] ) ? sanitize_email( wp_unslash( $source['account'] ) ) : ''; // phpcs:enable if ( ! self::valid_state( $state ) || ! self::valid_challenge( $challenge ) || 'S256' !== $method ) { self::emit_page( __( 'This link is not valid', 'xspeed' ), '' . esc_html__( 'Start again from xSpeed Hub: Add site, then Connect with WordPress.', 'xspeed' ) . '
', 400 ); } if ( $is_post ) { check_admin_referer( self::NONCE_ACTION ); $approved = ! empty( $_POST['approve'] ); $args = $approved ? array( 'state' => $state, 'code' => self::issue_code( $challenge, get_current_user_id() ), 'site_url' => Mcp_Hub::site_url_canonical(), ) : array( 'state' => $state, 'error' => 'access_denied', ); // Not wp_safe_redirect: the Hub is off-site by design. The address is // the configured hub_url(), never anything from this request. wp_redirect( self::return_url( $args ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- configured Hub URL, not request input. exit; } self::emit_consent( $state, $challenge, $account ); } /** The Approve / Deny screen. */ private static function emit_consent( string $state, string $challenge, string $account ): void { $hub_host = (string) wp_parse_url( Mcp_Hub::hub_url(), PHP_URL_HOST ); $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $user = wp_get_current_user(); $rows = '' . esc_html__( 'xSpeed Hub lets you and your AI tools manage this site’s cache alongside your other sites.', 'xspeed' ) . '
' . $rows . $form, 200 ); } /** * Emit a standalone page in the consent style and stop. * * @param string $title Plain text. * @param string $body Already escaped HTML. * @param int $status HTTP status. */ private static function emit_page( string $title, string $body, int $status ): void { status_header( $status ); nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); // The page carries a live nonce and a state; it must not be framed. header( 'X-Frame-Options: DENY' ); echo '