Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.67% covered (success)
97.67%
84 / 86
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Auth
97.67% covered (success)
97.67%
84 / 86
90.91% covered (success)
90.91%
10 / 11
25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 signRequest
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getAuthorizationHeader
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateSasToken
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 tryGetValueInsensitive
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 tryGetValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 startsWith
50.00% covered (warning)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 formatHeaders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 computeSignature
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 computeCanonicalizedHeaders
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 computeCanonicalizedResource
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Storage\Adapter\Azure;
16
17use Pop\Http\Client\Request;
18
19/**
20 * Azure storage auth class
21 *
22 * This class is ported over from the discontinued Azure Storage PHP library at
23 * https://github.com/Azure/azure-storage-php (EOL: 3/17/2025)
24 *
25 * @category   Pop
26 * @package    Pop\Storage
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    3.0.0
31 */
32class Auth extends AbstractAuth
33{
34
35    /**
36     * The included headers
37     * @var array
38     */
39    protected array $includedHeaders = [
40        'content-encoding', 'content-language', 'content-length', 'content-md5', 'content-type', 'date',
41        'if-modified-since', 'if-match', 'if-none-match', 'if-unmodified-since', 'range',
42    ];
43
44    /**
45     * Constructor.
46     *
47     * @param string $accountName
48     * @param string $accountKey
49     */
50    public function __construct(string $accountName, string $accountKey)
51    {
52        $this->setAccountName($accountName);
53        $this->setAccountKey($accountKey);
54    }
55
56    /**
57     * Adds authentication header to the request headers.
58     *
59     * @param  Request $request
60     * @return Request
61     */
62    public function signRequest(Request $request): Request
63    {
64        $queryParams = ($request->hasQuery()) ? $request->getQuery()->toArray() : [];
65
66        $signedKey = $this->getAuthorizationHeader(
67            self::formatHeaders($request->getHeadersAsArray()), $request->getUriAsString(),
68            $queryParams, $request->getMethod()
69        );
70
71        return $request->addHeader('authorization', $signedKey);
72    }
73
74    /**
75     * Returns authorization header to be included in the request.
76     *
77     * @param  array  $headers
78     * @param  string $url
79     * @param  array  $queryParams
80     * @param  string $httpMethod
81     * @return string
82     */
83    public function getAuthorizationHeader(array $headers, string $url, array $queryParams, string $httpMethod): string
84    {
85        $signature = $this->computeSignature($headers, $url, $queryParams, $httpMethod);
86
87        return 'SharedKey ' . $this->accountName . ':' . base64_encode(
88            hash_hmac('sha256', $signature, base64_decode($this->accountKey), true)
89        );
90    }
91
92    /**
93     * Generate a service SAS token for a blob
94     *
95     * @param  string     $resourcePath  e.g. '/container/blob.txt'
96     * @param  int        $expiresInSeconds
97     * @param  string     $permissions   e.g. 'r' for read-only
98     * @param  ?\DateTime $expiresAt     explicit expiry, overriding $expiresInSeconds (mainly for tests)
99     * @return string
100     */
101    public function generateSasToken(
102        string $resourcePath, int $expiresInSeconds, string $permissions = 'r', ?\DateTime $expiresAt = null
103    ): string
104    {
105        $expiry = $expiresAt ?? (new \DateTime('now', new \DateTimeZone('UTC')))->modify('+' . $expiresInSeconds . ' seconds');
106        $signedExpiry = $expiry->format('Y-m-d\TH:i:s\Z');
107        $canonicalizedResource = '/blob/' . $this->accountName . $resourcePath;
108
109        $stringToSign = implode("\n", [
110            $permissions,
111            '',
112            $signedExpiry,
113            $canonicalizedResource,
114            '',
115            '',
116            'https',
117            '2025-01-05',
118            'b',
119            '',
120            '',
121            '',
122            '',
123            '',
124            '',
125            '',
126        ]);
127
128        $signature = base64_encode(hash_hmac('sha256', $stringToSign, base64_decode($this->accountKey), true));
129
130        return http_build_query([
131            'sv'  => '2025-01-05',
132            'sr'  => 'b',
133            'sp'  => $permissions,
134            'se'  => $signedExpiry,
135            'spr' => 'https',
136            'sig' => $signature,
137        ]);
138    }
139
140    /**
141     * Returns the specified value of the $key passed from $array and in case that
142     * this $key doesn't exist, the default value is returned. The key matching is
143     * done in a case-insensitive manner.
144     *
145     * @param  string $key
146     * @param  array  $haystack
147     * @param  mixed  $default
148     * @return mixed
149     */
150    public static function tryGetValueInsensitive(string $key, array $haystack, mixed $default = null): mixed
151    {
152        $array = array_change_key_case($haystack);
153        return self::tryGetValue($array, strtolower($key), $default);
154    }
155
156    /**
157     * Returns the specified value of the $key passed from $array and in case that
158     * this $key doesn't exist, the default value is returned.
159     *
160     * @param  array $array
161     * @param  mixed $key
162     * @param  mixed $default
163     * @return mixed
164     */
165    public static function tryGetValue(array $array, mixed $key, mixed $default = null): mixed
166    {
167        return (!empty($array) && array_key_exists($key, $array)) ? $array[$key] : $default;
168    }
169
170    /**
171     * Checks if the passed $string starts with $prefix
172     *
173     * @param  string $string
174     * @param  string $prefix
175     * @param  bool   $ignoreCase
176     * @return bool
177     */
178    public static function startsWith(string $string, string $prefix, bool $ignoreCase = false): bool
179    {
180        if ($ignoreCase) {
181            $string = strtolower($string);
182            $prefix = strtolower($prefix);
183        }
184        return (str_starts_with($string, $prefix));
185    }
186
187    /**
188     * Convert a http headers array into a uniformed format for further process
189     *
190     * @param  array $headers
191     * @return array
192     */
193    public static function formatHeaders(array $headers): array
194    {
195        $result = [];
196        foreach ($headers as $key => $value) {
197            $result[strtolower($key)] = (is_array($value) && count($value) == 1) ? $value[0] : $value;
198        }
199
200        return $result;
201    }
202
203    /**
204     * Computes the authorization signature for blob and queue shared key.
205     *
206     * @param  array  $headers
207     * @param  string $url
208     * @param  array  $queryParams
209     * @param  string $httpMethod
210     * @return string
211     */
212    protected function computeSignature(array $headers, string $url, array $queryParams, string $httpMethod): string
213    {
214        $canonicalizedHeaders  = $this->computeCanonicalizedHeaders($headers);
215        $canonicalizedResource = $this->computeCanonicalizedResource($url, $queryParams);
216
217        $stringToSign   = [];
218        $stringToSign[] = strtoupper($httpMethod);
219
220        foreach ($this->includedHeaders as $header) {
221            $stringToSign[] = self::tryGetValueInsensitive($header, $headers);
222        }
223
224        if (count($canonicalizedHeaders) > 0) {
225            $stringToSign[] = implode("\n", $canonicalizedHeaders);
226        }
227
228        $stringToSign[] = $canonicalizedResource;
229        $string = implode("\n", $stringToSign);
230
231        return $string;
232    }
233
234    /**
235     * Computes canonicalized headers for headers array.
236     *
237     * @param  array $headers
238     * @return array
239     */
240    protected function computeCanonicalizedHeaders(array $headers): array
241    {
242        $canonicalizedHeaders = [];
243        $normalizedHeaders    = [];
244        $validPrefix          = 'x-ms-';
245
246        foreach ($headers as $header => $value) {
247            // Convert header to lower case.
248            $header = strtolower($header);
249
250            // Retrieve all headers for the resource that begin with x-ms-,
251            // including the x-ms-date header.
252            if (self::startsWith($header, $validPrefix)) {
253                // Unfold the string by replacing any breaking white space
254                // (meaning what splits the headers, which is \r\n) with a single
255                // space.
256                $value = str_replace("\r\n", ' ', $value);
257
258                // Trim any white space around the colon in the header.
259                $value  = ltrim($value);
260                $header = rtrim($header);
261
262                $normalizedHeaders[$header] = $value;
263            }
264        }
265
266        // Sort the headers lexicographically by header name, in ascending order.
267        // Note that each header may appear only once in the string.
268        ksort($normalizedHeaders);
269
270        foreach ($normalizedHeaders as $key => $value) {
271            $canonicalizedHeaders[] = $key . ':' . $value;
272        }
273
274        return $canonicalizedHeaders;
275    }
276
277    /**
278     * Computes canonicalized resources from URL.
279     *
280     * @param  string $url
281     * @param  array  $queryParams
282     * @return string
283     */
284    protected function computeCanonicalizedResource(string $url, array $queryParams): string
285    {
286        $queryParams = array_change_key_case($queryParams);
287
288        // 1. Beginning with an empty string (""), append a forward slash (/),
289        //    followed by the name of the account that owns the accessed resource.
290        $canonicalizedResource = '/' . $this->accountName;
291
292        // 2. Append the resource's encoded URI path, without any query parameters.
293        $canonicalizedResource .= parse_url($url, PHP_URL_PATH);
294
295        // 3. Retrieve all query parameters on the resource URI, including the comp
296        //    parameter if it exists.
297        // 4. Sort the query parameters lexicographically by parameter name, in
298        //    ascending order.
299        if (count($queryParams) > 0) {
300            ksort($queryParams);
301        }
302
303        // 5. Convert all parameter names to lowercase.
304        // 6. URL-decode each query parameter name and value.
305        // 7. Append each query parameter name and value to the string in the
306        //    following format:
307        //      parameter-name:parameter-value
308        // 9. Group query parameters
309        // 10. Append a new line character (\n) after each name-value pair.
310        foreach ($queryParams as $key => $value) {
311            // $value must already be ordered lexicographically
312            // See: ServiceRestProxy::groupQueryValues
313            $canonicalizedResource .= "\n" . $key . ':' . $value;
314        }
315
316        return $canonicalizedResource;
317    }
318
319}