Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
Authorization
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
8 / 8
29
100.00% covered (success)
100.00%
1 / 1
 parse
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 fromHeaderCollection
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 fromRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseApiKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseBasic
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 parseBearer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseDigest
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 parseAws
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/http-message.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @link      https://github.com/php-fast-forward/http-message
12 * @copyright Copyright (c) 2025 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
13 * @license   https://opensource.org/licenses/MIT MIT License
14 */
15
16namespace FastForward\Http\Message\Header;
17
18use FastForward\Http\Message\Header\Authorization\ApiKeyCredential;
19use FastForward\Http\Message\Header\Authorization\AuthorizationCredential;
20use FastForward\Http\Message\Header\Authorization\AwsCredential;
21use FastForward\Http\Message\Header\Authorization\BasicCredential;
22use FastForward\Http\Message\Header\Authorization\BearerCredential;
23use FastForward\Http\Message\Header\Authorization\DigestCredential;
24use Psr\Http\Message\RequestInterface;
25
26/**
27 * Enum Authorization.
28 *
29 * Represents supported HTTP `Authorization` header authentication schemes and
30 * provides helpers to parse raw header values into structured credential
31 * objects.
32 *
33 * The `Authorization` header is used to authenticate a user agent with a
34 * server, as defined primarily in RFC 7235 and scheme-specific RFCs. This
35 * utility enum MUST be used in a case-sensitive manner for its enum values
36 * but MUST treat incoming header names and schemes according to the
37 * specification of each scheme. Callers SHOULD use the parsing helpers to
38 * centralize and normalize authentication handling.
39 */
40enum Authorization: string
41{
42    /**
43     * A common, non-standard scheme for API key authentication.
44     *
45     * This scheme is not defined by an RFC and MAY vary between APIs.
46     * Implementations using this scheme SHOULD document how the key is
47     * generated, scoped, and validated.
48     */
49    case ApiKey = 'ApiKey';
50
51    /**
52     * Basic authentication scheme using Base64-encoded "username:password".
53     *
54     * Credentials are transmitted in plaintext (after Base64 decoding) and
55     * therefore MUST only be used over secure transports such as HTTPS.
56     *
57     * @see https://datatracker.ietf.org/doc/html/rfc7617
58     */
59    case Basic = 'Basic';
60
61    /**
62     * Bearer token authentication scheme.
63     *
64     * Commonly used with OAuth 2.0 access tokens and JWTs. Bearer tokens
65     * MUST be treated as opaque secrets; any party in possession of a valid
66     * token MAY use it to obtain access.
67     *
68     * @see https://datatracker.ietf.org/doc/html/rfc6750
69     */
70    case Bearer = 'Bearer';
71
72    /**
73     * Digest access authentication scheme.
74     *
75     * Uses a challenge-response mechanism to avoid sending passwords in
76     * cleartext. Implementations SHOULD fully follow the RFC requirements
77     * to avoid interoperability and security issues.
78     *
79     * @see https://datatracker.ietf.org/doc/html/rfc7616
80     */
81    case Digest = 'Digest';
82
83    /**
84     * Amazon Web Services Signature Version 4 scheme.
85     *
86     * Used to authenticate requests to AWS services. The credential
87     * components MUST be constructed according to the AWS Signature Version 4
88     * process, or validation will fail on the server side.
89     *
90     * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/signing-requests-v4.html
91     */
92    case Aws = 'AWS4-HMAC-SHA256';
93
94    /**
95     * Parses a raw Authorization header string into a structured credential object.
96     *
97     * This method MUST:
98     * - Split the header into an authentication scheme and a credentials part.
99     * - Resolve the scheme to a supported enum value.
100     * - Delegate to the appropriate scheme-specific parser.
101     *
102     * If the header is empty, malformed, or uses an unsupported scheme,
103     * this method MUST return null. Callers SHOULD treat a null result as
104     * an authentication parsing failure.
105     *
106     * @param string $header the raw value of the `Authorization` header
107     *
108     * @return null|AuthorizationCredential a credential object on successful parsing, or null on failure
109     */
110    public static function parse(string $header): ?AuthorizationCredential
111    {
112        if ('' === $header) {
113            return null;
114        }
115
116        $parts = explode(' ', $header, 2);
117        if (2 !== \count($parts)) {
118            return null;
119        }
120
121        [$scheme, $credentials] = $parts;
122
123        $authScheme = self::tryFrom($scheme);
124        if (null === $authScheme) {
125            return null;
126        }
127
128        return match ($authScheme) {
129            self::ApiKey => self::parseApiKey($credentials),
130            self::Basic  => self::parseBasic($credentials),
131            self::Bearer => self::parseBearer($credentials),
132            self::Digest => self::parseDigest($credentials),
133            self::Aws    => self::parseAws($credentials),
134        };
135    }
136
137    /**
138     * Extracts and parses the Authorization header from a collection of headers.
139     *
140     * This method MUST treat header names case-insensitively and SHALL use
141     * the first `Authorization` value if multiple values are provided. If the
142     * header is missing or cannot be parsed successfully, it MUST return null.
143     *
144     * @param array<string, string|string[]> $headers an associative array of HTTP headers
145     *
146     * @return null|AuthorizationCredential a parsed credential object or null if not present or invalid
147     */
148    public static function fromHeaderCollection(array $headers): ?AuthorizationCredential
149    {
150        $normalizedHeaders = array_change_key_case($headers, CASE_LOWER);
151
152        if (!isset($normalizedHeaders['authorization'])) {
153            return null;
154        }
155
156        $authHeaderValue = $normalizedHeaders['authorization'];
157
158        if (\is_array($authHeaderValue)) {
159            $authHeaderValue = $authHeaderValue[0];
160        }
161
162        return self::parse($authHeaderValue);
163    }
164
165    /**
166     * Extracts and parses the Authorization header from a PSR-7 request.
167     *
168     * This method SHALL delegate to {@see Authorization::fromHeaderCollection()}
169     * using the request's header collection. It MUST NOT modify the request.
170     *
171     * @param RequestInterface $request the PSR-7 request instance
172     *
173     * @return null|AuthorizationCredential a parsed credential object or null if not present or invalid
174     */
175    public static function fromRequest(RequestInterface $request): ?AuthorizationCredential
176    {
177        return self::fromHeaderCollection($request->getHeaders());
178    }
179
180    /**
181     * Parses credentials for the ApiKey authentication scheme.
182     *
183     * The complete credential string MUST be treated as the API key. No
184     * additional structure is assumed or validated here; callers MAY apply
185     * further validation according to application rules.
186     *
187     * @param string $credentials the raw credentials portion of the header
188     *
189     * @return ApiKeyCredential the parsed API key credential object
190     */
191    private static function parseApiKey(string $credentials): ApiKeyCredential
192    {
193        return new ApiKeyCredential($credentials);
194    }
195
196    /**
197     * Parses credentials for the Basic authentication scheme.
198     *
199     * This method MUST:
200     * - Base64-decode the credentials.
201     * - Split the decoded string into `username:password`.
202     *
203     * If decoding fails or the decoded value does not contain exactly one
204     * colon separator, this method MUST return null.
205     *
206     * @param string $credentials the Base64-encoded "username:password" string
207     *
208     * @return null|BasicCredential the parsed Basic credential, or null on failure
209     */
210    private static function parseBasic(string $credentials): ?BasicCredential
211    {
212        $decoded = base64_decode($credentials, true);
213        if (false === $decoded) {
214            return null;
215        }
216
217        $parts = explode(':', $decoded, 2);
218        if (2 !== \count($parts)) {
219            return null;
220        }
221
222        [$username, $password] = $parts;
223
224        return new BasicCredential($username, $password);
225    }
226
227    /**
228     * Parses credentials for the Bearer authentication scheme.
229     *
230     * The credentials MUST be treated as an opaque bearer token. This method
231     * SHALL NOT attempt to validate or inspect the token contents.
232     *
233     * @param string $credentials the bearer token string
234     *
235     * @return BearerCredential the parsed Bearer credential object
236     */
237    private static function parseBearer(string $credentials): BearerCredential
238    {
239        return new BearerCredential($credentials);
240    }
241
242    /**
243     * Parses credentials for the Digest authentication scheme.
244     *
245     * This method MUST parse comma-separated key=value pairs according to
246     * RFC 7616. Values MAY be quoted or unquoted. If any part is malformed
247     * or required parameters are missing, it MUST return null.
248     *
249     * Required parameters:
250     * - username
251     * - realm
252     * - nonce
253     * - uri
254     * - response
255     * - qop
256     * - nc
257     * - cnonce
258     *
259     * Optional parameters such as `opaque` and `algorithm` SHALL be included
260     * in the credential object when present.
261     *
262     * @param string $credentials the raw credentials portion of the header
263     *
264     * @return null|DigestCredential the parsed Digest credential object, or null on failure
265     */
266    private static function parseDigest(string $credentials): ?DigestCredential
267    {
268        $params = [];
269        $parts  = explode(',', $credentials);
270
271        foreach ($parts as $part) {
272            $part = mb_trim($part);
273
274            $pattern = '/^(?<key>[a-zA-Z0-9_-]+)=(?<value>"[^"]*"|[^"]*)$/i';
275
276            if (!preg_match($pattern, $part, $match)) {
277                return null;
278            }
279
280            $key          = mb_strtolower($match['key']);
281            $value        = mb_trim($match['value'], '"');
282            $params[$key] = $value;
283        }
284
285        $required = ['username', 'realm', 'nonce', 'uri', 'response', 'qop', 'nc', 'cnonce'];
286        foreach ($required as $key) {
287            if (!isset($params[$key])) {
288                return null;
289            }
290        }
291
292        return new DigestCredential(
293            username: $params['username'],
294            realm: $params['realm'],
295            nonce: $params['nonce'],
296            uri: $params['uri'],
297            response: $params['response'],
298            qop: $params['qop'],
299            nc: $params['nc'],
300            cnonce: $params['cnonce'],
301            opaque: $params['opaque'] ?? null,
302            algorithm: $params['algorithm'] ?? null,
303        );
304    }
305
306    /**
307     * Parses credentials for the AWS Signature Version 4 authentication scheme.
308     *
309     * This method MUST parse comma-separated key=value pairs and verify that
310     * the mandatory parameters `Credential`, `SignedHeaders`, and `Signature`
311     * are present. The `Signature` value MUST be a 64-character hexadecimal
312     * string. If parsing or validation fails, it MUST return null.
313     *
314     * The `Credential` parameter contains the full credential scope in the form
315     * `AccessKeyId/Date/Region/Service/aws4_request`, which SHALL be stored
316     * as-is for downstream processing.
317     *
318     * @param string $credentials the raw credentials portion of the header
319     *
320     * @return null|AwsCredential the parsed AWS credential object, or null on failure
321     */
322    private static function parseAws(string $credentials): ?AwsCredential
323    {
324        $params = [];
325        $parts  = explode(',', $credentials);
326
327        foreach ($parts as $part) {
328            $part = mb_trim($part);
329
330            $pattern = '/^(?<key>[a-zA-Z0-9_-]+)=(?<value>[^, ]+)$/';
331
332            if (!preg_match($pattern, $part, $match)) {
333                return null;
334            }
335
336            $key          = mb_trim($match['key']);
337            $value        = mb_trim($match['value']);
338            $params[$key] = $value;
339        }
340
341        $required = ['Credential', 'SignedHeaders', 'Signature'];
342        foreach ($required as $key) {
343            if (!isset($params[$key])) {
344                return null;
345            }
346        }
347
348        if (!preg_match('/^[0-9a-fA-F]{64}$/', $params['Signature'])) {
349            return null;
350        }
351
352        return new AwsCredential(
353            algorithm: self::Aws->value,
354            credentialScope: $params['Credential'],
355            signedHeaders: $params['SignedHeaders'],
356            signature: $params['Signature'],
357        );
358    }
359}