' . "\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 : '';
}
}