From 1af270b42f832d1425c3c2bba76911374b10c96a Mon Sep 17 00:00:00 2001 From: Barnaby Walters Date: Fri, 18 Jun 2021 00:39:21 +0200 Subject: [PATCH] Implemented, tested non-PKCE flow. Fixes #1 --- .../Taproot-IndieAuth-IndieAuthException.html | 50 +- docs/classes/Taproot-IndieAuth-Server.html | 47 +- .../AuthorizationFormInterface.php.html | 2 +- .../DefaultAuthorizationForm.php.html | 20 +- ...serPasswordAuthenticationCallback.php.html | 16 +- docs/coverage/Callback/dashboard.html | 2 +- docs/coverage/Callback/index.html | 2 +- docs/coverage/IndieAuthException.php.html | 134 +- .../Middleware/ClosureRequestHandler.php.html | 10 +- .../DoubleSubmitCookieCsrfMiddleware.php.html | 60 +- .../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 | 1455 +++++++++-------- .../Storage/FilesystemJsonStorage.php.html | 126 +- 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 | 18 +- docs/coverage/functions.php.html | 418 ++--- docs/coverage/index.html | 42 +- docs/files/src-functions.html | 79 +- docs/js/searchIndex.js | 10 + docs/namespaces/taproot-indieauth.html | 79 +- docs/packages/default.html | 79 +- src/IndieAuthException.php | 2 + src/Server.php | 87 +- src/functions.php | 10 +- tests/ServerTest.php | 190 ++- 31 files changed, 1691 insertions(+), 1263 deletions(-) diff --git a/docs/classes/Taproot-IndieAuth-IndieAuthException.html b/docs/classes/Taproot-IndieAuth-IndieAuthException.html index 9dc5c1c..b6ac752 100644 --- a/docs/classes/Taproot-IndieAuth-IndieAuthException.html +++ b/docs/classes/Taproot-IndieAuth-IndieAuthException.html @@ -150,6 +150,7 @@ self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'], self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'], self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'], + self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'], ]
@@ -215,6 +216,13 @@  = 13 +
+ +
+ INVALID_REQUEST_REDIRECT + +  = 14 +
@@ -374,7 +382,7 @@ @@ -398,6 +406,7 @@ self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'], self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'], self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'], + self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'], ] @@ -630,6 +639,31 @@ + +
+

+ INVALID_REQUEST_REDIRECT + +

+ + + + + + public + mixed + INVALID_REQUEST_REDIRECT + = 14 + + + + + +

@@ -705,7 +739,7 @@ @@ -739,7 +773,7 @@ @@ -795,7 +829,7 @@ @@ -827,7 +861,7 @@ @@ -859,7 +893,7 @@ @@ -891,7 +925,7 @@ @@ -923,7 +957,7 @@

Trust Query Params

diff --git a/docs/classes/Taproot-IndieAuth-Server.html b/docs/classes/Taproot-IndieAuth-Server.html index 2bd2638..9f18aae 100644 --- a/docs/classes/Taproot-IndieAuth-Server.html +++ b/docs/classes/Taproot-IndieAuth-Server.html @@ -275,6 +275,13 @@ documentation for both handling methods for further documentation about them.

 : LoggerInterface

+
+ +
+ $requirePkce + +  : bool +
@@ -714,6 +721,36 @@ documentation for both handling methods for further documentation about them.

+
+

+ $requirePkce + + + +

+ + + + + protected + bool + $requirePkce + + + + +
Server.php : - 191 + 193

Constructor

@@ -912,7 +949,7 @@ as the logger for any objects passed in config which implement Server.php : - 286 + 291 @@ -944,7 +981,7 @@ as the logger for any objects passed in config which implement Server.php : - 325 + 330

Handle Authorization Endpoint Request

@@ -1019,7 +1056,7 @@ error behaviour, one way to do so is to subclass Serve

Handle Token Endpoint Request

@@ -1075,7 +1112,7 @@ error behaviour, one way to do so is to subclass Serve

Handle Exception

diff --git a/docs/coverage/Callback/AuthorizationFormInterface.php.html b/docs/coverage/Callback/AuthorizationFormInterface.php.html index 2e105aa..73741d1 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Callback/DefaultAuthorizationForm.php.html b/docs/coverage/Callback/DefaultAuthorizationForm.php.html index d6d9e6c..80bb2e6 100644 --- a/docs/coverage/Callback/DefaultAuthorizationForm.php.html +++ b/docs/coverage/Callback/DefaultAuthorizationForm.php.html @@ -239,10 +239,10 @@ 47     * @param LoggerInterface|null $logger A logger. 48     */ 49    public function __construct(?string $formTemplatePath=null, ?string $csrfKey=null, ?LoggerInterface $logger=null) { - 50        $this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../../templates/default_authorization_page.html.php'; - 51        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; - 52        $this->logger = $logger ?? new NullLogger; - 53    } + 50        $this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../../templates/default_authorization_page.html.php'; + 51        $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; + 52        $this->logger = $logger ?? new NullLogger; + 53    } 54 55    public function showForm(ServerRequestInterface $request, array $authenticationResult, string $formAction, ?array $clientHApp): ResponseInterface { 56        // Show an authorization page. List all requested scopes, as this default @@ -279,21 +279,21 @@ 87 88    public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array { 89        // Add any granted scopes from the form to the code. - 90        $grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope[]'] ?? []; + 90        $grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope[]'] ?? []; 91 92        // This default implementation naievely accepts any scopes it receives from the form. 93        // You may wish to perform some sort of validation. - 94        $code['scope'] = join(' ', $grantedScopes); + 94        $code['scope'] = join(' ', $grantedScopes); 95 96        // You may wish to additionally make any other necessary changes to the the code based on 97        // the form submission, e.g. if the user set a custom token lifetime, or wanted extra data 98        // stored on the token to affect how it behaves. - 99        return $code; + 99        return $code; 100    } 101 102    public function setLogger(LoggerInterface $logger) { - 103        $this->logger = $logger; - 104    } + 103        $this->logger = $logger; + 104    } 105} @@ -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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html index f528270..dd1074d 100644 --- a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html +++ b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html @@ -209,18 +209,18 @@ 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'])) { + 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'])) { + 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    } + 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 @@ -247,7 +247,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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Callback/dashboard.html b/docs/coverage/Callback/dashboard.html index e91206e..7191317 100644 --- a/docs/coverage/Callback/dashboard.html +++ b/docs/coverage/Callback/dashboard.html @@ -136,7 +136,7 @@ diff --git a/docs/coverage/Callback/index.html b/docs/coverage/Callback/index.html index 520978f..f21d8d8 100644 --- a/docs/coverage/Callback/index.html +++ b/docs/coverage/Callback/index.html @@ -152,7 +152,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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/IndieAuthException.php.html b/docs/coverage/IndieAuthException.php.html index 80597d9..3f4a2f2 100644 --- a/docs/coverage/IndieAuthException.php.html +++ b/docs/coverage/IndieAuthException.php.html @@ -100,7 +100,7 @@ -  create +  create
0.00% covered (danger) @@ -121,7 +121,7 @@ -  getStatusCode +  getStatusCode
100.00% covered (success) @@ -142,7 +142,7 @@ -  getExplanation +  getExplanation
0.00% covered (danger) @@ -163,7 +163,7 @@ -  getInfo +  getInfo
100.00% covered (success) @@ -184,7 +184,7 @@ -  trustQueryParams +  trustQueryParams
0.00% covered (danger) @@ -205,7 +205,7 @@ -  getRequest +  getRequest
100.00% covered (success) @@ -254,66 +254,68 @@ 21    const INVALID_SCOPE = 11; 22    const INVALID_GRANT = 12; 23    const INVALID_REQUEST = 13; - 24 - 25    const EXC_INFO = [ - 26        self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'], - 27        self::INTERNAL_ERROR_REDIRECT => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'], - 28        self::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'], - 29        self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH => ['statusCode' => 302, 'name' => 'Request Missing Hash', 'error' => 'internal_error'], - 30        self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH => ['statusCode' => 302, 'name' => 'Request Hash Invalid', 'error' => 'internal_error'], - 31        // TODO: should this one be a 500 because it’s an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care? - 32        self::HTTP_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Error Fetching Client App URL',  'explanation' => 'Fetching the client app (client_id) failed.'], - 33        self::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Internal Error fetching client app URI', 'explanation' => 'Fetching the client app (client_id) failed due to an internal error.'], - 34        self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) either was not a valid URI, did not sufficiently match client_id, or did not exactly match any redirect URIs parsed from fetching the client_id.'], - 35        self::INVALID_CLIENT_ID => ['statusCode' => 400, 'name' => 'Invalid Client Identifier URI', 'explanation' => 'The Client Identifier was not valid.'], - 36        self::INVALID_STATE => ['statusCode' => 302, 'name' => 'Invalid state Parameter', 'error' => 'invalid_request'], - 37        self::INVALID_CODE_CHALLENGE => ['statusCode' => 302, 'name' => 'Invalid code_challenge Parameter', 'error' => 'invalid_request'], - 38        self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'], - 39        self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'], - 40        self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'], - 41    ]; - 42 - 43    protected ServerRequestInterface $request; + 24    const INVALID_REQUEST_REDIRECT = 14; + 25 + 26    const EXC_INFO = [ + 27        self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'], + 28        self::INTERNAL_ERROR_REDIRECT => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'], + 29        self::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'], + 30        self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH => ['statusCode' => 302, 'name' => 'Request Missing Hash', 'error' => 'internal_error'], + 31        self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH => ['statusCode' => 302, 'name' => 'Request Hash Invalid', 'error' => 'internal_error'], + 32        // TODO: should this one be a 500 because it’s an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care? + 33        self::HTTP_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Error Fetching Client App URL',  'explanation' => 'Fetching the client app (client_id) failed.'], + 34        self::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Internal Error fetching client app URI', 'explanation' => 'Fetching the client app (client_id) failed due to an internal error.'], + 35        self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) either was not a valid URI, did not sufficiently match client_id, or did not exactly match any redirect URIs parsed from fetching the client_id.'], + 36        self::INVALID_CLIENT_ID => ['statusCode' => 400, 'name' => 'Invalid Client Identifier URI', 'explanation' => 'The Client Identifier was not valid.'], + 37        self::INVALID_STATE => ['statusCode' => 302, 'name' => 'Invalid state Parameter', 'error' => 'invalid_request'], + 38        self::INVALID_CODE_CHALLENGE => ['statusCode' => 302, 'name' => 'Invalid code_challenge Parameter', 'error' => 'invalid_request'], + 39        self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'], + 40        self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'], + 41        self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'], + 42        self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'], + 43    ]; 44 - 45    public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self { - 46        // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code. - 47        if (!in_array($code, array_keys(self::EXC_INFO))) { - 48            $code = 0; - 49        } - 50        $message = self::EXC_INFO[$code]['name']; - 51        $e = new self($message, $code, $previous); - 52        $e->request = $request; - 53        return $e; - 54    } - 55 - 56    public function getStatusCode() { - 57        return $this->getInfo()['statusCode'] ?? 500; - 58    } - 59 - 60    public function getExplanation() { - 61        return $this->getInfo()['explanation'] ?? 'An unknown error occured.'; - 62    } - 63 - 64    public function getInfo() { - 65        return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; - 66    } - 67 - 68    /** - 69     * Trust Query Params - 70     *  - 71     * Only useful on authorization form submission requests. If this returns false, - 72     * the client_id and/or request_uri have likely been tampered with, and the error - 73     * page SHOULD NOT offer the user a link to them. - 74     */ - 75    public function trustQueryParams() { - 76        return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH - 77                || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH; - 78    } - 79 - 80    public function getRequest() { - 81        return $this->request; - 82    } - 83} + 45    protected ServerRequestInterface $request; + 46 + 47    public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self { + 48        // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code. + 49        if (!in_array($code, array_keys(self::EXC_INFO))) { + 50            $code = 0; + 51        } + 52        $message = self::EXC_INFO[$code]['name']; + 53        $e = new self($message, $code, $previous); + 54        $e->request = $request; + 55        return $e; + 56    } + 57 + 58    public function getStatusCode() { + 59        return $this->getInfo()['statusCode'] ?? 500; + 60    } + 61 + 62    public function getExplanation() { + 63        return $this->getInfo()['explanation'] ?? 'An unknown error occured.'; + 64    } + 65 + 66    public function getInfo() { + 67        return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; + 68    } + 69 + 70    /** + 71     * Trust Query Params + 72     *  + 73     * Only useful on authorization form submission requests. If this returns false, + 74     * the client_id and/or request_uri have likely been tampered with, and the error + 75     * page SHOULD NOT offer the user a link to them. + 76     */ + 77    public function trustQueryParams() { + 78        return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH + 79                || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH; + 80    } + 81 + 82    public function getRequest() { + 83        return $this->request; + 84    } + 85} @@ -324,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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Middleware/ClosureRequestHandler.php.html b/docs/coverage/Middleware/ClosureRequestHandler.php.html index 3b9ebf5..d7afa77 100644 --- a/docs/coverage/Middleware/ClosureRequestHandler.php.html +++ b/docs/coverage/Middleware/ClosureRequestHandler.php.html @@ -162,12 +162,12 @@ 12    protected array $args; 13 14    public function __construct(callable $callable) { - 15        $this->callable = $callable; - 16        $this->args = array_slice(func_get_args(), 1); - 17    } + 15        $this->callable = $callable; + 16        $this->args = array_slice(func_get_args(), 1); + 17    } 18 19    public function handle(ServerRequestInterface $request): ResponseInterface { - 20        return call_user_func_array($this->callable, array_merge([$request], $this->args)); + 20        return call_user_func_array($this->callable, array_merge([$request], $this->args)); 21    } 22} @@ -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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html index b449dba..7e166aa 100644 --- a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html +++ b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html @@ -255,60 +255,60 @@ 63     *   the return value of which will be returned as-is. 64     */ 65    public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) { - 66        $this->attribute = $attribute ?? self::ATTRIBUTE; - 67        $this->ttl = $ttl ?? self::TTL; - 68        $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH; + 66        $this->attribute = $attribute ?? self::ATTRIBUTE; + 67        $this->ttl = $ttl ?? self::TTL; + 68        $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH; 69 - 70        if (!is_callable($errorResponse)) { - 71            if (!$errorResponse instanceof ResponseInterface) { - 72                if (!is_string($errorResponse)) { + 70        if (!is_callable($errorResponse)) { + 71            if (!$errorResponse instanceof ResponseInterface) { + 72                if (!is_string($errorResponse)) { 73                    $errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING; 74                } - 75                $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse); + 75                $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse); 76            } - 77            $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; + 77            $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; }; 78        } - 79        $this->errorResponse = $errorResponse; + 79        $this->errorResponse = $errorResponse; 80 - 81        if (!$logger instanceof LoggerInterface) { - 82            $logger = new NullLogger(); + 81        if (!$logger instanceof LoggerInterface) { + 82            $logger = new NullLogger(); 83        } - 84        $this->logger = $logger; - 85    } + 84        $this->logger = $logger; + 85    } 86 87    public function setLogger(LoggerInterface $logger) { - 88        $this->logger = $logger; - 89    } + 88        $this->logger = $logger; + 89    } 90 91    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { 92        // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response. - 93        $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); - 94        $request = $request->withAttribute($this->attribute, $csrfToken); + 93        $csrfToken = generateRandomPrintableAsciiString($this->tokenLength); + 94        $request = $request->withAttribute($this->attribute, $csrfToken); 95 - 96        if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { + 96        if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) { 97            // This request is a write method with invalid CSRF parameters. 98            $response = call_user_func($this->errorResponse, $request); 99        } else { - 100            $response = $handler->handle($request); + 100            $response = $handler->handle($request); 101        } 102 103        // Add the new CSRF cookie, restricting its scope to match the current request. - 104        $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute) - 105                ->withValue($csrfToken) - 106                ->withMaxAge($this->ttl) - 107                ->withSecure($request->getUri()->getScheme() == 'https') - 108                ->withDomain($request->getUri()->getHost()) - 109                ->withPath($request->getUri()->getPath())); + 104        $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute) + 105                ->withValue($csrfToken) + 106                ->withMaxAge($this->ttl) + 107                ->withSecure($request->getUri()->getScheme() == 'https') + 108                ->withDomain($request->getUri()->getHost()) + 109                ->withPath($request->getUri()->getPath())); 110 - 111        return $response; + 111        return $response; 112    } 113 114    protected function isValid(ServerRequestInterface $request) { - 115        if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) { - 116            if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) { + 115        if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) { + 116            if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) { 117                // TODO: make sure CSRF token isn’t the empty string, possibly also check that it’s the same length 118                // as defined in $this->tokenLength. - 119                return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]); + 119                return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]); 120            } 121        } 122        return false; @@ -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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Middleware/NoOpMiddleware.php.html b/docs/coverage/Middleware/NoOpMiddleware.php.html index 9c5a72a..3980b91 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Middleware/ResponseRequestHandler.php.html b/docs/coverage/Middleware/ResponseRequestHandler.php.html index 1958da1..23a68c6 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Middleware/dashboard.html b/docs/coverage/Middleware/dashboard.html index a5e9a1c..7dbbe67 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 32c5786..094e44d 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

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

diff --git a/docs/coverage/Storage/FilesystemJsonStorage.php.html b/docs/coverage/Storage/FilesystemJsonStorage.php.html index b5aca98..3fdcd24 100644 --- a/docs/coverage/Storage/FilesystemJsonStorage.php.html +++ b/docs/coverage/Storage/FilesystemJsonStorage.php.html @@ -419,110 +419,110 @@ 38 39 40    public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) { - 41        $this->logger = $logger ?? new NullLogger(); + 41        $this->logger = $logger ?? new NullLogger(); 42 - 43        if (strlen($secret) < 64) { + 43        if (strlen($secret) < 64) { 44            throw new Exception("\$secret must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)"); 45        } - 46        $this->secret = $secret; + 46        $this->secret = $secret; 47 - 48        $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + 48        $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; 49 - 50        $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL; - 51        $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL; + 50        $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL; + 51        $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL; 52 - 53        @mkdir($this->path, 0777, true); + 53        @mkdir($this->path, 0777, true); 54 - 55        if ($cleanUpNow) { + 55        if ($cleanUpNow) { 56            $this->deleteExpiredTokens(); 57        } - 58    } + 58    } 59 60    // LoggerAwareInterface method. 61 62    public function setLogger(LoggerInterface $logger) { - 63        $this->logger = $logger; - 64    } + 63        $this->logger = $logger; + 64    } 65 66    // TokenStorageInterface Methods. 67 68    public function createAuthCode(array $data): ?string { - 69        $authCode = generateRandomString(self::TOKEN_LENGTH); - 70        $accessToken = $this->hash($authCode); + 69        $authCode = generateRandomString(self::TOKEN_LENGTH); + 70        $accessToken = $this->hash($authCode); 71 - 72        if (!array_key_exists('valid_until', $data)) { - 73            $data['valid_until'] = time() + $this->authCodeTtl; + 72        if (!array_key_exists('valid_until', $data)) { + 73            $data['valid_until'] = time() + $this->authCodeTtl; 74        } 75         - 76        if (!$this->put($accessToken, $data)) { + 76        if (!$this->put($accessToken, $data)) { 77            return null; 78        } - 79        return $authCode; + 79        return $authCode; 80    } 81 82    public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array { 83        // Hash the auth code to get the theoretical matching access token filename. - 84        $accessToken = $this->hash($code); + 84        $accessToken = $this->hash($code); 85 86        // Prevent the token file from being read, modified or deleted while we’re working with it. 87        // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t  88        // already exist. - 89        return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { + 89        return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) { 90            // Read the file contents. - 91            $fileContents = ''; - 92            while ($d = fread($fp, 1024)) { $fileContents .= $d; } + 91            $fileContents = ''; + 92            while ($d = fread($fp, 1024)) { $fileContents .= $d; } 93 - 94            $data = json_decode($fileContents, true); + 94            $data = json_decode($fileContents, true); 95             - 96            if (!is_array($data)) { return null; } + 96            if (!is_array($data)) { return null; } 97 98            // Make sure the auth code hasn’t already been redeemed. - 99            if ($data['exchanged_at'] ?? false) { return null; } + 99            if ($data['exchanged_at'] ?? false) { return null; } 100 101            // Make sure the auth code isn’t expired. - 102            if (($data['valid_until'] ?? 0) < time()) { return null; } + 102            if (($data['valid_until'] ?? 0) < time()) { return null; } 103 104            // The auth code is valid as far as we know, pass it to the validation callback passed from the 105            // Server. 106            try { - 107                $validateAuthCode($data); - 108            } catch (IndieAuthException $e) { + 107                $validateAuthCode($data); + 108            } catch (IndieAuthException $e) { 109                // If there was an issue with the auth code, delete it before bubbling the exception 110                // up to the Server for handling. We currently have a lock on the file path, so pass 111                // false to $observeLock to prevent a deadlock. - 112                $this->delete($accessToken, false); - 113                throw $e; + 112                $this->delete($accessToken, false); + 113                throw $e; 114            } 115 116            // If the access token is valid, mark it as redeemed and set a new expiry time. - 117            $data['exchanged_at'] = time(); + 117            $data['exchanged_at'] = time(); 118 - 119            if (is_int($data['_access_token_ttl'] ?? null)) { + 119            if (is_int($data['_access_token_ttl'] ?? null)) { 120                // This access token has a custom TTL, use that. 121                $data['valid_until'] = time() + $data['_access_code_ttl']; - 122            } elseif ($this->accessTokenTtl == 0) { + 122            } elseif ($this->accessTokenTtl == 0) { 123                // The token should be valid until explicitly revoked. 124                $data['valid_until'] = null; 125            } else { 126                // Use the default TTL. - 127                $data['valid_until'] = time() + $this->accessTokenTtl; + 127                $data['valid_until'] = time() + $this->accessTokenTtl; 128            } 129 130            // Write the new file contents, truncating afterwards in case the new data is shorter than the old data. - 131            $jsonData = json_encode($data); - 132            if (rewind($fp) === false) { return null; } - 133            if (fwrite($fp, $jsonData) === false) { return null; } - 134            if (ftruncate($fp, strlen($jsonData)) === false) { return null; } + 131            $jsonData = json_encode($data); + 132            if (rewind($fp) === false) { return null; } + 133            if (fwrite($fp, $jsonData) === false) { return null; } + 134            if (ftruncate($fp, strlen($jsonData)) === false) { return null; } 135 136            // Return the OAuth2-compatible access token data to the Server for passing onto 137            // the client app. Passed via array_filter to remove the scope key if scope is null. - 138            return array_filter([ - 139                'access_token' => $accessToken, - 140                'scope' => $data['scope'] ?? null, - 141                'me' => $data['me'], - 142                'profile' => $data['profile'] ?? null + 138            return array_filter([ + 139                'access_token' => $accessToken, + 140                'scope' => $data['scope'] ?? null, + 141                'me' => $data['me'], + 142                'profile' => $data['profile'] ?? null 143            ]); - 144        }); + 144        }); 145    } 146 147    public function getAccessToken(string $token): ?array { @@ -599,22 +599,22 @@ 218 219    public function put(string $key, array $data): bool { 220        // Ensure that the containing folder exists. - 221        @mkdir($this->path, 0777, true); + 221        @mkdir($this->path, 0777, true); 222         - 223        return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { - 224            return fwrite($fp, json_encode($data)) !== false; - 225        }); + 223        return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) { + 224            return fwrite($fp, json_encode($data)) !== false; + 225        }); 226    } 227 228    public function delete(string $key, $observeLock=true): bool { - 229        $path = $this->getPath($key); - 230        if (file_exists($path)) { - 231            if ($observeLock) { + 229        $path = $this->getPath($key); + 230        if (file_exists($path)) { + 231            if ($observeLock) { 232                return $this->withLock($path, 'r', function ($fp) use ($path) { 233                    return unlink($path); 234                }); 235            } else { - 236                return unlink($path); + 236                return unlink($path); 237            } 238        } 239        return false; @@ -622,35 +622,35 @@ 241 242    public function getPath(string $key): string { 243        // TODO: ensure that the calculated path is a child of $this->path. - 244        return $this->path . "$key.json"; + 244        return $this->path . "$key.json"; 245    } 246 247    protected function withLock(string $path, string $mode, callable $callback) { - 248        $fp = @fopen($path, $mode); + 248        $fp = @fopen($path, $mode); 249 - 250        if ($fp === false) { + 250        if ($fp === false) { 251            return null; 252        } 253 254        // Wait for a lock. - 255        if (flock($fp, LOCK_EX)) { - 256            $return = null; + 255        if (flock($fp, LOCK_EX)) { + 256            $return = null; 257            try { 258                // Perform whatever action on the file pointer. - 259                $return = $callback($fp); - 260            } finally { + 259                $return = $callback($fp); + 260            } finally { 261                // Regardless of what happens, release the lock. - 262                flock($fp, LOCK_UN); - 263                fclose($fp); + 262                flock($fp, LOCK_UN); + 263                fclose($fp); 264            } - 265            return $return; + 265            return $return; 266        } 267        // It wasn’t possible to get a lock. 268        return null; 269    } 270 271    protected function hash(string $token): string { - 272        return hash_hmac('sha256', $token, $this->secret); + 272        return hash_hmac('sha256', $token, $this->secret); 273    } 274} @@ -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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Storage/Sqlite3Storage.php.html b/docs/coverage/Storage/Sqlite3Storage.php.html index 2d01a02..a4cca04 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Storage/TokenStorageInterface.php.html b/docs/coverage/Storage/TokenStorageInterface.php.html index c38a5f4..b546673 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/coverage/Storage/dashboard.html b/docs/coverage/Storage/dashboard.html index e4064c9..267f0c0 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 be1da76..6614179 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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

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

diff --git a/docs/coverage/index.html b/docs/coverage/index.html index 5f2224a..7453dc0 100644 --- a/docs/coverage/index.html +++ b/docs/coverage/index.html @@ -44,21 +44,21 @@ Total
-
- 94.62% covered (success) +
+ 95.48% covered (success)
-
94.62%
-
510 / 539
+
95.48%
+
528 / 553
-
- 73.68% covered (warning) +
+ 75.44% covered (warning)
-
73.68%
-
42 / 57
+
75.44%
+
43 / 57
44.44% covered (danger) @@ -184,21 +184,21 @@ Server.php
-
- 94.98% covered (success) +
+ 96.69% covered (success)
-
94.98%
-
246 / 259
+
96.69%
+
263 / 272
-
- 60.00% covered (warning) +
+ 80.00% covered (warning)
-
60.00%
-
3 / 5
+
80.00%
+
4 / 5
0.00% covered (danger) @@ -212,13 +212,13 @@ functions.php
-
- 97.59% covered (success) +
+ 97.62% covered (success)
-
97.59%
-
81 / 83
+
97.62%
+
82 / 84
88.89% covered (warning) @@ -245,7 +245,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 Wed Jun 16 21:41:10 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 Thu Jun 17 22:38:11 UTC 2021.

diff --git a/docs/files/src-functions.html b/docs/files/src-functions.html index 30eecc4..90080e6 100644 --- a/docs/files/src-functions.html +++ b/docs/files/src-functions.html @@ -91,21 +91,21 @@
generateRandomString() -  : mixed +  : string
generateRandomPrintableAsciiString() -  : mixed +  : string
generatePKCECodeChallenge() -  : mixed +  : string
@@ -161,7 +161,7 @@
appendQueryParams() -  : mixed +  : string
Append Query Parameters
@@ -238,14 +238,14 @@ - generateRandomString(mixed $numBytes) : mixed + generateRandomString(int $numBytes) : string
Parameters
$numBytes - : mixed + : int
@@ -270,7 +270,7 @@ - generateRandomPrintableAsciiString(int $length) : mixed + generateRandomPrintableAsciiString(int $length) : string
Parameters
@@ -302,14 +302,14 @@ - generatePKCECodeChallenge(mixed $plaintext) : mixed + generatePKCECodeChallenge(string $plaintext) : string
Parameters
$plaintext - : mixed + : string
@@ -366,7 +366,7 @@ - hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ]) : string|null + hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ][, bool $requirePkce = true ]) : string|null
Parameters
@@ -398,6 +398,13 @@ = null
+
+
+ $requirePkce + : bool + = true
+
+
@@ -413,7 +420,7 @@ @@ -445,7 +452,7 @@ @@ -484,7 +491,7 @@ @@ -516,7 +523,7 @@ @@ -548,7 +555,7 @@ @@ -594,14 +601,14 @@

Append Query Parameters

- appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : mixed + appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : string

Converts $queryParams into a query string, then checks $uri for an existing query string. Then appends the newly generated query string @@ -638,7 +645,7 @@ with either ? or & as appropriate.

Try setLogger

@@ -670,6 +677,30 @@ to $logger. Returns $ +
+ Tags + +
+
+
+ psalm-suppress +
+
+ +

MissingReturnType

+
+ +
+
+ psalm-suppress +
+
+ +

MissingParamType

+
+ +
+
@@ -681,7 +712,7 @@ to $logger. Returns $ @@ -720,7 +751,7 @@ to $logger. Returns $

Check if a provided string matches the IndieAuth criteria for a Client Identifier.

@@ -771,7 +802,7 @@ to $logger. Returns $

Check if a provided string matches the IndieAuth criteria for a User Profile URL.

@@ -822,7 +853,7 @@ to $logger. Returns $

OAuth 2.0 limits what values are valid for state.

@@ -872,7 +903,7 @@ to $logger. Returns $

IndieAuth requires PKCE. This implementation supports only S256 for hashing.

@@ -921,7 +952,7 @@ to $logger. Returns $

OAuth 2.0 limits what values are valid for scope.

diff --git a/docs/js/searchIndex.js b/docs/js/searchIndex.js index 6f447d6..89883f7 100644 --- a/docs/js/searchIndex.js +++ b/docs/js/searchIndex.js @@ -290,6 +290,11 @@ Search.appendIndex( "name": "INVALID_REQUEST", "summary": "", "url": "classes/Taproot-IndieAuth-IndieAuthException.html#constant_INVALID_REQUEST" + }, { + "fqsen": "\\Taproot\\IndieAuth\\IndieAuthException\u003A\u003AINVALID_REQUEST_REDIRECT", + "name": "INVALID_REQUEST_REDIRECT", + "summary": "", + "url": "classes/Taproot-IndieAuth-IndieAuthException.html#constant_INVALID_REQUEST_REDIRECT" }, { "fqsen": "\\Taproot\\IndieAuth\\IndieAuthException\u003A\u003AEXC_INFO", "name": "EXC_INFO", @@ -535,6 +540,11 @@ Search.appendIndex( "name": "secret", "summary": "", "url": "classes/Taproot-IndieAuth-Server.html#property_secret" + }, { + "fqsen": "\\Taproot\\IndieAuth\\Server\u003A\u003A\u0024requirePkce", + "name": "requirePkce", + "summary": "", + "url": "classes/Taproot-IndieAuth-Server.html#property_requirePkce" }, { "fqsen": "\\Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage", "name": "FilesystemJsonStorage", diff --git a/docs/namespaces/taproot-indieauth.html b/docs/namespaces/taproot-indieauth.html index b95d5be..145f8f9 100644 --- a/docs/namespaces/taproot-indieauth.html +++ b/docs/namespaces/taproot-indieauth.html @@ -115,21 +115,21 @@
generateRandomString() -  : mixed +  : string
generateRandomPrintableAsciiString() -  : mixed +  : string
generatePKCECodeChallenge() -  : mixed +  : string
@@ -185,7 +185,7 @@
appendQueryParams() -  : mixed +  : string
Append Query Parameters
@@ -261,14 +261,14 @@ - generateRandomString(mixed $numBytes) : mixed + generateRandomString(int $numBytes) : string
Parameters
$numBytes - : mixed + : int
@@ -293,7 +293,7 @@ - generateRandomPrintableAsciiString(int $length) : mixed + generateRandomPrintableAsciiString(int $length) : string
Parameters
@@ -325,14 +325,14 @@ - generatePKCECodeChallenge(mixed $plaintext) : mixed + generatePKCECodeChallenge(string $plaintext) : string
Parameters
$plaintext - : mixed + : string
@@ -389,7 +389,7 @@ - hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ]) : string|null + hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ][, bool $requirePkce = true ]) : string|null
Parameters
@@ -421,6 +421,13 @@ = null
+
+
+ $requirePkce + : bool + = true
+
+
@@ -436,7 +443,7 @@ @@ -468,7 +475,7 @@ @@ -507,7 +514,7 @@ @@ -539,7 +546,7 @@ @@ -571,7 +578,7 @@ @@ -617,14 +624,14 @@

Append Query Parameters

- appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : mixed + appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : string

Converts $queryParams into a query string, then checks $uri for an existing query string. Then appends the newly generated query string @@ -661,7 +668,7 @@ with either ? or & as appropriate.

Try setLogger

@@ -693,6 +700,30 @@ to $logger. Returns $
+
+ Tags + +
+
+
+ psalm-suppress +
+
+ +

MissingReturnType

+
+ +
+
+ psalm-suppress +
+
+ +

MissingParamType

+
+ +
+
@@ -704,7 +735,7 @@ to $logger. Returns $ @@ -743,7 +774,7 @@ to $logger. Returns $

Check if a provided string matches the IndieAuth criteria for a Client Identifier.

@@ -794,7 +825,7 @@ to $logger. Returns $

Check if a provided string matches the IndieAuth criteria for a User Profile URL.

@@ -845,7 +876,7 @@ to $logger. Returns $

OAuth 2.0 limits what values are valid for state.

@@ -895,7 +926,7 @@ to $logger. Returns $

IndieAuth requires PKCE. This implementation supports only S256 for hashing.

@@ -944,7 +975,7 @@ to $logger. Returns $

OAuth 2.0 limits what values are valid for scope.

diff --git a/docs/packages/default.html b/docs/packages/default.html index 5c2e1ad..c84f384 100644 --- a/docs/packages/default.html +++ b/docs/packages/default.html @@ -122,21 +122,21 @@
generateRandomString() -  : mixed +  : string
generateRandomPrintableAsciiString() -  : mixed +  : string
generatePKCECodeChallenge() -  : mixed +  : string
@@ -192,7 +192,7 @@
appendQueryParams() -  : mixed +  : string
Append Query Parameters
@@ -268,14 +268,14 @@ - generateRandomString(mixed $numBytes) : mixed + generateRandomString(int $numBytes) : string
Parameters
$numBytes - : mixed + : int
@@ -300,7 +300,7 @@ - generateRandomPrintableAsciiString(int $length) : mixed + generateRandomPrintableAsciiString(int $length) : string
Parameters
@@ -332,14 +332,14 @@ - generatePKCECodeChallenge(mixed $plaintext) : mixed + generatePKCECodeChallenge(string $plaintext) : string
Parameters
$plaintext - : mixed + : string
@@ -396,7 +396,7 @@ - hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ]) : string|null + hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ][, bool $requirePkce = true ]) : string|null
Parameters
@@ -428,6 +428,13 @@ = null
+
+
+ $requirePkce + : bool + = true
+
+
@@ -443,7 +450,7 @@ @@ -475,7 +482,7 @@ @@ -514,7 +521,7 @@ @@ -546,7 +553,7 @@ @@ -578,7 +585,7 @@ @@ -624,14 +631,14 @@

Append Query Parameters

- appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : mixed + appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : string

Converts $queryParams into a query string, then checks $uri for an existing query string. Then appends the newly generated query string @@ -668,7 +675,7 @@ with either ? or & as appropriate.

Try setLogger

@@ -700,6 +707,30 @@ to $logger. Returns $
+
+ Tags + +
+
+
+ psalm-suppress +
+
+ +

MissingReturnType

+
+ +
+
+ psalm-suppress +
+
+ +

MissingParamType

+
+ +
+
@@ -711,7 +742,7 @@ to $logger. Returns $ @@ -750,7 +781,7 @@ to $logger. Returns $

Check if a provided string matches the IndieAuth criteria for a Client Identifier.

@@ -801,7 +832,7 @@ to $logger. Returns $

Check if a provided string matches the IndieAuth criteria for a User Profile URL.

@@ -852,7 +883,7 @@ to $logger. Returns $

OAuth 2.0 limits what values are valid for state.

@@ -902,7 +933,7 @@ to $logger. Returns $

IndieAuth requires PKCE. This implementation supports only S256 for hashing.

@@ -951,7 +982,7 @@ to $logger. Returns $

OAuth 2.0 limits what values are valid for scope.

diff --git a/src/IndieAuthException.php b/src/IndieAuthException.php index 7c4041c..e09d8a4 100644 --- a/src/IndieAuthException.php +++ b/src/IndieAuthException.php @@ -21,6 +21,7 @@ class IndieAuthException extends Exception { const INVALID_SCOPE = 11; const INVALID_GRANT = 12; const INVALID_REQUEST = 13; + const INVALID_REQUEST_REDIRECT = 14; const EXC_INFO = [ self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'], @@ -38,6 +39,7 @@ class IndieAuthException extends Exception { self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'], self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'], self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'], + self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'], ]; protected ServerRequestInterface $request; diff --git a/src/Server.php b/src/Server.php index 3efa42f..f2ec744 100644 --- a/src/Server.php +++ b/src/Server.php @@ -121,6 +121,8 @@ class Server { protected string $secret; + protected bool $requirePkce; + /** * Constructor * @@ -197,8 +199,11 @@ class Server { 'httpGetWithEffectiveUrl' => null, 'authorizationForm' => new DefaultAuthorizationForm(), 'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php', + 'requirePKCE' => true, ], $config); + $this->requirePkce = $config['requirePKCE']; + if (!is_string($config['exceptionTemplatePath'])) { throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path)."); } @@ -348,7 +353,7 @@ class Server { // on the auth code before it gets exchanged. $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { // Verify that all required parameters are included. - $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier']; + $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri']; $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); }); @@ -364,13 +369,22 @@ class Server { throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); } - // Check that the supplied code_verifier hashes to the stored code_challenge - // TODO: support method = plain as well as S256. - if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { - $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); + // If the auth code was requested with no code_challenge, but the exchange request provides a + // code_verifier, return an error. + if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) { + $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge."); throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); } + if ($this->requirePkce or !empty($authCode['code_challenge'])) { + // Check that the supplied code_verifier hashes to the stored code_challenge + // TODO: support method = plain as well as S256. + if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { + $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); + throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + } + } + // Check that this token either grants at most the profile scope. $requestedScopes = array_filter(explode(' ', $authCode['scope'] ?? '')); if (!empty($requestedScopes) && $requestedScopes != ['profile']) { @@ -441,7 +455,7 @@ class Server { // How most errors are handled depends on whether or not the request has a valid redirect_uri. In // order to know that, we need to also validate, fetch and parse the client_id. // If the request lacks a hash, or if the provided hash was invalid, perform the validation. - $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret); + $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($currentRequestHash) or !hash_equals($currentRequestHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { // All we need to know at this stage is whether the redirect_uri is valid. If it @@ -502,22 +516,29 @@ class Server { $this->logger->warning("The state provided in an authorization request was not valid.", $queryParams); throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request); } - - // Validate code_challenge parameter. - if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) { - $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams); - throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); - } - - if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) { - $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams); - throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); - } - // From now on, any redirect error responses should include the state parameter. // This is handled automatically in `handleException()` and is only noted here // for reference. + // If either PKCE parameter is present, validate both. + if (isset($queryParams['code_challenge']) or isset($queryParams['code_challenge_method'])) { + if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) { + $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams); + throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); + } + + if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) { + $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams); + throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request); + } + } else { + // If neither PKCE parameter is defined, and PKCE is required, throw an error. Otherwise, proceed. + if ($this->requirePkce) { + $this->logger->warning("PKCE is required, and both code_challenge and code_challenge_method were missing."); + throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST_REDIRECT, $request); + } + } + // Validate the scope parameter, if provided. if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) { $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams); @@ -535,8 +556,9 @@ class Server { // Build a URL containing the indieauth authorization request parameters, hashing them // to protect them from being changed. - // Make a hash of the protected indieauth-specific parameters. - $hash = hashAuthorizationRequestParameters($request, $this->secret); + // Make a hash of the protected indieauth-specific parameters. If PKCE is in use, include + // the PKCE parameters in the hash. Otherwise, leave them out. + $hash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); // Operate on a copy of $queryParams, otherwise requests will always have a valid hash! $redirectQueryParams = $queryParams; $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash; @@ -566,7 +588,7 @@ class Server { throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request); } - $expectedHash = hashAuthorizationRequestParameters($request, $this->secret); + $expectedHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce); if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { $this->logger->warning("The hash provided in the URL was invalid!", [ 'expected' => $expectedHash, @@ -580,8 +602,8 @@ class Server { 'client_id' => $queryParams['client_id'], 'redirect_uri' => $queryParams['redirect_uri'], 'state' => $queryParams['state'], - 'code_challenge' => $queryParams['code_challenge'], - 'code_challenge_method' => $queryParams['code_challenge_method'], + 'code_challenge' => $queryParams['code_challenge'] ?? null, + 'code_challenge_method' => $queryParams['code_challenge_method'] ?? null, 'requested_scope' => $queryParams['scope'] ?? '', ]); @@ -710,7 +732,7 @@ class Server { // on the auth code before it gets exchanged. $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { // Verify that all required parameters are included. - $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier']; + $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri']; $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); }); @@ -726,13 +748,22 @@ class Server { throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); } - // Check that the supplied code_verifier hashes to the stored code_challenge - // TODO: support method = plain as well as S256. - if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { - $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); + // If the auth code was requested with no code_challenge, but the exchange request provides a + // code_verifier, return an error. + if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) { + $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge."); throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); } + if ($this->requirePkce or !empty($authCode['code_challenge'])) { + // Check that the supplied code_verifier hashes to the stored code_challenge + // TODO: support method = plain as well as S256. + if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { + $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); + throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); + } + } + // Check that scope is not empty. if (empty($authCode['scope'])) { $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint."); diff --git a/src/functions.php b/src/functions.php index b0d4e0b..99ae0dc 100644 --- a/src/functions.php +++ b/src/functions.php @@ -42,11 +42,15 @@ function base64_urlencode(string $string): string { return rtrim(strtr(base64_encode($string), '+/', '-_'), '='); } -function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null): ?string { - $hashedParameters = $hashedParameters ?? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method']; +function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null, bool $requirePkce=true): ?string { + $queryParams = $request->getQueryParams(); + + if (is_null($hashedParameters)) { + $hashedParameters = ($requirePkce or isset($queryParams['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'] : ['client_id', 'redirect_uri']; + } + $algo = $algo ?? 'sha256'; - $queryParams = $request->getQueryParams(); $data = ''; foreach ($hashedParameters as $key) { if (!isset($queryParams[$key])) { diff --git a/tests/ServerTest.php b/tests/ServerTest.php index bbbf0e6..ccd0633 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -168,6 +168,18 @@ class ServerTest extends TestCase { } } } + + public function testRequestsMissingBothPkceParametersReturnsError() { + $s = $this->getDefaultServer(); + $req = $this->getIARequest(); + $qp = $req->getQueryParams(); + unset($qp['code_challenge']); + unset($qp['code_challenge_method']); + $req = $req->withQueryParams($qp); + + $res = $s->handleAuthorizationEndpointRequest($req); + $this->assertEquals(302, $res->getStatusCode()); + } public function testAuthorizationRequestWithInvalidClientIdOrRedirectUriShowsErrorToUser() { $testCases = [ @@ -305,7 +317,7 @@ class ServerTest extends TestCase { Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array { return ['me' => 'https://me.example.com']; }, - 'httpGetWithEffectiveUrl' => function ($url): array { + 'httpGetWithEffectiveUrl' => function (string $url): array { // An empty response suffices for this test. return [ new Response(200, ['content-type' => 'text/html'], '' ), @@ -388,7 +400,7 @@ EOT Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array { return ['me' => 'https://me.example.com']; }, - 'httpGetWithEffectiveUrl' => function ($url): array { + 'httpGetWithEffectiveUrl' => function (string $url): array { return [ new Response(200, [ 'content-type' => 'text/html', @@ -417,7 +429,7 @@ EOT Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array { return ['me' => 'https://me.example.com']; }, - 'httpGetWithEffectiveUrl' => function ($url): array { + 'httpGetWithEffectiveUrl' => function (string $url): array { return [ new Response(200, ['content-type' => 'text/html'], '' @@ -447,7 +459,7 @@ EOT Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) { return ['me' => 'https://me.example.com']; }, - 'httpGetWithEffectiveUrl' => function ($url) use ($correctHAppPhoto, $correctHAppName, $correctHAppUrl) { + 'httpGetWithEffectiveUrl' => function (string $url) use ($correctHAppPhoto, $correctHAppName, $correctHAppUrl): array { return [ new Response(200, ['content-type' => 'text/html'], <<getDefaultServer(); $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([ @@ -620,6 +632,34 @@ EOT $this->assertEquals('invalid_request', $tokenEndpointJson['error']); } + public function testExchangeFlowsReturnErrorsIfParametersAreMissing() { + $s = $this->getDefaultServer(); + + foreach ([ + [$s, 'handleAuthorizationEndpointRequest'], + [$s, 'handleTokenEndpointRequest'] + ] as $exchangeEndpoint) { + $authCode = $s->getTokenStorage()->createAuthCode([ + 'client_id' => 'https://client.example.com/', + 'redirect_uri' => 'https://client.example.com/auth', + 'code_challenge' => generatePKCECodeChallenge(generateRandomString(32)), + 'state' => '12345', + 'code_challenge_method' => 'S256', + 'scope' => 'create update' + ]); + + $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([ + 'grant_type' => 'authorization_code', + 'code' => $authCode + ]); + + $res = $exchangeEndpoint($req); + $this->assertEquals(400, $res->getStatusCode()); + $resJson = json_decode((string) $res->getBody(), true); + $this->assertEquals('invalid_request', $resJson['error']); + } + } + public function testExchangeFlowsReturnErrorOnInvalidParameters() { $s = $this->getDefaultServer(); $storage = new FilesystemJsonStorage(TOKEN_STORAGE_PATH, SERVER_SECRET); @@ -859,6 +899,146 @@ EOT $this->assertEquals($responseBody, (string) $res->getBody()); } + + /** + * Test Backwards-compatibility + */ + + public function testBackCompatRequestsWithoutPkceWorkCorrectlyWithBothExchangeFlows() { + $s = $this->getDefaultServer([ + Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request) { + return ['me' => 'https://me.example.com/']; + }, + 'httpGetWithEffectiveUrl' => function ($url): array { + return [new Response(200, ['content-type' => 'text/html'], ''), $url ]; + }, + 'requirePKCE' => false + ]); + + foreach ([ + 'Auth Endpoint Exchange' => [$s, 'handleAuthorizationEndpointRequest'], + 'Token Endpoint Exchange' => [$s, 'handleTokenEndpointRequest'] + ] as $testCase => $handleExchangeRequestEndpoint) { + // Clean up the test environment. + $this->setUp(); + // Build a valid, hashed request without either PKCE parameter. This takes some faffing around + // due to the supposedly elegant immutable Request objects. + $req = $this->getApprovalRequest(true, true, null, [ + 'taproot_indieauth_server_scope[]' => ['profile'] + ]); + $params = $req->getQueryParams(); + unset($params['code_challenge']); + unset($params['code_challenge_method']); + $req = $req->withQueryParams($params); + $hash = hashAuthorizationRequestParameters($req, SERVER_SECRET, null, null, false); + $params[Server::HASH_QUERY_STRING_KEY] = $hash; + $req = $req->withQueryParams($params); + + $res = $s->handleAuthorizationEndpointRequest($req); + + $this->assertEquals(302, $res->getStatusCode(), $testCase); + parse_str(parse_url($res->getHeaderLine('location'), PHP_URL_QUERY), $redirectQueryParams); + $this->assertArrayNotHasKey('error', $redirectQueryParams, "$testCase"); + $this->assertNotNull($redirectQueryParams['code'], $testCase); + + // Now check that the token exchange works. + // Build + $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([ + 'grant_type' => 'authorization_code', + 'code' => $redirectQueryParams['code'], + 'client_id' => $params['client_id'], + 'redirect_uri' => $params['redirect_uri'] + ]); + + $res = $handleExchangeRequestEndpoint($req); + $resJson = json_decode((string) $res->getBody(), true); + $this->assertEquals(200, $res->getStatusCode(), "$testCase"); + $this->assertIsArray($resJson, $testCase); + $this->assertArrayNotHasKey('error', $resJson, $testCase); + $this->assertEquals('https://me.example.com/', $resJson['me'], $testCase); + if ($testCase == 'Token Endpoint Exchange') { + $this->assertArrayHasKey('access_token', $resJson, $testCase); + } + } + } + + public function testBackCompatNonPkceRequestMustLackBothPkceParameters() { + $s = $this->getDefaultServer([ + Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request) { + return ['me' => 'https://me.example.com/']; + }, + 'httpGetWithEffectiveUrl' => function ($url): array { + return [new Response(200, ['content-type' => 'text/html'], ''), $url ]; + }, + 'requirePKCE' => false + ]); + + // Build a valid, hashed request without either PKCE parameter. This takes some faffing around + // due to the supposedly elegant immutable Request objects. + $req = $this->getIARequest(); + $params = $req->getQueryParams(); + unset($params['code_challenge']); + $req = $req->withQueryParams($params); + + $res = $s->handleAuthorizationEndpointRequest($req); + + $this->assertEquals(302, $res->getStatusCode()); + parse_str(parse_url($res->getHeaderLine('location'), PHP_URL_QUERY), $redirectQueryParams); + $this->assertEquals('invalid_request', $redirectQueryParams['error']); + } + + public function testBackCompatAuthCodeWithoutPkceCannotBeExchangedWithCodeVerifierBothExchangeEndpoints() { + $s = $this->getDefaultServer([ + Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request) { + return ['me' => 'https://me.example.com/']; + }, + 'httpGetWithEffectiveUrl' => function ($url): array { + return [new Response(200, ['content-type' => 'text/html'], ''), $url ]; + }, + 'requirePKCE' => false + ]); + + foreach ([ + 'Auth Endpoint Exchange' => [$s, 'handleAuthorizationEndpointRequest'], + 'Token Endpoint Exchange' => [$s, 'handleTokenEndpointRequest'] + ] as $testCase => $handleExchangeRequestEndpoint) { + // Clean up the test environment. + $this->setUp(); + $storage = new FilesystemJsonStorage(TOKEN_STORAGE_PATH, SERVER_SECRET); + + // Create an auth code without PKCE + $authCodeData = [ + 'client_id' => 'https://client.example.com/', + 'redirect_uri' => 'https://client.example.com/auth', + 'code_challenge' => null, + 'state' => '12345', + 'code_challenge_method' => null, + 'scope' => 'create update profile', + 'me' => 'https://me.example.com/', + 'profile' => [ + 'name' => 'Me' + ] + ]; + $authCode = $storage->createAuthCode($authCodeData); + + // Now check that the token exchange works. + // Build + $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'client_id' => $authCodeData['client_id'], + 'redirect_uri' => $authCodeData['redirect_uri'], + 'code_verifier' => 'There is no valid value for this parameter, its mere presence should trigger an error.' + ]); + + $res = $handleExchangeRequestEndpoint($req); + $resJson = json_decode((string) $res->getBody(), true); + $this->assertEquals(400, $res->getStatusCode(), "$testCase"); + $this->assertIsArray($resJson, $testCase); + $this->assertArrayHasKey('error', $resJson, $testCase); + $this->assertEquals('invalid_grant', $resJson['error'], $testCase); + } + } } /**