From 196d8a887ff6c36810f8d782c3d007cb896ba878 Mon Sep 17 00:00:00 2001 From: Barnaby Walters Date: Fri, 11 Jun 2021 01:21:39 +0200 Subject: [PATCH] Documented Server, both important Interfaces --- src/Callback/AuthorizationFormInterface.php | 35 +++- src/IndieAuthException.php | 7 + src/Server.php | 181 +++++++++++++++++++- src/Storage/TokenStorageInterface.php | 97 ++++++++++- 4 files changed, 308 insertions(+), 12 deletions(-) diff --git a/src/Callback/AuthorizationFormInterface.php b/src/Callback/AuthorizationFormInterface.php index 6f8049d..7fbba5a 100644 --- a/src/Callback/AuthorizationFormInterface.php +++ b/src/Callback/AuthorizationFormInterface.php @@ -37,7 +37,14 @@ interface AuthorizationFormInterface { * and make sure to include the required element. This will usually involve getting a * CSRF token with `$request->getAttribute()` and including it in an ``. * - * The form SHOULD present + * The form SHOULD offer the user the opportunity to choose which of the request scopes, + * if any, they wish to grant. It should describe what effect each scope grants. If no scopes are + * requested, tell the user that the app is only requesting authorization, not access to their data. + * + * The form MAY offer the user UIs for additional token configuration, e.g. a custom token lifetime. + * You may have to refer to the documentation for your instance of `TokenStorageInterface` to ensure + * that lifetime configuration works correctly. Any other additional data is not used by the IndieAuth + * library, but, if stored on the access token, will be available to your app for use. * * @param ServerRequestInterface $request The current request. * @param array $authenticationResult The array returned from the Authentication Handler. Guaranteed to contain a 'me' key, may also contain additional keys e.g. 'profile'. @@ -50,11 +57,35 @@ interface AuthorizationFormInterface { /** * Transform Authorization Code * + * This method is called on a successful authorization form submission. The `$code` array + * is a partially-constructed authorization code array, which is guaranteed to have the + * following keys: * + * * `client_id`: the validated `client_id` request parameter + * * `redirect_uri`: the validated `redirect_uri` request parameter + * * `state`: the `state` request parameter + * * `code_challenge`: the `code_challenge` request parameter + * * `code_challenge_method`: the `code_challenge_method` request parameter + * * `requested_scope`: the value of the `scope` request parameter + * * `me`: the value of the `me` key from the authentication result returned from the authentication request handler callback + * + * It may also have additional keys, which can come from the following locations: + * + * * All keys from the the authentication request handler callback result which do not clash + * with the keys listed above (with the exception of `me`, which is always present). Usually + * this is a `profile` key, but you may choose to return additional data from the authentication + * callback, which will be present in `$data`. + * + * This method should add any additional data to the auth code, before it is persisted and + * returned to the client app. Typically, this involves setting the `scope` key to be a + * valid space-separated scope string of any scopes granted by the user in the form. + * + * If the form offers additional token configuration, this method should set any relevant + * keys in `$code` based on the form data in `$request`. * * @param array $code The base authorization code data, to be added to. * @param ServerRequestInterface $request The current request. - * @return array The $code argument with any necessary changes. + * @return array The $code data after making any necessary changes. */ public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array; } \ No newline at end of file diff --git a/src/IndieAuthException.php b/src/IndieAuthException.php index 10f89ba..e4a6983 100644 --- a/src/IndieAuthException.php +++ b/src/IndieAuthException.php @@ -63,6 +63,13 @@ class IndieAuthException extends Exception { return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR]; } + /** + * Trust Query Params + * + * Only useful on authorization form submission requests. If this returns false, + * the client_id and/or request_uri have likely been tampered with, and the error + * page SHOULD NOT offer the user a link to them. + */ public function trustQueryParams() { return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH; diff --git a/src/Server.php b/src/Server.php index 04c851c..a31b27d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -19,21 +19,65 @@ use Psr\Log\NullLogger; use Taproot\IndieAuth\Callback\AuthorizationFormInterface; use Taproot\IndieAuth\Callback\DefaultAuthorizationForm; -/** - * Development Reference - * - * Specification: https://indieauth.spec.indieweb.org/ - * Error responses: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 - * indieweb/indieauth-client: https://github.com/indieweb/indieauth-client-php - * Existing implementation with various validation functions and links to relevant spec portions: https://github.com/Zegnat/php-mindee/blob/development/index.php - */ +/** + * IndieAuth Server + * + * A PSR-7 compatible implementation of the request-handling logic for IndieAuth authorization endpoints + * and token endpoints. + * + * Typical usage looks something like this: + * + * // Somewhere in your app set-up: + * + * use Taproot\IndieAuth; + * + * $server = new IndieAuth\Server([ + * 'secret' => APP_INDIEAUTH_SECRET, + * 'tokenStorage' => '/../data/auth_tokens/', + * 'handleAuthenticationRequestCallback' => new IndieAuth\Callback\SingleUserPasswordAuthenticationCallback([ + * 'me' => 'https://your-domain.com/' + * ], + * YOUR_HASHED_PASSWORD + * ) + * ]); + * + * // In your authorization endpoint route: + * return $server->handleAuthorizationEndpointRequest($request); + * + * // In your token endpoint route: + * return $server->handleTokenEndpointRequest($request); + * + * Refer to the `__construct` documentation for further configuration options, and to the + * documentation for both handling methods for further documentation about them. + * + * @link https://indieauth.spec.indieweb.org/ + * @link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 + * @link https://github.com/indieweb/indieauth-client-php + * @link https://github.com/Zegnat/php-mindee/blob/development/index.php + */ class Server { const HANDLE_NON_INDIEAUTH_REQUEST = 'handleNonIndieAuthRequestCallback'; const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback'; + + /** + * The query string parameter key used for storing the hash used for validating authorization request parameters. + */ const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash'; + + /** + * The key used to store the CSRF token everywhere it’s used: Request parameters, Request body, and Cookies. + */ const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf'; + + /** + * The form data key used for identifying a request as an authorization (consent screen) form submissions. + */ const APPROVE_ACTION_KEY = 'taproot_indieauth_action'; + + /** + * The form data value used for identifying a request as an authorization (consent screen) form submissions. + */ const APPROVE_ACTION_VALUE = 'approve'; protected Storage\TokenStorageInterface $tokenStorage; @@ -54,6 +98,69 @@ class Server { protected string $secret; + /** + * Constructor + * + * Server instances are configured by passing a config array to the constructor. + * + * The following keys are required: + * + * * `handleAuthenticationRequestCallback`: a callable with the signature + * `function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl): array|ResponseInterface`. + * This function is called on IndieAuth authorization requests, after validating the query parameters. + * + * It should check to see if $request is authenticated, then: + * * If it is authenticated, return an array which MUST have a `me` key, mapping to the + * canonical URL of the currently logged-in user. It may additionally have a `profile` key. These + * keys will be stored in the authorization code and sent to the client, if successful. + * * If it is not authenticated, either present or redirect to an authentication flow. This flow MUST + * redirect the logged-in used back to `$authenticationRedirect`. + * + * If the request has a valid `me` parameter, the canonicalized version of it is passed as + * `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used + * as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT + * be considered valid data. + * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used + * to hash and subsequently query parameters which get passed around. + * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path, + * which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization + * codes and access tokens, as well as implementation-specific parts of the exchange process which are + * out of the scope of the Server class (e.g. lifetimes and expiry). Refer to the `Storage\TokenStorageInterface` + * documentation for more details. + * + * The following keys may be required depending on which packages you have installed: + * + * * `httpGetWithEffectiveUrl`: must be a callable with the following signature: + * `function (string $url): array [ResponseInterface $response, string $effectiveUrl]`, where + * `$effectiveUrl` is the final URL after following any redirects (unfortunately, neither the PSR-7 + * Response nor the PSR-18 Client interfaces offer a standard way of getting this very important + * data, hence the unusual return signature). If `guzzlehttp/guzzle` is installed, this parameter + * will be created automatically. Otherwise, the user must provide their own callable. + * + * The following keys are optional: + * + * * `authorizationForm`: an instance of `AuthorizationFormInterface`. Defaults to `DefaultAuthorizationForm`. + * Refer to that implementation if you wish to replace the consent screen/scope choosing/authorization form. + * * `csrfMiddleware`: an instance of `MiddlewareInterface`, which will be used to CSRF-protect the + * user-facing authorization flow. By default an instance of `DoubleSubmitCookieCsrfMiddleware`. + * Refer to that implementation if you want to replace it with your own middleware — you will + * likely have to either make sure your middleware sets the same request attribute, or alter your + * templates accordingly. + * * `exceptionTemplatePath`: string, path to a template which will be used for displaying user-facing + * errors. Defaults to `../templates/default_exception_response.html.php`, refer to that if you wish + * to write your own template. + * * `handleNonIndieAuthRequestCallback`: A callback with the following signature: + * `function (ServerRequestInterface $request): ?ResponseInterface` which will be called if the + * authorization endpoint gets a request which is not identified as an IndieAuth request or authorization + * form submission request. You could use this to handle various requests e.g. client-side requests + * made by your authentication or authorization pages, if it’s not convenient to put them elsewhere. + * Returning `null` will result in a standard `invalid_request` error being returned. + * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set + * as the logger for most objects passed in config which implement `LoggerAwareInterface`. + * + * @param array $config An array of configuration variables + * @return self + */ public function __construct(array $config) { $config = array_merge([ 'csrfMiddleware' => null, @@ -149,6 +256,35 @@ class Server { /** * Handle Authorization Endpoint Request * + * This method handles all requests to your authorization endpoint, passing execution off to + * other callbacks when necessary. The logical flow can be summarised as follows: + * + * * If this request an **auth code exchange for profile information**, validate the request + * and return a response or error response. + * * Otherwise, proceed, wrapping all execution in CSRF-protection middleware. + * * Validate the request’s indieauth authorization code request parameters, returning an + * error response if any are missing or invalid. + * * Call the authentication callback + * * If the callback returned an instance of ResponseInterface, the user is not currently + * logged in. Return the Response, which will presumably start an authentication flow. + * * Otherwise, the callback returned information about the currently logged-in user. Continue. + * * If this request is an authorization form submission, validate the data, store and authorization + * code and return a redirect response to the client redirect_uri with code data. On an error, return + * an appropriate error response. + * * Otherwise, fetch the client_id, parse app data if present, validate the `redirect_uri` and present + * the authorization form/consent screen to the user. + * * If none of the above apply, try calling the non-indieauth request handler. If it returns a Response, + * return that, otherwise return an error response. + * + * This route should NOT be wrapped in additional CSRF-protection, due to the need to handle API + * POST requests from the client. Make sure you call it from a route which is excluded from any + * CSRF-protection you might be using. To customise the CSRF protection used internally, refer to the + * `__construct` config array documentation for the `csrfMiddleware` key. + * + * Most user-facing errors are thrown as instances of `IndieAuthException`, which are passed off to + * `handleException` to be turned into an instance of `ResponseInterface`. If you want to customise + * error behaviour, one way to do so is to subclass `Server` and override that method. + * * @param ServerRequestInterface $request * @return ResponseInterface */ @@ -246,6 +382,7 @@ class Server { }); if (!empty($missingRequiredParameters)) { $this->logger->warning('The authorization request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]); + // TODO: if the missing parameter isn’t redirect_uri or client_id, this should be a redirect error. throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request); } @@ -373,6 +510,11 @@ class Server { // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes. // Fetch the client_id URL to find information about the client to present to the user. + // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, + // it may be necessary to do this before returning any other kind of error response, as, per + // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters + // are missing or invalid. Otherwise, they should be sent back to the client with an error + // redirect response. try { /** @var ResponseInterface $clientIdResponse */ list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']); @@ -447,6 +589,24 @@ class Server { })); } + /** + * Handle Token Endpoint Request + * + * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows: + * + * * Check that the request is a code redeeming request. Return an error if not. + * * Ensure that all required parameters are present. Return an error if not. + * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails. + * * 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. + * * 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. + * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error. + * * Otherwise, return a success response containing information about the issued access token. + * + * This method must NOT be CSRF-protected as it accepts external requests from client apps. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface { if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { $this->logger->info('Handling a request to redeem an authorization code for profile information.'); @@ -524,6 +684,11 @@ class Server { ])); } + /** + * Handle Exception + * + * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`. + */ protected function handleException(IndieAuthException $exception): ResponseInterface { $exceptionData = $exception->getInfo(); diff --git a/src/Storage/TokenStorageInterface.php b/src/Storage/TokenStorageInterface.php index 905fc15..f88f31d 100644 --- a/src/Storage/TokenStorageInterface.php +++ b/src/Storage/TokenStorageInterface.php @@ -2,14 +2,107 @@ namespace Taproot\IndieAuth\Storage; -// TODO: document. - +/** + * Token Storage Interface + * + * This interface defines the bare minimum methods required by the Server class in order to + * implement auth code issuing and exchange flows, as well as to let external code get access + * tokens (for validating requests authenticated by an access_token) and revoke access tokens. + * + * The contract made between Server and implementations of TokenStorageInterface can broadly + * be summarized as follows: + * + * * The Server class is responsible for performing all validation which is + * defined in the IndieAuth spec and is not implementation-specific. For example: checking + * validity of all the authorization request parameters, checking that client_id, request_uri + * and code_verifier parameters in token exchange requests match with the stored data. + * * The TokenStorageInterface class is responsible for performing implementation-specific + * validation, such as assigning and checking expiry times for auth codes and access tokens. + * + * Implementations of TokenStorageInterface will usually implement additional methods to allow + * for lower-level querying, saving, updating and deletion of token data. These can be used to, + * for example, implement a UI for users to review and revoke currently valid access tokens. + * + * The behaviour of `TokenStorageInterface` is somewhat coupled with the implementation of your + * authentication handler callback (documented in `Server::__construct`) and `AuthorizationFormInterface`, + * so you should refer to the documentation for both while implementing `TokenStorageInterface`. + * + * Periodic deletion of expired tokens is out of the scope of this interface. Implementations may + * choose to offer a clean-up method, and potentially the option to call it once automatically + * on instanciation. + */ interface TokenStorageInterface { + /** + * Create Auth Code + * + * This method is called on a valid authorization token request. The `$data` + * array is guaranteed to have the following keys: + * + * * `client_id`: the validated `client_id` request parameter + * * `redirect_uri`: the validated `redirect_uri` request parameter + * * `state`: the `state` request parameter + * * `code_challenge`: the `code_challenge` request parameter + * * `code_challenge_method`: the `code_challenge_method` request parameter + * * `requested_scope`: the value of the `scope` request parameter + * * `me`: the value of the `me` key from the authentication result returned from the authentication request handler callback + * + * It may also have additional keys, which can come from the following locations: + * + * * All keys from the the authentication request handler callback result which do not clash + * with the keys listed above (with the exception of `me`, which is always present). Usually + * this is a `profile` key, but you may choose to return additional data from the authentication + * callback, which will be present in `$data`. + * * Any keys added by the `transformAuthorizationCode` method on the currently active instance + * of `Taproot\IndieAuth\Callback\AuthorizationFormInterface`. Typically this is the `scope` + * key, which is a valid scope string listing the scopes granted by the user on the consent + * screen. Other implementations of `AuthorizationFormInterface` may add additional data, such + * as custom token-specific settings, or a custom token lifetime. + * + * This method should store the data passed to it, generate a corresponding authorization code, + * and return an instance of `Storage\Token` containing both. Implementations will usually add + * an expiry time, usually under the `valid_until` key. + * + * The method call and data is structured such that implementations have a lot of flexibility + * about how to store authorization code data. It could be a record in an auth code database + * table, a record in a table which is used for both auth codes and access tokens, or even + * a stateless self-encrypted token — note that in the latter case, you must persist a copy + * of the auth code with it’s access token to check against, in order to prevent it being + * exchanged for an access token more than once. + * + * On an error, return null. The reason for the error is irrelevant for calling code, but it’s + * recommended to log it for reference. + */ public function createAuthCode(array $data): ?Token; + /** + * Exchange Authorization Code for Access Token + * + * Attempt to exchange an authorization code identified by `$code` for + * an access token, returning it in a `Token` on success and null on error. + * + * This method is responsible for ensuring that the matched auth code is + * not expired. If it is, it should return null, presumably after deleting + * the corresponding authorization code record. + * + * If the exchanged access token should expire, this method should set its + * expiry time, usually in a `valid_until` key. + */ public function exchangeAuthCodeForAccessToken(string $code): ?Token; + /** + * Get Access Token + * + * Fetch access token data identified by the token `$token`, returning + * null if it is expired or invalid. The data should be structured in + * exactly the same way it was stored by `exchangeAuthCodeForAccessToken`. + */ public function getAccessToken(string $token): ?Token; + /** + * Revoke Access Token + * + * Revoke the access token identified by `$token`. Return true on success, + * or false on error, including if the token did not exist. + */ public function revokeAccessToken(string $token): bool; }