I'm trying to authenticate against azures batch REST interface from my php server. According to the docs (https://learn.microsoft.com/en-us/rest/api/batchservice/authenticate-requests-to-the-azure-batch-service) I came up with this function:
use GuzzleHttp\Client;
const BATCH_ACCOUNT_NAME = "myAccount";
const BATCH_ACCOUNT_KEY = "mySuperSecretKey";
const BATCH_ENDPOINT = "https://myAccount.theRegion.batch.azure.com";
// Pool and Job constants
const POOL_ID = "MyTestPool";
const POOL_VM_SIZE = "STANDARD_A1_v2";
private function createPoolIfNotExists()
{
echo "-- creating batch pool --\n\n";
$client = new Client();
$body = [
"id" => POOL_ID,
"vmSize" => POOL_VM_SIZE,
];
$contentType = "application/json;odata=minimalmetadata";
$apiVersion = "2021-06-01.14.0";
$ocpDate = Carbon::now('UTC')->toString("R");
$signature = $this->createRidiculouslyOverComplicatedSignature(
"POST",
$contentType,
$apiVersion,
$ocpDate,
$body
);
$response = $client->post(BATCH_ENDPOINT . "/pools?api-version={$apiVersion}", [
'json' => $body,
'headers' => [
"ocp-date" => $ocpDate,
"Authorization" => "SharedKey " . BATCH_ACCOUNT_NAME . ":{$signature}"
]
]);
$contents = json_decode($response->getBody());
dd($contents);
}
private function createRidiculouslyOverComplicatedSignature($verb, $contentType, $apiVersion, $ocpDate, $body)
{
$contentLength = mb_strlen(json_encode($body, JSON_NUMERIC_CHECK), '8bit');
$canonicalizedHeaders = "ocp-date:{$ocpDate}";
$canonicalizedResource = "/" . BATCH_ACCOUNT_NAME . "/pools\napi-version:{$apiVersion}";
$stringToSign = "{$verb}\n\n\n{$contentLength}\n\n{$contentType}\n\n\n\n\n\n\n{$canonicalizedHeaders}\n{$canonicalizedResource}";
echo utf8_encode($stringToSign);
return base64_encode(hash_hmac('sha256', utf8_encode($stringToSign), BATCH_ACCOUNT_KEY));
}
However, I always get a 403 error:
"Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature"
Due to the complicated setup and the vague error message, I have a really hard time to figure out, where/why it's failing. Tried tweaking every option I could think of, but no. What am I missing here?
Update: I managed to convert the batch auth lib from the official python sdk into php. This is what I came up with:
private function createPoolIfNotExist()
{
echo "-- creating batch pool --\n\n";
$credentials = new BatchSharedKeyCredentials(
BATCH_ACCOUNT_NAME,
BATCH_ACCOUNT_KEY,
BATCH_ENDPOINT,
);
$body = [
"id" => POOL_ID,
"vmSize" => POOL_VM_SIZE,
"targetDedicatedNodes" => 0,
"targetLowPriorityNodes" => 1,
];
$stack = HandlerStack::create(new CurlHandler());
$stack->push(new BatchAuthentication($credentials));
$client = new Client([
'handler' => $stack,
]);
$client->post(BATCH_ENDPOINT . "/pools?api-version=2021-06-01.14.0", [
'json' => $body
]);
dd("end");
}
class BatchAuthentication
{
public BatchSharedKeyCredentials $credentials;
public function __construct(BatchSharedKeyCredentials $credentials)
{
$this->credentials = $credentials;
}
public function __invoke(callable $handler)
{
return function (RequestInterface $request, array $options) use ($handler) {
$newRequest = $this->signHeader($request);
return $handler(
$newRequest,
$options
);
};
}
private function sign(string $stringToSign)
{
$key = $this->credentials->keyValue;
$stringToSign = utf8_encode($stringToSign);
$key = base64_decode($key);
$sign = hash_hmac(
'sha256',
$stringToSign,
$key,
true
);
$signature = utf8_decode(base64_encode($sign));
echo ($signature);
return $signature;
}
private function signHeader(RequestInterface $request)
{
// Set Headers
if ($request->getHeader("ocp-date") == null) {
$dateTime = Carbon::now('UTC')->toRfc7231String();
$request = $request->withAddedHeader("ocp-date", $dateTime);
}
echo ("\n ocp date: " . $request->getHeader("ocp-date")[0] . "\n");
$signature = $request->getMethod() . "\n";
$signature .= $this->headerValue($request, "Content-Encoding") . "\n";
$signature .= $this->headerValue($request, "Content-Language") . "\n";
// Special handle content length
$length = -1;
if ($request->getBody() != null) {
$length = $request->getBody()->getSize();
}
$signature .= ($length >= 0 ? $length : "") . "\n";
$signature .= $this->headerValue($request, "Content-MD5") . "\n";
// Special handle content type header
$contentType = "";
if ($request->getBody() != null && $request->getBody()->getSize() != 0) {
//here it differs. But official docs say like this:
$contentType = "application/json; odata=minimalmetadata; charset=utf-8";
}
$signature .= $contentType . "\n";
$signature .= $this->headerValue($request, "Date") . "\n";
$signature .= $this->headerValue($request, "If-Modified-Since") . "\n";
$signature .= $this->headerValue($request, "If-Match") . "\n";
$signature .= $this->headerValue($request, "If-None-Match") . "\n";
$signature .= $this->headerValue($request, "If-Unmodified-Since") . "\n";
$signature .= $this->headerValue($request, "Range") . "\n";
$customHeaders = array();
foreach ($request->getHeaders() as $key => $value) {
if (str_starts_with(strtolower($key), "ocp-")) {
array_push($customHeaders, strtolower($key));
}
}
sort($customHeaders);
foreach ($customHeaders as $canonicalHeader) {
$value = $request->getHeader($canonicalHeader)[0];
$value = str_replace('\n', ' ', $value);
$value = str_replace('\r', ' ', $value);
$value = preg_replace("/^[ ]+/", "", $value);
$signature .= $canonicalHeader . ":" . $value . "\n";
}
$signature .= "/" . strtolower($this->credentials->accountName) . "/"
. str_replace("/", "", $request->getUri()->getPath());
$query = $request->getUri()->getQuery();
if ($query != null) {
$queryComponents = array();
$pairs = explode("&", $query);
foreach ($pairs as $pair) {
$idx = strpos($pair, "=");
$key = strtolower(urldecode(mb_strcut($pair, 0, $idx, "UTF-8")));
$queryComponents[$key] = $key . ":" . urldecode(mb_strcut($pair, $idx + 1, strlen($pair), "UTF-8"));
}
foreach ($queryComponents as $key => $value) {
$signature .= "\n" . $value;
}
}
echo ("\nsignature:\n" . $signature . "\n");
$signedSignature = $this->sign($signature);
$authorization = "SharedKey " . $this->credentials->accountName . ":" . $signedSignature;
$request = $request->withAddedHeader("Authorization", $authorization);
echo "\n";
foreach ($request->getHeaders() as $key => $value) {
echo ($key . " : " . $value[0] . "\n");
}
return $request;
}
private function headerValue(RequestInterface $request, String $headerName): String
{
$headerValue = $request->getHeader($headerName);
if ($headerValue == null) {
return "";
}
return $headerValue[0];
}
}
class BatchSharedKeyCredentials
{
public string $accountName;
public string $keyValue;
public string $baseUrl;
public function __construct(string $accountName, string $keyValue, string $baseUrl)
{
$this->accountName = $accountName;
$this->keyValue = $keyValue;
$this->baseUrl = $baseUrl;
}
}
I ran some tests, for the signing process with a "test-string" in both, the (working) python example and my php script. The signature is the same, so my signing function now definitely works!
I also compared headers and the string to sign. They are the same!
And yet in php it throws a 403 error, telling me
The MAC signature found in the HTTP request 'mySignatureCode' is not the same as any computed signature.
Took me a week to figure it out, it was the content-type header, that guzzle automatically sets if you don't specify it.
I post my whole script in case anyone else ever wants to do the same - no need to suffer too - it should work fine now:
<?php
namespace App\Http\Middleware;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\HandlerStack;
use Psr\Http\Message\RequestInterface;
use Illuminate\Support\Carbon;
class AzureBatchClient extends Client
{
public function __construct(array $config = [])
{
$stack = HandlerStack::create(new CurlHandler());
$stack->push(new BatchAuthentication(new BatchSharedKeyCredentials(
env("AZURE_BATCH_ACCOUNT"),env("AZURE_BATCH_KEY")
)));
$config['handler'] = $stack;
parent::__construct($config);
}
}
class BatchAuthentication
{
public BatchSharedKeyCredentials $credentials;
public function __construct(BatchSharedKeyCredentials $credentials)
{
$this->credentials = $credentials;
}
public function __invoke(callable $handler)
{
return function (RequestInterface $request, array $options) use ($handler) {
$newRequest = $this->signHeader($request);
return $handler(
$newRequest,
$options
);
};
}
private function sign(string $stringToSign)
{
$key = $this->credentials->keyValue;
$stringToSign = utf8_encode($stringToSign);
$key = base64_decode($key);
$sign = hash_hmac(
'sha256',
$stringToSign,
$key,
true
);
$signature = utf8_decode(base64_encode($sign));
//echo ($signature);
return $signature;
}
private function signHeader(RequestInterface $request)
{
// Set Headers
if ($request->getHeader("ocp-date") == null) {
$dateTime = Carbon::now('UTC')->toRfc7231String();
$request = $request->withAddedHeader("ocp-date", $dateTime);
}
//echo ("\n ocp date: " . $request->getHeader("ocp-date")[0] . "\n");
$signature = $request->getMethod() . "\n";
$signature .= $this->headerValue($request, "Content-Encoding") . "\n";
$signature .= $this->headerValue($request, "Content-Language") . "\n";
// Special handle content length
$length = -1;
if ($request->getBody() != null) {
$length = $request->getBody()->getSize();
}
$signature .= ($length > 0 ? $length : "") . "\n";
$signature .= $this->headerValue($request, "Content-MD5") . "\n";
// Special handle content type header
$contentType = "";
if ($request->getBody() != null && $request->getBody()->getSize() != 0) {
//here it differs. But official docs say like this:
$contentType = "application/json; odata=minimalmetadata; charset=utf-8";
}
$signature .= $contentType . "\n";
$signature .= $this->headerValue($request, "Date") . "\n";
$signature .= $this->headerValue($request, "If-Modified-Since") . "\n";
$signature .= $this->headerValue($request, "If-Match") . "\n";
$signature .= $this->headerValue($request, "If-None-Match") . "\n";
$signature .= $this->headerValue($request, "If-Unmodified-Since") . "\n";
$signature .= $this->headerValue($request, "Range") . "\n";
$customHeaders = array();
foreach ($request->getHeaders() as $key => $value) {
if (str_starts_with(strtolower($key), "ocp-")) {
array_push($customHeaders, strtolower($key));
}
}
sort($customHeaders);
foreach ($customHeaders as $canonicalHeader) {
$value = $request->getHeader($canonicalHeader)[0];
$value = str_replace('\n', ' ', $value);
$value = str_replace('\r', ' ', $value);
$value = preg_replace("/^[ ]+/", "", $value);
$signature .= $canonicalHeader . ":" . $value . "\n";
}
$path = substr_replace($request->getUri()->getPath(), "", 0, strlen("/"));
// echo $path;
$signature .= "/" . strtolower($this->credentials->accountName) . "/" . $path;
$query = $request->getUri()->getQuery();
if ($query != null) {
$queryComponents = array();
$pairs = explode("&", $query);
foreach ($pairs as $pair) {
$idx = strpos($pair, "=");
$key = strtolower(urldecode(mb_strcut($pair, 0, $idx, "UTF-8")));
$queryComponents[$key] = $key . ":" . urldecode(mb_strcut($pair, $idx + 1, strlen($pair), "UTF-8"));
}
foreach ($queryComponents as $key => $value) {
$signature .= "\n" . $value;
}
}
//echo ("\n\n" . str_replace("\n", "\\n", $signature) . "\n\n");
$signedSignature = $this->sign($signature);
$authorization = "SharedKey " . $this->credentials->accountName . ":" . $signedSignature;
$request = $request->withAddedHeader("Authorization", $authorization);
/*
foreach ($request->getHeaders() as $key => $value) {
echo ($key . " : " . $value[0] . "\n");
}
*/
return $request;
}
private function headerValue(RequestInterface $request, String $headerName): String
{
$headerValue = $request->getHeader($headerName);
if ($headerValue == null) {
return "";
}
return $headerValue[0];
}
}
class BatchSharedKeyCredentials
{
public string $accountName;
public string $keyValue;
public function __construct(string $accountName, string $keyValue)
{
$this->accountName = $accountName;
$this->keyValue = $keyValue;
}
}
And you use it like this for POST:
$client = new AzureBatchClient();
$client->post(BATCH_ENDPOINT . "/pools?api-version=" . API_VERISON, [
'json' => $body,
'headers' => [
"Content-Type" => "application/json; odata=minimalmetadata; charset=utf-8"
]
]);
And like this for GET etc:
$client = new AzureBatchClient();
$client->get(BATCH_ENDPOINT . "/jobs/{$jobId}?api-version=" . API_VERISON);
Just make sure, the content-type in your headers and in your signature string match.