we redirect the browser to the brain: * GET {BRAIN_URL}/connect?site=&return_url=&state= * 2. Brain authenticates the merchant and redirects back: * ?code=&state= * 3. We exchange the code server-to-server for the keys: * POST {BRAIN_URL}/api/connect/exchange { "code": "" } * -> { storeId, publishableKey, secretKey } (secretKey returned ONCE) * * @package Aisthetix\TryOn */ namespace Aisthetix\TryOn; defined( 'ABSPATH' ) || exit; /** * Connect flow handler. */ class Connect { /** * admin-post action name for initiating Connect. * * @var string */ const ACTION_START = 'aisthetix_tryon_connect'; /** * admin-post action name for disconnecting. * * @var string */ const ACTION_DISCONNECT = 'aisthetix_tryon_disconnect'; /** * Option key storing the short-lived Connect state nonce. * * @var string */ const OPTION_STATE = 'aisthetix_tryon_connect_state'; /** * Status component (refreshed after a successful connect). * * @var Status */ private $status; /** * Constructor. * * @param Status $status Status component. */ public function __construct( Status $status ) { $this->status = $status; } /** * Register hooks. * * @return void */ public function init() { add_action( 'admin_post_' . self::ACTION_START, array( $this, 'handle_start' ) ); add_action( 'admin_post_' . self::ACTION_DISCONNECT, array( $this, 'handle_disconnect' ) ); // The brain redirects back to wp-admin; we intercept the callback on the settings page load. add_action( 'admin_init', array( $this, 'maybe_handle_callback' ) ); } /** * URL the brain should redirect back to after approval. * * @return string */ public function callback_url() { return admin_url( 'admin.php?page=aisthetix-tryon&aisthetix_connect=callback' ); } /** * Build the URL to start Connect (the admin-post endpoint, nonce-protected). * * @return string */ public function start_url() { return wp_nonce_url( admin_url( 'admin-post.php?action=' . self::ACTION_START ), self::ACTION_START, '_aisthetix_nonce' ); } /** * Build the URL to disconnect. * * @return string */ public function disconnect_url() { return wp_nonce_url( admin_url( 'admin-post.php?action=' . self::ACTION_DISCONNECT ), self::ACTION_DISCONNECT, '_aisthetix_nonce' ); } /** * Handle the Connect initiation: generate state, persist it, redirect to brain. * * @return void */ public function handle_start() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'aisthetix-virtual-try-on' ) ); } check_admin_referer( self::ACTION_START, '_aisthetix_nonce' ); // Generate a random state token and persist it (10-minute window). $state = wp_generate_password( 32, false ); set_transient( self::OPTION_STATE, $state, 10 * MINUTE_IN_SECONDS ); $connect_url = add_query_arg( array( 'site' => rawurlencode( home_url() ), 'return_url' => rawurlencode( $this->callback_url() ), 'state' => rawurlencode( $state ), ), Plugin::brain_url() . '/connect' ); // Leaving WordPress for the brain (an external host): whitelist it so // wp_safe_redirect() allows the hop instead of a bare wp_redirect(). add_filter( 'allowed_redirect_hosts', static function ( $hosts ) { $brain_host = wp_parse_url( Plugin::brain_url(), PHP_URL_HOST ); if ( $brain_host ) { $hosts[] = $brain_host; } return $hosts; } ); wp_safe_redirect( esc_url_raw( $connect_url ) ); exit; } /** * Detect the brain callback on the settings page and process it. * * @return void */ public function maybe_handle_callback() { if ( ! is_admin() ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the brain (an external server) cannot send our WP nonce; integrity is enforced via the `state` token compared below. $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; $trigger = isset( $_GET['aisthetix_connect'] ) ? sanitize_text_field( wp_unslash( $_GET['aisthetix_connect'] ) ) : ''; if ( 'aisthetix-tryon' !== $page || 'callback' !== $trigger ) { return; } if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $expected_state = get_transient( self::OPTION_STATE ); delete_transient( self::OPTION_STATE ); if ( '' === $code || '' === $state || empty( $expected_state ) || ! hash_equals( (string) $expected_state, $state ) ) { $this->redirect_with_notice( 'connect_state_error' ); return; } $result = $this->exchange_code( $code, $state ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'connect_exchange_error' ); return; } // Persist keys. Secret key stays in options and is NEVER enqueued to the browser. update_option( Plugin::OPTION_STORE_ID, $result['storeId'] ); update_option( Plugin::OPTION_PUBLISHABLE_KEY, $result['publishableKey'] ); update_option( Plugin::OPTION_SECRET_KEY, $result['secretKey'] ); // Warm the status cache immediately so the wp-admin line is accurate. $this->status->refresh(); // Fresh connection, fresh catalog token: clear any old one and register // a new one right away so the brain can browse the catalogue for the // merchant's first dashboard visit. Connector::clear(); $aisthetix_connector = Plugin::instance()->connector; if ( $aisthetix_connector instanceof Connector ) { $aisthetix_connector->maybe_register_token(); } $this->redirect_with_notice( 'connect_success' ); } /** * Exchange the one-time code for keys (server-to-server). * * We include this site's home_url() as `site` so the brain can BIND the exchange * to the origin the code was issued for (it rejects a code redeemed from a * different host). The `state` nonce is echoed back too so the brain can * correlate the handshake. The WP-side `state` check in the callback remains the * local integrity gate; `site` binding is the additional server-side hardening. * * @param string $code One-time code from the brain redirect. * @param string $state State nonce echoed back by the brain (for binding). * @return array|\WP_Error Array with storeId/publishableKey/secretKey, or WP_Error. */ private function exchange_code( $code, $state = '' ) { $response = wp_remote_post( Plugin::brain_url() . '/api/connect/exchange', array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( array( 'code' => $code, 'site' => home_url(), 'state' => $state, ) ), ) ); if ( is_wp_error( $response ) ) { return $response; } $http = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $http || ! is_array( $data ) ) { return new \WP_Error( 'aisthetix_exchange_http', 'Unexpected response from brain', array( 'status' => $http ) ); } if ( empty( $data['publishableKey'] ) || empty( $data['secretKey'] ) || empty( $data['storeId'] ) ) { return new \WP_Error( 'aisthetix_exchange_payload', 'Incomplete key payload from brain' ); } return array( 'storeId' => sanitize_text_field( $data['storeId'] ), 'publishableKey' => sanitize_text_field( $data['publishableKey'] ), 'secretKey' => sanitize_text_field( $data['secretKey'] ), ); } /** * Handle disconnect: clear keys + cached status. * * @return void */ public function handle_disconnect() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'aisthetix-virtual-try-on' ) ); } check_admin_referer( self::ACTION_DISCONNECT, '_aisthetix_nonce' ); // Notify the brain so it can revoke the store's keys server-side. We do this // BEFORE clearing local options (the secret key is needed to authenticate the // revoke). Failure is tolerated: a flaky/unreachable brain must never trap the // merchant in a connected state, so we always proceed to the local disconnect. $this->revoke_on_brain(); delete_option( Plugin::OPTION_STORE_ID ); delete_option( Plugin::OPTION_PUBLISHABLE_KEY ); delete_option( Plugin::OPTION_SECRET_KEY ); delete_transient( Status::TRANSIENT ); // The catalog token belongs to the connection: a reconnect mints and // registers a fresh one against the fresh keys. Connector::clear(); $this->redirect_with_notice( 'disconnected' ); } /** * Best-effort server-to-server revoke of the store's keys on the brain. * * Authenticated with the SECRET key (server-only). Tolerant of every failure * mode — a non-200, a transport error, or a missing key all fall through so the * local disconnect still completes. The brain rotates/suspends the keys so a * leaked old key cannot keep serving after disconnect. * * @return void */ private function revoke_on_brain() { $secret = Plugin::secret_key(); if ( '' === $secret ) { return; } // Short timeout: disconnect should feel instant even if the brain is slow. wp_remote_post( Plugin::brain_url() . '/api/connect/revoke', array( 'timeout' => 8, 'blocking' => true, 'headers' => array( 'X-Aisthetix-Secret' => $secret, 'Accept' => 'application/json', ), ) ); // Intentionally ignore the result (best-effort). } /** * Redirect back to the settings page with a notice query arg. * * @param string $notice Notice slug. * @return void */ private function redirect_with_notice( $notice ) { $url = add_query_arg( array( 'page' => 'aisthetix-tryon', 'aisthetix_notice' => $notice, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $url ); exit; } }