From 7a62d383331b3cf29b84c5573eb1b1122db06e44 Mon Sep 17 00:00:00 2001 From: Barnaby Walters Date: Thu, 24 Jun 2021 13:29:39 +0200 Subject: [PATCH] Minor changes for PHP 7.3 compatibility --- .../AuthorizationFormInterface.php.html | 2 +- .../DefaultAuthorizationForm.php.html | 151 +- ...serPasswordAuthenticationCallback.php.html | 169 +- docs/coverage/Callback/dashboard.html | 6 +- docs/coverage/Callback/index.html | 6 +- docs/coverage/IndieAuthException.php.html | 97 +- .../Middleware/ClosureRequestHandler.php.html | 29 +- .../DoubleSubmitCookieCsrfMiddleware.php.html | 170 +- .../Middleware/NoOpMiddleware.php.html | 2 +- .../ResponseRequestHandler.php.html | 27 +- docs/coverage/Middleware/dashboard.html | 4 +- docs/coverage/Middleware/index.html | 2 +- docs/coverage/Server.php.html | 1479 +++++++++-------- .../Storage/FilesystemJsonStorage.php.html | 541 +++--- docs/coverage/Storage/Sqlite3Storage.php.html | 2 +- .../Storage/TokenStorageInterface.php.html | 2 +- docs/coverage/Storage/dashboard.html | 24 +- docs/coverage/Storage/index.html | 2 +- docs/coverage/dashboard.html | 36 +- docs/coverage/functions.php.html | 2 +- docs/coverage/index.html | 12 +- src/Callback/DefaultAuthorizationForm.php | 9 +- ...ngleUserPasswordAuthenticationCallback.php | 27 +- src/IndieAuthException.php | 3 +- src/Middleware/ClosureRequestHandler.php | 3 +- .../DoubleSubmitCookieCsrfMiddleware.php | 12 +- src/Middleware/ResponseRequestHandler.php | 3 +- src/Server.php | 21 +- src/Storage/FilesystemJsonStorage.php | 17 +- 29 files changed, 1467 insertions(+), 1393 deletions(-) diff --git a/docs/coverage/Callback/AuthorizationFormInterface.php.html b/docs/coverage/Callback/AuthorizationFormInterface.php.html index cd375c0..19b5cc3 100644 --- a/docs/coverage/Callback/AuthorizationFormInterface.php.html +++ b/docs/coverage/Callback/AuthorizationFormInterface.php.html @@ -163,7 +163,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Callback/DefaultAuthorizationForm.php.html b/docs/coverage/Callback/DefaultAuthorizationForm.php.html index 3283b2e..9525829 100644 --- a/docs/coverage/Callback/DefaultAuthorizationForm.php.html +++ b/docs/coverage/Callback/DefaultAuthorizationForm.php.html @@ -101,7 +101,7 @@ -  __construct +  __construct
100.00% covered (success) @@ -122,7 +122,7 @@ -  showForm +  showForm
100.00% covered (success) @@ -143,7 +143,7 @@ -  transformAuthorizationCode +  transformAuthorizationCode
100.00% covered (success) @@ -164,7 +164,7 @@ -  setLogger +  setLogger
100.00% covered (success) @@ -225,76 +225,79 @@ 33 * may make sense to create your own implementation of `AuthorizationFormInterface`. 34 */ 35class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwareInterface { - 36    public string $csrfKey; - 37 - 38    public string $formTemplatePath; - 39 - 40    public LoggerInterface $logger; + 36    /** @var string $csrfKey */ + 37    public $csrfKey; + 38 + 39    /** @var string $formTemplatePath */ + 40    public $formTemplatePath; 41 - 42    /** - 43     * Constructor - 44     *  - 45     * @param string|null $formTemplatePath The path to a custom template. Uses the default if null. - 46     * @param string|null $csrfKey The key used to retrieve a CSRF token from the request attributes, and as its form data name. Uses the default defined in Server if null. Only change this if you’re using a custom CSRF middleware. - 47     * @param LoggerInterface|null $logger A logger. - 48     */ - 49    public function __construct(?string $formTemplatePath=null, ?string $csrfKey=null, ?LoggerInterface $logger=null) { - 50        $this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../../templates/default_authorization_page.html.php'; - 51        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; - 52        $this->logger = $logger ?? new NullLogger; - 53    } - 54 - 55    public function showForm(ServerRequestInterface $request, array $authenticationResult, string $formAction, ?array $clientHApp): ResponseInterface { - 56        // Show an authorization page. List all requested scopes, as this default - 57        // function has now way of knowing which scopes are supported by the consumer. - 58        $scopes = []; - 59        foreach(explode(' ', $request->getQueryParams()['scope'] ?? '') as $s) { - 60            $scopes[$s] = null; // Ideally there would be a description of the scope here, we don’t have one though. - 61        } - 62 - 63        if (is_null($clientHApp)) { - 64            $clientHApp = [ - 65                'type' => ['h-app'], - 66                'properties' => [] - 67            ]; - 68        } - 69 - 70        $hApp = [ - 71            'name' => M\getProp($clientHApp, 'name'), - 72            'url' => M\getProp($clientHApp, 'url'), - 73            'photo' => M\getProp($clientHApp, 'photo') - 74        ]; - 75 - 76        return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplatePath, [ - 77            'scopes' => $scopes, - 78            'user' => $authenticationResult, - 79            'formAction' => $formAction, - 80            'request' => $request, - 81            'clientHApp' => $hApp, - 82            'clientId' => $request->getQueryParams()['client_id'], - 83            'clientRedirectUri' => $request->getQueryParams()['redirect_uri'], - 84            'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' - 85        ])); - 86    } - 87 - 88    public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array { - 89        // Add any granted scopes from the form to the code. - 90        $grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope'] ?? []; - 91 - 92        // This default implementation naievely accepts any scopes it receives from the form. - 93        // You may wish to perform some sort of validation. - 94        $code['scope'] = join(' ', $grantedScopes); - 95 - 96        // You may wish to additionally make any other necessary changes to the the code based on - 97        // the form submission, e.g. if the user set a custom token lifetime, or wanted extra data - 98        // stored on the token to affect how it behaves. - 99        return $code; - 100    } - 101 - 102    public function setLogger(LoggerInterface $logger) { - 103        $this->logger = $logger; - 104    } - 105} + 42    /** @var LoggerInterface $logger */ + 43    public $logger; + 44 + 45    /** + 46     * Constructor + 47     *  + 48     * @param string|null $formTemplatePath The path to a custom template. Uses the default if null. + 49     * @param string|null $csrfKey The key used to retrieve a CSRF token from the request attributes, and as its form data name. Uses the default defined in Server if null. Only change this if you’re using a custom CSRF middleware. + 50     * @param LoggerInterface|null $logger A logger. + 51     */ + 52    public function __construct(?string $formTemplatePath=null, ?string $csrfKey=null, ?LoggerInterface $logger=null) { + 53        $this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../../templates/default_authorization_page.html.php'; + 54        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; + 55        $this->logger = $logger ?? new NullLogger; + 56    } + 57 + 58    public function showForm(ServerRequestInterface $request, array $authenticationResult, string $formAction, ?array $clientHApp): ResponseInterface { + 59        // Show an authorization page. List all requested scopes, as this default + 60        // function has now way of knowing which scopes are supported by the consumer. + 61        $scopes = []; + 62        foreach(explode(' ', $request->getQueryParams()['scope'] ?? '') as $s) { + 63            $scopes[$s] = null; // Ideally there would be a description of the scope here, we don’t have one though. + 64        } + 65 + 66        if (is_null($clientHApp)) { + 67            $clientHApp = [ + 68                'type' => ['h-app'], + 69                'properties' => [] + 70            ]; + 71        } + 72 + 73        $hApp = [ + 74            'name' => M\getProp($clientHApp, 'name'), + 75            'url' => M\getProp($clientHApp, 'url'), + 76            'photo' => M\getProp($clientHApp, 'photo') + 77        ]; + 78 + 79        return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplatePath, [ + 80            'scopes' => $scopes, + 81            'user' => $authenticationResult, + 82            'formAction' => $formAction, + 83            'request' => $request, + 84            'clientHApp' => $hApp, + 85            'clientId' => $request->getQueryParams()['client_id'], + 86            'clientRedirectUri' => $request->getQueryParams()['redirect_uri'], + 87            'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' + 88        ])); + 89    } + 90 + 91    public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array { + 92        // Add any granted scopes from the form to the code. + 93        $grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope'] ?? []; + 94 + 95        // This default implementation naievely accepts any scopes it receives from the form. + 96        // You may wish to perform some sort of validation. + 97        $code['scope'] = join(' ', $grantedScopes); + 98 + 99        // You may wish to additionally make any other necessary changes to the the code based on + 100        // the form submission, e.g. if the user set a custom token lifetime, or wanted extra data + 101        // stored on the token to affect how it behaves. + 102        return $code; + 103    } + 104 + 105    public function setLogger(LoggerInterface $logger) { + 106        $this->logger = $logger; + 107    } + 108} @@ -305,7 +308,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html index f626ddf..5638397 100644 --- a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html +++ b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html @@ -68,7 +68,7 @@
100.00%
-
29 / 29
+
30 / 30
@@ -89,7 +89,7 @@
100.00%
2 / 2
- 9 + 10
100.00% covered (success) @@ -97,11 +97,11 @@
100.00%
-
29 / 29
+
30 / 30
-  
__construct +  __construct
100.00% covered (success) @@ -110,7 +110,7 @@
100.00%
1 / 1
- 4 + 5
100.00% covered (success) @@ -118,11 +118,11 @@
100.00%
-
13 / 13
+
14 / 14
-  __invoke +  __invoke
100.00% covered (success) @@ -199,78 +199,91 @@ 49    const LOGIN_HASH_COOKIE = 'taproot_indieauth_server_supauth_hash'; 50    const DEFAULT_COOKIE_TTL = 60 * 5; 51 - 52    public string $csrfKey; - 53    public string $formTemplate; - 54    protected array $user; - 55    protected string $hashedPassword; - 56    protected string $secret; - 57    protected int $ttl; - 58     - 59    /** - 60     * Constructor - 61     *  - 62     * @param string $secret A secret key used to encrypt cookies. Can be the same as the secret passed to IndieAuth\Server. - 63     * @param array $user An array representing the user, which will be returned on a successful authentication. MUST include a 'me' key, may also contain a 'profile' key, or other keys at your discretion. - 64     * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` - 65     * @param string|null $formTemplate The path to a template used to render the sign-in form. Uses default if null. - 66     * @param string|null $csrfKey The key under which to fetch a CSRF token from `$request` attributes, and as the CSRF token name in submitted form data. Defaults to the Server default, only change if you’re using a custom CSRF middleware. - 67     * @param int|null $ttl The lifetime of the authentication cookie, in seconds. Defaults to five minutes. - 68     */ - 69    public function __construct(string $secret, array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null, ?int $ttl=null) { - 70        if (strlen($secret) < 64) { - 71            throw new BadMethodCallException("\$secret must be a string with a minimum length of 64 characters."); - 72        } - 73        $this->secret = $secret; - 74 - 75        $this->ttl = $ttl ?? self::DEFAULT_COOKIE_TTL; - 76 - 77        if (!isset($user['me'])) { - 78            throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); - 79        } - 80         - 81        if (is_null(password_get_info($hashedPassword)['algo'])) { - 82            throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); + 52    /** @var string $csrfKey */ + 53    public $csrfKey; + 54 + 55    /** @var string $formTemplate */ + 56    public $formTemplate; + 57 + 58    /** @var array $user */ + 59    protected $user; + 60 + 61    /** @var string $hashedPassword */ + 62    protected $hashedPassword; + 63 + 64    /** @var string $secret */ + 65    protected $secret; + 66 + 67    /** @var int $ttl */ + 68    protected $ttl; + 69     + 70    /** + 71     * Constructor + 72     *  + 73     * @param string $secret A secret key used to encrypt cookies. Can be the same as the secret passed to IndieAuth\Server. + 74     * @param array $user An array representing the user, which will be returned on a successful authentication. MUST include a 'me' key, may also contain a 'profile' key, or other keys at your discretion. + 75     * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` + 76     * @param string|null $formTemplate The path to a template used to render the sign-in form. Uses default if null. + 77     * @param string|null $csrfKey The key under which to fetch a CSRF token from `$request` attributes, and as the CSRF token name in submitted form data. Defaults to the Server default, only change if you’re using a custom CSRF middleware. + 78     * @param int|null $ttl The lifetime of the authentication cookie, in seconds. Defaults to five minutes. + 79     */ + 80    public function __construct(string $secret, array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null, ?int $ttl=null) { + 81        if (strlen($secret) < 64) { + 82            throw new BadMethodCallException("\$secret must be a string with a minimum length of 64 characters."); 83        } - 84        $this->user = $user; - 85        $this->hashedPassword = $hashedPassword; - 86        $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php'; - 87        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; - 88    } - 89 - 90    public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { - 91        // If the request is logged in, return authentication data. - 92        $cookies = $request->getCookieParams(); - 93        if ( - 94            isset($cookies[self::LOGIN_HASH_COOKIE]) - 95            && hash_equals(hash_hmac('SHA256', json_encode($this->user), $this->secret), $cookies[self::LOGIN_HASH_COOKIE]) - 96        ) { - 97            return $this->user; - 98        } - 99 - 100        // If the request is a form submission with a matching password, return a redirect to the indieauth - 101        // flow, setting a cookie. - 102        if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { - 103            $response = new Response(302, ['Location' => $formAction]); - 104 - 105            // Set the user data hash cookie. - 106            $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create(self::LOGIN_HASH_COOKIE) - 107                    ->withValue(hash_hmac('SHA256', json_encode($this->user), $this->secret)) - 108                    ->withMaxAge($this->ttl) - 109                    ->withSecure($request->getUri()->getScheme() == 'https') - 110                    ->withDomain($request->getUri()->getHost()) - 111            ); + 84        $this->secret = $secret; + 85 + 86        $this->ttl = $ttl ?? self::DEFAULT_COOKIE_TTL; + 87 + 88        if (!isset($user['me'])) { + 89            throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); + 90        } + 91         + 92        $hashAlgo = password_get_info($hashedPassword)['algo']; + 93        // Invalid algorithms are null in PHP 7.4, 0 in PHP 7.3. + 94        if (is_null($hashAlgo) or 0 === $hashAlgo) { + 95            throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); + 96        } + 97        $this->user = $user; + 98        $this->hashedPassword = $hashedPassword; + 99        $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php'; + 100        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; + 101    } + 102 + 103    public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { + 104        // If the request is logged in, return authentication data. + 105        $cookies = $request->getCookieParams(); + 106        if ( + 107            isset($cookies[self::LOGIN_HASH_COOKIE]) + 108            && hash_equals(hash_hmac('SHA256', json_encode($this->user), $this->secret), $cookies[self::LOGIN_HASH_COOKIE]) + 109        ) { + 110            return $this->user; + 111        } 112 - 113            return $response; - 114        } - 115 - 116        // Otherwise, return a response containing the password form. - 117        return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [ - 118            'formAction' => $formAction, - 119            'request' => $request, - 120            'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' - 121        ])); - 122    } - 123} + 113        // If the request is a form submission with a matching password, return a redirect to the indieauth + 114        // flow, setting a cookie. + 115        if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { + 116            $response = new Response(302, ['Location' => $formAction]); + 117 + 118            // Set the user data hash cookie. + 119            $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create(self::LOGIN_HASH_COOKIE) + 120                    ->withValue(hash_hmac('SHA256', json_encode($this->user), $this->secret)) + 121                    ->withMaxAge($this->ttl) + 122                    ->withSecure($request->getUri()->getScheme() == 'https') + 123                    ->withDomain($request->getUri()->getHost()) + 124            ); + 125 + 126            return $response; + 127        } + 128 + 129        // Otherwise, return a response containing the password form. + 130        return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [ + 131            'formAction' => $formAction, + 132            'request' => $request, + 133            'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' + 134        ])); + 135    } + 136} @@ -281,7 +294,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Callback/dashboard.html b/docs/coverage/Callback/dashboard.html index 50fddc6..c92597f 100644 --- a/docs/coverage/Callback/dashboard.html +++ b/docs/coverage/Callback/dashboard.html @@ -136,7 +136,7 @@
@@ -223,7 +223,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Cyclomatic Complexity'); d3.select('#classComplexity svg') - .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,9,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"]], 'Class Complexity')) + .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,10,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"]], 'Class Complexity')) .transition() .duration(500) .call(chart); @@ -247,7 +247,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Method Complexity'); d3.select('#methodComplexity svg') - .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::showForm<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::transformAuthorizationCode<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::setLogger<\/a>"],[100,4,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,5,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__invoke<\/a>"]], 'Method Complexity')) + .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::showForm<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::transformAuthorizationCode<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::setLogger<\/a>"],[100,5,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,5,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__invoke<\/a>"]], 'Method Complexity')) .transition() .duration(500) .call(chart); diff --git a/docs/coverage/Callback/index.html b/docs/coverage/Callback/index.html index f453d9a..48b326b 100644 --- a/docs/coverage/Callback/index.html +++ b/docs/coverage/Callback/index.html @@ -51,7 +51,7 @@
100.00%
-
55 / 55
+
56 / 56
100.00% covered (success) @@ -120,7 +120,7 @@
100.00%
-
29 / 29
+
30 / 30
diff --git a/docs/coverage/IndieAuthException.php.html b/docs/coverage/IndieAuthException.php.html index c5fb09e..3c8fd3e 100644 --- a/docs/coverage/IndieAuthException.php.html +++ b/docs/coverage/IndieAuthException.php.html @@ -100,7 +100,7 @@ -  create +  create
0.00% covered (danger) @@ -121,7 +121,7 @@ -  getStatusCode +  getStatusCode
100.00% covered (success) @@ -142,7 +142,7 @@ -  getExplanation +  getExplanation
0.00% covered (danger) @@ -163,7 +163,7 @@ -  getInfo +  getInfo
100.00% covered (success) @@ -184,7 +184,7 @@ -  trustQueryParams +  trustQueryParams
0.00% covered (danger) @@ -205,7 +205,7 @@ -  getRequest +  getRequest
100.00% covered (success) @@ -275,47 +275,48 @@ 42        self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'], 43    ]; 44 - 45    protected ServerRequestInterface $request; - 46 - 47    public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self { - 48        // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code. - 49        if (!in_array($code, array_keys(self::EXC_INFO))) { - 50            $code = 0; - 51        } - 52        $message = self::EXC_INFO[$code]['name']; - 53        $e = new self($message, $code, $previous); - 54        $e->request = $request; - 55        return $e; - 56    } - 57 - 58    public function getStatusCode() { - 59        return $this->getInfo()['statusCode'] ?? 500; - 60    } - 61 - 62    public function getExplanation() { - 63        return $this->getInfo()['explanation'] ?? 'An unknown error occured.'; - 64    } - 65 - 66    public function getInfo() { - 67        return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; - 68    } - 69 - 70    /** - 71     * Trust Query Params - 72     *  - 73     * Only useful on authorization form submission requests. If this returns false, - 74     * the client_id and/or request_uri have likely been tampered with, and the error - 75     * page SHOULD NOT offer the user a link to them. - 76     */ - 77    public function trustQueryParams() { - 78        return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH - 79                || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH; - 80    } - 81 - 82    public function getRequest() { - 83        return $this->request; - 84    } - 85} + 45    /** @var ServerRequestInterface $request */ + 46    protected $request; + 47 + 48    public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self { + 49        // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code. + 50        if (!in_array($code, array_keys(self::EXC_INFO))) { + 51            $code = 0; + 52        } + 53        $message = self::EXC_INFO[$code]['name']; + 54        $e = new self($message, $code, $previous); + 55        $e->request = $request; + 56        return $e; + 57    } + 58 + 59    public function getStatusCode() { + 60        return $this->getInfo()['statusCode'] ?? 500; + 61    } + 62 + 63    public function getExplanation() { + 64        return $this->getInfo()['explanation'] ?? 'An unknown error occured.'; + 65    } + 66 + 67    public function getInfo() { + 68        return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; + 69    } + 70 + 71    /** + 72     * Trust Query Params + 73     *  + 74     * Only useful on authorization form submission requests. If this returns false, + 75     * the client_id and/or request_uri have likely been tampered with, and the error + 76     * page SHOULD NOT offer the user a link to them. + 77     */ + 78    public function trustQueryParams() { + 79        return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH + 80                || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH; + 81    } + 82 + 83    public function getRequest() { + 84        return $this->request; + 85    } + 86} @@ -326,7 +327,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Middleware/ClosureRequestHandler.php.html b/docs/coverage/Middleware/ClosureRequestHandler.php.html index 1da4b9b..0a1d5fc 100644 --- a/docs/coverage/Middleware/ClosureRequestHandler.php.html +++ b/docs/coverage/Middleware/ClosureRequestHandler.php.html @@ -101,7 +101,7 @@ -  __construct +  __construct
100.00% covered (success) @@ -122,7 +122,7 @@ -  handle +  handle
100.00% covered (success) @@ -159,17 +159,18 @@ 9class ClosureRequestHandler implements RequestHandlerInterface { 10    protected $callable; 11 - 12    protected array $args; - 13 - 14    public function __construct(callable $callable) { - 15        $this->callable = $callable; - 16        $this->args = array_slice(func_get_args(), 1); - 17    } - 18 - 19    public function handle(ServerRequestInterface $request): ResponseInterface { - 20        return call_user_func_array($this->callable, array_merge([$request], $this->args)); - 21    } - 22} + 12    /** @var array $args */ + 13    protected $args; + 14 + 15    public function __construct(callable $callable) { + 16        $this->callable = $callable; + 17        $this->args = array_slice(func_get_args(), 1); + 18    } + 19 + 20    public function handle(ServerRequestInterface $request): ResponseInterface { + 21        return call_user_func_array($this->callable, array_merge([$request], $this->args)); + 22    } + 23} @@ -180,7 +181,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html index 4cc1561..58d9d7f 100644 --- a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html +++ b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html @@ -101,7 +101,7 @@ -  __construct +  __construct
0.00% covered (danger) @@ -122,7 +122,7 @@ -  setLogger +  setLogger
100.00% covered (success) @@ -143,7 +143,7 @@ -  process +  process
100.00% covered (success) @@ -164,7 +164,7 @@ -  isValid +  isValid
100.00% covered (success) @@ -233,87 +233,91 @@ 41    const DEFAULT_ERROR_RESPONSE_STRING = 'Invalid or missing CSRF token!'; 42    const CSRF_TOKEN_LENGTH = 128; 43     - 44    public string $attribute; - 45 - 46    public int $ttl; - 47 - 48    public $errorResponse; + 44    /** @var string $attribute */ + 45    public $attribute; + 46 + 47    /** @var int $ttl */ + 48    public $ttl; 49 - 50    public int $tokenLength; + 50    public $errorResponse; 51 - 52    public LoggerInterface $logger; - 53 - 54    /** - 55     * Constructor - 56     *  - 57     * The `$errorResponse` parameter can be used to customse the error response returned when a - 58     * write request has invalid CSRF parameters. It can take the following forms: - 59     *  - 60     * * A `string`, which will be returned as-is with a 400 Status Code and `Content-type: text/plain` header - 61     * * An instance of `ResponseInterface`, which will be returned as-is - 62     * * A callable with the signature `function (ServerRequestInterface $request): ResponseInterface`, - 63     *   the return value of which will be returned as-is. - 64     */ - 65    public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) { - 66        $this->attribute = $attribute ?? self::ATTRIBUTE; - 67        $this->ttl = $ttl ?? self::TTL; - 68        $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH; - 69 - 70        if (!is_callable($errorResponse)) { - 71            if (!$errorResponse instanceof ResponseInterface) { - 72                if (!is_string($errorResponse)) { - 73                    $errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING; - 74                } - 75                $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse); - 76            } - 77            $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; - 78        } - 79        $this->errorResponse = $errorResponse; - 80 - 81        if (!$logger instanceof LoggerInterface) { - 82            $logger = new NullLogger(); - 83        } - 84        $this->logger = $logger; - 85    } - 86 - 87    public function setLogger(LoggerInterface $logger) { - 88        $this->logger = $logger; - 89    } + 52    /** @var int $tokenLength */ + 53    public $tokenLength; + 54 + 55    /** @var LoggerInterface $logger */ + 56    public $logger; + 57 + 58    /** + 59     * Constructor + 60     *  + 61     * The `$errorResponse` parameter can be used to customse the error response returned when a + 62     * write request has invalid CSRF parameters. It can take the following forms: + 63     *  + 64     * * A `string`, which will be returned as-is with a 400 Status Code and `Content-type: text/plain` header + 65     * * An instance of `ResponseInterface`, which will be returned as-is + 66     * * A callable with the signature `function (ServerRequestInterface $request): ResponseInterface`, + 67     *   the return value of which will be returned as-is. + 68     */ + 69    public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) { + 70        $this->attribute = $attribute ?? self::ATTRIBUTE; + 71        $this->ttl = $ttl ?? self::TTL; + 72        $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH; + 73 + 74        if (!is_callable($errorResponse)) { + 75            if (!$errorResponse instanceof ResponseInterface) { + 76                if (!is_string($errorResponse)) { + 77                    $errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING; + 78                } + 79                $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse); + 80            } + 81            $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; + 82        } + 83        $this->errorResponse = $errorResponse; + 84 + 85        if (!$logger instanceof LoggerInterface) { + 86            $logger = new NullLogger(); + 87        } + 88        $this->logger = $logger; + 89    } 90 - 91    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - 92        // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response. - 93        $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); - 94        $request = $request->withAttribute($this->attribute, $csrfToken); - 95 - 96        if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { - 97            // This request is a write method with invalid CSRF parameters. - 98            $response = call_user_func($this->errorResponse, $request); - 99        } else { - 100            $response = $handler->handle($request); - 101        } - 102 - 103        // Add the new CSRF cookie, restricting its scope to match the current request. - 104        $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute) - 105                ->withValue($csrfToken) - 106                ->withMaxAge($this->ttl) - 107                ->withSecure($request->getUri()->getScheme() == 'https') - 108                ->withDomain($request->getUri()->getHost()) - 109                ->withPath($request->getUri()->getPath())); - 110 - 111        return $response; - 112    } - 113 - 114    protected function isValid(ServerRequestInterface $request) { - 115        if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) { - 116            if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) { - 117                // TODO: make sure CSRF token isn’t the empty string, possibly also check that it’s the same length - 118                // as defined in $this->tokenLength. - 119                return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]); - 120            } - 121        } - 122        return false; - 123    } - 124} + 91    public function setLogger(LoggerInterface $logger) { + 92        $this->logger = $logger; + 93    } + 94 + 95    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + 96        // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response. + 97        $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); + 98        $request = $request->withAttribute($this->attribute, $csrfToken); + 99 + 100        if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { + 101            // This request is a write method with invalid CSRF parameters. + 102            $response = call_user_func($this->errorResponse, $request); + 103        } else { + 104            $response = $handler->handle($request); + 105        } + 106 + 107        // Add the new CSRF cookie, restricting its scope to match the current request. + 108        $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute) + 109                ->withValue($csrfToken) + 110                ->withMaxAge($this->ttl) + 111                ->withSecure($request->getUri()->getScheme() == 'https') + 112                ->withDomain($request->getUri()->getHost()) + 113                ->withPath($request->getUri()->getPath())); + 114 + 115        return $response; + 116    } + 117 + 118    protected function isValid(ServerRequestInterface $request) { + 119        if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) { + 120            if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) { + 121                // TODO: make sure CSRF token isn’t the empty string, possibly also check that it’s the same length + 122                // as defined in $this->tokenLength. + 123                return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]); + 124            } + 125        } + 126        return false; + 127    } + 128} @@ -324,7 +328,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Middleware/NoOpMiddleware.php.html b/docs/coverage/Middleware/NoOpMiddleware.php.html index 5e67154..05b8192 100644 --- a/docs/coverage/Middleware/NoOpMiddleware.php.html +++ b/docs/coverage/Middleware/NoOpMiddleware.php.html @@ -157,7 +157,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Middleware/ResponseRequestHandler.php.html b/docs/coverage/Middleware/ResponseRequestHandler.php.html index d4e1fee..c0278f4 100644 --- a/docs/coverage/Middleware/ResponseRequestHandler.php.html +++ b/docs/coverage/Middleware/ResponseRequestHandler.php.html @@ -101,7 +101,7 @@ -  __construct +  __construct
100.00% covered (success) @@ -122,7 +122,7 @@ -  handle +  handle
100.00% covered (success) @@ -157,16 +157,17 @@ 7use Psr\Http\Server\RequestHandlerInterface; 8 9class ResponseRequestHandler implements RequestHandlerInterface { - 10    public ResponseInterface $response; - 11 - 12    public function __construct(ResponseInterface $response) { - 13        $this->response = $response; - 14    } - 15 - 16    public function handle(ServerRequestInterface $request): ResponseInterface { - 17        return $this->response; - 18    } - 19} + 10    /** @var ResponseInterface $response */ + 11    public $response; + 12 + 13    public function __construct(ResponseInterface $response) { + 14        $this->response = $response; + 15    } + 16 + 17    public function handle(ServerRequestInterface $request): ResponseInterface { + 18        return $this->response; + 19    } + 20} @@ -177,7 +178,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Middleware/dashboard.html b/docs/coverage/Middleware/dashboard.html index c453628..05f173b 100644 --- a/docs/coverage/Middleware/dashboard.html +++ b/docs/coverage/Middleware/dashboard.html @@ -138,7 +138,7 @@
@@ -249,7 +249,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Method Complexity'); d3.select('#methodComplexity svg') - .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::handle<\/a>"],[92.85714285714286,5,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::process<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::isValid<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware::process<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::handle<\/a>"]], 'Method Complexity')) + .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::handle<\/a>"],[92.85714285714286,5,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::process<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::isValid<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware::process<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::handle<\/a>"]], 'Method Complexity')) .transition() .duration(500) .call(chart); diff --git a/docs/coverage/Middleware/index.html b/docs/coverage/Middleware/index.html index f043cba..680be06 100644 --- a/docs/coverage/Middleware/index.html +++ b/docs/coverage/Middleware/index.html @@ -195,7 +195,7 @@ High: 90% to 100%

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Server.php.html b/docs/coverage/Server.php.html index 0490f7c..bff0d16 100644 --- a/docs/coverage/Server.php.html +++ b/docs/coverage/Server.php.html @@ -100,7 +100,7 @@ -  __construct +  __construct
100.00% covered (success) @@ -121,7 +121,7 @@ -  getTokenStorage +  getTokenStorage
100.00% covered (success) @@ -142,7 +142,7 @@ -  handleAuthorizationEndpointRequest +  handleAuthorizationEndpointRequest
0.00% covered (danger) @@ -163,7 +163,7 @@ -  handleTokenEndpointRequest +  handleTokenEndpointRequest
100.00% covered (success) @@ -184,7 +184,7 @@ -  handleException +  handleException
100.00% covered (success) @@ -312,755 +312,762 @@ 100     */ 101    const APPROVE_ACTION_VALUE = 'approve'; 102 - 103    protected Storage\TokenStorageInterface $tokenStorage; - 104 - 105    protected AuthorizationFormInterface $authorizationForm; - 106 - 107    protected MiddlewareInterface $csrfMiddleware; + 103    /** @var Storage\TokenStorageInterface $tokenStorage */ + 104    protected $tokenStorage; + 105 + 106    /** @var AuthorizationFormInterface $authorizationForm */ + 107    protected $authorizationForm; 108 - 109    protected LoggerInterface $logger; - 110 - 111    /** @var callable */ - 112    protected $httpGetWithEffectiveUrl; - 113 - 114    /** @var callable */ - 115    protected $handleAuthenticationRequestCallback; - 116 - 117    /** @var callable */ - 118    protected $handleNonIndieAuthRequest; - 119 - 120    protected string $exceptionTemplatePath; - 121 - 122    protected string $secret; + 109    /** @var MiddlewareInterface $csrfMiddleware */ + 110    protected $csrfMiddleware; + 111 + 112    /** @var LoggerInterface $logger */ + 113    protected $logger; + 114 + 115    /** @var callable */ + 116    protected $httpGetWithEffectiveUrl; + 117 + 118    /** @var callable */ + 119    protected $handleAuthenticationRequestCallback; + 120 + 121    /** @var callable */ + 122    protected $handleNonIndieAuthRequest; 123 - 124    protected bool $requirePkce; - 125 - 126    /** - 127     * Constructor - 128     *  - 129     * Server instances are configured by passing a config array to the constructor. - 130     *  - 131     * The following keys are required: - 132     *  - 133     * * `authenticationHandler`: a callable with the signature - 134     *   `function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl): array|ResponseInterface`. - 135     *   This function is called on IndieAuth authorization requests, after validating the query parameters. - 136     *    - 137     *   It should check to see if $request is authenticated, then: - 138     *     * If it is authenticated, return an array which MUST have a `me` key, mapping to the  - 139     *       canonical URL of the currently logged-in user. It may additionally have a `profile` key. These - 140     *       keys will be stored in the authorization code and sent to the client, if successful. - 141     *     * If it is not authenticated, either present or redirect to an authentication flow. This flow MUST - 142     *       redirect the logged-in used back to `$authenticationRedirect`. + 124    /** @var string $exceptionTemplatePath */ + 125    protected $exceptionTemplatePath; + 126 + 127    /** @var string $secret */ + 128    protected $secret; + 129 + 130    /** @var bool $requirePkce */ + 131    protected $requirePkce; + 132 + 133    /** + 134     * Constructor + 135     *  + 136     * Server instances are configured by passing a config array to the constructor. + 137     *  + 138     * The following keys are required: + 139     *  + 140     * * `authenticationHandler`: a callable with the signature + 141     *   `function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl): array|ResponseInterface`. + 142     *   This function is called on IndieAuth authorization requests, after validating the query parameters. 143     *    - 144     *   If the request has a valid `me` parameter, the canonicalized version of it is passed as - 145     *   `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used  - 146     *   as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT - 147     *   be considered valid data. - 148     *    - 149     *   If redirecting to an existing authentication flow, this callable can usually be implemented as a - 150     *   closure. The callable may also implement its own authentication logic. For an example, see  - 151     *   `Callback\SingleUserPasswordAuthenticationCallback`. - 152     * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used - 153     *   to hash and subsequently verify request query parameters which get passed around. - 154     * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path, - 155     *   which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization - 156     *   codes and access tokens, as well as implementation-specific parts of the exchange process which are  - 157     *   out of the scope of the Server class (e.g. lifetimes and expiry). Refer to the `Storage\TokenStorageInterface` - 158     *   documentation for more details. - 159     *  - 160     * The following keys may be required depending on which packages you have installed: - 161     *  - 162     * * `httpGetWithEffectiveUrl`: must be a callable with the following signature: - 163     *   `function (string $url): array [ResponseInterface $response, string $effectiveUrl]`, where  - 164     *   `$effectiveUrl` is the final URL after following any redirects (unfortunately, neither the PSR-7 - 165     *   Response nor the PSR-18 Client interfaces offer a standard way of getting this very important - 166     *   data, hence the unusual return signature).  If `guzzlehttp/guzzle` is installed, this parameter - 167     *   will be created automatically. Otherwise, the user must provide their own callable. + 144     *   It should check to see if $request is authenticated, then: + 145     *     * If it is authenticated, return an array which MUST have a `me` key, mapping to the  + 146     *       canonical URL of the currently logged-in user. It may additionally have a `profile` key. These + 147     *       keys will be stored in the authorization code and sent to the client, if successful. + 148     *     * If it is not authenticated, either present or redirect to an authentication flow. This flow MUST + 149     *       redirect the logged-in used back to `$authenticationRedirect`. + 150     *    + 151     *   If the request has a valid `me` parameter, the canonicalized version of it is passed as + 152     *   `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used  + 153     *   as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT + 154     *   be considered valid data. + 155     *    + 156     *   If redirecting to an existing authentication flow, this callable can usually be implemented as a + 157     *   closure. The callable may also implement its own authentication logic. For an example, see  + 158     *   `Callback\SingleUserPasswordAuthenticationCallback`. + 159     * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used + 160     *   to hash and subsequently verify request query parameters which get passed around. + 161     * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path, + 162     *   which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization + 163     *   codes and access tokens, as well as implementation-specific parts of the exchange process which are  + 164     *   out of the scope of the Server class (e.g. lifetimes and expiry). Refer to the `Storage\TokenStorageInterface` + 165     *   documentation for more details. + 166     *  + 167     * The following keys may be required depending on which packages you have installed: 168     *  - 169     * The following keys are optional: - 170     *  - 171     * * `authorizationForm`: an instance of `AuthorizationFormInterface`. Defaults to `DefaultAuthorizationForm`. - 172     *   Refer to that implementation if you wish to replace the consent screen/scope choosing/authorization form. - 173     * * `csrfMiddleware`: an instance of `MiddlewareInterface`, which will be used to CSRF-protect the - 174     *   user-facing authorization flow. By default an instance of `DoubleSubmitCookieCsrfMiddleware`. - 175     *   Refer to that implementation if you want to replace it with your own middleware — you will  - 176     *   likely have to either make sure your middleware sets the same request attribute, or alter your - 177     *   templates accordingly. - 178     * * `exceptionTemplatePath`: string, path to a template which will be used for displaying user-facing - 179     *   errors. Defaults to `../templates/default_exception_response.html.php`, refer to that if you wish - 180     *   to write your own template. - 181     * * `handleNonIndieAuthRequestCallback`: A callback with the following signature: - 182     *   `function (ServerRequestInterface $request): ?ResponseInterface` which will be called if the - 183     *   authorization endpoint gets a request which is not identified as an IndieAuth request or authorization - 184     *   form submission request. You could use this to handle various requests e.g. client-side requests - 185     *   made by your authentication or authorization pages, if it’s not convenient to put them elsewhere. - 186     *   Returning `null` will result in a standard `invalid_request` error being returned. - 187     * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set - 188     *   as the logger for any objects passed in config which implement `LoggerAwareInterface`. - 189     * * `requirePKCE`: bool, default true. Setting this to `false` allows requests which don’t provide PKCE - 190     *   parameters (code_challenge, code_challenge_method, code_verifier), under the following conditions: - 191     *     * If any of the PKCE parameters are present in an authorization code request, all must be present - 192     *       and valid. - 193     *     * If an authorization code request lacks PKCE parameters, the created auth code can only be exchanged - 194     *       by an exchange request without parameters. - 195     *     * If authorization codes are stored without PKCE parameters, and then `requirePKCE` is set to `true`, - 196     *       these old authorization codes will no longer be redeemable. - 197     *  - 198     * @param array $config An array of configuration variables - 199     * @return self - 200     */ - 201    public function __construct(array $config) { - 202        $config = array_merge([ - 203            'csrfMiddleware' => new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY), - 204            'logger' => null, - 205            self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op. - 206            'tokenStorage' => null, - 207            'httpGetWithEffectiveUrl' => null, - 208            'authorizationForm' => new DefaultAuthorizationForm(), - 209            'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php', - 210            'requirePKCE' => true, - 211        ], $config); - 212 - 213        $this->requirePkce = $config['requirePKCE']; - 214 - 215        if (!is_string($config['exceptionTemplatePath'])) { - 216            throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path)."); - 217        } - 218        $this->exceptionTemplatePath = $config['exceptionTemplatePath']; + 169     * * `httpGetWithEffectiveUrl`: must be a callable with the following signature: + 170     *   `function (string $url): array [ResponseInterface $response, string $effectiveUrl]`, where  + 171     *   `$effectiveUrl` is the final URL after following any redirects (unfortunately, neither the PSR-7 + 172     *   Response nor the PSR-18 Client interfaces offer a standard way of getting this very important + 173     *   data, hence the unusual return signature).  If `guzzlehttp/guzzle` is installed, this parameter + 174     *   will be created automatically. Otherwise, the user must provide their own callable. + 175     *  + 176     * The following keys are optional: + 177     *  + 178     * * `authorizationForm`: an instance of `AuthorizationFormInterface`. Defaults to `DefaultAuthorizationForm`. + 179     *   Refer to that implementation if you wish to replace the consent screen/scope choosing/authorization form. + 180     * * `csrfMiddleware`: an instance of `MiddlewareInterface`, which will be used to CSRF-protect the + 181     *   user-facing authorization flow. By default an instance of `DoubleSubmitCookieCsrfMiddleware`. + 182     *   Refer to that implementation if you want to replace it with your own middleware — you will  + 183     *   likely have to either make sure your middleware sets the same request attribute, or alter your + 184     *   templates accordingly. + 185     * * `exceptionTemplatePath`: string, path to a template which will be used for displaying user-facing + 186     *   errors. Defaults to `../templates/default_exception_response.html.php`, refer to that if you wish + 187     *   to write your own template. + 188     * * `handleNonIndieAuthRequestCallback`: A callback with the following signature: + 189     *   `function (ServerRequestInterface $request): ?ResponseInterface` which will be called if the + 190     *   authorization endpoint gets a request which is not identified as an IndieAuth request or authorization + 191     *   form submission request. You could use this to handle various requests e.g. client-side requests + 192     *   made by your authentication or authorization pages, if it’s not convenient to put them elsewhere. + 193     *   Returning `null` will result in a standard `invalid_request` error being returned. + 194     * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set + 195     *   as the logger for any objects passed in config which implement `LoggerAwareInterface`. + 196     * * `requirePKCE`: bool, default true. Setting this to `false` allows requests which don’t provide PKCE + 197     *   parameters (code_challenge, code_challenge_method, code_verifier), under the following conditions: + 198     *     * If any of the PKCE parameters are present in an authorization code request, all must be present + 199     *       and valid. + 200     *     * If an authorization code request lacks PKCE parameters, the created auth code can only be exchanged + 201     *       by an exchange request without parameters. + 202     *     * If authorization codes are stored without PKCE parameters, and then `requirePKCE` is set to `true`, + 203     *       these old authorization codes will no longer be redeemable. + 204     *  + 205     * @param array $config An array of configuration variables + 206     * @return self + 207     */ + 208    public function __construct(array $config) { + 209        $config = array_merge([ + 210            'csrfMiddleware' => new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY), + 211            'logger' => null, + 212            self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op. + 213            'tokenStorage' => null, + 214            'httpGetWithEffectiveUrl' => null, + 215            'authorizationForm' => new DefaultAuthorizationForm(), + 216            'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php', + 217            'requirePKCE' => true, + 218        ], $config); 219 - 220        $secret = $config['secret'] ?? ''; - 221        if (!is_string($secret) || strlen($secret) < 64) { - 222            throw new BadMethodCallException("\$config['secret'] must be a string with a minimum length of 64 characters."); - 223        } - 224        $this->secret = $secret; - 225 - 226        if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) { - 227            throw new BadMethodCallException("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null."); - 228        } - 229        $this->logger = $config['logger'] ?? new NullLogger(); - 230 - 231        if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) { - 232            throw new BadMethodCallException('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.'); - 233        } - 234        $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST]; - 235         - 236        if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) { - 237            throw new BadMethodCallException("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable"); - 238        } - 239        $this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST]; - 240 - 241        $tokenStorage = $config['tokenStorage']; - 242        if (!$tokenStorage instanceof Storage\TokenStorageInterface) { - 243            if (is_string($tokenStorage)) { - 244                // Create a default access token storage with a TTL of 7 days. - 245                $tokenStorage = new Storage\FilesystemJsonStorage($tokenStorage, $this->secret); - 246            } else { - 247                throw new BadMethodCallException("\$config['tokenStorage'] parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface."); - 248            } - 249        } - 250        trySetLogger($tokenStorage, $this->logger); - 251        $this->tokenStorage = $tokenStorage; - 252 - 253        $csrfMiddleware = $config['csrfMiddleware']; - 254        if (!$csrfMiddleware instanceof MiddlewareInterface) { - 255            throw new BadMethodCallException("\$config['csrfMiddleware'] must be null or implement MiddlewareInterface."); + 220        $this->requirePkce = $config['requirePKCE']; + 221 + 222        if (!is_string($config['exceptionTemplatePath'])) { + 223            throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path)."); + 224        } + 225        $this->exceptionTemplatePath = $config['exceptionTemplatePath']; + 226 + 227        $secret = $config['secret'] ?? ''; + 228        if (!is_string($secret) || strlen($secret) < 64) { + 229            throw new BadMethodCallException("\$config['secret'] must be a string with a minimum length of 64 characters."); + 230        } + 231        $this->secret = $secret; + 232 + 233        if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) { + 234            throw new BadMethodCallException("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null."); + 235        } + 236        $this->logger = $config['logger'] ?? new NullLogger(); + 237 + 238        if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) { + 239            throw new BadMethodCallException('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.'); + 240        } + 241        $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST]; + 242         + 243        if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) { + 244            throw new BadMethodCallException("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable"); + 245        } + 246        $this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST]; + 247 + 248        $tokenStorage = $config['tokenStorage']; + 249        if (!$tokenStorage instanceof Storage\TokenStorageInterface) { + 250            if (is_string($tokenStorage)) { + 251                // Create a default access token storage with a TTL of 7 days. + 252                $tokenStorage = new Storage\FilesystemJsonStorage($tokenStorage, $this->secret); + 253            } else { + 254                throw new BadMethodCallException("\$config['tokenStorage'] parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface."); + 255            } 256        } - 257        trySetLogger($csrfMiddleware, $this->logger); - 258        $this->csrfMiddleware = $csrfMiddleware; + 257        trySetLogger($tokenStorage, $this->logger); + 258        $this->tokenStorage = $tokenStorage; 259 - 260        $httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl']; - 261        if (is_null($httpGetWithEffectiveUrl)) { - 262            if (class_exists('\GuzzleHttp\Client')) { - 263                $httpGetWithEffectiveUrl = function (string $uri): array { - 264                    // This code can’t be tested, ignore it for coverage purposes. - 265                    // @codeCoverageIgnoreStart - 266                    $resp = (new \GuzzleHttp\Client([ - 267                        \GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => [ - 268                            'max' => 10, - 269                            'strict' => true, - 270                            'referer' => true, - 271                            'track_redirects' => true - 272                        ] - 273                    ]))->get($uri); - 274                     - 275                    $rdh = $resp->getHeader('X-Guzzle-Redirect-History'); - 276                    $effectiveUrl = empty($rdh) ? $uri : array_values($rdh)[count($rdh) - 1]; - 277 - 278                    return [$resp, $effectiveUrl]; - 279                }; - 280            } else { - 281                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] was not provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable."); - 282                // @codeCoverageIgnoreEnd - 283            } - 284        } else { - 285            if (!is_callable($httpGetWithEffectiveUrl)) { - 286                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] must be callable."); - 287            } - 288        } - 289        trySetLogger($httpGetWithEffectiveUrl, $this->logger); - 290        $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl; - 291 - 292        if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) { - 293            throw new BadMethodCallException("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm."); - 294        } - 295        $this->authorizationForm = $config['authorizationForm']; - 296        trySetLogger($this->authorizationForm, $this->logger); - 297    } + 260        $csrfMiddleware = $config['csrfMiddleware']; + 261        if (!$csrfMiddleware instanceof MiddlewareInterface) { + 262            throw new BadMethodCallException("\$config['csrfMiddleware'] must be null or implement MiddlewareInterface."); + 263        } + 264        trySetLogger($csrfMiddleware, $this->logger); + 265        $this->csrfMiddleware = $csrfMiddleware; + 266 + 267        $httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl']; + 268        if (is_null($httpGetWithEffectiveUrl)) { + 269            if (class_exists('\GuzzleHttp\Client')) { + 270                $httpGetWithEffectiveUrl = function (string $uri): array { + 271                    // This code can’t be tested, ignore it for coverage purposes. + 272                    // @codeCoverageIgnoreStart + 273                    $resp = (new \GuzzleHttp\Client([ + 274                        \GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => [ + 275                            'max' => 10, + 276                            'strict' => true, + 277                            'referer' => true, + 278                            'track_redirects' => true + 279                        ] + 280                    ]))->get($uri); + 281                     + 282                    $rdh = $resp->getHeader('X-Guzzle-Redirect-History'); + 283                    $effectiveUrl = empty($rdh) ? $uri : array_values($rdh)[count($rdh) - 1]; + 284 + 285                    return [$resp, $effectiveUrl]; + 286                }; + 287            } else { + 288                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] was not provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable."); + 289                // @codeCoverageIgnoreEnd + 290            } + 291        } else { + 292            if (!is_callable($httpGetWithEffectiveUrl)) { + 293                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] must be callable."); + 294            } + 295        } + 296        trySetLogger($httpGetWithEffectiveUrl, $this->logger); + 297        $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl; 298 - 299    public function getTokenStorage(): TokenStorageInterface { - 300        return $this->tokenStorage; - 301    } - 302 - 303    /** - 304     * Handle Authorization Endpoint Request - 305     *  - 306     * This method handles all requests to your authorization endpoint, passing execution off to - 307     * other callbacks when necessary. The logical flow can be summarised as follows: - 308     *  - 309     * * If this request an **auth code exchange for profile information**, validate the request - 310     *   and return a response or error response. - 311     * * Otherwise, proceed, wrapping all execution in CSRF-protection middleware. - 312     * * Validate the request’s indieauth authorization code request parameters, returning an  - 313     *   error response if any are missing or invalid. - 314     * * Call the authentication callback - 315     *     * If the callback returned an instance of ResponseInterface, the user is not currently - 316     *       logged in. Return the Response, which will presumably start an authentication flow. - 317     *     * Otherwise, the callback returned information about the currently logged-in user. Continue. - 318     * * If this request is an authorization form submission, validate the data, store and authorization - 319     *   code and return a redirect response to the client redirect_uri with code data. On an error, return - 320     *   an appropriate error response. - 321     * * Otherwise, fetch the client_id, parse app data if present, validate the `redirect_uri` and present - 322     *   the authorization form/consent screen to the user. - 323     * * If none of the above apply, try calling the non-indieauth request handler. If it returns a Response, - 324     *   return that, otherwise return an error response. - 325     *  - 326     * This route should NOT be wrapped in additional CSRF-protection, due to the need to handle API  - 327     * POST requests from the client. Make sure you call it from a route which is excluded from any - 328     * CSRF-protection you might be using. To customise the CSRF protection used internally, refer to the - 329     * `__construct` config array documentation for the `csrfMiddleware` key. - 330     *  - 331     * Most user-facing errors are thrown as instances of `IndieAuthException`, which are passed off to - 332     * `handleException` to be turned into an instance of `ResponseInterface`. If you want to customise - 333     * error behaviour, one way to do so is to subclass `Server` and override that method. - 334     *  - 335     * @param ServerRequestInterface $request - 336     * @return ResponseInterface - 337     */ - 338    public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface { - 339        $this->logger->info('Handling an IndieAuth Authorization Endpoint request.'); - 340         - 341        // If it’s a profile information request: - 342        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { - 343            $this->logger->info('Handling a request to redeem an authorization code for profile information.'); - 344             - 345            $bodyParams = $request->getParsedBody(); - 346 - 347            if (!isset($bodyParams['code'])) { - 348                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.'); - 349                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 350                    'error' => 'invalid_request', - 351                    'error_description' => 'The code parameter was missing.' - 352                ])); - 353            } - 354 - 355            // Attempt to internally exchange the provided auth code for an access token. - 356            // We do this before anything else so that the auth code is invalidated as soon as the request starts, - 357            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler - 358            // and more flexible interface for TokenStorage implementors. - 359            try { - 360                // Call the token exchange method, passing in a callback which performs additional validation - 361                // on the auth code before it gets exchanged. - 362                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { - 363                    // Verify that all required parameters are included. - 364                    $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri']; - 365                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { - 366                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); - 367                    }); - 368                    if (!empty($missingRequiredParameters)) { - 369                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); - 370                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request); - 371                    } - 372 - 373                    // Verify that it was issued for the same client_id and redirect_uri - 374                    if ($authCode['client_id'] !== $bodyParams['client_id'] - 375                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) { - 376                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token."); - 377                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 299        if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) { + 300            throw new BadMethodCallException("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm."); + 301        } + 302        $this->authorizationForm = $config['authorizationForm']; + 303        trySetLogger($this->authorizationForm, $this->logger); + 304    } + 305 + 306    public function getTokenStorage(): TokenStorageInterface { + 307        return $this->tokenStorage; + 308    } + 309 + 310    /** + 311     * Handle Authorization Endpoint Request + 312     *  + 313     * This method handles all requests to your authorization endpoint, passing execution off to + 314     * other callbacks when necessary. The logical flow can be summarised as follows: + 315     *  + 316     * * If this request an **auth code exchange for profile information**, validate the request + 317     *   and return a response or error response. + 318     * * Otherwise, proceed, wrapping all execution in CSRF-protection middleware. + 319     * * Validate the request’s indieauth authorization code request parameters, returning an  + 320     *   error response if any are missing or invalid. + 321     * * Call the authentication callback + 322     *     * If the callback returned an instance of ResponseInterface, the user is not currently + 323     *       logged in. Return the Response, which will presumably start an authentication flow. + 324     *     * Otherwise, the callback returned information about the currently logged-in user. Continue. + 325     * * If this request is an authorization form submission, validate the data, store and authorization + 326     *   code and return a redirect response to the client redirect_uri with code data. On an error, return + 327     *   an appropriate error response. + 328     * * Otherwise, fetch the client_id, parse app data if present, validate the `redirect_uri` and present + 329     *   the authorization form/consent screen to the user. + 330     * * If none of the above apply, try calling the non-indieauth request handler. If it returns a Response, + 331     *   return that, otherwise return an error response. + 332     *  + 333     * This route should NOT be wrapped in additional CSRF-protection, due to the need to handle API  + 334     * POST requests from the client. Make sure you call it from a route which is excluded from any + 335     * CSRF-protection you might be using. To customise the CSRF protection used internally, refer to the + 336     * `__construct` config array documentation for the `csrfMiddleware` key. + 337     *  + 338     * Most user-facing errors are thrown as instances of `IndieAuthException`, which are passed off to + 339     * `handleException` to be turned into an instance of `ResponseInterface`. If you want to customise + 340     * error behaviour, one way to do so is to subclass `Server` and override that method. + 341     *  + 342     * @param ServerRequestInterface $request + 343     * @return ResponseInterface + 344     */ + 345    public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface { + 346        $this->logger->info('Handling an IndieAuth Authorization Endpoint request.'); + 347         + 348        // If it’s a profile information request: + 349        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { + 350            $this->logger->info('Handling a request to redeem an authorization code for profile information.'); + 351             + 352            $bodyParams = $request->getParsedBody(); + 353 + 354            if (!isset($bodyParams['code'])) { + 355                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.'); + 356                return new Response(400, ['content-type' => 'application/json'], json_encode([ + 357                    'error' => 'invalid_request', + 358                    'error_description' => 'The code parameter was missing.' + 359                ])); + 360            } + 361 + 362            // Attempt to internally exchange the provided auth code for an access token. + 363            // We do this before anything else so that the auth code is invalidated as soon as the request starts, + 364            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler + 365            // and more flexible interface for TokenStorage implementors. + 366            try { + 367                // Call the token exchange method, passing in a callback which performs additional validation + 368                // on the auth code before it gets exchanged. + 369                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { + 370                    // Verify that all required parameters are included. + 371                    $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri']; + 372                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { + 373                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); + 374                    }); + 375                    if (!empty($missingRequiredParameters)) { + 376                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); + 377                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request); 378                    } 379 - 380                    // If the auth code was requested with no code_challenge, but the exchange request provides a  - 381                    // code_verifier, return an error. - 382                    if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) { - 383                        $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge."); - 384                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 380                    // Verify that it was issued for the same client_id and redirect_uri + 381                    if ($authCode['client_id'] !== $bodyParams['client_id'] + 382                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) { + 383                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token."); + 384                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); 385                    } 386 - 387                    if ($this->requirePkce or !empty($authCode['code_challenge'])) { - 388                        // Check that the supplied code_verifier hashes to the stored code_challenge - 389                        // TODO: support method = plain as well as S256. - 390                        if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { - 391                            $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); - 392                            throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); - 393                        } - 394                    } - 395 - 396                    // Check that this token either grants at most the profile scope. - 397                    $requestedScopes = array_filter(explode(' ', $authCode['scope'] ?? '')); - 398                    if (!empty($requestedScopes) && $requestedScopes != ['profile']) { - 399                        $this->logger->error("An exchange request for a token granting scopes other than “profile” was sent to the authorization endpoint."); - 400                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 387                    // If the auth code was requested with no code_challenge, but the exchange request provides a  + 388                    // code_verifier, return an error. + 389                    if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) { + 390                        $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge."); + 391                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 392                    } + 393 + 394                    if ($this->requirePkce or !empty($authCode['code_challenge'])) { + 395                        // Check that the supplied code_verifier hashes to the stored code_challenge + 396                        // TODO: support method = plain as well as S256. + 397                        if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { + 398                            $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); + 399                            throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 400                        } 401                    } - 402                }); - 403            } catch (IndieAuthException $e) { - 404                // If an exception was thrown, return a corresponding error response. - 405                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 406                    'error' => $e->getInfo()['error'], - 407                    'error_description' => $e->getMessage() - 408                ])); - 409            } - 410 - 411            if (is_null($tokenData)) { - 412                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams); - 413                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 414                    'error' => 'invalid_grant', - 415                    'error_description' => 'The provided credentials were not valid.' - 416                ])); - 417            } - 418 - 419            // TODO: return an error if the token doesn’t contain a me key. - 420 - 421            // If everything checked out, return {"me": "https://example.com"} response - 422            return new Response(200, [ - 423                'content-type' => 'application/json', - 424                'cache-control' => 'no-store', - 425            ], json_encode(array_filter($tokenData, function (string $k) { - 426                // Prevent codes exchanged at the authorization endpoint from returning any information other than - 427                // me and profile. - 428                return in_array($k, ['me', 'profile']); - 429            }, ARRAY_FILTER_USE_KEY))); - 430        } - 431 - 432        // Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly - 433        // closure gymnastics here to selectively-CSRF-protect requests which do need it. - 434        return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) { - 435            // Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it - 436            // to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a - 437            // response. - 438            try { - 439                $queryParams = $request->getQueryParams(); - 440 - 441                /** @var ResponseInterface|null $clientIdResponse */ - 442                /** @var string|null $clientIdEffectiveUrl */ - 443                /** @var array|null $clientIdMf2 */ - 444                list($clientIdResponse, $clientIdEffectiveUrl, $clientIdMf2) = [null, null, null]; - 445 - 446                // If this is an authorization or approval request (allowing POST requests as well to accommodate  - 447                // approval requests and custom auth form submission. - 448                if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) { - 449                    $this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]); - 450 - 451                    // Validate the Client ID. - 452                    if (!isset($queryParams['client_id']) || false === filter_var($queryParams['client_id'], FILTER_VALIDATE_URL) || !isClientIdentifier($queryParams['client_id'])) { - 453                        $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams); - 454                        throw IndieAuthException::create(IndieAuthException::INVALID_CLIENT_ID, $request); - 455                    } - 456 - 457                    // Validate the redirect URI. - 458                    if (!isset($queryParams['redirect_uri']) || false === filter_var($queryParams['redirect_uri'], FILTER_VALIDATE_URL)) { - 459                        $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams); - 460                        throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request); - 461                    } - 462 - 463                    // How most errors are handled depends on whether or not the request has a valid redirect_uri. In - 464                    // order to know that, we need to also validate, fetch and parse the client_id. - 465                    // If the request lacks a hash, or if the provided hash was invalid, perform the validation. - 466                    $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); - 467                    if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($currentRequestHash) or !hash_equals($currentRequestHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { - 468 - 469                        // All we need to know at this stage is whether the redirect_uri is valid. If it - 470                        // sufficiently matches the client_id, we don’t (yet) need to fetch the client_id. - 471                        if (!urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT])) { - 472                            // If we do need to fetch the client_id, store the response and effective URL in variables - 473                            // we defined earlier, so they’re available to the approval request code path, which additionally - 474                            // needs to parse client_id for h-app markup. - 475                            try { - 476                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']); - 477                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl); - 478                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) { - 479                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [ - 480                                    'client_id' => $queryParams['client_id'], - 481                                    'exception' => $e->__toString() - 482                                ]); - 483 - 484                                throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e); - 485                            } catch (Exception $e) { - 486                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ - 487                                    'exception' => $e->__toString() - 488                                ]); - 489 - 490                                throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e); - 491                            } - 492                             - 493                            // Search for all link@rel=redirect_uri at the client_id. - 494                            $clientIdRedirectUris = []; - 495                            if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) { - 496                                $clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']); - 497                            } - 498                             - 499                            foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) { - 500                                if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) { - 501                                    // Strip off the < > which surround the link URL for some reason. - 502                                    $clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2); - 503                                } + 402 + 403                    // Check that this token either grants at most the profile scope. + 404                    $requestedScopes = array_filter(explode(' ', $authCode['scope'] ?? '')); + 405                    if (!empty($requestedScopes) && $requestedScopes != ['profile']) { + 406                        $this->logger->error("An exchange request for a token granting scopes other than “profile” was sent to the authorization endpoint."); + 407                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 408                    } + 409                }); + 410            } catch (IndieAuthException $e) { + 411                // If an exception was thrown, return a corresponding error response. + 412                return new Response(400, ['content-type' => 'application/json'], json_encode([ + 413                    'error' => $e->getInfo()['error'], + 414                    'error_description' => $e->getMessage() + 415                ])); + 416            } + 417 + 418            if (is_null($tokenData)) { + 419                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams); + 420                return new Response(400, ['content-type' => 'application/json'], json_encode([ + 421                    'error' => 'invalid_grant', + 422                    'error_description' => 'The provided credentials were not valid.' + 423                ])); + 424            } + 425 + 426            // TODO: return an error if the token doesn’t contain a me key. + 427 + 428            // If everything checked out, return {"me": "https://example.com"} response + 429            return new Response(200, [ + 430                'content-type' => 'application/json', + 431                'cache-control' => 'no-store', + 432            ], json_encode(array_filter($tokenData, function (string $k) { + 433                // Prevent codes exchanged at the authorization endpoint from returning any information other than + 434                // me and profile. + 435                return in_array($k, ['me', 'profile']); + 436            }, ARRAY_FILTER_USE_KEY))); + 437        } + 438 + 439        // Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly + 440        // closure gymnastics here to selectively-CSRF-protect requests which do need it. + 441        return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) { + 442            // Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it + 443            // to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a + 444            // response. + 445            try { + 446                $queryParams = $request->getQueryParams(); + 447 + 448                /** @var ResponseInterface|null $clientIdResponse */ + 449                /** @var string|null $clientIdEffectiveUrl */ + 450                /** @var array|null $clientIdMf2 */ + 451                list($clientIdResponse, $clientIdEffectiveUrl, $clientIdMf2) = [null, null, null]; + 452 + 453                // If this is an authorization or approval request (allowing POST requests as well to accommodate  + 454                // approval requests and custom auth form submission. + 455                if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) { + 456                    $this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]); + 457 + 458                    // Validate the Client ID. + 459                    if (!isset($queryParams['client_id']) || false === filter_var($queryParams['client_id'], FILTER_VALIDATE_URL) || !isClientIdentifier($queryParams['client_id'])) { + 460                        $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams); + 461                        throw IndieAuthException::create(IndieAuthException::INVALID_CLIENT_ID, $request); + 462                    } + 463 + 464                    // Validate the redirect URI. + 465                    if (!isset($queryParams['redirect_uri']) || false === filter_var($queryParams['redirect_uri'], FILTER_VALIDATE_URL)) { + 466                        $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams); + 467                        throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request); + 468                    } + 469 + 470                    // How most errors are handled depends on whether or not the request has a valid redirect_uri. In + 471                    // order to know that, we need to also validate, fetch and parse the client_id. + 472                    // If the request lacks a hash, or if the provided hash was invalid, perform the validation. + 473                    $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); + 474                    if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($currentRequestHash) or !hash_equals($currentRequestHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { + 475 + 476                        // All we need to know at this stage is whether the redirect_uri is valid. If it + 477                        // sufficiently matches the client_id, we don’t (yet) need to fetch the client_id. + 478                        if (!urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT])) { + 479                            // If we do need to fetch the client_id, store the response and effective URL in variables + 480                            // we defined earlier, so they’re available to the approval request code path, which additionally + 481                            // needs to parse client_id for h-app markup. + 482                            try { + 483                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']); + 484                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl); + 485                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) { + 486                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [ + 487                                    'client_id' => $queryParams['client_id'], + 488                                    'exception' => $e->__toString() + 489                                ]); + 490 + 491                                throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e); + 492                            } catch (Exception $e) { + 493                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ + 494                                    'exception' => $e->__toString() + 495                                ]); + 496 + 497                                throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e); + 498                            } + 499                             + 500                            // Search for all link@rel=redirect_uri at the client_id. + 501                            $clientIdRedirectUris = []; + 502                            if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) { + 503                                $clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']); 504                            } - 505 - 506                            // If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error. - 507                            if (!in_array($queryParams['redirect_uri'], $clientIdRedirectUris)) { - 508                                $this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [ - 509                                    'provided_redirect_uri' => $queryParams['redirect_uri'], - 510                                    'provided_client_id' => $queryParams['client_id'], - 511                                    'discovered_redirect_uris' => $clientIdRedirectUris - 512                                ]); - 513 - 514                                throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request); - 515                            } - 516                        }                         - 517                    } - 518 - 519                    // From now on, we can assume that redirect_uri is valid. Any IndieAuth-related errors should be - 520                    // reported by redirecting to redirect_uri with error parameters. - 521 - 522                    // Validate the state parameter. - 523                    if (!isset($queryParams['state']) or !isValidState($queryParams['state'])) { - 524                        $this->logger->warning("The state provided in an authorization request was not valid.", $queryParams); - 525                        throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request); - 526                    } - 527                    // From now on, any redirect error responses should include the state parameter. - 528                    // This is handled automatically in `handleException()` and is only noted here - 529                    // for reference. - 530 - 531                    // If either PKCE parameter is present, validate both. - 532                    if (isset($queryParams['code_challenge']) or isset($queryParams['code_challenge_method'])) { - 533                        if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) { - 534                            $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams); - 535                            throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); - 536                        } - 537     - 538                        if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) { - 539                            $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams); - 540                            throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); - 541                        } - 542                    } else { - 543                        // If neither PKCE parameter is defined, and PKCE is required, throw an error. Otherwise, proceed. - 544                        if ($this->requirePkce) { - 545                            $this->logger->warning("PKCE is required, and both code_challenge and code_challenge_method were missing."); - 546                            throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST_REDIRECT, $request); - 547                        } - 548                    } - 549 - 550                    // Validate the scope parameter, if provided. - 551                    if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) { - 552                        $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams); - 553                        throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request); - 554                    } - 555 - 556                    // Normalise the me parameter, if it exists. - 557                    if (array_key_exists('me', $queryParams)) { - 558                        $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']); - 559                        // If the me parameter is not a valid profile URL, ignore it. - 560                        if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) { - 561                            $queryParams['me'] = null; - 562                        } - 563                    } - 564 - 565                    // Build a URL containing the indieauth authorization request parameters, hashing them - 566                    // to protect them from being changed. - 567                    // Make a hash of the protected indieauth-specific parameters. If PKCE is in use, include  - 568                    // the PKCE parameters in the hash. Otherwise, leave them out. - 569                    $hash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); - 570                    // Operate on a copy of $queryParams, otherwise requests will always have a valid hash! - 571                    $redirectQueryParams = $queryParams; - 572                    $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash; - 573                    $authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString(); - 574                     - 575                    // User-facing requests always start by calling the authentication request callback. - 576                    $this->logger->info('Calling handle_authentication_request callback'); - 577                    $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null); - 578                     - 579                    // If the authentication handler returned a Response, return that as-is. - 580                    if ($authenticationResult instanceof ResponseInterface) { - 581                        return $authenticationResult; - 582                    } elseif (is_array($authenticationResult)) { - 583                        // Check the resulting array for errors. - 584                        if (!array_key_exists('me', $authenticationResult)) { - 585                            $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]); - 586                            throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request); - 587                        } - 588 - 589                        // If this is a POST request sent from the authorization (i.e. scope-choosing) form: - 590                        if (isAuthorizationApprovalRequest($request)) { - 591                            // Authorization approval requests MUST include a hash protecting the sensitive IndieAuth - 592                            // authorization request parameters from being changed, e.g. by a malicious script which - 593                            // found its way onto the authorization form. - 594                            if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) { - 595                                $this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter."); - 596                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request); - 597                            } - 598 - 599                            $expectedHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); - 600                            if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { - 601                                $this->logger->warning("The hash provided in the URL was invalid!", [ - 602                                    'expected' => $expectedHash, - 603                                    'actual' => $queryParams[self::HASH_QUERY_STRING_KEY] - 604                                ]); - 605                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request); - 606                            } - 607                             - 608                            // Assemble the data for the authorization code, store it somewhere persistent. - 609                            $code = array_merge($authenticationResult, [ - 610                                'client_id' => $queryParams['client_id'], - 611                                'redirect_uri' => $queryParams['redirect_uri'], - 612                                'state' => $queryParams['state'], - 613                                'code_challenge' => $queryParams['code_challenge'] ?? null, - 614                                'code_challenge_method' => $queryParams['code_challenge_method'] ?? null, - 615                                'requested_scope' => $queryParams['scope'] ?? '', - 616                            ]); - 617 - 618                            // Pass it to the auth code customisation callback. - 619                            $code = $this->authorizationForm->transformAuthorizationCode($request, $code); - 620                            $this->logger->info("Creating an authorization code:", ['data' => $code]); - 621 - 622                            // Store the authorization code. - 623                            $authCode = $this->tokenStorage->createAuthCode($code); - 624                            if (is_null($authCode)) { - 625                                // If saving the authorization code failed silently, there isn’t much we can do about it, - 626                                // but should at least log and return an error. - 627                                $this->logger->error("Saving the authorization code failed and returned false without raising an exception."); - 628                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request); - 629                            } - 630                             - 631                            // Return a redirect to the client app. - 632                            return new Response(302, [ - 633                                'Location' => appendQueryParams($queryParams['redirect_uri'], [ - 634                                    'code' => $authCode, - 635                                    'state' => $code['state'] - 636                                ]), - 637                                'Cache-control' => 'no-cache' - 638                            ]); - 639                        } - 640 - 641                        // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes. - 642 - 643                        // Fetch the client_id URL to find information about the client to present to the user. - 644                        // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, - 645                        // it may be necessary to do this before returning any other kind of error response, as, per - 646                        // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters - 647                        // are missing or invalid. Otherwise, they should be sent back to the client with an error - 648                        // redirect response. - 649                        if (is_null($clientIdResponse) || is_null($clientIdEffectiveUrl) || is_null($clientIdMf2)) { - 650                            try { - 651                                /** @var ResponseInterface $clientIdResponse */ - 652                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']); - 653                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl); - 654                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) { - 655                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [ - 656                                    'client_id' => $queryParams['client_id'], - 657                                    'exception' => $e->__toString() - 658                                ]); - 659                                 - 660                                // At this point in the flow, we’ve already guaranteed that the redirect_uri is valid, - 661                                // so in theory we should report these errors by redirecting there. - 662                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e); - 663                            } catch (Exception $e) { - 664                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ - 665                                    'exception' => $e->__toString() - 666                                ]); - 667     - 668                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e); - 669                            } - 670                        } - 671                         - 672                        // Search for an h-app with u-url matching the client_id. - 673                        $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']); - 674                        $clientHApp = empty($clientHApps) ? null : $clientHApps[0]; - 675 - 676                        // Present the authorization UI. - 677                        return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp) - 678                                ->withAddedHeader('Cache-control', 'no-cache'); - 679                    } - 680                } - 681 - 682                // If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid - 683                // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.) - 684                $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request); - 685                if ($nonIndieAuthRequestResult instanceof ResponseInterface) { - 686                    return $nonIndieAuthRequestResult; - 687                } else { - 688                    // In this code path we have not validated the redirect_uri, so show a regular error page - 689                    // rather than returning a redirect error. - 690                    throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request); - 691                } - 692            } catch (IndieAuthException $e) { - 693                // All IndieAuthExceptions will already have been logged. - 694                return $this->handleException($e); - 695            } catch (Exception $e) { - 696                // Unknown exceptions will not have been logged; do so now. - 697                $this->logger->error("Caught unknown exception: {$e}"); - 698                return $this->handleException(IndieAuthException::create(0, $request, $e)); - 699            } - 700        }));     - 701    } - 702 - 703    /** - 704     * Handle Token Endpoint Request - 705     *  - 706     * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows: - 707     *  - 708     * * Check that the request is a code redeeming request. Return an error if not. - 709     * * Ensure that all required parameters are present. Return an error if not. - 710     * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails. - 711     * * Make sure the client_id and redirect_uri request parameters match those stored in the auth code. If not, revoke the access token and return an error. - 712     * * Make sure the provided code_verifier hashes to the code_challenge stored in the auth code. If not, revoke the access token and return an error. - 713     * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error. - 714     * * Otherwise, return a success response containing information about the issued access token. - 715     *  - 716     * This method must NOT be CSRF-protected as it accepts external requests from client apps. - 717     *  - 718     * @param ServerRequestInterface $request - 719     * @return ResponseInterface - 720     */ - 721    public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface { - 722        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { - 723            $this->logger->info('Handling a request to redeem an authorization code for an access token.'); - 724             - 725            $bodyParams = $request->getParsedBody(); - 726 - 727            if (!isset($bodyParams['code'])) { - 728                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.'); - 729                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 730                    'error' => 'invalid_request', - 731                    'error_description' => 'The code parameter was missing.' - 732                ])); - 733            } - 734 - 735            // Attempt to internally exchange the provided auth code for an access token. - 736            // We do this before anything else so that the auth code is invalidated as soon as the request starts, - 737            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler - 738            // and more flexible interface for TokenStorage implementors. - 739            try { - 740                // Call the token exchange method, passing in a callback which performs additional validation - 741                // on the auth code before it gets exchanged. - 742                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { - 743                    // Verify that all required parameters are included. - 744                    $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri']; - 745                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { - 746                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); - 747                    }); - 748                    if (!empty($missingRequiredParameters)) { - 749                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); - 750                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request); - 751                    } - 752 - 753                    // Verify that it was issued for the same client_id and redirect_uri - 754                    if ($authCode['client_id'] !== $bodyParams['client_id'] - 755                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) { - 756                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token."); - 757                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 505                             + 506                            foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) { + 507                                if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) { + 508                                    // Strip off the < > which surround the link URL for some reason. + 509                                    $clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2); + 510                                } + 511                            } + 512 + 513                            // If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error. + 514                            if (!in_array($queryParams['redirect_uri'], $clientIdRedirectUris)) { + 515                                $this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [ + 516                                    'provided_redirect_uri' => $queryParams['redirect_uri'], + 517                                    'provided_client_id' => $queryParams['client_id'], + 518                                    'discovered_redirect_uris' => $clientIdRedirectUris + 519                                ]); + 520 + 521                                throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request); + 522                            } + 523                        }                         + 524                    } + 525 + 526                    // From now on, we can assume that redirect_uri is valid. Any IndieAuth-related errors should be + 527                    // reported by redirecting to redirect_uri with error parameters. + 528 + 529                    // Validate the state parameter. + 530                    if (!isset($queryParams['state']) or !isValidState($queryParams['state'])) { + 531                        $this->logger->warning("The state provided in an authorization request was not valid.", $queryParams); + 532                        throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request); + 533                    } + 534                    // From now on, any redirect error responses should include the state parameter. + 535                    // This is handled automatically in `handleException()` and is only noted here + 536                    // for reference. + 537 + 538                    // If either PKCE parameter is present, validate both. + 539                    if (isset($queryParams['code_challenge']) or isset($queryParams['code_challenge_method'])) { + 540                        if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) { + 541                            $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams); + 542                            throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); + 543                        } + 544     + 545                        if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) { + 546                            $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams); + 547                            throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); + 548                        } + 549                    } else { + 550                        // If neither PKCE parameter is defined, and PKCE is required, throw an error. Otherwise, proceed. + 551                        if ($this->requirePkce) { + 552                            $this->logger->warning("PKCE is required, and both code_challenge and code_challenge_method were missing."); + 553                            throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST_REDIRECT, $request); + 554                        } + 555                    } + 556 + 557                    // Validate the scope parameter, if provided. + 558                    if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) { + 559                        $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams); + 560                        throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request); + 561                    } + 562 + 563                    // Normalise the me parameter, if it exists. + 564                    if (array_key_exists('me', $queryParams)) { + 565                        $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']); + 566                        // If the me parameter is not a valid profile URL, ignore it. + 567                        if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) { + 568                            $queryParams['me'] = null; + 569                        } + 570                    } + 571 + 572                    // Build a URL containing the indieauth authorization request parameters, hashing them + 573                    // to protect them from being changed. + 574                    // Make a hash of the protected indieauth-specific parameters. If PKCE is in use, include  + 575                    // the PKCE parameters in the hash. Otherwise, leave them out. + 576                    $hash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); + 577                    // Operate on a copy of $queryParams, otherwise requests will always have a valid hash! + 578                    $redirectQueryParams = $queryParams; + 579                    $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash; + 580                    $authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString(); + 581                     + 582                    // User-facing requests always start by calling the authentication request callback. + 583                    $this->logger->info('Calling handle_authentication_request callback'); + 584                    $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null); + 585                     + 586                    // If the authentication handler returned a Response, return that as-is. + 587                    if ($authenticationResult instanceof ResponseInterface) { + 588                        return $authenticationResult; + 589                    } elseif (is_array($authenticationResult)) { + 590                        // Check the resulting array for errors. + 591                        if (!array_key_exists('me', $authenticationResult)) { + 592                            $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]); + 593                            throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request); + 594                        } + 595 + 596                        // If this is a POST request sent from the authorization (i.e. scope-choosing) form: + 597                        if (isAuthorizationApprovalRequest($request)) { + 598                            // Authorization approval requests MUST include a hash protecting the sensitive IndieAuth + 599                            // authorization request parameters from being changed, e.g. by a malicious script which + 600                            // found its way onto the authorization form. + 601                            if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) { + 602                                $this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter."); + 603                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request); + 604                            } + 605 + 606                            $expectedHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); + 607                            if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { + 608                                $this->logger->warning("The hash provided in the URL was invalid!", [ + 609                                    'expected' => $expectedHash, + 610                                    'actual' => $queryParams[self::HASH_QUERY_STRING_KEY] + 611                                ]); + 612                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request); + 613                            } + 614                             + 615                            // Assemble the data for the authorization code, store it somewhere persistent. + 616                            $code = array_merge($authenticationResult, [ + 617                                'client_id' => $queryParams['client_id'], + 618                                'redirect_uri' => $queryParams['redirect_uri'], + 619                                'state' => $queryParams['state'], + 620                                'code_challenge' => $queryParams['code_challenge'] ?? null, + 621                                'code_challenge_method' => $queryParams['code_challenge_method'] ?? null, + 622                                'requested_scope' => $queryParams['scope'] ?? '', + 623                            ]); + 624 + 625                            // Pass it to the auth code customisation callback. + 626                            $code = $this->authorizationForm->transformAuthorizationCode($request, $code); + 627                            $this->logger->info("Creating an authorization code:", ['data' => $code]); + 628 + 629                            // Store the authorization code. + 630                            $authCode = $this->tokenStorage->createAuthCode($code); + 631                            if (is_null($authCode)) { + 632                                // If saving the authorization code failed silently, there isn’t much we can do about it, + 633                                // but should at least log and return an error. + 634                                $this->logger->error("Saving the authorization code failed and returned false without raising an exception."); + 635                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request); + 636                            } + 637                             + 638                            // Return a redirect to the client app. + 639                            return new Response(302, [ + 640                                'Location' => appendQueryParams($queryParams['redirect_uri'], [ + 641                                    'code' => $authCode, + 642                                    'state' => $code['state'] + 643                                ]), + 644                                'Cache-control' => 'no-cache' + 645                            ]); + 646                        } + 647 + 648                        // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes. + 649 + 650                        // Fetch the client_id URL to find information about the client to present to the user. + 651                        // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, + 652                        // it may be necessary to do this before returning any other kind of error response, as, per + 653                        // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters + 654                        // are missing or invalid. Otherwise, they should be sent back to the client with an error + 655                        // redirect response. + 656                        if (is_null($clientIdResponse) || is_null($clientIdEffectiveUrl) || is_null($clientIdMf2)) { + 657                            try { + 658                                /** @var ResponseInterface $clientIdResponse */ + 659                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']); + 660                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl); + 661                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) { + 662                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [ + 663                                    'client_id' => $queryParams['client_id'], + 664                                    'exception' => $e->__toString() + 665                                ]); + 666                                 + 667                                // At this point in the flow, we’ve already guaranteed that the redirect_uri is valid, + 668                                // so in theory we should report these errors by redirecting there. + 669                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e); + 670                            } catch (Exception $e) { + 671                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ + 672                                    'exception' => $e->__toString() + 673                                ]); + 674     + 675                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e); + 676                            } + 677                        } + 678                         + 679                        // Search for an h-app with u-url matching the client_id. + 680                        $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']); + 681                        $clientHApp = empty($clientHApps) ? null : $clientHApps[0]; + 682 + 683                        // Present the authorization UI. + 684                        return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp) + 685                                ->withAddedHeader('Cache-control', 'no-cache'); + 686                    } + 687                } + 688 + 689                // If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid + 690                // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.) + 691                $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request); + 692                if ($nonIndieAuthRequestResult instanceof ResponseInterface) { + 693                    return $nonIndieAuthRequestResult; + 694                } else { + 695                    // In this code path we have not validated the redirect_uri, so show a regular error page + 696                    // rather than returning a redirect error. + 697                    throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request); + 698                } + 699            } catch (IndieAuthException $e) { + 700                // All IndieAuthExceptions will already have been logged. + 701                return $this->handleException($e); + 702            } catch (Exception $e) { + 703                // Unknown exceptions will not have been logged; do so now. + 704                $this->logger->error("Caught unknown exception: {$e}"); + 705                return $this->handleException(IndieAuthException::create(0, $request, $e)); + 706            } + 707        }));     + 708    } + 709 + 710    /** + 711     * Handle Token Endpoint Request + 712     *  + 713     * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows: + 714     *  + 715     * * Check that the request is a code redeeming request. Return an error if not. + 716     * * Ensure that all required parameters are present. Return an error if not. + 717     * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails. + 718     * * Make sure the client_id and redirect_uri request parameters match those stored in the auth code. If not, revoke the access token and return an error. + 719     * * Make sure the provided code_verifier hashes to the code_challenge stored in the auth code. If not, revoke the access token and return an error. + 720     * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error. + 721     * * Otherwise, return a success response containing information about the issued access token. + 722     *  + 723     * This method must NOT be CSRF-protected as it accepts external requests from client apps. + 724     *  + 725     * @param ServerRequestInterface $request + 726     * @return ResponseInterface + 727     */ + 728    public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface { + 729        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { + 730            $this->logger->info('Handling a request to redeem an authorization code for an access token.'); + 731             + 732            $bodyParams = $request->getParsedBody(); + 733 + 734            if (!isset($bodyParams['code'])) { + 735                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.'); + 736                return new Response(400, ['content-type' => 'application/json'], json_encode([ + 737                    'error' => 'invalid_request', + 738                    'error_description' => 'The code parameter was missing.' + 739                ])); + 740            } + 741 + 742            // Attempt to internally exchange the provided auth code for an access token. + 743            // We do this before anything else so that the auth code is invalidated as soon as the request starts, + 744            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler + 745            // and more flexible interface for TokenStorage implementors. + 746            try { + 747                // Call the token exchange method, passing in a callback which performs additional validation + 748                // on the auth code before it gets exchanged. + 749                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { + 750                    // Verify that all required parameters are included. + 751                    $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri']; + 752                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { + 753                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); + 754                    }); + 755                    if (!empty($missingRequiredParameters)) { + 756                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); + 757                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request); 758                    } 759 - 760                    // If the auth code was requested with no code_challenge, but the exchange request provides a  - 761                    // code_verifier, return an error. - 762                    if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) { - 763                        $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge."); - 764                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 760                    // Verify that it was issued for the same client_id and redirect_uri + 761                    if ($authCode['client_id'] !== $bodyParams['client_id'] + 762                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) { + 763                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token."); + 764                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); 765                    } 766 - 767                    if ($this->requirePkce or !empty($authCode['code_challenge'])) { - 768                        // Check that the supplied code_verifier hashes to the stored code_challenge - 769                        // TODO: support method = plain as well as S256. - 770                        if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { - 771                            $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); - 772                            throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); - 773                        } - 774                    } - 775                     - 776                    // Check that scope is not empty. - 777                    if (empty($authCode['scope'])) { - 778                        $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint."); - 779                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); - 780                    } - 781                }); - 782            } catch (IndieAuthException $e) { - 783                // If an exception was thrown, return a corresponding error response. - 784                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 785                    'error' => $e->getInfo()['error'], - 786                    'error_description' => $e->getMessage() - 787                ])); - 788            } - 789 - 790            if (is_null($tokenData)) { - 791                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams); - 792                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 793                    'error' => 'invalid_grant', - 794                    'error_description' => 'The provided credentials were not valid.' - 795                ])); - 796            } - 797 - 798            // TODO: return an error if the token doesn’t contain a me key. - 799 - 800            // If everything checked out, return {"me": "https://example.com"} response - 801            return new Response(200, [ - 802                'content-type' => 'application/json', - 803                'cache-control' => 'no-store', - 804            ], json_encode(array_merge([ - 805                // Ensure that the token_type key is present, if tokenStorage doesn’t include it. - 806                'token_type' => 'Bearer' - 807            ], array_filter($tokenData, function (string $k) { - 808                // We should be able to trust the return data from tokenStorage, but there’s no harm in - 809                // preventing code_challenges from leaking, per OAuth2. - 810                return !in_array($k, ['code_challenge', 'code_challenge_method']); - 811            }, ARRAY_FILTER_USE_KEY)))); - 812        } - 813 - 814        return new Response(400, ['content-type' => 'application/json'], json_encode([ - 815            'error' => 'invalid_request', - 816            'error_description' => 'Request to token endpoint was not a valid code exchange request.' - 817        ])); - 818    } - 819 - 820    /** - 821     * Handle Exception - 822     *  - 823     * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`. - 824     */ - 825    protected function handleException(IndieAuthException $exception): ResponseInterface { - 826        $exceptionData = $exception->getInfo(); - 827 - 828        if ($exceptionData['statusCode'] == 302) { - 829            // This exception is handled by redirecting to the redirect_uri with error parameters. - 830            $redirectQueryParams = [ - 831                'error' => $exceptionData['error'] ?? 'invalid_request', - 832                'error_description' => (string) $exception - 833            ]; + 767                    // If the auth code was requested with no code_challenge, but the exchange request provides a  + 768                    // code_verifier, return an error. + 769                    if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) { + 770                        $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge."); + 771                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 772                    } + 773 + 774                    if ($this->requirePkce or !empty($authCode['code_challenge'])) { + 775                        // Check that the supplied code_verifier hashes to the stored code_challenge + 776                        // TODO: support method = plain as well as S256. + 777                        if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { + 778                            $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); + 779                            throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 780                        } + 781                    } + 782                     + 783                    // Check that scope is not empty. + 784                    if (empty($authCode['scope'])) { + 785                        $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint."); + 786                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + 787                    } + 788                }); + 789            } catch (IndieAuthException $e) { + 790                // If an exception was thrown, return a corresponding error response. + 791                return new Response(400, ['content-type' => 'application/json'], json_encode([ + 792                    'error' => $e->getInfo()['error'], + 793                    'error_description' => $e->getMessage() + 794                ])); + 795            } + 796 + 797            if (is_null($tokenData)) { + 798                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams); + 799                return new Response(400, ['content-type' => 'application/json'], json_encode([ + 800                    'error' => 'invalid_grant', + 801                    'error_description' => 'The provided credentials were not valid.' + 802                ])); + 803            } + 804 + 805            // TODO: return an error if the token doesn’t contain a me key. + 806 + 807            // If everything checked out, return {"me": "https://example.com"} response + 808            return new Response(200, [ + 809                'content-type' => 'application/json', + 810                'cache-control' => 'no-store', + 811            ], json_encode(array_merge([ + 812                // Ensure that the token_type key is present, if tokenStorage doesn’t include it. + 813                'token_type' => 'Bearer' + 814            ], array_filter($tokenData, function (string $k) { + 815                // We should be able to trust the return data from tokenStorage, but there’s no harm in + 816                // preventing code_challenges from leaking, per OAuth2. + 817                return !in_array($k, ['code_challenge', 'code_challenge_method']); + 818            }, ARRAY_FILTER_USE_KEY)))); + 819        } + 820 + 821        return new Response(400, ['content-type' => 'application/json'], json_encode([ + 822            'error' => 'invalid_request', + 823            'error_description' => 'Request to token endpoint was not a valid code exchange request.' + 824        ])); + 825    } + 826 + 827    /** + 828     * Handle Exception + 829     *  + 830     * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`. + 831     */ + 832    protected function handleException(IndieAuthException $exception): ResponseInterface { + 833        $exceptionData = $exception->getInfo(); 834 - 835            // If the state parameter was valid, include it in the error redirect. - 836            if ($exception->getCode() !== IndieAuthException::INVALID_STATE) { - 837                $redirectQueryParams['state'] = $exception->getRequest()->getQueryParams()['state']; - 838            } - 839 - 840            return new Response($exceptionData['statusCode'], [ - 841                'Location' => appendQueryParams((string) $exception->getRequest()->getQueryParams()['redirect_uri'], $redirectQueryParams) - 842            ]); - 843        } else { - 844            // This exception should be shown to the user. - 845            return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [ - 846                'request' => $exception->getRequest(), - 847                'exception' => $exception - 848            ])); - 849        } - 850    } - 851} + 835        if ($exceptionData['statusCode'] == 302) { + 836            // This exception is handled by redirecting to the redirect_uri with error parameters. + 837            $redirectQueryParams = [ + 838                'error' => $exceptionData['error'] ?? 'invalid_request', + 839                'error_description' => (string) $exception + 840            ]; + 841 + 842            // If the state parameter was valid, include it in the error redirect. + 843            if ($exception->getCode() !== IndieAuthException::INVALID_STATE) { + 844                $redirectQueryParams['state'] = $exception->getRequest()->getQueryParams()['state']; + 845            } + 846 + 847            return new Response($exceptionData['statusCode'], [ + 848                'Location' => appendQueryParams((string) $exception->getRequest()->getQueryParams()['redirect_uri'], $redirectQueryParams) + 849            ]); + 850        } else { + 851            // This exception should be shown to the user. + 852            return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [ + 853                'request' => $exception->getRequest(), + 854                'exception' => $exception + 855            ])); + 856        } + 857    } + 858} @@ -1071,7 +1078,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Storage/FilesystemJsonStorage.php.html b/docs/coverage/Storage/FilesystemJsonStorage.php.html index e00ae36..cc7e7c4 100644 --- a/docs/coverage/Storage/FilesystemJsonStorage.php.html +++ b/docs/coverage/Storage/FilesystemJsonStorage.php.html @@ -101,7 +101,7 @@ -  __construct +  __construct
0.00% covered (danger) @@ -122,7 +122,7 @@ -  setLogger +  setLogger
100.00% covered (success) @@ -143,7 +143,7 @@ -  createAuthCode +  createAuthCode
0.00% covered (danger) @@ -164,7 +164,7 @@ -  exchangeAuthCodeForAccessToken +  exchangeAuthCodeForAccessToken
0.00% covered (danger) @@ -185,7 +185,7 @@ -  getAccessToken +  getAccessToken
0.00% covered (danger) @@ -206,7 +206,7 @@ -  revokeAccessToken +  revokeAccessToken
100.00% covered (success) @@ -227,7 +227,7 @@ -  deleteExpiredTokens +  deleteExpiredTokens
100.00% covered (success) @@ -248,7 +248,7 @@ -  get +  get
0.00% covered (danger) @@ -269,7 +269,7 @@ -  put +  put
100.00% covered (success) @@ -290,7 +290,7 @@ -  delete +  delete
0.00% covered (danger) @@ -311,7 +311,7 @@ -  getPath +  getPath
100.00% covered (success) @@ -332,7 +332,7 @@ -  withLock +  withLock
0.00% covered (danger) @@ -353,7 +353,7 @@ -  hash +  hash
100.00% covered (success) @@ -410,273 +410,280 @@ 29     30    const TOKEN_LENGTH = 64; 31 - 32    protected string $path; - 33    protected int $authCodeTtl; - 34    protected int $accessTokenTtl; - 35    protected string $secret; - 36 - 37    protected LoggerInterface $logger; - 38 - 39 - 40    public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) { - 41        $this->logger = $logger ?? new NullLogger(); - 42 - 43        if (strlen($secret) < 64) { - 44            throw new Exception("\$secret must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)"); - 45        } - 46        $this->secret = $secret; - 47 - 48        $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + 32    /** @var string $path */ + 33    protected $path; + 34     + 35    /** @var int $authCodeTtl */ + 36    protected $authCodeTtl; + 37 + 38    /** @var int $accessTokenTtl */ + 39    protected $accessTokenTtl; + 40 + 41    /** @var string $secret */ + 42    protected $secret; + 43 + 44    /** @var LoggerInterface $logger */ + 45    protected $logger; + 46 + 47    public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) { + 48        $this->logger = $logger ?? new NullLogger(); 49 - 50        $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL; - 51        $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL; - 52 - 53        @mkdir($this->path, 0777, true); + 50        if (strlen($secret) < 64) { + 51            throw new Exception("\$secret must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)"); + 52        } + 53        $this->secret = $secret; 54 - 55        if ($cleanUpNow) { - 56            $this->deleteExpiredTokens(); - 57        } - 58    } + 55        $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + 56 + 57        $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL; + 58        $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL; 59 - 60    // LoggerAwareInterface method. + 60        @mkdir($this->path, 0777, true); 61 - 62    public function setLogger(LoggerInterface $logger) { - 63        $this->logger = $logger; - 64    } - 65 - 66    // TokenStorageInterface Methods. - 67 - 68    public function createAuthCode(array $data): ?string { - 69        $this->logger->info("Creating authorization code.", $data); - 70        $authCode = generateRandomString(self::TOKEN_LENGTH); - 71        $accessToken = $this->hash($authCode); + 62        if ($cleanUpNow) { + 63            $this->deleteExpiredTokens(); + 64        } + 65    } + 66 + 67    // LoggerAwareInterface method. + 68 + 69    public function setLogger(LoggerInterface $logger) { + 70        $this->logger = $logger; + 71    } 72 - 73        // TODO: valid_until should be expire_after(? — look up), and should not be set here, - 74        // as it applies only to access tokens, not auth codes! Auth code TTL should always be  - 75        // the default. - 76        if (!array_key_exists('valid_until', $data)) { - 77            $data['valid_until'] = time() + $this->authCodeTtl; - 78        } - 79         - 80        if (!$this->put($accessToken, $data)) { - 81            return null; - 82        } - 83        return $authCode; - 84    } - 85 - 86    public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array { - 87        // Hash the auth code to get the theoretical matching access token filename. - 88        $accessToken = $this->hash($code); - 89 - 90        // Prevent the token file from being read, modified or deleted while we’re working with it. - 91        // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t  - 92        // already exist. - 93        return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { - 94            // Read the file contents. - 95            $fileContents = ''; - 96            while ($d = fread($fp, 1024)) { $fileContents .= $d; } - 97 - 98            $data = json_decode($fileContents, true); - 99             - 100            if (!is_array($data)) {  - 101                $this->logger->error('Authoriazation Code data could not be parsed as a JSON object.'); - 102                return null;  - 103            } + 73    // TokenStorageInterface Methods. + 74 + 75    public function createAuthCode(array $data): ?string { + 76        $this->logger->info("Creating authorization code.", $data); + 77        $authCode = generateRandomString(self::TOKEN_LENGTH); + 78        $accessToken = $this->hash($authCode); + 79 + 80        // TODO: valid_until should be expire_after(? — look up), and should not be set here, + 81        // as it applies only to access tokens, not auth codes! Auth code TTL should always be  + 82        // the default. + 83        if (!array_key_exists('valid_until', $data)) { + 84            $data['valid_until'] = time() + $this->authCodeTtl; + 85        } + 86         + 87        if (!$this->put($accessToken, $data)) { + 88            return null; + 89        } + 90        return $authCode; + 91    } + 92 + 93    public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array { + 94        // Hash the auth code to get the theoretical matching access token filename. + 95        $accessToken = $this->hash($code); + 96 + 97        // Prevent the token file from being read, modified or deleted while we’re working with it. + 98        // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t  + 99        // already exist. + 100        return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { + 101            // Read the file contents. + 102            $fileContents = ''; + 103            while ($d = fread($fp, 1024)) { $fileContents .= $d; } 104 - 105            // Make sure the auth code hasn’t already been redeemed. - 106            if ($data['exchanged_at'] ?? false) { - 107                $this->logger->error("This authorization code has already been exchanged."); - 108                return null; - 109            } - 110 - 111            // Make sure the auth code isn’t expired. - 112            if (($data['valid_until'] ?? 0) < time()) { - 113                $this->logger->error("This authorization code has expired."); - 114                return null; - 115            } - 116 - 117            // The auth code is valid as far as we know, pass it to the validation callback passed from the - 118            // Server. - 119            try { - 120                $validateAuthCode($data); - 121            } catch (IndieAuthException $e) { - 122                // If there was an issue with the auth code, delete it before bubbling the exception - 123                // up to the Server for handling. We currently have a lock on the file path, so pass - 124                // false to $observeLock to prevent a deadlock. - 125                $this->logger->info("Deleting authorization code, as it failed the Server-level validation."); - 126                $this->delete($accessToken, false); - 127                throw $e; - 128            } - 129 - 130            // If the access token is valid, mark it as redeemed and set a new expiry time. - 131            $data['exchanged_at'] = time(); - 132 - 133            if (is_int($data['_access_token_ttl'] ?? null)) { - 134                // This access token has a custom TTL, use that. - 135                $data['valid_until'] = time() + $data['_access_code_ttl']; - 136            } elseif ($this->accessTokenTtl == 0) { - 137                // The token should be valid until explicitly revoked. - 138                $data['valid_until'] = null; - 139            } else { - 140                // Use the default TTL. - 141                $data['valid_until'] = time() + $this->accessTokenTtl; - 142            } - 143 - 144            // Write the new file contents, truncating afterwards in case the new data is shorter than the old data. - 145            $jsonData = json_encode($data); - 146            if (rewind($fp) === false) { return null; } - 147            if (fwrite($fp, $jsonData) === false) { return null; } - 148            if (ftruncate($fp, strlen($jsonData)) === false) { return null; } - 149 - 150            // Return the OAuth2-compatible access token data to the Server for passing onto - 151            // the client app. Passed via array_filter to remove the scope key if scope is null. - 152            return array_filter([ - 153                'access_token' => $accessToken, - 154                'scope' => $data['scope'] ?? null, - 155                'me' => $data['me'], - 156                'profile' => $data['profile'] ?? null - 157            ]); - 158        }); - 159    } - 160 - 161    public function getAccessToken(string $token): ?array { - 162        $data = $this->get($token); - 163 - 164        if (!is_array($data)) { - 165            $this->logger->error("The access token could not be parsed as a JSON object."); - 166            return null; - 167        } - 168 - 169        // Check that this is a redeemed access token. - 170        if (($data['exchanged_at'] ?? false) === false) { - 171            $this->logger->error("This authorization code has not yet been exchanged for an access token."); - 172            return null; - 173        } - 174 - 175        // Check that the access token is still valid. valid_until=null means it should live until - 176        // explicitly revoked. - 177        if (is_int($data['valid_until']) && $data['valid_until'] < time()) { - 178            $this->logger->error("This access token has expired."); + 105            $data = json_decode($fileContents, true); + 106             + 107            if (!is_array($data)) {  + 108                $this->logger->error('Authoriazation Code data could not be parsed as a JSON object.'); + 109                return null;  + 110            } + 111 + 112            // Make sure the auth code hasn’t already been redeemed. + 113            if ($data['exchanged_at'] ?? false) { + 114                $this->logger->error("This authorization code has already been exchanged."); + 115                return null; + 116            } + 117 + 118            // Make sure the auth code isn’t expired. + 119            if (($data['valid_until'] ?? 0) < time()) { + 120                $this->logger->error("This authorization code has expired."); + 121                return null; + 122            } + 123 + 124            // The auth code is valid as far as we know, pass it to the validation callback passed from the + 125            // Server. + 126            try { + 127                $validateAuthCode($data); + 128            } catch (IndieAuthException $e) { + 129                // If there was an issue with the auth code, delete it before bubbling the exception + 130                // up to the Server for handling. We currently have a lock on the file path, so pass + 131                // false to $observeLock to prevent a deadlock. + 132                $this->logger->info("Deleting authorization code, as it failed the Server-level validation."); + 133                $this->delete($accessToken, false); + 134                throw $e; + 135            } + 136 + 137            // If the access token is valid, mark it as redeemed and set a new expiry time. + 138            $data['exchanged_at'] = time(); + 139 + 140            if (is_int($data['_access_token_ttl'] ?? null)) { + 141                // This access token has a custom TTL, use that. + 142                $data['valid_until'] = time() + $data['_access_code_ttl']; + 143            } elseif ($this->accessTokenTtl == 0) { + 144                // The token should be valid until explicitly revoked. + 145                $data['valid_until'] = null; + 146            } else { + 147                // Use the default TTL. + 148                $data['valid_until'] = time() + $this->accessTokenTtl; + 149            } + 150 + 151            // Write the new file contents, truncating afterwards in case the new data is shorter than the old data. + 152            $jsonData = json_encode($data); + 153            if (rewind($fp) === false) { return null; } + 154            if (fwrite($fp, $jsonData) === false) { return null; } + 155            if (ftruncate($fp, strlen($jsonData)) === false) { return null; } + 156 + 157            // Return the OAuth2-compatible access token data to the Server for passing onto + 158            // the client app. Passed via array_filter to remove the scope key if scope is null. + 159            return array_filter([ + 160                'access_token' => $accessToken, + 161                'scope' => $data['scope'] ?? null, + 162                'me' => $data['me'], + 163                'profile' => $data['profile'] ?? null + 164            ]); + 165        }); + 166    } + 167 + 168    public function getAccessToken(string $token): ?array { + 169        $data = $this->get($token); + 170 + 171        if (!is_array($data)) { + 172            $this->logger->error("The access token could not be parsed as a JSON object."); + 173            return null; + 174        } + 175 + 176        // Check that this is a redeemed access token. + 177        if (($data['exchanged_at'] ?? false) === false) { + 178            $this->logger->error("This authorization code has not yet been exchanged for an access token."); 179            return null; 180        } 181 - 182        // The token is valid! - 183        return $data; - 184    } - 185 - 186    public function revokeAccessToken(string $token): bool { - 187        $this->logger->info("Deleting access token {$token}"); - 188        return $this->delete($token); - 189    } - 190 - 191    // Implementation-Specifc Methods. + 182        // Check that the access token is still valid. valid_until=null means it should live until + 183        // explicitly revoked. + 184        if (is_int($data['valid_until']) && $data['valid_until'] < time()) { + 185            $this->logger->error("This access token has expired."); + 186            return null; + 187        } + 188 + 189        // The token is valid! + 190        return $data; + 191    } 192 - 193    public function deleteExpiredTokens(): int { - 194        $deleted = 0; - 195 - 196        foreach (new DirectoryIterator($this->path) as $fileInfo) { - 197            if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json') { - 198                // Only delete files which we can lock. - 199                $successfullyDeleted = $this->withLock($fileInfo->getPathname(), 'r', function ($fp) use ($fileInfo) { - 200                    // Read the file, check expiry date! Only unlink if file is expired. - 201                    $fileContents = ''; - 202                    while ($d = fread($fp, 1024)) { $fileContents .= $d; } - 203 - 204                    $data = json_decode($fileContents, true); - 205 - 206                    if (!is_array($data)) { return; } - 207                     - 208                    // If valid_until is a valid time, and is in the past, delete the token. - 209                    if (is_int($data['valid_until'] ?? null) && $data['valid_until'] < time()) { - 210                        return unlink($fileInfo->getPathname()); - 211                    } - 212                }); - 213 - 214                if ($successfullyDeleted) { $deleted++; } - 215            } - 216        } - 217 - 218        return $deleted; - 219    } + 193    public function revokeAccessToken(string $token): bool { + 194        $this->logger->info("Deleting access token {$token}"); + 195        return $this->delete($token); + 196    } + 197 + 198    // Implementation-Specifc Methods. + 199 + 200    public function deleteExpiredTokens(): int { + 201        $deleted = 0; + 202 + 203        foreach (new DirectoryIterator($this->path) as $fileInfo) { + 204            if ($fileInfo->isFile() && $fileInfo->getExtension() == 'json') { + 205                // Only delete files which we can lock. + 206                $successfullyDeleted = $this->withLock($fileInfo->getPathname(), 'r', function ($fp) use ($fileInfo) { + 207                    // Read the file, check expiry date! Only unlink if file is expired. + 208                    $fileContents = ''; + 209                    while ($d = fread($fp, 1024)) { $fileContents .= $d; } + 210 + 211                    $data = json_decode($fileContents, true); + 212 + 213                    if (!is_array($data)) { return; } + 214                     + 215                    // If valid_until is a valid time, and is in the past, delete the token. + 216                    if (is_int($data['valid_until'] ?? null) && $data['valid_until'] < time()) { + 217                        return unlink($fileInfo->getPathname()); + 218                    } + 219                }); 220 - 221    public function get(string $key): ?array { - 222        $path = $this->getPath($key); - 223 - 224        if (!file_exists($path)) { - 225            return null; - 226        } + 221                if ($successfullyDeleted) { $deleted++; } + 222            } + 223        } + 224 + 225        return $deleted; + 226    } 227 - 228        return $this->withLock($path, 'r', function ($fp) { - 229            $fileContents = ''; - 230            while ($data = fread($fp, 1024)) { - 231                $fileContents .= $data; - 232            } - 233            $result = json_decode($fileContents, true); + 228    public function get(string $key): ?array { + 229        $path = $this->getPath($key); + 230 + 231        if (!file_exists($path)) { + 232            return null; + 233        } 234 - 235            if (is_array($result)) { - 236                return $result; - 237            } - 238 - 239            return null; - 240        }); - 241    } - 242 - 243    public function put(string $key, array $data): bool { - 244        // Ensure that the containing folder exists. - 245        @mkdir($this->path, 0777, true); - 246         - 247        return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { - 248            return fwrite($fp, json_encode($data)) !== false; - 249        }); - 250    } - 251 - 252    public function delete(string $key, $observeLock=true): bool { - 253        $path = $this->getPath($key); - 254        if (file_exists($path)) { - 255            if ($observeLock) { - 256                return $this->withLock($path, 'r', function ($fp) use ($path) { - 257                    return unlink($path); - 258                }); - 259            } else { - 260                return unlink($path); - 261            } - 262        } - 263        return false; - 264    } - 265 - 266    public function getPath(string $key): string { - 267        // TODO: ensure that the calculated path is a child of $this->path. - 268        return $this->path . "$key.json"; - 269    } - 270 - 271    protected function withLock(string $path, string $mode, callable $callback) { - 272        $fp = @fopen($path, $mode); - 273 - 274        if ($fp === false) { - 275            return null; - 276        } + 235        return $this->withLock($path, 'r', function ($fp) { + 236            $fileContents = ''; + 237            while ($data = fread($fp, 1024)) { + 238                $fileContents .= $data; + 239            } + 240            $result = json_decode($fileContents, true); + 241 + 242            if (is_array($result)) { + 243                return $result; + 244            } + 245 + 246            return null; + 247        }); + 248    } + 249 + 250    public function put(string $key, array $data): bool { + 251        // Ensure that the containing folder exists. + 252        @mkdir($this->path, 0777, true); + 253         + 254        return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { + 255            return fwrite($fp, json_encode($data)) !== false; + 256        }); + 257    } + 258 + 259    public function delete(string $key, $observeLock=true): bool { + 260        $path = $this->getPath($key); + 261        if (file_exists($path)) { + 262            if ($observeLock) { + 263                return $this->withLock($path, 'r', function ($fp) use ($path) { + 264                    return unlink($path); + 265                }); + 266            } else { + 267                return unlink($path); + 268            } + 269        } + 270        return false; + 271    } + 272 + 273    public function getPath(string $key): string { + 274        // TODO: ensure that the calculated path is a child of $this->path. + 275        return $this->path . "$key.json"; + 276    } 277 - 278        // Wait for a lock. - 279        if (flock($fp, LOCK_EX)) { - 280            $return = null; - 281            try { - 282                // Perform whatever action on the file pointer. - 283                $return = $callback($fp); - 284            } finally { - 285                // Regardless of what happens, release the lock. - 286                flock($fp, LOCK_UN); - 287                fclose($fp); - 288            } - 289            return $return; - 290        } - 291        // It wasn’t possible to get a lock. - 292        return null; - 293    } - 294 - 295    protected function hash(string $token): string { - 296        return hash_hmac('sha256', $token, $this->secret); - 297    } - 298} + 278    protected function withLock(string $path, string $mode, callable $callback) { + 279        $fp = @fopen($path, $mode); + 280 + 281        if ($fp === false) { + 282            return null; + 283        } + 284 + 285        // Wait for a lock. + 286        if (flock($fp, LOCK_EX)) { + 287            $return = null; + 288            try { + 289                // Perform whatever action on the file pointer. + 290                $return = $callback($fp); + 291            } finally { + 292                // Regardless of what happens, release the lock. + 293                flock($fp, LOCK_UN); + 294                fclose($fp); + 295            } + 296            return $return; + 297        } + 298        // It wasn’t possible to get a lock. + 299        return null; + 300    } + 301 + 302    protected function hash(string $token): string { + 303        return hash_hmac('sha256', $token, $this->secret); + 304    } + 305} @@ -687,7 +694,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Storage/Sqlite3Storage.php.html b/docs/coverage/Storage/Sqlite3Storage.php.html index 1d41db8..4c55e42 100644 --- a/docs/coverage/Storage/Sqlite3Storage.php.html +++ b/docs/coverage/Storage/Sqlite3Storage.php.html @@ -79,7 +79,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Storage/TokenStorageInterface.php.html b/docs/coverage/Storage/TokenStorageInterface.php.html index a523843..acc9d0b 100644 --- a/docs/coverage/Storage/TokenStorageInterface.php.html +++ b/docs/coverage/Storage/TokenStorageInterface.php.html @@ -235,7 +235,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/Storage/dashboard.html b/docs/coverage/Storage/dashboard.html index 5a7bd09..2fb5c19 100644 --- a/docs/coverage/Storage/dashboard.html +++ b/docs/coverage/Storage/dashboard.html @@ -113,11 +113,11 @@ - getAccessToken63% - exchangeAuthCodeForAccessToken77% - __construct81% - createAuthCode87% - delete87% + getAccessToken63% + exchangeAuthCodeForAccessToken77% + __construct81% + createAuthCode87% + delete87% @@ -134,11 +134,11 @@ - exchangeAuthCodeForAccessToken12 - getAccessToken6 - __construct3 - createAuthCode3 - delete3 + exchangeAuthCodeForAccessToken12 + getAccessToken6 + __construct3 + createAuthCode3 + delete3 @@ -148,7 +148,7 @@
@@ -259,7 +259,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Method Complexity'); d3.select('#methodComplexity svg') - .datum(getComplexityData([[81.81818181818183,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::setLogger<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[77.14285714285715,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[63.63636363636363,5,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getAccessToken<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::revokeAccessToken<\/a>"],[100,9,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::deleteExpiredTokens<\/a>"],[91.66666666666666,4,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::get<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::put<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::delete<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getPath<\/a>"],[90.9090909090909,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::withLock<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::hash<\/a>"]], 'Method Complexity')) + .datum(getComplexityData([[81.81818181818183,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::setLogger<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[77.14285714285715,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[63.63636363636363,5,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getAccessToken<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::revokeAccessToken<\/a>"],[100,9,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::deleteExpiredTokens<\/a>"],[91.66666666666666,4,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::get<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::put<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::delete<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getPath<\/a>"],[90.9090909090909,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::withLock<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::hash<\/a>"]], 'Method Complexity')) .transition() .duration(500) .call(chart); diff --git a/docs/coverage/Storage/index.html b/docs/coverage/Storage/index.html index b93d0a6..af38b87 100644 --- a/docs/coverage/Storage/index.html +++ b/docs/coverage/Storage/index.html @@ -137,7 +137,7 @@ High: 90% to 100%

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/dashboard.html b/docs/coverage/dashboard.html index 433c7c2..e02386b 100644 --- a/docs/coverage/dashboard.html +++ b/docs/coverage/dashboard.html @@ -115,15 +115,15 @@ - getExplanation0% - trustQueryParams0% + getExplanation0% + trustQueryParams0% process0% - getAccessToken63% - exchangeAuthCodeForAccessToken77% - __construct81% - create83% - createAuthCode87% - delete87% + getAccessToken63% + exchangeAuthCodeForAccessToken77% + __construct81% + create83% + createAuthCode87% + delete87% @@ -140,13 +140,13 @@ - exchangeAuthCodeForAccessToken12 - getAccessToken6 - trustQueryParams6 - __construct3 - createAuthCode3 - delete3 - create2 + exchangeAuthCodeForAccessToken12 + getAccessToken6 + trustQueryParams6 + __construct3 + createAuthCode3 + delete3 + create2 @@ -156,7 +156,7 @@
@@ -243,7 +243,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Cyclomatic Complexity'); d3.select('#classComplexity svg') - .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,9,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"],[66.66666666666666,8,"Taproot\\IndieAuth\\IndieAuthException<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler<\/a>"],[96.875,12,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler<\/a>"],[96.7032967032967,105,"Taproot\\IndieAuth\\Server<\/a>"],[84.87394957983193,46,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage<\/a>"]], 'Class Complexity')) + .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,10,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"],[66.66666666666666,8,"Taproot\\IndieAuth\\IndieAuthException<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler<\/a>"],[96.875,12,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler<\/a>"],[96.7032967032967,105,"Taproot\\IndieAuth\\Server<\/a>"],[84.87394957983193,46,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage<\/a>"]], 'Class Complexity')) .transition() .duration(500) .call(chart); @@ -267,7 +267,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Method Complexity'); d3.select('#methodComplexity svg') - .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::showForm<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::transformAuthorizationCode<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::setLogger<\/a>"],[100,4,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,5,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__invoke<\/a>"],[83.33333333333334,2,"Taproot\\IndieAuth\\IndieAuthException::create<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getStatusCode<\/a>"],[0,1,"Taproot\\IndieAuth\\IndieAuthException::getExplanation<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getInfo<\/a>"],[0,2,"Taproot\\IndieAuth\\IndieAuthException::trustQueryParams<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getRequest<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::handle<\/a>"],[92.85714285714286,5,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::process<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::isValid<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware::process<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::handle<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Server::getTokenStorage<\/a>"],[94.64285714285714,67,"Taproot\\IndieAuth\\Server::handleAuthorizationEndpointRequest<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::handleTokenEndpointRequest<\/a>"],[100,3,"Taproot\\IndieAuth\\Server::handleException<\/a>"],[81.81818181818183,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::setLogger<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[77.14285714285715,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[63.63636363636363,5,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getAccessToken<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::revokeAccessToken<\/a>"],[100,9,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::deleteExpiredTokens<\/a>"],[91.66666666666666,4,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::get<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::put<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::delete<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getPath<\/a>"],[90.9090909090909,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::withLock<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::hash<\/a>"]], 'Method Complexity')) + .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::showForm<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::transformAuthorizationCode<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::setLogger<\/a>"],[100,5,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,5,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__invoke<\/a>"],[83.33333333333334,2,"Taproot\\IndieAuth\\IndieAuthException::create<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getStatusCode<\/a>"],[0,1,"Taproot\\IndieAuth\\IndieAuthException::getExplanation<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getInfo<\/a>"],[0,2,"Taproot\\IndieAuth\\IndieAuthException::trustQueryParams<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getRequest<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::handle<\/a>"],[92.85714285714286,5,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::process<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::isValid<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware::process<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::handle<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Server::getTokenStorage<\/a>"],[94.64285714285714,67,"Taproot\\IndieAuth\\Server::handleAuthorizationEndpointRequest<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::handleTokenEndpointRequest<\/a>"],[100,3,"Taproot\\IndieAuth\\Server::handleException<\/a>"],[81.81818181818183,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::setLogger<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[77.14285714285715,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[63.63636363636363,5,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getAccessToken<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::revokeAccessToken<\/a>"],[100,9,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::deleteExpiredTokens<\/a>"],[91.66666666666666,4,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::get<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::put<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::delete<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getPath<\/a>"],[90.9090909090909,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::withLock<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::hash<\/a>"]], 'Method Complexity')) .transition() .duration(500) .call(chart); diff --git a/docs/coverage/functions.php.html b/docs/coverage/functions.php.html index 7a1c0ae..a58447c 100644 --- a/docs/coverage/functions.php.html +++ b/docs/coverage/functions.php.html @@ -687,7 +687,7 @@

Legend

ExecutedNot ExecutedDead Code

- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Fri Jun 18 14:43:22 UTC 2021. + Generated by php-code-coverage 9.2.6 using PHP 7.3.28 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 24 11:29:20 UTC 2021.

diff --git a/docs/coverage/index.html b/docs/coverage/index.html index a066a87..0e6dae1 100644 --- a/docs/coverage/index.html +++ b/docs/coverage/index.html @@ -44,13 +44,13 @@ Total
-
- 94.00% covered (success) +
+ 94.01% covered (success)
-
94.00%
-
548 / 583
+
94.01%
+
549 / 584
73.68% covered (warning) @@ -78,7 +78,7 @@
100.00%
-
55 / 55
+
56 / 56
diff --git a/src/Callback/DefaultAuthorizationForm.php b/src/Callback/DefaultAuthorizationForm.php index 8fe5126..8022b1f 100644 --- a/src/Callback/DefaultAuthorizationForm.php +++ b/src/Callback/DefaultAuthorizationForm.php @@ -33,11 +33,14 @@ use function Taproot\IndieAuth\renderTemplate; * may make sense to create your own implementation of `AuthorizationFormInterface`. */ class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwareInterface { - public string $csrfKey; + /** @var string $csrfKey */ + public $csrfKey; - public string $formTemplatePath; + /** @var string $formTemplatePath */ + public $formTemplatePath; - public LoggerInterface $logger; + /** @var LoggerInterface $logger */ + public $logger; /** * Constructor diff --git a/src/Callback/SingleUserPasswordAuthenticationCallback.php b/src/Callback/SingleUserPasswordAuthenticationCallback.php index a8803ae..5913c7d 100644 --- a/src/Callback/SingleUserPasswordAuthenticationCallback.php +++ b/src/Callback/SingleUserPasswordAuthenticationCallback.php @@ -49,12 +49,23 @@ class SingleUserPasswordAuthenticationCallback { const LOGIN_HASH_COOKIE = 'taproot_indieauth_server_supauth_hash'; const DEFAULT_COOKIE_TTL = 60 * 5; - public string $csrfKey; - public string $formTemplate; - protected array $user; - protected string $hashedPassword; - protected string $secret; - protected int $ttl; + /** @var string $csrfKey */ + public $csrfKey; + + /** @var string $formTemplate */ + public $formTemplate; + + /** @var array $user */ + protected $user; + + /** @var string $hashedPassword */ + protected $hashedPassword; + + /** @var string $secret */ + protected $secret; + + /** @var int $ttl */ + protected $ttl; /** * Constructor @@ -78,7 +89,9 @@ class SingleUserPasswordAuthenticationCallback { throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); } - if (is_null(password_get_info($hashedPassword)['algo'])) { + $hashAlgo = password_get_info($hashedPassword)['algo']; + // Invalid algorithms are null in PHP 7.4, 0 in PHP 7.3. + if (is_null($hashAlgo) or 0 === $hashAlgo) { throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); } $this->user = $user; diff --git a/src/IndieAuthException.php b/src/IndieAuthException.php index e09d8a4..5f54f84 100644 --- a/src/IndieAuthException.php +++ b/src/IndieAuthException.php @@ -42,7 +42,8 @@ class IndieAuthException extends Exception { self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'], ]; - protected ServerRequestInterface $request; + /** @var ServerRequestInterface $request */ + protected $request; public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self { // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code. diff --git a/src/Middleware/ClosureRequestHandler.php b/src/Middleware/ClosureRequestHandler.php index 177adcb..c768c68 100644 --- a/src/Middleware/ClosureRequestHandler.php +++ b/src/Middleware/ClosureRequestHandler.php @@ -9,7 +9,8 @@ use Psr\Http\Server\RequestHandlerInterface; class ClosureRequestHandler implements RequestHandlerInterface { protected $callable; - protected array $args; + /** @var array $args */ + protected $args; public function __construct(callable $callable) { $this->callable = $callable; diff --git a/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php b/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php index 47fddc5..afee842 100644 --- a/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php +++ b/src/Middleware/DoubleSubmitCookieCsrfMiddleware.php @@ -41,15 +41,19 @@ class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface, LoggerAwa const DEFAULT_ERROR_RESPONSE_STRING = 'Invalid or missing CSRF token!'; const CSRF_TOKEN_LENGTH = 128; - public string $attribute; + /** @var string $attribute */ + public $attribute; - public int $ttl; + /** @var int $ttl */ + public $ttl; public $errorResponse; - public int $tokenLength; + /** @var int $tokenLength */ + public $tokenLength; - public LoggerInterface $logger; + /** @var LoggerInterface $logger */ + public $logger; /** * Constructor diff --git a/src/Middleware/ResponseRequestHandler.php b/src/Middleware/ResponseRequestHandler.php index ea02dc3..4b26adb 100644 --- a/src/Middleware/ResponseRequestHandler.php +++ b/src/Middleware/ResponseRequestHandler.php @@ -7,7 +7,8 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Server\RequestHandlerInterface; class ResponseRequestHandler implements RequestHandlerInterface { - public ResponseInterface $response; + /** @var ResponseInterface $response */ + public $response; public function __construct(ResponseInterface $response) { $this->response = $response; diff --git a/src/Server.php b/src/Server.php index 1329644..588b27b 100644 --- a/src/Server.php +++ b/src/Server.php @@ -100,13 +100,17 @@ class Server { */ const APPROVE_ACTION_VALUE = 'approve'; - protected Storage\TokenStorageInterface $tokenStorage; + /** @var Storage\TokenStorageInterface $tokenStorage */ + protected $tokenStorage; - protected AuthorizationFormInterface $authorizationForm; + /** @var AuthorizationFormInterface $authorizationForm */ + protected $authorizationForm; - protected MiddlewareInterface $csrfMiddleware; + /** @var MiddlewareInterface $csrfMiddleware */ + protected $csrfMiddleware; - protected LoggerInterface $logger; + /** @var LoggerInterface $logger */ + protected $logger; /** @var callable */ protected $httpGetWithEffectiveUrl; @@ -117,11 +121,14 @@ class Server { /** @var callable */ protected $handleNonIndieAuthRequest; - protected string $exceptionTemplatePath; + /** @var string $exceptionTemplatePath */ + protected $exceptionTemplatePath; - protected string $secret; + /** @var string $secret */ + protected $secret; - protected bool $requirePkce; + /** @var bool $requirePkce */ + protected $requirePkce; /** * Constructor diff --git a/src/Storage/FilesystemJsonStorage.php b/src/Storage/FilesystemJsonStorage.php index aab3adf..28d81c0 100644 --- a/src/Storage/FilesystemJsonStorage.php +++ b/src/Storage/FilesystemJsonStorage.php @@ -29,13 +29,20 @@ class FilesystemJsonStorage implements TokenStorageInterface, LoggerAwareInterfa const TOKEN_LENGTH = 64; - protected string $path; - protected int $authCodeTtl; - protected int $accessTokenTtl; - protected string $secret; + /** @var string $path */ + protected $path; + + /** @var int $authCodeTtl */ + protected $authCodeTtl; - protected LoggerInterface $logger; + /** @var int $accessTokenTtl */ + protected $accessTokenTtl; + /** @var string $secret */ + protected $secret; + + /** @var LoggerInterface $logger */ + protected $logger; public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) { $this->logger = $logger ?? new NullLogger();