flareOps service), site secret, and signed plugin -> service requests. * * Contract: docs/architecture-v1.md, "Identity and secrets" and "Connect flow". * Before Connect, nothing is sent outbound. The site secret lives in wp_options with autoload off and is never echoed. * * @package FlareOps */ namespace FlareOps; if ( ! defined( 'ABSPATH' ) ) { exit; } final class Connect { public const SITE_ID_OPTION = 'flareops_site_id'; public const SECRET_OPTION = 'flareops_site_secret'; public const INFO_OPTION = 'flareops_connection'; public const PENDING = 'flareops_connect_pending'; public const PENDING_TTL = 15 * MINUTE_IN_SECONDS; public const DEFAULT_APP = 'https://app.flareops.ai'; public const DEFAULT_API = 'https://api.flareops.ai'; /** Confirm endpoint limits: per client (HMAC of IP) and site-wide, per minute. */ public const CONFIRM_CLIENT_PER_MINUTE = 10; public const CONFIRM_SITE_PER_MINUTE = 60; /* ---------- endpoints (overridable for local testing: constant or filter) ---------- */ private static function const_or( string $const, string $default ): string { return ( defined( $const ) && is_string( constant( $const ) ) && '' !== constant( $const ) ) ? (string) constant( $const ) : $default; } private static function clean_url( $u, string $default ): string { $u = untrailingslashit( esc_url_raw( (string) $u, array( 'https', 'http' ) ) ); return '' === $u ? $default : $u; } /** https://app.flareops.ai, or the FLAREOPS_APP_URL constant, then the flareops_app_url filter (local testing). */ public static function app_url(): string { return self::clean_url( apply_filters( 'flareops_app_url', self::const_or( 'FLAREOPS_APP_URL', self::DEFAULT_APP ) ), self::DEFAULT_APP ); } /** https://api.flareops.ai, or the FLAREOPS_API_URL constant, then the flareops_api_url filter (local testing). */ public static function api_url(): string { return self::clean_url( apply_filters( 'flareops_api_url', self::const_or( 'FLAREOPS_API_URL', self::DEFAULT_API ) ), self::DEFAULT_API ); } /* ---------- state ---------- */ public static function site_id(): string { $v = get_option( self::SITE_ID_OPTION, '' ); return is_string( $v ) ? $v : ''; } public static function secret(): string { $v = get_option( self::SECRET_OPTION, '' ); return is_string( $v ) ? $v : ''; } public static function is_connected(): bool { return '' !== self::site_id() && '' !== self::secret(); } public static function info(): array { $v = get_option( self::INFO_OPTION, array() ); return is_array( $v ) ? $v : array(); } public static function b64url( string $bin ): string { return rtrim( strtr( base64_encode( $bin ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } /** challenge = base64url(sha256(verifier)), as in PKCE S256. */ public static function challenge_for( string $verifier ): string { return self::b64url( hash( 'sha256', $verifier, true ) ); } public static function site_url(): string { return trailingslashit( home_url() ); } /* ---------- step 2: start (admin-post.php?action=flareops_connect) ---------- */ /** Create state/verifier/challenge, keep them 15 minutes, and return the app.flareops.ai URL to send the admin to. */ public static function begin( int $user_id ): string { $verifier = self::b64url( random_bytes( 32 ) ); $state = self::b64url( random_bytes( 24 ) ); $pending = array( 'state' => $state, 'verifier' => $verifier, 'challenge' => self::challenge_for( $verifier ), 'user_id' => $user_id, 'created' => time(), ); set_transient( self::PENDING, $pending, self::PENDING_TTL ); return add_query_arg( array( 'site' => rawurlencode( self::site_url() ), 'state' => rawurlencode( $state ), 'challenge' => rawurlencode( $pending['challenge'] ), 'return' => rawurlencode( Admin::page_url() ), 'v' => rawurlencode( FLAREOPS_VERSION ), ), self::app_url() . '/connect' ); } public static function handle_start(): void { if ( ! current_user_can( Admin::cap() ) ) { wp_die( esc_html__( 'Not allowed.', 'flareops' ), 403 ); } check_admin_referer( 'flareops_connect', 'flareops_connect_nonce' ); $url = self::begin( get_current_user_id() ); $host = (string) wp_parse_url( $url, PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', static function ( $hosts ) use ( $host ) { $hosts[] = $host; return $hosts; } ); wp_safe_redirect( $url ); exit; } private static function pending(): ?array { $p = get_transient( self::PENDING ); if ( ! is_array( $p ) || empty( $p['state'] ) || empty( $p['verifier'] ) || empty( $p['challenge'] ) ) { return null; } if ( time() - (int) ( $p['created'] ?? 0 ) > self::PENDING_TTL ) { return null; } return $p; } /* ---------- step 4: ownership check, public REST POST /connect/confirm {state, challenge} ---------- */ /** * 200 only when this site holds exactly that pending pair. Every failure gets the same 404 body (no info leak). * * @param array $body JSON body. * @param int $body_bytes Raw body length. * @return \WP_REST_Response */ public static function confirm( array $body, int $body_bytes ): \WP_REST_Response { $limited = self::confirm_throttle( $body_bytes ); if ( null !== $limited ) { $res = new \WP_REST_Response( array( 'ok' => false ), 'too_large' === $limited ? 413 : 429 ); if ( 'too_large' !== $limited ) { $res->header( 'Retry-After', '60' ); } $res->header( 'Cache-Control', 'no-store' ); return $res; } $state = is_string( $body['state'] ?? null ) ? $body['state'] : ''; $challenge = is_string( $body['challenge'] ?? null ) ? $body['challenge'] : ''; $p = self::pending(); $ok = null !== $p && '' !== $state && '' !== $challenge && hash_equals( (string) $p['state'], $state ) && hash_equals( (string) $p['challenge'], $challenge ); $res = new \WP_REST_Response( array( 'ok' => $ok ), $ok ? 200 : 404 ); $res->header( 'Cache-Control', 'no-store' ); return $res; } private static function confirm_throttle( int $body_bytes ): ?string { if ( $body_bytes > 2048 ) { return 'too_large'; } $minute = gmdate( 'YmdHi' ); $all = 'flareops_cfm_all_' . $minute; $n = (int) get_transient( $all ); if ( $n >= self::CONFIRM_SITE_PER_MINUTE ) { return 'rate_limited'; } set_transient( $all, $n + 1, 2 * MINUTE_IN_SECONDS ); $ip = Util::server_ip( 'REMOTE_ADDR' ); $key = 'flareops_cfm_c_' . substr( hash_hmac( 'sha256', $ip . '|' . $minute, wp_salt( 'nonce' ) ), 0, 20 ); $c = (int) get_transient( $key ); if ( $c >= self::CONFIRM_CLIENT_PER_MINUTE ) { return 'rate_limited'; } set_transient( $key, $c + 1, 2 * MINUTE_IN_SECONDS ); return null; } /* ---------- steps 5-7: return handler (admin page with ?flareops_code=&state=) ---------- */ /** Runs on admin_init. Only acts on the flareOps page when flareops_code is present. */ public static function maybe_handle_return(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the one-time "state" is the CSRF token here (redirect from app.flareops.ai). if ( ! isset( $_GET['page'], $_GET['flareops_code'] ) || Admin::SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } if ( ! current_user_can( Admin::cap() ) ) { return; } $code = sanitize_text_field( wp_unslash( $_GET['flareops_code'] ) ); $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; // phpcs:enable $res = self::complete( $code, $state, get_current_user_id() ); if ( is_wp_error( $res ) ) { set_transient( 'flareops_admin_err_' . get_current_user_id(), $res->get_error_message(), 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( Admin::page_url( array( 'flareops_msg' => 'connect_failed' ) ) ); exit; } wp_safe_redirect( Admin::page_url( array( 'flareops_msg' => 'connected' ) ) ); exit; } /** * Check state, exchange the one-time code server-to-server, store site_id + secret + pushed settings, set consent. * * @return true|\WP_Error */ public static function complete( string $code, string $state, int $user_id ) { $p = self::pending(); if ( null === $p ) { return new \WP_Error( 'flareops_connect', __( 'No connection attempt is pending (it may have expired). Click "Connect to flareOps" again.', 'flareops' ) ); } if ( '' === $state || ! hash_equals( (string) $p['state'], $state ) || (int) $p['user_id'] !== $user_id ) { // Not consumed: a forged return link must not cancel the admin's real attempt. return new \WP_Error( 'flareops_connect', __( 'The connection response did not match this attempt (state mismatch). Nothing was changed.', 'flareops' ) ); } if ( ! preg_match( '/^[A-Za-z0-9_\-\.]{8,512}$/', $code ) ) { return new \WP_Error( 'flareops_connect', __( 'The connection code is malformed.', 'flareops' ) ); } // Single use: consume before the exchange, so a replayed return link can never redeem twice. delete_transient( self::PENDING ); $res = wp_remote_post( self::api_url() . '/v1/connect/token', array( 'timeout' => 15, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', ), 'body' => wp_json_encode( array( 'code' => $code, 'verifier' => (string) $p['verifier'], 'site' => self::site_url(), ) ), ) ); if ( is_wp_error( $res ) ) { Util::log( 'connect token request failed: ' . $res->get_error_code() ); return new \WP_Error( 'flareops_connect', __( 'Could not reach the flareOps service. Please try again.', 'flareops' ) ); } $http = (int) wp_remote_retrieve_response_code( $res ); $data = json_decode( (string) wp_remote_retrieve_body( $res ), true ); if ( 200 !== $http || ! is_array( $data ) ) { Util::log( 'connect token exchange refused: HTTP ' . $http ); return new \WP_Error( 'flareops_connect', __( 'The flareOps service refused the connection (the code may have expired or been used). Please try again.', 'flareops' ) ); } $site_id = is_string( $data['site_id'] ?? null ) ? $data['site_id'] : ''; $secret = is_string( $data['site_secret'] ?? null ) ? $data['site_secret'] : ''; if ( ! preg_match( '/^[A-Za-z0-9_\-]{1,64}$/', $site_id ) || ! preg_match( '/^[A-Za-z0-9_\-]{32,256}$/', $secret ) ) { return new \WP_Error( 'flareops_connect', __( 'The flareOps service sent an invalid response.', 'flareops' ) ); } update_option( self::SECRET_OPTION, $secret, false ); update_option( self::SITE_ID_OPTION, $site_id, false ); $user = get_userdata( $user_id ); update_option( self::INFO_OPTION, array( 'connected_at' => Util::iso(), 'by' => $user ? $user->user_login : '', 'api' => self::api_url(), ), false ); // Pushed settings: allowlisted data only. Invalid values are ignored (logged), never fatal. $settings = is_array( $data['settings'] ?? null ) ? $data['settings'] : array(); $pushed = array(); foreach ( Settings::PUSHED_KEYS as $k ) { if ( array_key_exists( $k, $settings ) ) { $v = Settings::parse_pushed( $k, $settings[ $k ] ); if ( is_wp_error( $v ) ) { Util::log( 'connect: ignored invalid pushed setting ' . $k ); continue; } $pushed[ $k ] = $v; } } if ( $pushed ) { Settings::set_pushed( $pushed ); } // The merchant approved data use on app.flareops.ai (contract step 3/7). Settings::set_consent( true, ( $user ? $user->user_login : '' ) . ' (flareOps Connect)' ); do_action( 'flareops_connected', $site_id ); return true; } /* ---------- step 9: disconnect ---------- */ public static function handle_disconnect(): void { if ( ! current_user_can( Admin::cap() ) ) { wp_die( esc_html__( 'Not allowed.', 'flareops' ), 403 ); } check_admin_referer( 'flareops_disconnect', 'flareops_disconnect_nonce' ); $user = wp_get_current_user(); self::disconnect( $user ? $user->user_login : '' ); wp_safe_redirect( Admin::page_url( array( 'flareops_msg' => 'disconnected' ) ) ); exit; } /** Revoke on the service (best effort), then clear the secret, pushed settings and verification tags locally. */ public static function disconnect( string $by = '' ): void { if ( self::is_connected() ) { $res = self::signed_request( 'POST', '/v1/connect/revoke', array( 'site_id' => self::site_id() ), 5 ); if ( is_wp_error( $res ) ) { Util::log( 'disconnect: revoke not confirmed: ' . $res->get_error_code() ); } } delete_option( self::SECRET_OPTION ); delete_option( self::SITE_ID_OPTION ); delete_option( self::INFO_OPTION ); delete_transient( self::PENDING ); Settings::clear_pushed(); $s = Settings::all(); if ( '' !== $s['merchant_google_verification'] || '' !== $s['merchant_bing_verification'] ) { Settings::update( array( 'merchant_google_verification' => '', 'merchant_bing_verification' => '', ) ); } Events::clear(); // Consent came from the Connect approval; without a connection nothing should leave the site. Settings::set_consent( false, $by . ' (disconnect)' ); do_action( 'flareops_disconnected' ); } /* ---------- signed plugin -> service requests ---------- */ /** * Headers for a signed request: X-FlareOps-Site, X-FlareOps-Timestamp and * X-FlareOps-Signature = hex HMAC-SHA256(site_secret, ts + "\n" + METHOD + "\n" + path + "\n" + hex sha256(body)). * * @param string $method HTTP method. * @param string $path Request path as sent (e.g. /v1/events), without query string. * @param string $body Exact request body bytes. * @param int|null $ts Unix time (tests). * @return array */ public static function sign_headers( string $method, string $path, string $body, ?int $ts = null ): array { $ts = null === $ts ? time() : $ts; $base = $ts . "\n" . strtoupper( $method ) . "\n" . $path . "\n" . hash( 'sha256', $body ); return array( 'X-FlareOps-Site' => self::site_id(), 'X-FlareOps-Timestamp' => (string) $ts, 'X-FlareOps-Signature' => hash_hmac( 'sha256', $base, self::secret() ), ); } /** * Send a signed JSON request to the flareOps API. Only when connected. * * @param string $method POST (or GET). * @param string $path API path, e.g. /v1/events. * @param array $payload JSON body. * @param int $timeout Seconds. * @return array|\WP_Error wp_remote_request() result. */ public static function signed_request( string $method, string $path, array $payload, int $timeout = 10 ) { if ( ! self::is_connected() ) { return new \WP_Error( 'flareops_not_connected', 'not connected' ); } $url = self::api_url() . $path; $sign_path = (string) wp_parse_url( $url, PHP_URL_PATH ); $body = (string) wp_json_encode( $payload ); $res = wp_remote_request( $url, array( 'method' => strtoupper( $method ), 'timeout' => $timeout, 'redirection' => 0, 'headers' => array_merge( array( 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', ), self::sign_headers( $method, $sign_path, $body ) ), 'body' => $body, ) ); if ( is_wp_error( $res ) ) { return $res; } $code = (int) wp_remote_retrieve_response_code( $res ); if ( $code < 200 || $code >= 300 ) { return new \WP_Error( 'flareops_http_' . $code, 'HTTP ' . $code ); } return $res; } }