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