Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
n/a
0 / 0
88.89% covered (warning)
88.89%
16 / 18
CRAP
97.62% covered (success)
97.62%
82 / 84
Taproot\IndieAuth\generateRandomString
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
3 / 3
Taproot\IndieAuth\generateRandomPrintableAsciiString
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
Taproot\IndieAuth\generatePKCECodeChallenge
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
Taproot\IndieAuth\base64_urlencode
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
Taproot\IndieAuth\hashAuthorizationRequestParameters
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
10 / 10
Taproot\IndieAuth\isIndieAuthAuthorizationCodeRedeemingRequest
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
3 / 3
Taproot\IndieAuth\isIndieAuthAuthorizationRequest
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
3 / 3
Taproot\IndieAuth\isAuthorizationApprovalRequest
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
3 / 3
Taproot\IndieAuth\buildQueryString
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
Taproot\IndieAuth\urlComponentsMatch
0.00% covered (danger)
0.00%
0 / 1
4.03
87.50% covered (warning)
87.50%
7 / 8
Taproot\IndieAuth\appendQueryParams
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
6 / 6
Taproot\IndieAuth\trySetLogger
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
Taproot\IndieAuth\renderTemplate
0.00% covered (danger)
0.00%
0 / 1
1.00
90.91% covered (success)
90.91%
10 / 11
Taproot\IndieAuth\isClientIdentifier
100.00% covered (success)
100.00%
1 / 1
11
100.00% covered (success)
100.00%
11 / 11
Taproot\IndieAuth\isProfileUrl
100.00% covered (success)
100.00%
1 / 1
10
100.00% covered (success)
100.00%
10 / 10
Taproot\IndieAuth\isValidState
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
Taproot\IndieAuth\isValidCodeChallenge
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
Taproot\IndieAuth\isValidScope
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
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            && $request->getQueryParams()['response_type'] == 'code';
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        $qs[] = urlencode($k) . '=' . urlencode($v);
86    }
87    return join('&', $qs);
88}
89
90function urlComponentsMatch(string $url1, string $url2, ?array $components=null): bool {
91    $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];
92    $components = $components ?? $validComponents;
93
94    foreach ($components as $cmp) {
95        if (!in_array($cmp, $validComponents)) {
96            throw new Exception("Invalid parse_url() component passed: $cmp");
97        }
98
99        if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) {
100            return false;
101        }
102    }
103
104    return true;
105}
106
107/**
108 * Append Query Parameters
109 * 
110 * Converts `$queryParams` into a query string, then checks `$uri` for an
111 * existing query string. Then appends the newly generated query string
112 * with either ? or & as appropriate.
113 */
114function appendQueryParams(string $uri, array $queryParams): string {
115    if (empty($queryParams)) {
116        return $uri;
117    }
118    
119    $queryString = buildQueryString($queryParams);
120    $separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?';
121    $uri = rtrim($uri, '?&');
122    return "{$uri}{$separator}{$queryString}";
123}
124
125/**
126 * Try setLogger
127 * 
128 * If `$target` implements `LoggerAwareInterface`, set it’s logger
129 * to `$logger`. Returns `$target`.
130 * 
131 * @psalm-suppress MissingReturnType
132 * @psalm-suppress MissingParamType
133 */
134function trySetLogger($target, LoggerInterface $logger) {
135    if ($target instanceof LoggerAwareInterface) {
136        $target->setLogger($logger);
137    }
138    return $target;
139}
140
141function renderTemplate(string $template, array $context=[]) {
142    $render = function ($__template, $__templateData) {
143        $render = function ($template, $data){
144            return renderTemplate($template, $data);
145        };
146        ob_start();
147        extract($__templateData);
148        unset($__templateData);
149        include $__template;
150        return ob_get_clean();
151    };
152    return $render($template, $context);
153}
154
155// IndieAuth/OAuth2-related Validation Functions
156// Mostly taken or adapted by https://github.com/Zegnat/php-mindee/ â€” thanks Zegnat!
157// Code was not licensed at time of writing, permission granted here https://chat.indieweb.org/dev/2021-06-10/1623327498355700
158
159/**
160 * Check if a provided string matches the IndieAuth criteria for a Client Identifier.
161 * @see https://indieauth.spec.indieweb.org/#client-identifier
162 * 
163 * @param string $client_id The client ID provided by the OAuth Client
164 * @return bool true if the value is allowed by IndieAuth
165 */
166function isClientIdentifier(string $client_id): bool {
167    return ($url_components = parse_url($client_id)) &&                     // Clients are identified by a URL.
168            in_array($url_components['scheme'] ?? '', ['http', 'https']) &&     // Client identifier URLs MUST have either an https or http scheme,
169            0 < strlen($url_components['path'] ?? '') &&                        // MUST contain a path component,
170            false === strpos($url_components['path'], '/./') &&                 // MUST NOT contain single-dot
171            false === strpos($url_components['path'], '/../') &&                // or double-dot path segments,
172            false === isset($url_components['fragment']) &&                     // MUST NOT contain a fragment component,
173            false === isset($url_components['user']) &&                         // MUST NOT contain a username
174            false === isset($url_components['pass']) &&                         // or password component,
175            (
176                false === filter_var($url_components['host'], FILTER_VALIDATE_IP) ||  // MUST NOT be an IP address
177                ($url_components['host'] ?? null) == '127.0.0.1' ||                   // except for 127.0.0.1
178                ($url_components['host'] ?? null) == '[::1]'                          // or [::1]
179            )
180    ;
181}
182
183/**
184 * Check if a provided string matches the IndieAuth criteria for a User Profile URL.
185 * @see https://indieauth.spec.indieweb.org/#user-profile-url
186 * 
187 * @param string $profile_url The profile URL provided by the IndieAuth Client as me
188 * @return bool true if the value is allowed by IndieAuth
189 */
190function isProfileUrl(string $profile_url): bool {
191    return ($url_components = parse_url($profile_url)) &&                   // Users are identified by a URL.
192            in_array($url_components['scheme'] ?? '', ['http', 'https']) &&     // Profile URLs MUST have either an https or http scheme,
193            0 < strlen($url_components['path'] ?? '') &&                        // MUST contain a path component,
194            false === strpos($url_components['path'], '/./') &&                 // MUST NOT contain single-dot
195            false === strpos($url_components['path'], '/../') &&                // or double-dot path segments,
196            false === isset($url_components['fragment']) &&                     // MUST NOT contain a fragment component,
197            false === isset($url_components['user']) &&                         // MUST NOT contain a username
198            false === isset($url_components['pass']) &&                         // or password component,
199            false === isset($url_components['port']) &&                         // MUST NOT contain a port,
200            false === filter_var($url_components['host'], FILTER_VALIDATE_IP)   // MUST NOT be an IP address.
201    ;
202}
203
204/**
205 * OAuth 2.0 limits what values are valid for state.
206 * We check this first, because if valid, we want to send it along with other errors.
207 * @see https://tools.ietf.org/html/rfc6749#appendix-A.5
208 */
209function isValidState(string $state): bool {
210    return false !== filter_var($state, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[\x20-\x7E]*$/']]);
211}
212
213/**
214 * IndieAuth requires PKCE. This implementation supports only S256 for hashing.
215 * 
216 * @see https://indieauth.spec.indieweb.org/#authorization-request
217 */
218function isValidCodeChallenge(string $challenge): bool {
219    return false !== filter_var($challenge, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[A-Za-z0-9_-]+$/']]);
220}
221
222/**
223 * OAuth 2.0 limits what values are valid for scope.
224 * @see https://tools.ietf.org/html/rfc6749#section-3.3
225 */
226function isValidScope(string $scope): bool {
227    return false !== filter_var($scope, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*)?$/']]);
228}