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 * @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 = self::tryFrom($scheme);
126        if (null === $authScheme) {
127            return null;
128        }
129
130        return match ($authScheme) {
131            self::ApiKey => self::parseApiKey($credentials),
132            self::Basic  => self::parseBasic($credentials),
133            self::Bearer => self::parseBearer($credentials),
134            self::Digest => self::parseDigest($credentials),
135            self::Aws    => self::parseAws($credentials),
136        };
137    }
138
139    /**
140     * Extracts and parses the Authorization header from a collection of headers.
141     *
142     * This method MUST treat header names case-insensitively and SHALL use
143     * the first `Authorization` value if multiple values are provided. If the
144     * header is missing or cannot be parsed successfully, it MUST return null.
145     *
146     * @param array $headers an associative array of HTTP headers
147     *
148     * @return AuthorizationCredential|null a parsed credential object or null if not present or invalid
149     */
150    public static function fromHeaderCollection(array $headers): ?AuthorizationCredential
151    {
152        $normalizedHeaders = array_change_key_case($headers, \CASE_LOWER);
153
154        if (! isset($normalizedHeaders['authorization'])) {
155            return null;
156        }
157
158        $authHeaderValue = $normalizedHeaders['authorization'];
159
160        if (\is_array($authHeaderValue)) {
161            $authHeaderValue = $authHeaderValue[0];
162        }
163
164        return self::parse($authHeaderValue);
165    }
166
167    /**
168     * Extracts and parses the Authorization header from a PSR-7 request.
169     *
170     * This method SHALL delegate to {@see Authorization::fromHeaderCollection()}
171     * using the request's header collection. It MUST NOT modify the request.
172     *
173     * @param RequestInterface $request the PSR-7 request instance
174     *
175     * @return AuthorizationCredential|null a parsed credential object or null if not present or invalid
176     */
177    public static function fromRequest(RequestInterface $request): ?AuthorizationCredential
178    {
179        return self::fromHeaderCollection($request->getHeaders());
180    }
181
182    /**
183     * Parses credentials for the ApiKey authentication scheme.
184     *
185     * The complete credential string MUST be treated as the API key. No
186     * additional structure is assumed or validated here; callers MAY apply
187     * further validation according to application rules.
188     *
189     * @param string $credentials the raw credentials portion of the header
190     *
191     * @return ApiKeyCredential the parsed API key credential object
192     */
193    private static function parseApiKey(string $credentials): ApiKeyCredential
194    {
195        return new ApiKeyCredential($credentials);
196    }
197
198    /**
199     * Parses credentials for the Basic authentication scheme.
200     *
201     * This method MUST:
202     * - Base64-decode the credentials.
203     * - Split the decoded string into `username:password`.
204     * If decoding fails or the decoded value does not contain exactly one
205     * colon separator, this method MUST return null.
206     *
207     * @param string $credentials the Base64-encoded "username:password" string
208     *
209     * @return BasicCredential|null the parsed Basic credential, or null on failure
210     */
211    private static function parseBasic(string $credentials): ?BasicCredential
212    {
213        $decoded = base64_decode($credentials, true);
214        if (false === $decoded) {
215            return null;
216        }
217
218        $parts = explode(':', $decoded, 2);
219        if (2 !== \count($parts)) {
220            return null;
221        }
222
223        [$username, $password] = $parts;
224
225        return new BasicCredential($username, $password);
226    }
227
228    /**
229     * Parses credentials for the Bearer authentication scheme.
230     *
231     * The credentials MUST be treated as an opaque bearer token. This method
232     * SHALL NOT attempt to validate or inspect the token contents.
233     *
234     * @param string $credentials the bearer token string
235     *
236     * @return BearerCredential the parsed Bearer credential object
237     */
238    private static function parseBearer(string $credentials): BearerCredential
239    {
240        return new BearerCredential($credentials);
241    }
242
243    /**
244     * Parses credentials for the Digest authentication scheme.
245     *
246     * This method MUST parse comma-separated key=value pairs according to
247     * RFC 7616. Values MAY be quoted or unquoted. If any part is malformed
248     * or required parameters are missing, it MUST return null.
249     * Required parameters:
250     * - username
251     * - realm
252     * - nonce
253     * - uri
254     * - response
255     * - qop
256     * - nc
257     * - cnonce
258     * Optional parameters such as `opaque` and `algorithm` SHALL be included
259     * in the credential object when present.
260     *
261     * @param string $credentials the raw credentials portion of the header
262     *
263     * @return DigestCredential|null the parsed Digest credential object, or null on failure
264     */
265    private static function parseDigest(string $credentials): ?DigestCredential
266    {
267        $params = [];
268        $parts  = explode(',', $credentials);
269
270        foreach ($parts as $part) {
271            $part = mb_trim($part);
272
273            $pattern = '/^(?<key>[a-zA-Z0-9_-]+)=(?<value>"[^"]*"|[^"]*)$/i';
274
275            if (! preg_match($pattern, $part, $match)) {
276                return null;
277            }
278
279            $key          = mb_strtolower($match['key']);
280            $value        = mb_trim($match['value'], '"');
281            $params[$key] = $value;
282        }
283
284        $required = ['username', 'realm', 'nonce', 'uri', 'response', 'qop', 'nc', 'cnonce'];
285        foreach ($required as $key) {
286            if (! isset($params[$key])) {
287                return null;
288            }
289        }
290
291        return new DigestCredential(
292            username: $params['username'],
293            realm: $params['realm'],
294            nonce: $params['nonce'],
295            uri: $params['uri'],
296            response: $params['response'],
297            qop: $params['qop'],
298            nc: $params['nc'],
299            cnonce: $params['cnonce'],
300            opaque: $params['opaque'] ?? null,
301            algorithm: $params['algorithm'] ?? null,
302        );
303    }
304
305    /**
306     * Parses credentials for the AWS Signature Version 4 authentication scheme.
307     *
308     * This method MUST parse comma-separated key=value pairs and verify that
309     * the mandatory parameters `Credential`, `SignedHeaders`, and `Signature`
310     * are present. The `Signature` value MUST be a 64-character hexadecimal
311     * string. If parsing or validation fails, it MUST return null.
312     * The `Credential` parameter contains the full credential scope in the form
313     * `AccessKeyId/Date/Region/Service/aws4_request`, which SHALL be stored
314     * as-is for downstream processing.
315     *
316     * @param string $credentials the raw credentials portion of the header
317     *
318     * @return AwsCredential|null the parsed AWS credential object, or null on failure
319     */
320    private static function parseAws(string $credentials): ?AwsCredential
321    {
322        $params = [];
323        $parts  = explode(',', $credentials);
324
325        foreach ($parts as $part) {
326            $part = mb_trim($part);
327
328            $pattern = '/^(?<key>[a-zA-Z0-9_-]+)=(?<value>[^, ]+)$/';
329
330            if (! preg_match($pattern, $part, $match)) {
331                return null;
332            }
333
334            $key          = mb_trim($match['key']);
335            $value        = mb_trim($match['value']);
336            $params[$key] = $value;
337        }
338
339        $required = ['Credential', 'SignedHeaders', 'Signature'];
340        foreach ($required as $key) {
341            if (! isset($params[$key])) {
342                return null;
343            }
344        }
345
346        if (! preg_match('/^[0-9a-fA-F]{64}$/', $params['Signature'])) {
347            return null;
348        }
349
350        return new AwsCredential(
351            algorithm: self::Aws->value,
352            credentialScope: $params['Credential'],
353            signedHeaders: $params['SignedHeaders'],
354            signature: $params['Signature'],
355        );
356    }
357}