$verifier, 'user' => get_current_user_id(), ), self::STATE_TTL ); return add_query_arg( array( 'platform' => 'wordpress', 'site' => rawurlencode( home_url( '/' ) ), 'redirect_uri' => rawurlencode( self::callback_url() ), 'code_challenge' => self::challenge( $verifier ), 'state' => $state, ), Api::console_base() . '/connect/authorize' ); } /** * Finish the connection after the approval in the console. * * @param string $code One time code. * @param string $state State issued by authorize_url(). * @return string Result code for the notice. */ public static function complete( string $code, string $state ): string { if ( ! self::well_formed( $code ) || ! self::well_formed( $state ) ) { return 'invalid'; } $key = self::state_key( $state ); $pending = get_transient( $key ); delete_transient( $key ); if ( ! is_array( $pending ) || (int) ( $pending['user'] ?? 0 ) !== get_current_user_id() || empty( $pending['verifier'] ) ) { return 'expired'; } return self::exchange( $code, (string) $pending['verifier'] ); } /** * Exchange a one time code for a connection. * * @param string $code One time code. * @param string $verifier PKCE verifier. * @return string Result code: connected, failed, unreachable, limit. */ public static function exchange( string $code, string $verifier ): string { $pair = sodium_crypto_sign_keypair(); $secret = sodium_crypto_sign_secretkey( $pair ); $public = sodium_crypto_sign_publickey( $pair ); $response = Api::post( '/api/connect/v1/token', array( 'code' => $code, 'codeVerifier' => $verifier, 'publicKey' => Api::spki( $public ), 'pluginVersion' => FAMMRO_VERSION, ) ); if ( null === $response ) { return 'unreachable'; } $body = $response['body']; if ( 200 !== $response['status'] || ! self::valid_widget_key( (string) ( $body['widgetKey'] ?? '' ) ) || ! wp_is_uuid( (string) ( $body['installationId'] ?? '' ) ) ) { return 'WEBSITE_LIMIT' === ( $body['error'] ?? '' ) ? 'limit' : 'failed'; } // A new connection replaces an old one: the old key pair is no longer valid. WooCommerce::revoke_key(); Store::save_connection( (string) $body['installationId'], (string) $body['widgetKey'], $secret, self::console_link( (string) ( $body['consoleUrl'] ?? '' ) ) ); Store::save_settings( array( 'enabled' => true ) ); if ( Store::settings()['shop_data'] ) { WooCommerce::share(); } return 'connected'; } /** * Disconnect at FAMMRO and forget the connection here. Local data is removed even when FAMMRO * cannot be reached, so the chat window disappears in any case. */ public static function disconnect(): void { if ( null !== Store::connection() ) { Api::signed( 'POST', '/api/connect/v1/disconnect', array() ); } WooCommerce::revoke_key(); Store::clear_connection(); Store::save_settings( array( 'shop_data' => false ) ); } /** * Tell FAMMRO whether the chat window is switched on, so the console shows the same state. * * @param bool $enabled Switched on. */ public static function report_enabled( bool $enabled ): void { Api::signed( 'PATCH', '/api/connect/v1/installation', array( 'enabled' => $enabled, 'pluginVersion' => FAMMRO_VERSION, ) ); } /** * Remote state of the installation, null when unknown. * * @return array|null */ public static function remote_state(): ?array { $response = Api::signed( 'GET', '/api/connect/v1/installation' ); if ( null === $response || 200 !== $response['status'] ) { return null; } return $response['body']; } /** * PKCE challenge (S256, base64url without padding). * * @param string $verifier Verifier. */ public static function challenge( string $verifier ): string { return self::base64url( hash( 'sha256', $verifier, true ) ); } /** * Only codes, states and verifiers in base64url with a sane length are accepted. * * @param string $value Value. */ public static function well_formed( string $value ): bool { return 1 === preg_match( '/^[A-Za-z0-9_-]{16,128}$/', $value ); } /** * Public chat window key as issued by FAMMRO. * * @param string $key Key. */ private static function valid_widget_key( string $key ): bool { return 1 === preg_match( '/^fam_live_[A-Za-z0-9_]{16,200}$/', $key ); } /** * Console link only on the FAMMRO console host. * * @param string $url URL from the API. */ private static function console_link( string $url ): string { $host = wp_parse_url( $url, PHP_URL_HOST ); $base = wp_parse_url( Api::console_base(), PHP_URL_HOST ); return ( str_starts_with( $url, 'https://' ) && is_string( $host ) && $host === $base ) ? esc_url_raw( $url ) : Api::console_base() . '/app'; } /** * Random token in base64url. */ private static function random_token(): string { return self::base64url( random_bytes( 32 ) ); } /** * Base64url without padding. * * @param string $bytes Raw bytes. */ private static function base64url( string $bytes ): string { return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- PKCE encoding. } /** * Transient name for a state. * * @param string $state State. */ private static function state_key( string $state ): string { return 'fammro_auth_' . substr( hash( 'sha256', $state ), 0, 32 ); } /** * Scheme and host of a URL. * * @param string $url URL. */ private static function origin( string $url ): string { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return ''; } $port = isset( $parts['port'] ) ? ':' . $parts['port'] : ''; return strtolower( ( $parts['scheme'] ?? '' ) . '://' . $parts['host'] . $port ); } }