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