Skip to content
Advertisement

Authenticating requests to azure’s batch REST interface (php)

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.

User contributions licensed under: CC BY-SA
2 People found this is helpful
Advertisement