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