Documented Server, both important Interfaces

This commit is contained in:
Barnaby Walters 2021-06-11 01:21:39 +02:00
parent db39fff517
commit 196d8a887f
4 changed files with 308 additions and 12 deletions

View File

@ -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 `<input type="hidden" …/>`.
*
* 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;
}

View File

@ -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;

View File

@ -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 its 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 its 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 requests 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 isnt 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();

View File

@ -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 its 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 its
* 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;
}