check_rate_limit( $request ) ) { return new \WP_Error( 'rate_limited', __( 'Rate limit exceeded. Try again later.', 'ai-discoverability-kit' ), array( 'status' => 429 ) ); } return true; } // API key mode. $auth_header = $request->get_header( 'authorization' ); if ( ! $auth_header || ! str_starts_with( $auth_header, 'Bearer ' ) ) { return new \WP_Error( 'unauthorized', __( 'Authorization header required. Format: Bearer YOUR_TOKEN', 'ai-discoverability-kit' ), array( 'status' => 401 ) ); } $token = substr( $auth_header, 7 ); $hashed = hash( 'sha256', $token ); $stored_keys = get_option( 'aicommerce_mcp_api_keys', array() ); // Constant-time comparison against all stored key hashes. $valid = false; foreach ( $stored_keys as $stored_hash => $meta ) { if ( hash_equals( (string) $stored_hash, $hashed ) ) { $valid = true; break; } } if ( ! $valid ) { return new \WP_Error( 'unauthorized', __( 'Invalid API key.', 'ai-discoverability-kit' ), array( 'status' => 401 ) ); } if ( ! $this->check_rate_limit( $request, $hashed ) ) { return new \WP_Error( 'rate_limited', __( 'Rate limit exceeded. Try again later.', 'ai-discoverability-kit' ), array( 'status' => 429 ) ); } return true; } /** * Check rate limit for a request. * * @param \WP_REST_Request $request The request. * @param string $key_id Hashed API key, or empty for IP-based. * * @return bool True if within limit, false if exceeded. */ public function check_rate_limit( \WP_REST_Request $request, string $key_id = '' ): bool { $identifier = $key_id ? $key_id : $this->get_client_ip(); $transient_key = 'aicommerce_mcp_rate_' . substr( md5( $identifier ), 0, 12 ); $count = (int) get_transient( $transient_key ); if ( $count >= self::RATE_LIMIT ) { return false; } set_transient( $transient_key, $count + 1, HOUR_IN_SECONDS ); return true; } /** * Generate a new MCP API key. * * Returns the raw token once — only the hash is stored. * * @param string $label Optional label for the key. * * @return array{raw_token: string, hashed_token: string} */ public function generate_api_key( string $label = '' ): array { $raw = bin2hex( random_bytes( 32 ) ); $hashed = hash( 'sha256', $raw ); $stored = get_option( 'aicommerce_mcp_api_keys', array() ); $stored[ $hashed ] = array( 'created_at' => current_time( 'mysql' ), 'label' => $label ?: 'API Key ' . ( count( $stored ) + 1 ), ); update_option( 'aicommerce_mcp_api_keys', $stored, false ); return array( 'raw_token' => $raw, 'hashed_token' => $hashed, ); } /** * Revoke an MCP API key by its hash. * * @param string $hashed_token The SHA-256 hash of the key. * * @return bool True if revoked, false if not found. */ public function revoke_api_key( string $hashed_token ): bool { $stored = get_option( 'aicommerce_mcp_api_keys', array() ); if ( ! isset( $stored[ $hashed_token ] ) ) { return false; } unset( $stored[ $hashed_token ] ); update_option( 'aicommerce_mcp_api_keys', $stored, false ); return true; } /** * List all stored API keys (hashes + metadata only, never raw tokens). * * @return array */ public function list_api_keys(): array { return get_option( 'aicommerce_mcp_api_keys', array() ); } /** * Handle REST request to generate a new API key. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function handle_generate_key( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params(); $label = sanitize_text_field( $params['label'] ?? '' ); $result = $this->generate_api_key( $label ); return new \WP_REST_Response( array( 'token' => $result['raw_token'], 'hashed_token' => $result['hashed_token'], 'message' => __( 'Save this token — it will not be shown again.', 'ai-discoverability-kit' ), ), 201 ); } /** * Handle REST request to list API keys. * * @return \WP_REST_Response */ public function handle_list_keys(): \WP_REST_Response { $keys = $this->list_api_keys(); $list = array(); foreach ( $keys as $hash => $meta ) { $list[] = array( 'hashed_token' => $hash, 'label' => $meta['label'] ?? '', 'created_at' => $meta['created_at'] ?? '', ); } return new \WP_REST_Response( $list, 200 ); } /** * Handle REST request to revoke an API key. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function handle_revoke_key( \WP_REST_Request $request ): \WP_REST_Response { $hash = $request->get_param( 'hash' ); $revoked = $this->revoke_api_key( $hash ); if ( ! $revoked ) { return new \WP_REST_Response( array( 'error' => __( 'Key not found.', 'ai-discoverability-kit' ) ), 404 ); } return new \WP_REST_Response( array( 'deleted' => true ), 200 ); } /** * Get the client IP address for rate limiting. * * Uses REMOTE_ADDR only. The X-Forwarded-For header is deliberately NOT * trusted here: it is attacker-controllable and would let a client bypass * rate limiting (and grow the transient cache) by spoofing unique values. * * @return string Client IP address. */ private function get_client_ip(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; return '' !== $ip ? $ip : '0.0.0.0'; } }