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