Search code examples
phpazureazure-batch

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://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.


Solution

  • 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.