Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.59% covered (success)
97.59%
81 / 83
88.89% covered (warning)
88.89%
16 / 18
CRAP
n/a
0 / 0
Taproot\IndieAuth\generateRandomString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
Taproot\IndieAuth\generateRandomPrintableAsciiString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
Taproot\IndieAuth\generatePKCECodeChallenge
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Taproot\IndieAuth\base64_urlencode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Taproot\IndieAuth\hashAuthorizationRequestParameters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
Taproot\IndieAuth\isIndieAuthAuthorizationCodeRedeemingRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
Taproot\IndieAuth\isIndieAuthAuthorizationRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
Taproot\IndieAuth\isAuthorizationApprovalRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
Taproot\IndieAuth\buildQueryString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
Taproot\IndieAuth\urlComponentsMatch
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
Taproot\IndieAuth\appendQueryParams
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
Taproot\IndieAuth\trySetLogger
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
Taproot\IndieAuth\renderTemplate
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
1.00
Taproot\IndieAuth\isClientIdentifier
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
11
Taproot\IndieAuth\isProfileUrl
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
10
Taproot\IndieAuth\isValidState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Taproot\IndieAuth\isValidCodeChallenge
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Taproot\IndieAuth\isValidScope
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Taproot\IndieAuth;
4
5use Exception;
6use Psr\Http\Message\ServerRequestInterface;
7use Psr\Log\LoggerAwareInterface;
8use Psr\Log\LoggerInterface;
9
10// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
11function generateRandomString(int $numBytes): string {
12    if (function_exists('random_bytes')) {
13        $bytes = random_bytes($numBytes);
14        // We can’t easily test the following code.
15        // @codeCoverageIgnoreStart
16    } elseif (function_exists('openssl_random_pseudo_bytes')){
17        $bytes = openssl_random_pseudo_bytes($numBytes);
18    } else {
19        $bytes = '';
20        for($i=0, $bytes=''; $i < $numBytes; $i++) {
21            $bytes .= chr(mt_rand(0, 255));
22        }
23        // @codeCoverageIgnoreEnd
24    }
25    return bin2hex($bytes);
26}
27
28function generateRandomPrintableAsciiString(int $length): string {
29    $chars = [];
30    while (count($chars) < $length) {
31        // 0x21 to 0x7E is the entire printable ASCII range, not including space (0x20).
32        $chars[] = chr(random_int(0x21, 0x7E));
33    }
34    return join('', $chars);
35}
36
37function generatePKCECodeChallenge(string $plaintext): string {
38    return base64_urlencode(hash('sha256', $plaintext, true));
39}
40
41function base64_urlencode(string $string): string {
42    return rtrim(strtr(base64_encode($string), '+/', '-_'), '=');
43}
44
45function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null, bool $requirePkce=true): ?string {
46    $queryParams = $request->getQueryParams();
47
48    if (is_null($hashedParameters)) {
49        $hashedParameters = ($requirePkce or isset($queryParams['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'] : ['client_id', 'redirect_uri'];
50    }
51    
52    $algo = $algo ?? 'sha256';
53
54    $data = '';
55    foreach ($hashedParameters as $key) {
56        if (!isset($queryParams[$key])) {
57            return null;
58        }
59        $data .= $queryParams[$key];
60    }
61    return hash_hmac($algo, $data, $secret);
62}
63
64function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request): bool {
65    return strtolower($request->getMethod()) == 'post'
66            && array_key_exists('grant_type', $request->getParsedBody() ?? [])
67            && $request->getParsedBody()['grant_type'] == 'authorization_code';
68}
69
70function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, array $permittedMethods=['get']): bool {
71    return in_array(strtolower($request->getMethod()), array_map('strtolower', $permittedMethods))
72            && array_key_exists('response_type', $request->getQueryParams())
73            && in_array($request->getQueryParams()['response_type'], ['code', 'id']);
74}
75
76function isAuthorizationApprovalRequest(ServerRequestInterface $request): bool {
77    return strtolower($request->getMethod()) == 'post'
78            && array_key_exists('taproot_indieauth_action', $request->getParsedBody() ?? [])
79            && $request->getParsedBody()[Server::APPROVE_ACTION_KEY] == Server::APPROVE_ACTION_VALUE;
80}
81
82function buildQueryString(array $parameters): string {
83    $qs = [];
84    foreach ($parameters as $k => $v) {
85        if (!is_null($v)) {
86            $qs[] = urlencode($k) . '=' . urlencode($v);
87        }
88    }
89    return join('&', $qs);
90}
91
92function urlComponentsMatch(string $url1, string $url2, ?array $components=null): bool {
93    $validComponents = [PHP_URL_HOST, PHP_URL_PASS, PHP_URL_PATH, PHP_URL_PORT, PHP_URL_USER, PHP_URL_QUERY, PHP_URL_SCHEME, PHP_URL_FRAGMENT];
94    $components = $components ?? $validComponents;
95
96    foreach ($components as $cmp) {
97        if (!in_array($cmp, $validComponents)) {
98            throw new Exception("Invalid parse_url() component passed: $cmp");
99        }
100
101        if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) {
102            return false;
103        }
104    }
105
106    return true;
107}
108
109/**
110 * Append Query Parameters
111 * 
112 * Converts `$queryParams` into a query string, then checks `$uri` for an
113 * existing query string. Then appends the newly generated query string
114 * with either ? or & as appropriate.
115 */
116function appendQueryParams(string $uri, array $queryParams): string {
117    if (empty($queryParams)) {
118        return $uri;
119    }
120    
121    $queryString = buildQueryString($queryParams);
122    $separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?';
123    $uri = rtrim($uri, '?&');
124    return "{$uri}{$separator}{$queryString}";
125}
126
127/**
128 * Try setLogger
129 * 
130 * If `$target` implements `LoggerAwareInterface`, set it’s logger
131 * to `$logger`. Returns `$target`.
132 * 
133 * @psalm-suppress MissingReturnType
134 * @psalm-suppress MissingParamType
135 */
136function trySetLogger($target, LoggerInterface $logger) {
137    if ($target instanceof LoggerAwareInterface) {
138        $target->setLogger($logger);
139    }
140    return $target;
141}
142
143function renderTemplate(string $template, array $context=[]) {
144    $render = function ($__template, $__templateData) {
145        $render = function ($template, $data) {
146            return renderTemplate($template, $data);
147        };
148        ob_start();
149        extract($__templateData);
150        unset($__templateData);
151        include $__template;
152        return ob_get_clean();
153    };
154    return $render($template, $context);
155}
156
157// IndieAuth/OAuth2-related Validation Functions
158// Mostly taken or adapted by https://github.com/Zegnat/php-mindee/ â€” thanks Zegnat!
159// Code was not licensed at time of writing, permission granted here https://chat.indieweb.org/dev/2021-06-10/1623327498355700
160
161/**
162 * Check if a provided string matches the IndieAuth criteria for a Client Identifier.
163 * @see https://indieauth.spec.indieweb.org/#client-identifier
164 * 
165 * @param string $client_id The client ID provided by the OAuth Client
166 * @return bool true if the value is allowed by IndieAuth
167 */
168function isClientIdentifier(string $client_id): bool {
169    return ($url_components = parse_url($client_id)) &&                     // Clients are identified by a URL.
170            in_array($url_components['scheme'] ?? '', ['http', 'https']) &&     // Client identifier URLs MUST have either an https or http scheme,
171            0 < strlen($url_components['path'] ?? '') &&                        // MUST contain a path component,
172            false === strpos($url_components['path'], '/./') &&                 // MUST NOT contain single-dot
173            false === strpos($url_components['path'], '/../') &&                // or double-dot path segments,
174            false === isset($url_components['fragment']) &&                     // MUST NOT contain a fragment component,
175            false === isset($url_components['user']) &&                         // MUST NOT contain a username
176            false === isset($url_components['pass']) &&                         // or password component,
177            (
178                false === filter_var($url_components['host'], FILTER_VALIDATE_IP) ||  // MUST NOT be an IP address
179                ($url_components['host'] ?? null) == '127.0.0.1' ||                   // except for 127.0.0.1
180                ($url_components['host'] ?? null) == '[::1]'                          // or [::1]
181            )
182    ;
183}
184
185/**
186 * Check if a provided string matches the IndieAuth criteria for a User Profile URL.
187 * @see https://indieauth.spec.indieweb.org/#user-profile-url
188 * 
189 * @param string $profile_url The profile URL provided by the IndieAuth Client as me
190 * @return bool true if the value is allowed by IndieAuth
191 */
192function isProfileUrl(string $profile_url): bool {
193    return ($url_components = parse_url($profile_url)) &&                   // Users are identified by a URL.
194            in_array($url_components['scheme'] ?? '', ['http', 'https']) &&     // Profile URLs MUST have either an https or http scheme,
195            0 < strlen($url_components['path'] ?? '') &&                        // MUST contain a path component,
196            false === strpos($url_components['path'], '/./') &&                 // MUST NOT contain single-dot
197            false === strpos($url_components['path'], '/../') &&                // or double-dot path segments,
198            false === isset($url_components['fragment']) &&                     // MUST NOT contain a fragment component,
199            false === isset($url_components['user']) &&                         // MUST NOT contain a username
200            false === isset($url_components['pass']) &&                         // or password component,
201            false === isset($url_components['port']) &&                         // MUST NOT contain a port,
202            false === filter_var($url_components['host'], FILTER_VALIDATE_IP)   // MUST NOT be an IP address.
203    ;
204}
205
206/**
207 * OAuth 2.0 limits what values are valid for state.
208 * We check this first, because if valid, we want to send it along with other errors.
209 * @see https://tools.ietf.org/html/rfc6749#appendix-A.5
210 */
211function isValidState(string $state): bool {
212    return false !== filter_var($state, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[\x20-\x7E]*$/']]);
213}
214
215/**
216 * IndieAuth requires PKCE. This implementation supports only S256 for hashing.
217 * 
218 * @see https://indieauth.spec.indieweb.org/#authorization-request
219 */
220function isValidCodeChallenge(string $challenge): bool {
221    return false !== filter_var($challenge, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[A-Za-z0-9_\-.~]+$/']]);
222}
223
224/**
225 * OAuth 2.0 limits what values are valid for scope.
226 * @see https://tools.ietf.org/html/rfc6749#section-3.3
227 */
228function isValidScope(string $scope): bool {
229    return false !== filter_var($scope, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*)?$/']]);
230}