authorize_request( self::ACTION_START ); $verifier = self::random_token( 48 ); $state = self::random_token( 24 ); $challenge = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- RFC 7636 S256 challenge encoding. $env = Env::current(); update_user_meta( get_current_user_id(), self::PENDING_META, array( 'state' => $state, 'verifier' => $verifier, 'env' => $env, 'expires' => time() + 15 * MINUTE_IN_SECONDS, ) ); $url = add_query_arg( array( 'client_id' => self::CLIENT_ID, 'redirect_uri' => rawurlencode( self::redirect_uri() ), 'response_type' => 'code', 'scope' => rawurlencode( self::SCOPE ), 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ), Env::host( 'api', $env ) . '/v1/platform/oauth/authorize' ); // Thridify's authorize endpoint is on a different host by design. wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- fixed Thridify host from Env, never user input. exit; } /** * Callback: verify state, redeem the code with the PKCE verifier, then link * the site and start the first catalog sync. * * @return void */ public function callback() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to connect Thridify.', 'thridify' ), 403 ); } $user_id = get_current_user_id(); $pending = get_user_meta( $user_id, self::PENDING_META, true ); delete_user_meta( $user_id, self::PENDING_META ); // The OAuth state parameter is this flow's CSRF token (it was minted // for this user in start(), which itself required a nonce). // phpcs:disable WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( ! is_array( $pending ) || empty( $pending['state'] ) || ( $pending['expires'] ?? 0 ) < time() || '' === $state || ! hash_equals( (string) $pending['state'], $state ) ) { self::back( 'state' ); } if ( 'access_denied' === $error ) { self::back( 'denied' ); } if ( '' === $code ) { self::back( 'failed' ); } $env = Env::SANDBOX === ( $pending['env'] ?? '' ) ? Env::SANDBOX : Env::PRODUCTION; $response = wp_remote_post( Env::host( 'api', $env ) . '/v1/platform/oauth/token', array( 'timeout' => 20, 'headers' => array( 'Accept' => 'application/json' ), 'body' => array( 'grant_type' => 'authorization_code', 'client_id' => self::CLIENT_ID, 'code' => $code, 'code_verifier' => (string) $pending['verifier'], 'redirect_uri' => self::redirect_uri(), ), ) ); $body = is_wp_error( $response ) ? array() : json_decode( (string) wp_remote_retrieve_body( $response ), true ); $token = is_array( $body ) && isset( $body['access_token'] ) ? (string) $body['access_token'] : ''; if ( 0 !== strpos( $token, 'thr_pl_' ) || ! Connection::store_token( $token, $env ) ) { self::back( 'failed' ); } $linked = self::link_site( $env ); self::back( true === $linked ? 'connected' : 'linked_partial' ); } /** * After a token exists: health, site link, product schema, match field, * first catalog sync. Safe to run again (each step is idempotent). * * @param string|null $env Environment. * @return true|string True, or the first error message. */ public static function link_site( $env = null ) { $health = Api::refresh_health( $env ); if ( is_wp_error( $health ) ) { return $health->get_error_message(); } $site = Api::request( 'POST', '/site', array( 'projectId' => 'wp-' . md5( home_url( '/' ) ), 'domain' => (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ), ), $env ); if ( ! Api::ok( $site ) ) { $message = Api::error_message( $site ); Connection::merge( array( 'link_error' => $message ), $env ); return $message; } Connection::merge( array( 'link_error' => '' ), $env ); $schema = Api::request( 'POST', '/schema', CatalogSync::schema(), $env ); if ( ! Api::ok( $schema ) ) { return Api::error_message( $schema ); } $match = Api::request( 'PUT', '/matchfield', array( 'matchField' => Settings::get( 'match_field' ) ), $env ); if ( ! Api::ok( $match ) ) { return Api::error_message( $match ); } Api::refresh_health( $env ); CatalogSync::queue( 'connect' ); return true; } /** * Disconnect: tell Thridify (releases the domain and the link), then forget locally. * * @return void */ public function disconnect() { $this->authorize_request( self::ACTION_UNLINK ); self::release( Env::current() ); self::back( 'disconnected' ); } /** * Release one environment's link. Best effort: a 401 means it is already gone. * * @param string $env Environment. * @return void */ public static function release( $env ) { if ( null !== Connection::token( $env ) ) { Api::request( 'DELETE', '/site', null, $env, 10 ); } Connection::forget( $env ); CatalogSync::reset_status(); } /** * Capability + nonce gate for the admin-post actions. * * @param string $action Nonce action. * @return void */ private function authorize_request( $action ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to manage Thridify.', 'thridify' ), 403 ); } check_admin_referer( $action ); } /** * URL-safe random string. * * @param int $bytes Entropy bytes. * @return string */ private static function random_token( $bytes ) { try { $raw = random_bytes( $bytes ); } catch ( \Exception $e ) { $raw = wp_generate_password( $bytes, false, false ); } return rtrim( strtr( base64_encode( $raw ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- URL-safe encoding of random bytes. } /** * Back to the settings page with a notice code. * * @param string $notice Notice code. * @return void */ private static function back( $notice ) { wp_safe_redirect( add_query_arg( array( 'page' => 'thridify', 'thridify_notice' => $notice, ), admin_url( 'admin.php' ) ) ); exit; } }