I’m trying to authenticate against azures batch REST interface from my php server. According to the docs (https://docs.microsoft.com/en-us/rest/api/batchservice/authenticate-requests-to-the-azure-batch-service) I came up with this function:
use GuzzleHttpClient; 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 --nn"; $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 . "/poolsnapi-version:{$apiVersion}"; $stringToSign = "{$verb}nnn{$contentLength}nn{$contentType}nnnnnnn{$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 --nn"; $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.
Advertisement
Answer
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 AppHttpMiddleware; use GuzzleHttpClient; use GuzzleHttpHandlerCurlHandler; use GuzzleHttpHandlerStack; use PsrHttpMessageRequestInterface; use IlluminateSupportCarbon; 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 ("nn" . str_replace("n", "\n", $signature) . "nn"); $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.