From ce541c360792bb5df2fb8c3f52a768227a2e742b Mon Sep 17 00:00:00 2001 From: Barnaby Walters Date: Wed, 16 Jun 2021 23:41:51 +0200 Subject: [PATCH] Added test for missing parameters, now also validating code_challenge_method --- .../AuthorizationFormInterface.php.html | 2 +- .../DefaultAuthorizationForm.php.html | 14 +- ...serPasswordAuthenticationCallback.php.html | 16 +- docs/coverage/Callback/dashboard.html | 2 +- docs/coverage/Callback/index.html | 2 +- docs/coverage/IndieAuthException.php.html | 18 +- .../Middleware/ClosureRequestHandler.php.html | 10 +- .../DoubleSubmitCookieCsrfMiddleware.php.html | 54 +- .../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 | 741 +++++++++--------- .../Storage/FilesystemJsonStorage.php.html | 24 +- docs/coverage/Storage/Sqlite3Storage.php.html | 2 +- .../Storage/TokenStorageInterface.php.html | 2 +- docs/coverage/Storage/dashboard.html | 2 +- docs/coverage/Storage/index.html | 2 +- docs/coverage/dashboard.html | 6 +- docs/coverage/functions.php.html | 295 +++---- docs/coverage/index.html | 18 +- src/Server.php | 9 +- tests/ServerTest.php | 27 +- 23 files changed, 646 insertions(+), 608 deletions(-) diff --git a/docs/coverage/Callback/AuthorizationFormInterface.php.html b/docs/coverage/Callback/AuthorizationFormInterface.php.html index b037bf3..2e105aa 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Callback/DefaultAuthorizationForm.php.html b/docs/coverage/Callback/DefaultAuthorizationForm.php.html index bbce7af..d6d9e6c 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 @@ -292,8 +292,8 @@ 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html index 79a8387..f528270 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Callback/dashboard.html b/docs/coverage/Callback/dashboard.html index 02d4b09..e91206e 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 3914250..520978f 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/IndieAuthException.php.html b/docs/coverage/IndieAuthException.php.html index 4e8341f..80597d9 100644 --- a/docs/coverage/IndieAuthException.php.html +++ b/docs/coverage/IndieAuthException.php.html @@ -277,17 +277,17 @@ 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))) { + 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; + 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; + 57        return $this->getInfo()['statusCode'] ?? 500; 58    } 59 60    public function getExplanation() { @@ -295,7 +295,7 @@ 62    } 63 64    public function getInfo() { - 65        return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; + 65        return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; 66    } 67 68    /** @@ -311,7 +311,7 @@ 78    } 79 80    public function getRequest() { - 81        return $this->request; + 81        return $this->request; 82    } 83} @@ -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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Middleware/ClosureRequestHandler.php.html b/docs/coverage/Middleware/ClosureRequestHandler.php.html index 4b0f4e2..3b9ebf5 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html index 52aba37..b449dba 100644 --- a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html +++ b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html @@ -255,52 +255,52 @@ 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) { @@ -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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Middleware/NoOpMiddleware.php.html b/docs/coverage/Middleware/NoOpMiddleware.php.html index 8db9a64..9c5a72a 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Middleware/ResponseRequestHandler.php.html b/docs/coverage/Middleware/ResponseRequestHandler.php.html index 07e7b50..1958da1 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Middleware/dashboard.html b/docs/coverage/Middleware/dashboard.html index 0492922..a5e9a1c 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 9898ff8..32c5786 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Server.php.html b/docs/coverage/Server.php.html index 91ba945..b86bab1 100644 --- a/docs/coverage/Server.php.html +++ b/docs/coverage/Server.php.html @@ -61,13 +61,13 @@
3 / 5
CRAP
-
- 94.92% covered (success) +
+ 94.98% covered (success)
-
94.92%
-
243 / 256
+
94.98%
+
246 / 259
@@ -88,15 +88,15 @@
60.00%
3 / 5
- 86.97 + 91.02
-
- 94.92% covered (success) +
+ 94.98% covered (success)
-
94.92%
-
243 / 256
+
94.98%
+
246 / 259
@@ -151,19 +151,19 @@
0.00%
0 / 1
- 55.02 + 59.11
-
- 92.95% covered (success) +
+ 93.08% covered (success)
-
92.95%
-
145 / 156
+
93.08%
+
148 / 159
-  handleTokenEndpointRequest +  handleTokenEndpointRequest
0.00% covered (danger) @@ -184,7 +184,7 @@ -  handleException +  handleException
100.00% covered (success) @@ -401,65 +401,65 @@ 189     * @return self 190     */ 191    public function __construct(array $config) { - 192        $config = array_merge([ - 193            'csrfMiddleware' => new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY), + 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. + 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(), + 198            'authorizationForm' => new DefaultAuthorizationForm(), 199            'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php', 200        ], $config); 201 - 202        if (!is_string($config['exceptionTemplatePath'])) { + 202        if (!is_string($config['exceptionTemplatePath'])) { 203            throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path)."); 204        } - 205        $this->exceptionTemplatePath = $config['exceptionTemplatePath']; + 205        $this->exceptionTemplatePath = $config['exceptionTemplatePath']; 206 - 207        $secret = $config['secret'] ?? ''; - 208        if (!is_string($secret) || strlen($secret) < 64) { + 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; + 211        $this->secret = $secret; 212 - 213        if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) { + 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."); 215        } - 216        $this->logger = $config['logger'] ?? new NullLogger(); + 216        $this->logger = $config['logger'] ?? new NullLogger(); 217 - 218        if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) { + 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.'); 220        } - 221        $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST]; + 221        $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST]; 222         - 223        if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) { + 223        if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) { 224            throw new BadMethodCallException("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable"); 225        } - 226        $this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST]; + 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)) { + 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); + 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; + 237        trySetLogger($tokenStorage, $this->logger); + 238        $this->tokenStorage = $tokenStorage; 239 - 240        $csrfMiddleware = $config['csrfMiddleware']; - 241        if (!$csrfMiddleware instanceof MiddlewareInterface) { + 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; + 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) { + 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([ @@ -485,15 +485,15 @@ 273                throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] must be callable."); 274            } 275        } - 276        trySetLogger($httpGetWithEffectiveUrl, $this->logger); - 277        $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl; + 276        trySetLogger($httpGetWithEffectiveUrl, $this->logger); + 277        $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl; 278 - 279        if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) { + 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    } + 282        $this->authorizationForm = $config['authorizationForm']; + 283        trySetLogger($this->authorizationForm, $this->logger); + 284    } 285 286    public function getTokenStorage(): TokenStorageInterface { 287        return $this->tokenStorage; @@ -535,10 +535,10 @@ 323     * @return ResponseInterface 324     */ 325    public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface { - 326        $this->logger->info('Handling an IndieAuth Authorization Endpoint request.'); + 326        $this->logger->info('Handling an IndieAuth Authorization Endpoint request.'); 327         328        // If it’s a profile information request: - 329        if (isIndieAuthAuthorizationCodeRedeemingRequest($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(); @@ -621,44 +621,44 @@ 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) { + 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(); + 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]; + 422                list($clientIdResponse, $clientIdEffectiveUrl, $clientIdMf2) = [null, null, null]; 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()]); + 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); + 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); + 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])) { + 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])) { + 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. @@ -710,312 +710,317 @@ 498                    // reported by redirecting to redirect_uri with error parameters. 499 500                    // Validate the state parameter. - 501                    if (!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); + 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                    } 505 506                    // Validate code_challenge parameter. - 507                    if (!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); + 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                    // From now on, any redirect error responses should include the state parameter. - 513                    // This is handled automatically in `handleException()` and is only noted here - 514                    // for reference. - 515 - 516                    // Validate the scope parameter, if provided. - 517                    if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) { - 518                        $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams); - 519                        throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request); - 520                    } - 521 - 522                    // Normalise the me parameter, if it exists. - 523                    if (array_key_exists('me', $queryParams)) { - 524                        $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']); - 525                        // If the me parameter is not a valid profile URL, ignore it. - 526                        if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) { - 527                            $queryParams['me'] = null; - 528                        } - 529                    } - 530 - 531                    // Build a URL containing the indieauth authorization request parameters, hashing them - 532                    // to protect them from being changed. - 533                    // Make a hash of the protected indieauth-specific parameters. - 534                    $hash = hashAuthorizationRequestParameters($request, $this->secret); - 535                    // Operate on a copy of $queryParams, otherwise requests will always have a valid hash! - 536                    $redirectQueryParams = $queryParams; - 537                    $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash; - 538                    $authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString(); - 539                     - 540                    // User-facing requests always start by calling the authentication request callback. - 541                    $this->logger->info('Calling handle_authentication_request callback'); - 542                    $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null); - 543                     - 544                    // If the authentication handler returned a Response, return that as-is. - 545                    if ($authenticationResult instanceof ResponseInterface) { - 546                        return $authenticationResult; - 547                    } elseif (is_array($authenticationResult)) { - 548                        // Check the resulting array for errors. - 549                        if (!array_key_exists('me', $authenticationResult)) { - 550                            $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]); - 551                            throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request); - 552                        } - 553 - 554                        // If this is a POST request sent from the authorization (i.e. scope-choosing) form: - 555                        if (isAuthorizationApprovalRequest($request)) { - 556                            // Authorization approval requests MUST include a hash protecting the sensitive IndieAuth - 557                            // authorization request parameters from being changed, e.g. by a malicious script which - 558                            // found its way onto the authorization form. - 559                            if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) { - 560                                $this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter."); - 561                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request); - 562                            } - 563 - 564                            $expectedHash = hashAuthorizationRequestParameters($request, $this->secret); - 565                            if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) { - 566                                $this->logger->warning("The hash provided in the URL was invalid!", [ - 567                                    'expected' => $expectedHash, - 568                                    'actual' => $queryParams[self::HASH_QUERY_STRING_KEY] - 569                                ]); - 570                                throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request); - 571                            } - 572                             - 573                            // Assemble the data for the authorization code, store it somewhere persistent. - 574                            $code = array_merge($authenticationResult, [ - 575                                'client_id' => $queryParams['client_id'], - 576                                'redirect_uri' => $queryParams['redirect_uri'], - 577                                'state' => $queryParams['state'], - 578                                'code_challenge' => $queryParams['code_challenge'], - 579                                'code_challenge_method' => $queryParams['code_challenge_method'], - 580                                'requested_scope' => $queryParams['scope'] ?? '', - 581                            ]); - 582 - 583                            // Pass it to the auth code customisation callback. - 584                            $code = $this->authorizationForm->transformAuthorizationCode($request, $code); - 585 - 586                            // Store the authorization code. - 587                            $authCode = $this->tokenStorage->createAuthCode($code); - 588                            if (is_null($authCode)) { - 589                                // If saving the authorization code failed silently, there isn’t much we can do about it, - 590                                // but should at least log and return an error. - 591                                $this->logger->error("Saving the authorization code failed and returned false without raising an exception."); - 592                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request); - 593                            } - 594                             - 595                            // Return a redirect to the client app. - 596                            return new Response(302, [ - 597                                'Location' => appendQueryParams($queryParams['redirect_uri'], [ - 598                                    'code' => $authCode, - 599                                    'state' => $code['state'] - 600                                ]), - 601                                'Cache-control' => 'no-cache' - 602                            ]); - 603                        } - 604 - 605                        // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes. - 606 - 607                        // Fetch the client_id URL to find information about the client to present to the user. - 608                        // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, - 609                        // it may be necessary to do this before returning any other kind of error response, as, per - 610                        // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters - 611                        // are missing or invalid. Otherwise, they should be sent back to the client with an error - 612                        // redirect response. - 613                        if (is_null($clientIdResponse) || is_null($clientIdEffectiveUrl) || is_null($clientIdMf2)) { - 614                            try { - 615                                /** @var ResponseInterface $clientIdResponse */ - 616                                list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']); - 617                                $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl); - 618                            } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) { - 619                                $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [ - 620                                    'client_id' => $queryParams['client_id'], - 621                                    'exception' => $e->__toString() - 622                                ]); - 623                                 - 624                                // At this point in the flow, we’ve already guaranteed that the redirect_uri is valid, - 625                                // so in theory we should report these errors by redirecting there. - 626                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e); - 627                            } catch (Exception $e) { - 628                                $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [ - 629                                    'exception' => $e->__toString() - 630                                ]); - 631     - 632                                throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e); - 633                            } - 634                        } - 635                         - 636                        // Search for an h-app with u-url matching the client_id. - 637                        $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']); - 638                        $clientHApp = empty($clientHApps) ? null : $clientHApps[0]; - 639 - 640                        // Present the authorization UI. - 641                        return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp) - 642                                ->withAddedHeader('Cache-control', 'no-cache'); - 643                    } - 644                } - 645 - 646                // If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid - 647                // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.) - 648                $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request); - 649                if ($nonIndieAuthRequestResult instanceof ResponseInterface) { - 650                    return $nonIndieAuthRequestResult; - 651                } else { - 652                    // In this code path we have not validated the redirect_uri, so show a regular error page - 653                    // rather than returning a redirect error. - 654                    throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request); - 655                } - 656            } catch (IndieAuthException $e) { - 657                // All IndieAuthExceptions will already have been logged. - 658                return $this->handleException($e); - 659            } catch (Exception $e) { - 660                // Unknown exceptions will not have been logged; do so now. - 661                $this->logger->error("Caught unknown exception: {$e}"); - 662                return $this->handleException(IndieAuthException::create(0, $request, $e)); - 663            } - 664        }));     - 665    } - 666 - 667    /** - 668     * Handle Token Endpoint Request - 669     *  - 670     * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows: - 671     *  - 672     * * Check that the request is a code redeeming request. Return an error if not. - 673     * * Ensure that all required parameters are present. Return an error if not. - 674     * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails. - 675     * * 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. - 676     * * 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. - 677     * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error. - 678     * * Otherwise, return a success response containing information about the issued access token. - 679     *  - 680     * This method must NOT be CSRF-protected as it accepts external requests from client apps. - 681     *  - 682     * @param ServerRequestInterface $request - 683     * @return ResponseInterface - 684     */ - 685    public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface { - 686        if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { - 687            $this->logger->info('Handling a request to redeem an authorization code for profile information.'); - 688             - 689            $bodyParams = $request->getParsedBody(); - 690 - 691            if (!isset($bodyParams['code'])) { - 692                $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.'); - 693                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 694                    'error' => 'invalid_request', - 695                    'error_description' => 'The code parameter was missing.' - 696                ])); - 697            } - 698 - 699            // Attempt to internally exchange the provided auth code for an access token. - 700            // We do this before anything else so that the auth code is invalidated as soon as the request starts, - 701            // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler - 702            // and more flexible interface for TokenStorage implementors. - 703            try { - 704                // Call the token exchange method, passing in a callback which performs additional validation - 705                // on the auth code before it gets exchanged. - 706                $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) { - 707                    // Verify that all required parameters are included. - 708                    $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier']; - 709                    $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) { - 710                        return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]); - 711                    }); - 712                    if (!empty($missingRequiredParameters)) { - 713                        $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); - 714                        throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request); - 715                    } - 716 - 717                    // Verify that it was issued for the same client_id and redirect_uri - 718                    if ($authCode['client_id'] !== $bodyParams['client_id'] - 719                        || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) { - 720                        $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token."); - 721                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); - 722                    } - 723 - 724                    // Check that the supplied code_verifier hashes to the stored code_challenge - 725                    // TODO: support method = plain as well as S256. - 726                    if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) { - 727                        $this->logger->error("The provided code_verifier did not hash to the stored code_challenge"); - 728                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); - 729                    } - 730 - 731                    // Check that scope is not empty. - 732                    if (empty($authCode['scope'])) { - 733                        $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint."); - 734                        throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request); - 735                    } - 736                }); - 737            } catch (IndieAuthException $e) { - 738                // If an exception was thrown, return a corresponding error response. - 739                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 740                    'error' => $e->getInfo()['error'], - 741                    'error_description' => $e->getMessage() - 742                ])); - 743            } - 744 - 745            if (is_null($tokenData)) { - 746                $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams); - 747                return new Response(400, ['content-type' => 'application/json'], json_encode([ - 748                    'error' => 'invalid_grant', - 749                    'error_description' => 'The provided credentials were not valid.' - 750                ])); - 751            } - 752 - 753            // TODO: return an error if the token doesn’t contain a me key. - 754 - 755            // If everything checked out, return {"me": "https://example.com"} response - 756            return new Response(200, [ - 757                'content-type' => 'application/json', - 758                'cache-control' => 'no-store', - 759            ], json_encode(array_merge([ - 760                // Ensure that the token_type key is present, if tokenStorage doesn’t include it. - 761                'token_type' => 'Bearer' - 762            ], array_filter($tokenData, function (string $k) { - 763                // We should be able to trust the return data from tokenStorage, but there’s no harm in - 764                // preventing code_challenges from leaking, per OAuth2. - 765                return !in_array($k, ['code_challenge', 'code_challenge_method']); - 766            }, ARRAY_FILTER_USE_KEY)))); - 767        } - 768 - 769        return new Response(400, ['content-type' => 'application/json'], json_encode([ - 770            'error' => 'invalid_request', - 771            'error_description' => 'Request to token endpoint was not a valid code exchange request.' - 772        ])); - 773    } - 774 - 775    /** - 776     * Handle Exception - 777     *  - 778     * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`. - 779     */ - 780    protected function handleException(IndieAuthException $exception): ResponseInterface { - 781        $exceptionData = $exception->getInfo(); - 782 - 783        if ($exceptionData['statusCode'] == 302) { - 784            // This exception is handled by redirecting to the redirect_uri with error parameters. - 785            $redirectQueryParams = [ - 786                'error' => $exceptionData['error'] ?? 'invalid_request', - 787                'error_description' => (string) $exception - 788            ]; - 789 - 790            // If the state parameter was valid, include it in the error redirect. - 791            if ($exception->getCode() !== IndieAuthException::INVALID_STATE) { - 792                $redirectQueryParams['state'] = $exception->getRequest()->getQueryParams()['state']; - 793            } + 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; + 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); + 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); + 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                        } + 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            } + 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            return new Response($exceptionData['statusCode'], [ - 796                'Location' => appendQueryParams((string) $exception->getRequest()->getQueryParams()['redirect_uri'], $redirectQueryParams) - 797            ]); - 798        } else { - 799            // This exception should be shown to the user. - 800            return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [ - 801                'request' => $exception->getRequest(), - 802                'exception' => $exception - 803            ])); - 804        } - 805    } - 806} + 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} @@ -1026,7 +1031,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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Storage/FilesystemJsonStorage.php.html b/docs/coverage/Storage/FilesystemJsonStorage.php.html index 33a7b55..b5aca98 100644 --- a/docs/coverage/Storage/FilesystemJsonStorage.php.html +++ b/docs/coverage/Storage/FilesystemJsonStorage.php.html @@ -419,30 +419,30 @@ 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 @@ -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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Storage/Sqlite3Storage.php.html b/docs/coverage/Storage/Sqlite3Storage.php.html index cbd00c1..2d01a02 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Storage/TokenStorageInterface.php.html b/docs/coverage/Storage/TokenStorageInterface.php.html index b780ef7..c38a5f4 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/Storage/dashboard.html b/docs/coverage/Storage/dashboard.html index 5d0eb95..e4064c9 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 33db78b..be1da76 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/dashboard.html b/docs/coverage/dashboard.html index f184e23..25b40f1 100644 --- a/docs/coverage/dashboard.html +++ b/docs/coverage/dashboard.html @@ -150,7 +150,7 @@
@@ -237,7 +237,7 @@ $(document).ready(function() { chart.yAxis.axisLabel('Cyclomatic Complexity'); d3.select('#classComplexity svg') - .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,6,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"],[66.66666666666666,8,"Taproot\\IndieAuth\\IndieAuthException<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler<\/a>"],[96.875,12,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler<\/a>"],[94.921875,86,"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>"],[94.98069498069498,90,"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>"],[92.94871794871796,54,"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>"],[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')) .transition() .duration(500) .call(chart); diff --git a/docs/coverage/functions.php.html b/docs/coverage/functions.php.html index 655a844..c9fa574 100644 --- a/docs/coverage/functions.php.html +++ b/docs/coverage/functions.php.html @@ -66,7 +66,7 @@ - Taproot\IndieAuth\generateRandomString + Taproot\IndieAuth\generateRandomString
100.00% covered (success) @@ -87,7 +87,7 @@ - Taproot\IndieAuth\generateRandomPrintableAsciiString + Taproot\IndieAuth\generateRandomPrintableAsciiString
100.00% covered (success) @@ -108,7 +108,7 @@ - Taproot\IndieAuth\generatePKCECodeChallenge + Taproot\IndieAuth\generatePKCECodeChallenge
100.00% covered (success) @@ -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) @@ -459,7 +459,7 @@ 8use Psr\Log\LoggerInterface; 9 10// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk. - 11function generateRandomString($numBytes) { + 11function generateRandomString(int $numBytes): string { 12    if (function_exists('random_bytes')) { 13        $bytes = random_bytes($numBytes); 14        // We can’t easily test the following code. @@ -476,16 +476,16 @@ 25    return bin2hex($bytes); 26} 27 - 28function generateRandomPrintableAsciiString(int $length) { - 29    $chars = []; - 30    while (count($chars) < $length) { + 28function generateRandomPrintableAsciiString(int $length): string { + 29    $chars = []; + 30    while (count($chars) < $length) { 31        // 0x21 to 0x7E is the entire printable ASCII range, not including space (0x20). - 32        $chars[] = chr(random_int(0x21, 0x7E)); + 32        $chars[] = chr(random_int(0x21, 0x7E)); 33    } - 34    return join('', $chars); + 34    return join('', $chars); 35} 36 - 37function generatePKCECodeChallenge($plaintext) { + 37function generatePKCECodeChallenge(string $plaintext): string { 38    return base64_urlencode(hash('sha256', $plaintext, true)); 39} 40 @@ -494,30 +494,30 @@ 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'; + 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; + 49    $queryParams = $request->getQueryParams(); + 50    $data = ''; + 51    foreach ($hashedParameters as $key) { + 52        if (!isset($queryParams[$key])) { + 53            return null; 54        } - 55        $data .= $queryParams[$key]; + 55        $data .= $queryParams[$key]; 56    } - 57    return hash_hmac($algo, $data, $secret); + 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'; + 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'; + 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 { @@ -527,28 +527,28 @@ 76} 77 78function buildQueryString(array $parameters): string { - 79    $qs = []; - 80    foreach ($parameters as $k => $v) { - 81        $qs[] = urlencode($k) . '=' . urlencode($v); + 79    $qs = []; + 80    foreach ($parameters as $k => $v) { + 81        $qs[] = urlencode($k) . '=' . urlencode($v); 82    } - 83    return join('&', $qs); + 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; + 87    $validComponents = [PHP_URL_HOST, PHP_URL_PASS, PHP_URL_PATH, PHP_URL_PORT, PHP_URL_USER, PHP_URL_QUERY, PHP_URL_SCHEME, PHP_URL_FRAGMENT]; + 88    $components = $components ?? $validComponents; 89 - 90    foreach ($components as $cmp) { - 91        if (!in_array($cmp, $validComponents)) { + 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)) { + 95        if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) { 96            return false; 97        } 98    } 99 - 100    return true; + 100    return true; 101} 102 103/** @@ -558,15 +558,15 @@ 107 * existing query string. Then appends the newly generated query string 108 * with either ? or & as appropriate. 109 */ - 110function appendQueryParams(string $uri, array $queryParams) { - 111    if (empty($queryParams)) { + 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}"; + 115    $queryString = buildQueryString($queryParams); + 116    $separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?'; + 117    $uri = rtrim($uri, '?&'); + 118    return "{$uri}{$separator}{$queryString}"; 119} 120 121/** @@ -574,102 +574,105 @@ 123 *  124 * If `$target` implements `LoggerAwareInterface`, set it’s logger 125 * to `$logger`. Returns `$target`. - 126 */ - 127function trySetLogger($target, LoggerInterface $logger) { - 128    if ($target instanceof LoggerAwareInterface) { - 129        $target->setLogger($logger); - 130    } - 131    return $target; - 132} - 133 - 134function renderTemplate(string $template, array $context=[]) { - 135    $render = function ($__template, $__templateData) { - 136        $render = function ($template, $data){ - 137            return renderTemplate($template, $data); - 138        }; - 139        ob_start(); - 140        extract($__templateData); - 141        unset($__templateData); - 142        include $__template; - 143        return ob_get_clean(); - 144    }; - 145    return $render($template, $context); - 146} - 147 - 148// IndieAuth/OAuth2-related Validation Functions - 149// Mostly taken or adapted by https://github.com/Zegnat/php-mindee/ — thanks Zegnat! - 150// Code was not licensed at time of writing, permission granted here https://chat.indieweb.org/dev/2021-06-10/1623327498355700 - 151 - 152/** - 153 * Check if a provided string matches the IndieAuth criteria for a Client Identifier. - 154 * @see https://indieauth.spec.indieweb.org/#client-identifier - 155 *  - 156 * @param string $client_id The client ID provided by the OAuth Client - 157 * @return bool true if the value is allowed by IndieAuth - 158 */ - 159function isClientIdentifier(string $client_id): bool { - 160    return ($url_components = parse_url($client_id)) &&                     // Clients are identified by a URL. - 161            in_array($url_components['scheme'] ?? '', ['http', 'https']) &&     // Client identifier URLs MUST have either an https or http scheme, - 162            0 < strlen($url_components['path'] ?? '') &&                        // MUST contain a path component, - 163            false === strpos($url_components['path'], '/./') &&                 // MUST NOT contain single-dot - 164            false === strpos($url_components['path'], '/../') &&                // or double-dot path segments, - 165            false === isset($url_components['fragment']) &&                     // MUST NOT contain a fragment component, - 166            false === isset($url_components['user']) &&                         // MUST NOT contain a username - 167            false === isset($url_components['pass']) &&                         // or password component, - 168            ( - 169                false === filter_var($url_components['host'], FILTER_VALIDATE_IP) ||  // MUST NOT be an IP address - 170                ($url_components['host'] ?? null) == '127.0.0.1' ||                   // except for 127.0.0.1 - 171                ($url_components['host'] ?? null) == '[::1]'                          // or [::1] - 172            ) - 173    ; - 174} - 175 - 176/** - 177 * Check if a provided string matches the IndieAuth criteria for a User Profile URL. - 178 * @see https://indieauth.spec.indieweb.org/#user-profile-url - 179 *  - 180 * @param string $profile_url The profile URL provided by the IndieAuth Client as me - 181 * @return bool true if the value is allowed by IndieAuth - 182 */ - 183function isProfileUrl(string $profile_url): bool { - 184    return ($url_components = parse_url($profile_url)) &&                   // Users are identified by a URL. - 185            in_array($url_components['scheme'] ?? '', ['http', 'https']) &&     // Profile URLs MUST have either an https or http scheme, - 186            0 < strlen($url_components['path'] ?? '') &&                        // MUST contain a path component, - 187            false === strpos($url_components['path'], '/./') &&                 // MUST NOT contain single-dot - 188            false === strpos($url_components['path'], '/../') &&                // or double-dot path segments, - 189            false === isset($url_components['fragment']) &&                     // MUST NOT contain a fragment component, - 190            false === isset($url_components['user']) &&                         // MUST NOT contain a username - 191            false === isset($url_components['pass']) &&                         // or password component, - 192            false === isset($url_components['port']) &&                         // MUST NOT contain a port, - 193            false === filter_var($url_components['host'], FILTER_VALIDATE_IP)   // MUST NOT be an IP address. - 194    ; - 195} - 196 - 197/** - 198 * OAuth 2.0 limits what values are valid for state. - 199 * We check this first, because if valid, we want to send it along with other errors. - 200 * @see https://tools.ietf.org/html/rfc6749#appendix-A.5 - 201 */ - 202function isValidState(string $state): bool { - 203    return false !== filter_var($state, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[\x20-\x7E]*$/']]); - 204} - 205 - 206/** - 207 * IndieAuth requires PKCE. This implementation supports only S256 for hashing. - 208 *  - 209 * @see https://indieauth.spec.indieweb.org/#authorization-request - 210 */ - 211function isValidCodeChallenge(string $challenge): bool { - 212    return false !== filter_var($challenge, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[A-Za-z0-9_-]+$/']]); - 213} - 214 - 215/** - 216 * OAuth 2.0 limits what values are valid for scope. - 217 * @see https://tools.ietf.org/html/rfc6749#section-3.3 - 218 */ - 219function isValidScope(string $scope): bool { - 220    return false !== filter_var($scope, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*)?$/']]); - 221} + 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 + 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} @@ -680,7 +683,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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/docs/coverage/index.html b/docs/coverage/index.html index 4b0a5d0..5f2224a 100644 --- a/docs/coverage/index.html +++ b/docs/coverage/index.html @@ -44,13 +44,13 @@ Total
-
- 94.59% covered (success) +
+ 94.62% covered (success)
-
94.59%
-
507 / 536
+
94.62%
+
510 / 539
73.68% covered (warning) @@ -184,13 +184,13 @@ Server.php
-
- 94.92% covered (success) +
+ 94.98% covered (success)
-
94.92%
-
243 / 256
+
94.98%
+
246 / 259
60.00% 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 Mon Jun 14 23:14:13 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 Wed Jun 16 21:41:10 UTC 2021.

diff --git a/src/Server.php b/src/Server.php index 138017d..3efa42f 100644 --- a/src/Server.php +++ b/src/Server.php @@ -498,17 +498,22 @@ class Server { // reported by redirecting to redirect_uri with error parameters. // Validate the state parameter. - if (!isValidState($queryParams['state'])) { + if (!isset($queryParams['state']) or !isValidState($queryParams['state'])) { $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 (!isValidCodeChallenge($queryParams['code_challenge'])) { + 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. diff --git a/tests/ServerTest.php b/tests/ServerTest.php index edd83e7..bbbf0e6 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -56,7 +56,7 @@ class ServerTest extends TestCase { 'redirect_uri' => 'https://app.example.com/indieauth', 'state' => '12345', 'code_challenge' => hash('sha256', 'code'), - 'code_challenge_method' => 'sha256' + 'code_challenge_method' => 'S256' ], $params)); } @@ -144,6 +144,31 @@ class ServerTest extends TestCase { * Authorization Request Tests */ + public function testAuthorizationEndpointReturnsErrorOnMissingParameter() { + $missingParameters = [ + 'client_id' => 'static_error', + 'redirect_uri' => 'static_error', + 'code_challenge' => 'redirect_error', + 'code_challenge_method' => 'redirect_error', + 'state' => 'redirect_error', + ]; + $s = $this->getDefaultServer(); + foreach ($missingParameters as $missingParam => $errorType) { + $req = $this->getIARequest(); + $qp = $req->getQueryParams(); + unset($qp[$missingParam]); + $req = $req->withQueryParams($qp); + + $res = $s->handleAuthorizationEndpointRequest($req); + + if ($errorType == 'static_error') { + $this->assertEquals(400, $res->getStatusCode(), $missingParam); + } else { + $this->assertEquals(302, $res->getStatusCode(), $missingParam); + } + } + } + public function testAuthorizationRequestWithInvalidClientIdOrRedirectUriShowsErrorToUser() { $testCases = [ 'client_id not a URI' => [