I have translated the Python example in the documentation of how to construct the signature required in the Authorization header for requests made to the data collector API, into Rust. My Rust program produces a SharedKey <workspace_id>:<signature>
that is rejected by the server with code 403 and with the body of the response simply saying
{\\\"Error\\\":\\\"InvalidAuthorization\\\",\\\"Message\\\":\\\"An invalid signature was specified in the Authorization header\\\"}
On the other hand, I've taken the same date in RFC1123 format from the Rust program execution and hardcoded it in the Python example script. It produces the exact same SharedKey <workspace_id>:<signature>
value for the authorization header and is successful in using the API to send logs.
There is no other information about the nature of the problem with the signature from the Rust program in the response. As far as I can tell, the error is perhaps reporting the wrong problem, since they are exactly the same signature and one works while the other doesn't.
What other reasons could this error type be returned from this API?
I'd be happy to share some Rust code if anyone thinks it's useful, but I am certain that the authorization header from the example Python script in their documentation and my Rust program are identical. So I feel that it's something else.
Edit: Here is a slightly modified version of Python example from the link above, which is successful in using the API and I see the test message show up in the log analytics workspace:
import requests
import datetime
import hashlib
import hmac
import base64
WORKSPACE_ID = "<my workspace ID>"
SHARED_KEY = "<my primary key>"
def build_signature(message, secret):
key_bytes = base64.b64decode(secret)
message_bytes = bytes(message, encoding="utf-8")
hmacsha256 = hmac.new(key_bytes, message_bytes, digestmod=hashlib.sha256).digest()
encoded_hash = base64.b64encode(hmacsha256).decode()
return encoded_hash
def post_data():
data = '{"hello":"world"}'
date_string = datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
# date_string = "Thu, 14 Sep 2023 01:27:12 GMT"
content_length = len(data)
string_to_hash = f"POST\n{content_length}\napplication/json\nx-ms-date:{date_string}\n/api/logs"
hashed_string = build_signature(string_to_hash, SHARED_KEY)
signature = f"SharedKey {WORKSPACE_ID}:{hashed_string}"
print(signature)
query = "api-version=2016-04-01";
url = f"https://{WORKSPACE_ID}.ods.opinsights.azure.com/api/logs?{query}"
headers = {
'content-type': "application/json",
'Authorization': signature,
'Log-Type': "my-event-type",
'x-ms-date': date_string
}
response = requests.post(url, data=data, headers=headers)
if (response.status_code >= 200 and response.status_code <= 299):
print('Accepted')
else:
print("Response: {}".format(response.content))
print("Response code: {}".format(response.status_code))
if __name__ == "__main__":
post_data()
Here is my Rust version that produces the exact same authorization header value, yet an InvalidAuthorization
error when trying to use it:
use base64;
use chrono::Utc;
use ring::hmac;
#[derive(Clone, Debug, Error)]
enum MyError {
MessageSendError(String),
}
struct SentinelClient {
azure_collector_url: String,
workspace_id: String,
shared_key: String,
http_client: reqwest::Client,
}
impl SentinelClient {
fn new(workspace_id: String, shared_key: String) -> Self {
let query = "api-version=2016-04-01";
let azure_collector_url =
format!("https://{workspace_id}.ods.opinsights.azure.com/api/logs?{query}");
Self {
azure_collector_url,
workspace_id,
shared_key,
http_client: reqwest::Client::new(),
}
}
fn send(&self, data: String) -> Result<(), MyError> {
let workspace_id = &self.workspace_id;
let date_string = format!("{}", Utc::now().format("%a, %d %b %Y %H:%M:%S GMT"));
println!("date string {}", date_string);
let content_length = data.to_string().len();
let string_to_hash =
format!("POST\n{content_length}\napplication/json\nx-ms-date:{date_string}\n/api/logs");
let hashed_string = Self::build_signature(&string_to_hash, &self.shared_key)?;
let signature = format!("SharedKey {workspace_id}:{hashed_string}");
println!("{}", signature);
let url = &self.azure_collector_url;
let request = self
.http_client
.post(url)
.json(&data)
.header("Authorization", signature)
.header("Log-Type", "my-event-type")
.header("x-ms-date", date_string);
match request.send().await {
Ok(resp) => {
if resp.status().is_success() {
println!("request successful: {:?}", &resp);
Ok(())
} else {
let body = resp
.text()
.await
.map_err(|e| SinkError::MessageSendError(e.to_string()))?;
println!("request unsuccessful: {body}");
Err(MyError::MessageSendError(body))
}
}
Err(e) => Err(MyError::MessageSendError(e.to_string())),
}
/// Build the API signature for the Azure Monitor HTTP Data Collector
/// authorization header.
///
/// See https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-collector-api?tabs=python#authorization.
fn build_signature(message: &str, secret: &str) -> Result<String, MyError> {
let key_bytes = base64::decode(secret).map_err(|e| {
MyError::MessageSendError(format!("could not decode shared key: {e}"))
})?;
let message_bytes = message.as_bytes();
let hmacsha256 = hmac::Key::new(hmac::HMAC_SHA256, &key_bytes);
let encoded_hash = base64::encode(hmac::sign(&hmacsha256, message_bytes).as_ref());
Ok(encoded_hash)
}
}
I am told that the JSON request body must be utf-8 encoded, otherwise the server rejects the request with a 403 Unauthorized, which seems incredibly misleading. In my case, the request body was a Rust String
representing a JSON object, which is utf-8 encoded, and which I was passing as the request body via
http_client.post(url).json(&data)
It's unclear to me yet why this works instead,
http_client
.post(url)
.body(data)
.header(reqwest::header::CONTENT_TYPE, "application/json")
since I was under the impression that the json
method did exactly that. But the json
method must do something with how the data is encoded that body
does not.