From 01a15f0b4627bd18317348c26605b3adbc719a82 Mon Sep 17 00:00:00 2001 From: Barnaby Walters Date: Fri, 18 Jun 2021 16:11:49 +0200 Subject: [PATCH] Made SingleUserAuth callback set a cookie So that auth data is preserved across multiple requests. --- .../AuthorizationFormInterface.php.html | 2 +- .../DefaultAuthorizationForm.php.html | 2 +- ...serPasswordAuthenticationCallback.php.html | 221 +-- docs/coverage/Callback/dashboard.html | 6 +- docs/coverage/Callback/index.html | 6 +- docs/coverage/IndieAuthException.php.html | 2 +- .../Middleware/ClosureRequestHandler.php.html | 2 +- .../DoubleSubmitCookieCsrfMiddleware.php.html | 2 +- .../Middleware/NoOpMiddleware.php.html | 2 +- .../ResponseRequestHandler.php.html | 2 +- docs/coverage/Middleware/dashboard.html | 2 +- docs/coverage/Middleware/index.html | 2 +- docs/coverage/Server.php.html | 1300 +++++++++-------- .../Storage/FilesystemJsonStorage.php.html | 2 +- docs/coverage/Storage/Sqlite3Storage.php.html | 2 +- .../Storage/TokenStorageInterface.php.html | 2 +- docs/coverage/Storage/dashboard.html | 2 +- docs/coverage/Storage/index.html | 2 +- docs/coverage/dashboard.html | 6 +- docs/coverage/functions.php.html | 2 +- docs/coverage/index.html | 12 +- ...ngleUserPasswordAuthenticationCallback.php | 42 +- tests/ServerTest.php | 2 +- ...UserPasswordAuthenticationCallbackTest.php | 47 +- 24 files changed, 892 insertions(+), 780 deletions(-) diff --git a/docs/coverage/Callback/AuthorizationFormInterface.php.html b/docs/coverage/Callback/AuthorizationFormInterface.php.html index 73741d1..c83981e 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Callback/DefaultAuthorizationForm.php.html b/docs/coverage/Callback/DefaultAuthorizationForm.php.html index 80bb2e6..c14bd48 100644 --- a/docs/coverage/Callback/DefaultAuthorizationForm.php.html +++ b/docs/coverage/Callback/DefaultAuthorizationForm.php.html @@ -305,7 +305,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html index dd1074d..1fd6f55 100644 --- a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html +++ b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html @@ -68,7 +68,7 @@
100.00%
-
15 / 15
+
29 / 29
@@ -89,7 +89,7 @@
100.00%
2 / 2
- 6 + 9
100.00% covered (success) @@ -97,11 +97,11 @@
100.00%
-
15 / 15
+
29 / 29
-  
__construct +  __construct
100.00% covered (success) @@ -110,7 +110,7 @@
100.00%
1 / 1
- 3 + 4
100.00% covered (success) @@ -118,11 +118,11 @@
100.00%
-
9 / 9
+
13 / 13
-  __invoke +  __invoke
100.00% covered (success) @@ -131,7 +131,7 @@
100.00%
1 / 1
- 3 + 5
100.00% covered (success) @@ -139,7 +139,7 @@
100.00%
-
6 / 6
+
16 / 16
@@ -153,90 +153,123 @@ 3namespace Taproot\IndieAuth\Callback; 4 5use BadMethodCallException; - 6use Nyholm\Psr7\Response; - 7use Psr\Http\Message\ServerRequestInterface; - 8 - 9use function Taproot\IndieAuth\renderTemplate; - 10 - 11/** - 12 * Single User Password Authentication Callback - 13 *  - 14 * A simple example authentication callback which performs authentication itself rather - 15 * than redirecting to an existing authentication flow. - 16 *  - 17 * In some cases, it may make sense for your IndieAuth server to be able to authenticate - 18 * users itself, rather than redirecting them to an existing authentication flow. This - 19 * implementation provides a simple single-user password authentication method intended - 20 * for bootstrapping and testing purposes. - 21 *  - 22 * The sign-in form can be customised by making your own template and passing the path to - 23 * the constructor. - 24 *  - 25 * Minimal usage: - 26 *  - 27 * ```php - 28 * // One-off during app configuration: - 29 * YOUR_HASHED_PASSWORD = password_hash('my super strong password', PASSWORD_DEFAULT); - 30 *  - 31 * // In your app: - 32 * use Taproot\IndieAuth; - 33 * $server = new IndieAuth\Server([ - 34 *   … - 35 *   'authenticationHandler' => new IndieAuth\Callback\SingleUserPasswordAuthenticationCallback( - 36 *     ['me' => 'https://me.example.com/'], - 37 *     YOUR_HASHED_PASSWORD - 38 *   ) - 39 *   … - 40 * ]); - 41 * ``` - 42 *  - 43 * See documentation for `__construct()` for information about customising behaviour. - 44 */ - 45class SingleUserPasswordAuthenticationCallback { - 46    const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password'; - 47 - 48    public string $csrfKey; - 49    public string $formTemplate; - 50    protected array $user; - 51    protected string $hashedPassword; - 52     - 53    /** - 54     * Constructor - 55     *  - 56     * @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. - 57     * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` - 58     * @param string|null $formTemplate The path to a template used to render the sign-in form. Uses default if null. - 59     * @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. - 60     */ - 61    public function __construct(array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null) { - 62        if (!isset($user['me'])) { - 63            throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); - 64        } - 65         - 66        if (is_null(password_get_info($hashedPassword)['algo'])) { - 67            throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); - 68        } - 69        $this->user = $user; - 70        $this->hashedPassword = $hashedPassword; - 71        $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php'; - 72        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; - 73    } - 74 - 75    public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { - 76        // If the request is a form submission with a matching password, return the corresponding - 77        // user data. - 78        if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { - 79            return $this->user; - 80        } - 81 - 82        // Otherwise, return a response containing the password form. - 83        return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [ - 84            'formAction' => $formAction, - 85            'request' => $request, - 86            'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' - 87        ])); - 88    } - 89} + 6use Dflydev\FigCookies; + 7use Nyholm\Psr7\Response; + 8use Psr\Http\Message\ServerRequestInterface; + 9 + 10use function Taproot\IndieAuth\renderTemplate; + 11 + 12/** + 13 * Single User Password Authentication Callback + 14 *  + 15 * A simple example authentication callback which performs authentication itself rather + 16 * than redirecting to an existing authentication flow. + 17 *  + 18 * In some cases, it may make sense for your IndieAuth server to be able to authenticate + 19 * users itself, rather than redirecting them to an existing authentication flow. This + 20 * implementation provides a simple single-user password authentication method intended + 21 * for bootstrapping and testing purposes. + 22 *  + 23 * The sign-in form can be customised by making your own template and passing the path to + 24 * the constructor. + 25 *  + 26 * Minimal usage: + 27 *  + 28 * ```php + 29 * // One-off during app configuration: + 30 * YOUR_HASHED_PASSWORD = password_hash('my super strong password', PASSWORD_DEFAULT); + 31 *  + 32 * // In your app: + 33 * use Taproot\IndieAuth; + 34 * $server = new IndieAuth\Server([ + 35 *   … + 36 *   'authenticationHandler' => new IndieAuth\Callback\SingleUserPasswordAuthenticationCallback( + 37 *     ['me' => 'https://me.example.com/'], + 38 *     YOUR_HASHED_PASSWORD + 39 *   ) + 40 *   … + 41 * ]); + 42 * ``` + 43 *  + 44 * See documentation for `__construct()` for information about customising behaviour. + 45 */ + 46class SingleUserPasswordAuthenticationCallback { + 47    const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password'; + 48    const LOGIN_HASH_COOKIE = 'taproot_indieauth_server_supauth_hash'; + 49    const DEFAULT_COOKIE_TTL = 60 * 5; + 50 + 51    public string $csrfKey; + 52    public string $formTemplate; + 53    protected array $user; + 54    protected string $hashedPassword; + 55    protected string $secret; + 56    protected int $ttl; + 57     + 58    /** + 59     * Constructor + 60     *  + 61     * @param string $secret A secret key used to encrypt cookies. Can be the same as the secret passed to IndieAuth\Server. + 62     * @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. + 63     * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` + 64     * @param string|null $formTemplate The path to a template used to render the sign-in form. Uses default if null. + 65     * @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. + 66     * @param int|null $ttl The lifetime of the authentication cookie. Defaults to five minutes. + 67     */ + 68    public function __construct(string $secret, array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null, ?int $ttl=self::DEFAULT_COOKIE_TTL) { + 69        if (strlen($secret) < 64) { + 70            throw new BadMethodCallException("\$secret must be a string with a minimum length of 64 characters."); + 71        } + 72        $this->secret = $secret; + 73 + 74        $this->ttl = $ttl; + 75 + 76        if (!isset($user['me'])) { + 77            throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); + 78        } + 79         + 80        if (is_null(password_get_info($hashedPassword)['algo'])) { + 81            throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.'); + 82        } + 83        $this->user = $user; + 84        $this->hashedPassword = $hashedPassword; + 85        $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php'; + 86        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; + 87    } + 88 + 89    public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { + 90        // If the request is logged in, return authentication data. + 91        $cookies = $request->getCookieParams(); + 92        if ( + 93            isset($cookies[self::LOGIN_HASH_COOKIE]) + 94            && hash_equals(hash_hmac('SHA256', json_encode($this->user), $this->secret), $cookies[self::LOGIN_HASH_COOKIE]) + 95        ) { + 96            return $this->user; + 97        } + 98 + 99        // If the request is a form submission with a matching password, return a redirect to the indieauth + 100        // flow, setting a cookie. + 101        if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { + 102            $response = new Response(302, ['Location' => $formAction]); + 103 + 104            // Set the user data hash cookie. + 105            $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create(self::LOGIN_HASH_COOKIE) + 106                    ->withValue(hash_hmac('SHA256', json_encode($this->user), $this->secret)) + 107                    ->withMaxAge($this->ttl) + 108                    ->withSecure($request->getUri()->getScheme() == 'https') + 109                    ->withDomain($request->getUri()->getHost()) + 110            ); + 111 + 112            return $response; + 113        } + 114 + 115        // Otherwise, return a response containing the password form. + 116        return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [ + 117            'formAction' => $formAction, + 118            'request' => $request, + 119            'csrfFormElement' => '<input type="hidden" name="' . htmlentities($this->csrfKey) . '" value="' . htmlentities($request->getAttribute($this->csrfKey)) . '" />' + 120        ])); + 121    } + 122} @@ -247,7 +280,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Callback/dashboard.html b/docs/coverage/Callback/dashboard.html index 7191317..716d0cd 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,6,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"]], 'Class Complexity')) + .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,9,"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,3,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,3,"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,4,"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 f21d8d8..c80d6eb 100644 --- a/docs/coverage/Callback/index.html +++ b/docs/coverage/Callback/index.html @@ -51,7 +51,7 @@
100.00%
-
41 / 41
+
55 / 55
100.00% covered (success) @@ -120,7 +120,7 @@
100.00%
-
15 / 15
+
29 / 29
diff --git a/docs/coverage/IndieAuthException.php.html b/docs/coverage/IndieAuthException.php.html index 3f4a2f2..1d2873e 100644 --- a/docs/coverage/IndieAuthException.php.html +++ b/docs/coverage/IndieAuthException.php.html @@ -326,7 +326,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Middleware/ClosureRequestHandler.php.html b/docs/coverage/Middleware/ClosureRequestHandler.php.html index d7afa77..9becc60 100644 --- a/docs/coverage/Middleware/ClosureRequestHandler.php.html +++ b/docs/coverage/Middleware/ClosureRequestHandler.php.html @@ -180,7 +180,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html index 7e166aa..ffef89b 100644 --- a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html +++ b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html @@ -324,7 +324,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Middleware/NoOpMiddleware.php.html b/docs/coverage/Middleware/NoOpMiddleware.php.html index 3980b91..2e11a6b 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Middleware/ResponseRequestHandler.php.html b/docs/coverage/Middleware/ResponseRequestHandler.php.html index 23a68c6..25849cb 100644 --- a/docs/coverage/Middleware/ResponseRequestHandler.php.html +++ b/docs/coverage/Middleware/ResponseRequestHandler.php.html @@ -177,7 +177,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Middleware/dashboard.html b/docs/coverage/Middleware/dashboard.html index 7dbbe67..a0b8511 100644 --- a/docs/coverage/Middleware/dashboard.html +++ b/docs/coverage/Middleware/dashboard.html @@ -138,7 +138,7 @@
diff --git a/docs/coverage/Middleware/index.html b/docs/coverage/Middleware/index.html index 094e44d..7627520 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

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

diff --git a/docs/coverage/Storage/FilesystemJsonStorage.php.html b/docs/coverage/Storage/FilesystemJsonStorage.php.html index 3fdcd24..eedc1e0 100644 --- a/docs/coverage/Storage/FilesystemJsonStorage.php.html +++ b/docs/coverage/Storage/FilesystemJsonStorage.php.html @@ -663,7 +663,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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Storage/Sqlite3Storage.php.html b/docs/coverage/Storage/Sqlite3Storage.php.html index a4cca04..f05ea4e 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Storage/TokenStorageInterface.php.html b/docs/coverage/Storage/TokenStorageInterface.php.html index b546673..3bcf7e3 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/Storage/dashboard.html b/docs/coverage/Storage/dashboard.html index 267f0c0..24b21b6 100644 --- a/docs/coverage/Storage/dashboard.html +++ b/docs/coverage/Storage/dashboard.html @@ -142,7 +142,7 @@
diff --git a/docs/coverage/Storage/index.html b/docs/coverage/Storage/index.html index 6614179..d21768a 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/dashboard.html b/docs/coverage/dashboard.html index f631cf1..192b480 100644 --- a/docs/coverage/dashboard.html +++ b/docs/coverage/dashboard.html @@ -150,7 +150,7 @@
@@ -237,7 +237,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Cyclomatic Complexity'); d3.select('#classComplexity svg') - .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,6,"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.69117647058823,105,"Taproot\\IndieAuth\\Server<\/a>"],[92.3076923076923,46,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage<\/a>"]], 'Class Complexity')) + .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.69117647058823,105,"Taproot\\IndieAuth\\Server<\/a>"],[92.3076923076923,46,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage<\/a>"]], 'Class Complexity')) .transition() .duration(500) .call(chart); @@ -261,7 +261,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,3,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,3,"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.61077844311377,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>"],[85.71428571428571,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[92.85714285714286,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[100,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,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.61077844311377,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>"],[85.71428571428571,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[92.85714285714286,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[100,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 7c7bf44..a944d14 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 Thu Jun 17 22:38:11 UTC 2021. + 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:09:49 UTC 2021.

diff --git a/docs/coverage/index.html b/docs/coverage/index.html index 7453dc0..3d45d1f 100644 --- a/docs/coverage/index.html +++ b/docs/coverage/index.html @@ -44,13 +44,13 @@ Total
-
- 95.48% covered (success) +
+ 95.59% covered (success)
-
95.48%
-
528 / 553
+
95.59%
+
542 / 567
75.44% covered (warning) @@ -78,7 +78,7 @@
100.00%
-
41 / 41
+
55 / 55
diff --git a/src/Callback/SingleUserPasswordAuthenticationCallback.php b/src/Callback/SingleUserPasswordAuthenticationCallback.php index 7b1fba8..a8803ae 100644 --- a/src/Callback/SingleUserPasswordAuthenticationCallback.php +++ b/src/Callback/SingleUserPasswordAuthenticationCallback.php @@ -3,6 +3,7 @@ namespace Taproot\IndieAuth\Callback; use BadMethodCallException; +use Dflydev\FigCookies; use Nyholm\Psr7\Response; use Psr\Http\Message\ServerRequestInterface; @@ -33,6 +34,7 @@ use function Taproot\IndieAuth\renderTemplate; * $server = new IndieAuth\Server([ * … * 'authenticationHandler' => new IndieAuth\Callback\SingleUserPasswordAuthenticationCallback( + * YOUR_SECRET, * ['me' => 'https://me.example.com/'], * YOUR_HASHED_PASSWORD * ) @@ -44,21 +46,34 @@ use function Taproot\IndieAuth\renderTemplate; */ class SingleUserPasswordAuthenticationCallback { const PASSWORD_FORM_PARAMETER = 'taproot_indieauth_server_password'; + 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; /** * Constructor * + * @param string $secret A secret key used to encrypt cookies. Can be the same as the secret passed to IndieAuth\Server. * @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. * @param string $hashedPassword The password used to authenticate as $user, hashed by `password_hash($pass, PASSWORD_DEFAULT)` * @param string|null $formTemplate The path to a template used to render the sign-in form. Uses default if null. * @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. + * @param int|null $ttl The lifetime of the authentication cookie, in seconds. Defaults to five minutes. */ - public function __construct(array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null) { + public function __construct(string $secret, array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null, ?int $ttl=null) { + if (strlen($secret) < 64) { + throw new BadMethodCallException("\$secret must be a string with a minimum length of 64 characters."); + } + $this->secret = $secret; + + $this->ttl = $ttl ?? self::DEFAULT_COOKIE_TTL; + if (!isset($user['me'])) { throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.'); } @@ -73,12 +88,31 @@ class SingleUserPasswordAuthenticationCallback { } public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) { - // If the request is a form submission with a matching password, return the corresponding - // user data. - if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { + // If the request is logged in, return authentication data. + $cookies = $request->getCookieParams(); + if ( + isset($cookies[self::LOGIN_HASH_COOKIE]) + && hash_equals(hash_hmac('SHA256', json_encode($this->user), $this->secret), $cookies[self::LOGIN_HASH_COOKIE]) + ) { return $this->user; } + // If the request is a form submission with a matching password, return a redirect to the indieauth + // flow, setting a cookie. + if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { + $response = new Response(302, ['Location' => $formAction]); + + // Set the user data hash cookie. + $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create(self::LOGIN_HASH_COOKIE) + ->withValue(hash_hmac('SHA256', json_encode($this->user), $this->secret)) + ->withMaxAge($this->ttl) + ->withSecure($request->getUri()->getScheme() == 'https') + ->withDomain($request->getUri()->getHost()) + ); + + return $response; + } + // Otherwise, return a response containing the password form. return new Response(200, ['content-type' => 'text/html'], renderTemplate($this->formTemplate, [ 'formAction' => $formAction, diff --git a/tests/ServerTest.php b/tests/ServerTest.php index ccd0633..7e130a2 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -44,7 +44,7 @@ class ServerTest extends TestCase { // With this template, IndieAuthException response bodies will contain only their IndieAuthException error code, for ease of comparison. 'exceptionTemplatePath' => CODE_EXCEPTION_TEMPLATE_PATH, // Default to a simple single-user password authentication handler. - Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT), Server::DEFAULT_CSRF_KEY), + Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(SERVER_SECRET, ['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT), Server::DEFAULT_CSRF_KEY), 'authorizationForm' => new DefaultAuthorizationForm(AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH), ], $config)); } diff --git a/tests/SingleUserPasswordAuthenticationCallbackTest.php b/tests/SingleUserPasswordAuthenticationCallbackTest.php index 5c309b5..2cac629 100644 --- a/tests/SingleUserPasswordAuthenticationCallbackTest.php +++ b/tests/SingleUserPasswordAuthenticationCallbackTest.php @@ -3,6 +3,7 @@ namespace Taproot\IndieAuth\Test; use BadMethodCallException; +use Dflydev\FigCookies; use Exception; use Nyholm\Psr7; use Nyholm\Psr7\ServerRequest; @@ -13,7 +14,7 @@ use Taproot\IndieAuth\Server; class SingleUserPasswordAuthenticationCallbackTest extends TestCase { public function testThrowsExceptionIfUserDataHasNoMeKey() { try { - $c = new SingleUserPasswordAuthenticationCallback([ + $c = new SingleUserPasswordAuthenticationCallback(SERVER_SECRET, [ 'not_me' => 'blah' ], password_hash('password', PASSWORD_DEFAULT)); $this->fail(); @@ -22,9 +23,20 @@ class SingleUserPasswordAuthenticationCallbackTest extends TestCase { } } + public function testThrowsExceptionIfSecretIsTooShort() { + try { + $c = new SingleUserPasswordAuthenticationCallback('not long enough', [ + 'me' => 'blah' + ], password_hash('password', PASSWORD_DEFAULT)); + $this->fail(); + } catch (BadMethodCallException $e) { + $this->assertEquals('$secret must be a string with a minimum length of 64 characters.', $e->getMessage()); + } + } + public function testThrowsExceptionIfHashedPasswordIsInvalid() { try { - $c = new SingleUserPasswordAuthenticationCallback([ + $c = new SingleUserPasswordAuthenticationCallback(SERVER_SECRET, [ 'me' => 'https://me.example.com/' ], 'definitely not a hashed password'); $this->fail(); @@ -34,7 +46,7 @@ class SingleUserPasswordAuthenticationCallbackTest extends TestCase { } public function testShowsAuthenticationFormOnUnauthenticatedRequest() { - $callback = new SingleUserPasswordAuthenticationCallback([ + $callback = new SingleUserPasswordAuthenticationCallback(SERVER_SECRET, [ 'me' => 'https://me.example.com/' ], password_hash('password', PASSWORD_DEFAULT)); @@ -48,7 +60,7 @@ class SingleUserPasswordAuthenticationCallbackTest extends TestCase { $this->assertStringContainsString($formAction, (string) $res->getBody()); } - public function testReturnsUserDataOnAuthenticatedRequest() { + public function testReturnsCookieRedirectOnAuthenticatedRequest() { $userData = [ 'me' => 'https://me.example.com', 'profile' => ['name' => 'Me'] @@ -56,7 +68,7 @@ class SingleUserPasswordAuthenticationCallbackTest extends TestCase { $password = 'my very secure password'; - $callback = new SingleUserPasswordAuthenticationCallback($userData, password_hash($password, PASSWORD_DEFAULT)); + $callback = new SingleUserPasswordAuthenticationCallback(SERVER_SECRET, $userData, password_hash($password, PASSWORD_DEFAULT)); $req = (new ServerRequest('POST', 'https://example.com/login')) ->withAttribute(Server::DEFAULT_CSRF_KEY, 'csrf token') @@ -66,6 +78,31 @@ class SingleUserPasswordAuthenticationCallbackTest extends TestCase { $res = $callback($req, 'form_action'); + $this->assertEquals(302, $res->getStatusCode()); + $this->assertEquals('form_action', $res->getHeaderLine('location')); + $resCookies = FigCookies\SetCookies::fromResponse($res); + $hashCookie = $resCookies->get(SingleUserPasswordAuthenticationCallback::LOGIN_HASH_COOKIE); + $this->assertEquals(hash_hmac('SHA256', json_encode($userData), SERVER_SECRET), $hashCookie->getValue()); + } + + public function testReturnsUserDataOnResponseWithValidHashCookie() { + $userData = [ + 'me' => 'https://me.example.com', + 'profile' => ['name' => 'Me'] + ]; + + $password = 'my very secure password'; + + $callback = new SingleUserPasswordAuthenticationCallback(SERVER_SECRET, $userData, password_hash($password, PASSWORD_DEFAULT)); + + $req = (new ServerRequest('POST', 'https://example.com/login')) + ->withAttribute(Server::DEFAULT_CSRF_KEY, 'csrf token') + ->withCookieParams([ + SingleUserPasswordAuthenticationCallback::LOGIN_HASH_COOKIE => hash_hmac('SHA256', json_encode($userData), SERVER_SECRET) + ]); + + $res = $callback($req, 'form_action'); + $this->assertEquals($userData, $res); } }