' . "\n"; $json = wp_json_encode( $nonce ); if ( is_string( $json ) ) { wp_print_inline_script_tag( 'window.agentmaticaWpRestNonce=' . $json . ';' ); } } /** * Where the widget sends shoppers to sign in. The My Account page can be * renamed, translated or under a subdirectory, so never guess /my-account/. * Same URL for every visitor, so this is safe on cached pages. */ public static function print_account_url(): void { if ( is_admin() || ! function_exists( 'wc_get_page_permalink' ) ) { return; } $url = wc_get_page_permalink( 'myaccount' ); if ( ! is_string( $url ) || $url === '' ) { return; } echo '' . "\n"; } /** * Cookie-auth REST requires a wp_rest nonce. The widget fetch often has none. * Only skip that check for same-origin XHR/fetch — never for top-level GET * navigations (CSRF: bind a logged-in shopper to an attacker's session_key). * * @param WP_Error|true|null $result * @return WP_Error|true|null */ public static function allow_cookie_auth_without_nonce( $result ) { if ( ! is_wp_error( $result ) ) { return $result; } if ( $result->get_error_code() !== 'rest_cookie_invalid_nonce' ) { return $result; } if ( ! self::is_customer_token_rest_route() ) { return $result; } // A nonce was sent and core rejected it: keep the error, never fall back. if ( self::request_header( 'HTTP_X_WP_NONCE' ) !== '' ) { return $result; } if ( ! self::request_is_same_origin_fetch() ) { return $result; } return true; } private static function is_customer_token_rest_route(): bool { $route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? (string) $GLOBALS['wp']->query_vars['rest_route'] : ''; return false !== strpos( $route, '/agentmatica/v1/customer-token' ); } /** * Valid wp_rest nonce, or a same-origin fetch (not a document navigation). */ private static function request_has_csrf_protection( WP_REST_Request $request ): bool { $nonce = $request->get_header( 'X-WP-Nonce' ); if ( is_string( $nonce ) && $nonce !== '' && wp_verify_nonce( $nonce, 'wp_rest' ) ) { return true; } return self::request_is_same_origin_fetch(); } /** * Browsers send Sec-Fetch-Site: same-origin on storefront fetch(). * Top-level clicks send Sec-Fetch-Mode: navigate and must be refused. */ public static function request_is_same_origin_fetch(): bool { // Only fetch()/XHR: refuse navigations and subresources such as // (mode no-cors, // dest image), which also carry Sec-Fetch-Site: same-origin. $mode = strtolower( self::request_header( 'HTTP_SEC_FETCH_MODE' ) ); if ( $mode !== '' && ! in_array( $mode, array( 'cors', 'same-origin' ), true ) ) { return false; } $dest = strtolower( self::request_header( 'HTTP_SEC_FETCH_DEST' ) ); if ( $dest !== '' && $dest !== 'empty' ) { return false; } $site = strtolower( self::request_header( 'HTTP_SEC_FETCH_SITE' ) ); if ( $site !== '' ) { return $site === 'same-origin'; } $origin = self::request_header( 'HTTP_ORIGIN' ); if ( $origin === '' ) { $origin = self::request_header( 'HTTP_REFERER' ); } return $origin !== '' && self::url_is_this_site( $origin ); } private static function request_header( string $server_key ): string { $value = isset( $_SERVER[ $server_key ] ) ? wp_unslash( $_SERVER[ $server_key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Header compared as origin/host, not stored. return is_string( $value ) ? trim( $value ) : ''; } private static function url_is_this_site( string $url ): bool { $given = wp_parse_url( $url ); if ( ! is_array( $given ) || empty( $given['host'] ) ) { return false; } foreach ( array( home_url( '/' ), site_url( '/' ) ) as $base ) { $parsed = wp_parse_url( $base ); if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) { continue; } if ( self::host_port( $given ) === self::host_port( $parsed ) ) { return true; } } return false; } /** * @param array $parsed */ private static function host_port( array $parsed ): string { $host = strtolower( (string) ( $parsed['host'] ?? '' ) ); $port = isset( $parsed['port'] ) ? (string) (int) $parsed['port'] : ''; return $host . ':' . $port; } public static function register_routes(): void { register_rest_route( 'agentmatica/v1', '/customer-token', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'issue_token' ), 'permission_callback' => array( __CLASS__, 'logged_in_permission' ), 'args' => array( 'session_key' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => array( Agentmatica_WooCommerce_Cart::class, 'sanitize_session_key' ), ), ), ) ); } public static function logged_in_permission( WP_REST_Request $request ): bool { if ( ! self::request_has_csrf_protection( $request ) ) { return false; } if ( is_user_logged_in() ) { return true; } foreach ( $_COOKIE as $name => $value ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Cookie names/values validated via wp_validate_auth_cookie. if ( ! is_string( $name ) || strpos( $name, 'wordpress_logged_in_' ) !== 0 ) { continue; } $user_id = wp_validate_auth_cookie( (string) $value, 'logged_in' ); if ( $user_id ) { wp_set_current_user( $user_id ); return true; } } return false; } /** * @param WP_REST_Request $request * @return WP_REST_Response|WP_Error */ public static function issue_token( WP_REST_Request $request ) { if ( ! Agentmatica_WooCommerce_Orders::is_order_lookup_enabled() ) { return new WP_Error( 'agentmatica_order_lookup_disabled', __( 'Order lookup is disabled on this store.', 'agentmatica-mcp-connector' ), array( 'status' => 403 ) ); } $session_key = Agentmatica_WooCommerce_Cart::sanitize_session_key( (string) $request->get_param( 'session_key' ) ); if ( $session_key === '' ) { return new WP_Error( 'agentmatica_invalid_session', __( 'session_key is required.', 'agentmatica-mcp-connector' ), array( 'status' => 400 ) ); } $user_id = get_current_user_id(); $user = get_user_by( 'id', $user_id ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'agentmatica_not_logged_in', __( 'You must be logged in.', 'agentmatica-mcp-connector' ), array( 'status' => 401 ) ); } // Binding happens only when the Agentmatica server verifies this token // (verify_customer_token), never just because a token was minted. $exp = time() + self::TOKEN_TTL; $token = self::sign_token( array( 'wp_user_id' => $user_id, 'session_key' => $session_key, 'email' => strtolower( trim( (string) $user->user_email ) ), 'name' => trim( (string) $user->display_name ), 'exp' => $exp, ) ); return rest_ensure_response( array( 'token' => $token, 'expires_at' => gmdate( DATE_ATOM, $exp ), 'customer' => array( 'id' => $user_id, 'email' => strtolower( trim( (string) $user->user_email ) ), 'name' => trim( (string) $user->display_name ), ), ) ); } /** * @param array $claims */ public static function sign_token( array $claims ): string { $secret = self::get_secret(); $json = wp_json_encode( $claims ); $payload = self::base64url_encode( false === $json ? '{}' : $json ); $signature = hash_hmac( 'sha256', $payload, $secret, true ); return $payload . '.' . self::base64url_encode( $signature ); } /** * @return array|null */ public static function verify_token( string $token ): ?array { $parts = explode( '.', $token, 2 ); if ( count( $parts ) !== 2 ) { return null; } list( $payload, $signature ) = $parts; $secret = self::get_secret(); $expected = self::base64url_encode( hash_hmac( 'sha256', $payload, $secret, true ) ); if ( ! hash_equals( $expected, $signature ) ) { return null; } $decoded = json_decode( self::base64url_decode( $payload ), true ); if ( ! is_array( $decoded ) ) { return null; } $exp = isset( $decoded['exp'] ) ? (int) $decoded['exp'] : 0; if ( $exp <= time() ) { return null; } return $decoded; } private static function get_secret(): string { $secret = (string) get_option( self::SECRET_OPTION, '' ); if ( $secret === '' ) { $secret = wp_generate_password( 64, true, true ); update_option( self::SECRET_OPTION, $secret, false ); } return $secret; } private static function base64url_encode( string $value ): string { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url for signed tokens, not obfuscation. return rtrim( strtr( base64_encode( $value ), '+/', '-_' ), '=' ); } private static function base64url_decode( string $value ): string { $remainder = strlen( $value ) % 4; if ( $remainder > 0 ) { $value .= str_repeat( '=', 4 - $remainder ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- base64url for signed tokens, not obfuscation. $decoded = base64_decode( strtr( $value, '-_', '+/' ), true ); return is_string( $decoded ) ? $decoded : ''; } }