2021-06-06 01:18:44 +02:00
< ? php declare ( strict_types = 1 );
namespace Taproot\IndieAuth ;
use Exception ;
2021-06-06 14:47:05 +02:00
use IndieAuth\Client as IndieAuthClient ;
use Mf2 ;
use BarnabyWalters\Mf2 as M ;
use GuzzleHttp\Psr7\Header as HeaderParser ;
2021-06-06 01:18:44 +02:00
use Nyholm\Psr7\Response ;
2021-06-08 00:58:19 +02:00
use Psr\Http\Client\ClientExceptionInterface ;
2021-06-06 14:47:05 +02:00
use Psr\Http\Client\NetworkExceptionInterface ;
use Psr\Http\Client\RequestExceptionInterface ;
2021-06-06 01:18:44 +02:00
use Psr\Http\Message\ResponseInterface ;
2021-06-06 14:47:05 +02:00
use Psr\Http\Message\ServerRequestInterface ;
use Psr\Http\Server\MiddlewareInterface ;
2021-06-06 01:18:44 +02:00
use Psr\Log\LoggerInterface ;
use Psr\Log\NullLogger ;
2021-06-07 20:32:02 +02:00
use Taproot\IndieAuth\Callback\AuthorizationFormInterface ;
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm ;
2021-06-06 01:18:44 +02:00
2021-06-11 01:21:39 +02:00
2021-06-06 01:18:44 +02:00
/**
2021-06-11 01:21:39 +02:00
* 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 );
2021-06-09 21:56:16 +02:00
*
2021-06-11 01:21:39 +02:00
* 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
2021-06-06 01:18:44 +02:00
*/
class Server {
2021-06-07 20:32:02 +02:00
const HANDLE_NON_INDIEAUTH_REQUEST = 'handleNonIndieAuthRequestCallback' ;
const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback' ;
2021-06-11 01:21:39 +02:00
/**
* The query string parameter key used for storing the hash used for validating authorization request parameters .
*/
2021-06-07 20:32:02 +02:00
const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash' ;
2021-06-11 01:21:39 +02:00
/**
* The key used to store the CSRF token everywhere it’ s used : Request parameters , Request body , and Cookies .
*/
2021-06-06 01:18:44 +02:00
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf' ;
2021-06-11 01:21:39 +02:00
/**
* The form data key used for identifying a request as an authorization ( consent screen ) form submissions .
*/
2021-06-09 00:06:35 +02:00
const APPROVE_ACTION_KEY = 'taproot_indieauth_action' ;
2021-06-11 01:21:39 +02:00
/**
* The form data value used for identifying a request as an authorization ( consent screen ) form submissions .
*/
2021-06-09 00:06:35 +02:00
const APPROVE_ACTION_VALUE = 'approve' ;
2021-06-06 01:18:44 +02:00
2021-06-10 14:11:58 +02:00
protected Storage\TokenStorageInterface $tokenStorage ;
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
protected AuthorizationFormInterface $authorizationForm ;
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
protected MiddlewareInterface $csrfMiddleware ;
2021-06-07 20:32:02 +02:00
2021-06-09 00:06:35 +02:00
protected LoggerInterface $logger ;
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
protected $httpGetWithEffectiveUrl ;
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
protected $handleAuthenticationRequestCallback ;
2021-06-06 14:47:05 +02:00
2021-06-09 00:06:35 +02:00
protected $handleNonIndieAuthRequest ;
2021-06-07 20:32:02 +02:00
2021-06-09 00:06:35 +02:00
protected string $exceptionTemplatePath ;
2021-06-07 20:32:02 +02:00
protected string $secret ;
2021-06-06 01:18:44 +02:00
2021-06-11 01:21:39 +02:00
/**
* 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
*/
2021-06-06 14:47:05 +02:00
public function __construct ( array $config ) {
2021-06-07 20:32:02 +02:00
$config = array_merge ([
2021-06-06 14:47:05 +02:00
'csrfMiddleware' => null ,
'logger' => null ,
2021-06-07 20:32:02 +02:00
self :: HANDLE_NON_INDIEAUTH_REQUEST => function ( ServerRequestInterface $request ) { return null ; }, // Default to no-op.
2021-06-10 14:11:58 +02:00
'tokenStorage' => null ,
2021-06-07 20:32:02 +02:00
'httpGetWithEffectiveUrl' => null ,
'authorizationForm' => new DefaultAuthorizationForm (),
2021-06-09 00:26:27 +02:00
'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php' ,
2021-06-06 14:47:05 +02:00
], $config );
2021-06-09 00:06:35 +02:00
if ( ! is_string ( $config [ 'exceptionTemplatePath' ])) {
2021-06-10 20:46:01 +02:00
throw new Exception ( " \$ config['exceptionTemplatePath'] must be a string (path). " );
2021-06-09 00:06:35 +02:00
}
$this -> exceptionTemplatePath = $config [ 'exceptionTemplatePath' ];
2021-06-07 20:32:02 +02:00
$secret = $config [ 'secret' ] ? ? '' ;
if ( ! is_string ( $secret ) || strlen ( $secret ) < 64 ) {
2021-06-10 14:11:58 +02:00
throw new Exception ( " \$ config['secret'] must be a string with a minimum length of 64 characters. Make one with Taproot \ IndieAuth \ generateRandomString(64) " );
2021-06-07 20:32:02 +02:00
}
$this -> secret = $secret ;
if ( ! is_null ( $config [ 'logger' ]) && ! $config [ 'logger' ] instanceof LoggerInterface ) {
2021-06-06 14:47:05 +02:00
throw new Exception ( " \$ config['logger'] must be an instance of \\ Psr \\ Log \\ LoggerInterface or null. " );
}
$this -> logger = $config [ 'logger' ] ? ? new NullLogger ();
2021-06-06 01:18:44 +02:00
2021-06-07 20:32:02 +02:00
if ( ! ( array_key_exists ( self :: HANDLE_AUTHENTICATION_REQUEST , $config ) and is_callable ( $config [ self :: HANDLE_AUTHENTICATION_REQUEST ]))) {
2021-06-06 01:18:44 +02:00
throw new Exception ( '$callbacks[\'' . self :: HANDLE_AUTHENTICATION_REQUEST . '\'] must be present and callable.' );
}
2021-06-07 20:32:02 +02:00
$this -> handleAuthenticationRequestCallback = $config [ self :: HANDLE_AUTHENTICATION_REQUEST ];
2021-06-06 14:47:05 +02:00
2021-06-07 20:32:02 +02:00
if ( ! is_callable ( $config [ self :: HANDLE_NON_INDIEAUTH_REQUEST ])) {
throw new Exception ( " \$ config[' " . self :: HANDLE_NON_INDIEAUTH_REQUEST . " '] must be callable " );
}
$this -> handleNonIndieAuthRequest = $config [ self :: HANDLE_NON_INDIEAUTH_REQUEST ];
2021-06-10 14:11:58 +02:00
$tokenStorage = $config [ 'tokenStorage' ];
if ( ! $tokenStorage instanceof Storage\TokenStorageInterface ) {
if ( is_string ( $tokenStorage )) {
2021-06-06 01:18:44 +02:00
// Create a default access token storage with a TTL of 7 days.
2021-06-10 14:11:58 +02:00
$tokenStorage = new Storage\FilesystemJsonStorage ( $tokenStorage , $this -> secret );
2021-06-06 01:18:44 +02:00
} else {
2021-06-10 14:11:58 +02:00
throw new Exception ( " \$ config['tokenStorage'] parameter must be either a string (path) or an instance of Taproot \ IndieAuth \T okenStorageInterface. " );
2021-06-06 01:18:44 +02:00
}
}
2021-06-10 14:11:58 +02:00
trySetLogger ( $tokenStorage , $this -> logger );
$this -> tokenStorage = $tokenStorage ;
2021-06-06 01:18:44 +02:00
2021-06-06 14:47:05 +02:00
$csrfMiddleware = $config [ 'csrfMiddleware' ];
2021-06-06 01:18:44 +02:00
if ( ! $csrfMiddleware instanceof MiddlewareInterface ) {
// Default to the statless Double-Submit Cookie CSRF Middleware, with default settings.
2021-06-07 20:32:02 +02:00
$csrfMiddleware = new Middleware\DoubleSubmitCookieCsrfMiddleware ( self :: DEFAULT_CSRF_KEY );
2021-06-06 01:18:44 +02:00
}
2021-06-06 14:47:05 +02:00
trySetLogger ( $csrfMiddleware , $this -> logger );
2021-06-06 01:18:44 +02:00
$this -> csrfMiddleware = $csrfMiddleware ;
2021-06-06 14:47:05 +02:00
$httpGetWithEffectiveUrl = $config [ 'httpGetWithEffectiveUrl' ];
if ( ! is_callable ( $httpGetWithEffectiveUrl )) {
if ( class_exists ( '\GuzzleHttp\Client' )) {
$httpGetWithEffectiveUrl = function ( string $uri ) {
2021-06-09 00:21:33 +02:00
// This code can’ t be tested, ignore it for coverage purposes.
// @codeCoverageIgnoreStart
2021-06-06 14:47:05 +02:00
$resp = ( new \GuzzleHttp\Client ([
\GuzzleHttp\RequestOptions :: ALLOW_REDIRECTS => [
'max' => 10 ,
'strict' => true ,
'referer' => true ,
'track_redirects' => true
]
])) -> get ( $uri );
$rdh = $resp -> getHeader ( 'X-Guzzle-Redirect-History' );
$effectiveUrl = empty ( $rdh ) ? $uri : array_values ( $rdh )[ count ( $rdh ) - 1 ];
return [ $resp , $effectiveUrl ];
};
} else {
throw new Exception ( 'No valid $httpGetWithEffectiveUrl was provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable.' );
2021-06-10 20:46:01 +02:00
// @codeCoverageIgnoreEnd
2021-06-06 14:47:05 +02:00
}
}
trySetLogger ( $httpGetWithEffectiveUrl , $this -> logger );
$this -> httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl ;
2021-06-07 20:32:02 +02:00
if ( ! $config [ 'authorizationForm' ] instanceof AuthorizationFormInterface ) {
throw new Exception ( " When provided, \$ config['authorizationForm'] must implement Taproot \ IndieAuth \ Callback \ AuthorizationForm. " );
}
$this -> authorizationForm = $config [ 'authorizationForm' ];
trySetLogger ( $this -> authorizationForm , $this -> logger );
2021-06-06 01:18:44 +02:00
}
2021-06-09 00:06:35 +02:00
/**
* Handle Authorization Endpoint Request
*
2021-06-11 01:21:39 +02:00
* 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 .
*
2021-06-09 00:06:35 +02:00
* @ param ServerRequestInterface $request
* @ return ResponseInterface
*/
2021-06-06 01:18:44 +02:00
public function handleAuthorizationEndpointRequest ( ServerRequestInterface $request ) : ResponseInterface {
2021-06-06 14:47:05 +02:00
$this -> logger -> info ( 'Handling an IndieAuth Authorization Endpoint request.' );
2021-06-06 01:18:44 +02:00
// If it’ s a profile information request:
if ( isIndieAuthAuthorizationCodeRedeemingRequest ( $request )) {
2021-06-06 14:47:05 +02:00
$this -> logger -> info ( 'Handling a request to redeem an authorization code for profile information.' );
2021-06-10 17:49:27 +02:00
$bodyParams = $request -> getParsedBody ();
// Verify that all required parameters are included.
$requiredParameters = [ 'client_id' , 'redirect_uri' , 'code' , 'code_verifier' ];
$missingRequiredParameters = array_filter ( $requiredParameters , function ( $p ) use ( $bodyParams ) {
return ! array_key_exists ( $p , $bodyParams ) || empty ( $bodyParams [ $p ]);
});
if ( ! empty ( $missingRequiredParameters )) {
$this -> logger -> warning ( 'The exchange request was missing required parameters. Returning an error response.' , [ 'missing' => $missingRequiredParameters ]);
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_request' ,
'error_description' => 'The following required parameters were missing or empty: ' . join ( ', ' , $missingRequiredParameters )
]));
}
// Attempt to internally exchange the provided auth code for an access token.
$token = $this -> tokenStorage -> exchangeAuthCodeForAccessToken ( $bodyParams [ 'code' ]);
if ( is_null ( $token )) {
$this -> logger -> error ( 'Attempting to exchange an auth code for a token resulted in null.' , $bodyParams );
2021-06-10 18:18:49 +02:00
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
2021-06-10 17:49:27 +02:00
}
2021-06-06 01:18:44 +02:00
// Verify that it was issued for the same client_id and redirect_uri
2021-06-10 18:18:49 +02:00
if ( $token -> getData ()[ 'client_id' ] !== $bodyParams [ 'client_id' ]
|| $token -> getData ()[ 'redirect_uri' ] !== $bodyParams [ 'redirect_uri' ]) {
2021-06-10 20:05:26 +02:00
$this -> tokenStorage -> revokeAccessToken ( $token -> getKey ());
2021-06-10 18:18:49 +02:00
$this -> logger -> error ( " The provided client_id and/or redirect_uri did not match those stored in the token. " );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
2021-06-06 01:18:44 +02:00
// Check that the supplied code_verifier hashes to the stored code_challenge
2021-06-10 18:23:21 +02:00
// TODO: support method = plain as well as S256.
if ( ! hash_equals ( $token -> getData ()[ 'code_challenge' ], generatePKCECodeChallenge ( $bodyParams [ 'code_verifier' ]))) {
2021-06-10 20:05:26 +02:00
$this -> tokenStorage -> revokeAccessToken ( $token -> getKey ());
2021-06-10 18:23:21 +02:00
$this -> logger -> error ( " The provided code_verifier did not hash to the stored code_challenge " );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
2021-06-06 01:18:44 +02:00
2021-06-10 20:05:26 +02:00
// Check that this token either grants at most the profile scope.
$requestedScopes = explode ( ' ' , $token -> getData ()[ 'scope' ] ? ? '' );
if ( ! empty ( $requestedScopes ) && $requestedScopes != [ 'profile' ]) {
$this -> tokenStorage -> revokeAccessToken ( $token -> getKey ());
$this -> logger -> error ( " An exchange request for a token granting scopes other than “profile” was sent to the authorization endpoint. " );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
// TODO: return an error if the token doesn’ t contain a me key.
2021-06-06 01:18:44 +02:00
// If everything checked out, return {"me": "https://example.com"} response
2021-06-10 20:05:26 +02:00
return new Response ( 200 , [ 'content-type' => 'application/json' ], json_encode ( array_filter ( $token -> getData (), function ( $k ) {
return in_array ( $k , [ 'me' , 'profile' ]);
}, ARRAY_FILTER_USE_KEY )));
2021-06-06 01:18:44 +02:00
}
// Because the special case above isn’ t allowed to be CSRF-protected, we have to do some rather silly
2021-06-09 00:06:35 +02:00
// closure gymnastics here to selectively-CSRF-protect requests which do need it.
2021-06-06 15:13:13 +02:00
return $this -> csrfMiddleware -> process ( $request , new Middleware\ClosureRequestHandler ( function ( ServerRequestInterface $request ) {
2021-06-09 00:06:35 +02:00
// Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it
// to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a
// response.
try {
// If this is an authorization or approval request (allowing POST requests as well to accommodate
// approval requests and custom auth form submission.
if ( isIndieAuthAuthorizationRequest ( $request , [ 'get' , 'post' ])) {
$this -> logger -> info ( 'Handling an authorization request' , [ 'method' => $request -> getMethod ()]);
$queryParams = $request -> getQueryParams ();
// Return an error if we’ re missing required parameters.
$requiredParameters = [ 'client_id' , 'redirect_uri' , 'state' , 'code_challenge' , 'code_challenge_method' ];
$missingRequiredParameters = array_filter ( $requiredParameters , function ( $p ) use ( $queryParams ) {
return ! array_key_exists ( $p , $queryParams ) || empty ( $queryParams [ $p ]);
});
if ( ! empty ( $missingRequiredParameters )) {
$this -> logger -> warning ( 'The authorization request was missing required parameters. Returning an error response.' , [ 'missing' => $missingRequiredParameters ]);
2021-06-11 01:21:39 +02:00
// TODO: if the missing parameter isn’ t redirect_uri or client_id, this should be a redirect error.
2021-06-09 00:06:35 +02:00
throw IndieAuthException :: create ( IndieAuthException :: REQUEST_MISSING_PARAMETER , $request );
2021-06-06 14:47:05 +02:00
}
2021-06-06 01:18:44 +02:00
2021-06-10 17:49:27 +02:00
// Validate the Client ID.
if ( false === filter_var ( $queryParams [ 'client_id' ], FILTER_VALIDATE_URL ) || ! isClientIdentifier ( $queryParams [ 'client_id' ])) {
$this -> logger -> warning ( " The client_id provided in an authorization request was not valid. " , $queryParams );
throw IndieAuthException :: create ( IndieAuthException :: INVALID_CLIENT_ID , $request );
}
// Validate the redirect URI — at this stage only superficially, we’ ll check it properly later if
// things go well.
if ( false === filter_var ( $queryParams [ 'redirect_uri' ], FILTER_VALIDATE_URL )) {
$this -> logger -> warning ( " The client_id provided in an authorization request was not valid. " , $queryParams );
throw IndieAuthException :: create ( IndieAuthException :: INVALID_REDIRECT_URI , $request );
}
// Validate the state parameter.
if ( ! isValidState ( $queryParams [ 'state' ])) {
$this -> logger -> warning ( " The state provided in an authorization request was not valid. " , $queryParams );
throw IndieAuthException :: create ( IndieAuthException :: INVALID_STATE , $request );
}
// Validate code_challenge parameter.
if ( ! isValidCodeChallenge ( $queryParams [ 'code_challenge' ])) {
$this -> logger -> warning ( " The code_challenge provided in an authorization request was not valid. " , $queryParams );
throw IndieAuthException :: create ( IndieAuthException :: INVALID_CODE_CHALLENGE , $request );
}
// Validate the scope parameter, if provided.
if ( array_key_exists ( 'scope' , $queryParams ) && ! isValidScope ( $queryParams [ 'scope' ])) {
$this -> logger -> warning ( " The scope provided in an authorization request was not valid. " , $queryParams );
throw IndieAuthException :: create ( IndieAuthException :: INVALID_SCOPE , $request );
}
2021-06-09 00:06:35 +02:00
// Normalise the me parameter, if it exists.
if ( array_key_exists ( 'me' , $queryParams )) {
$queryParams [ 'me' ] = IndieAuthClient :: normalizeMeURL ( $queryParams [ 'me' ]);
2021-06-10 17:49:27 +02:00
// If the me parameter is not a valid profile URL, ignore it.
if ( false === $queryParams [ 'me' ] || ! isProfileUrl ( $queryParams [ 'me' ])) {
$queryParams [ 'me' ] = null ;
}
2021-06-06 14:47:05 +02:00
}
2021-06-09 00:06:35 +02:00
// Build a URL containing the indieauth authorization request parameters, hashing them
// to protect them from being changed.
// Make a hash of the protected indieauth-specific parameters.
$hash = hashAuthorizationRequestParameters ( $request , $this -> secret );
// Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
$redirectQueryParams = $queryParams ;
$redirectQueryParams [ self :: HASH_QUERY_STRING_KEY ] = $hash ;
$authenticationRedirect = $request -> getUri () -> withQuery ( buildQueryString ( $redirectQueryParams )) -> __toString ();
2021-06-06 14:47:05 +02:00
2021-06-09 00:06:35 +02:00
// User-facing requests always start by calling the authentication request callback.
$this -> logger -> info ( 'Calling handle_authentication_request callback' );
2021-06-10 17:49:27 +02:00
$authenticationResult = call_user_func ( $this -> handleAuthenticationRequestCallback , $request , $authenticationRedirect , $queryParams [ 'me' ] ? ? null );
2021-06-09 00:06:35 +02:00
// If the authentication handler returned a Response, return that as-is.
if ( $authenticationResult instanceof ResponseInterface ) {
return $authenticationResult ;
} elseif ( is_array ( $authenticationResult )) {
// Check the resulting array for errors.
if ( ! array_key_exists ( 'me' , $authenticationResult )) {
$this -> logger -> error ( 'The handle_authentication_request callback returned an array with no me key.' , [ 'array' => $authenticationResult ]);
throw IndieAuthException :: create ( IndieAuthException :: AUTHENTICATION_CALLBACK_MISSING_ME_PARAM , $request );
2021-06-06 14:47:05 +02:00
}
2021-06-09 00:06:35 +02:00
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
if ( isAuthorizationApprovalRequest ( $request )) {
// Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
// authorization request parameters from being changed, e.g. by a malicious script which
// found its way onto the authorization form.
$expectedHash = hashAuthorizationRequestParameters ( $request , $this -> secret );
if ( is_null ( $expectedHash )) {
// In theory this code should never be reached, as we already checked the request for valid parameters.
// However, it’ s possible for hashAuthorizationRequestParameters() to return null, and if for whatever
// reason it does, the library should handle that case as elegantly as possible.
2021-06-09 00:21:33 +02:00
// @codeCoverageIgnoreStart
2021-06-09 00:06:35 +02:00
$this -> logger -> warning ( " Calculating the expected hash for an authorization approval request failed. This SHOULD NOT happen; if you encounter this error please contact the maintainers of taproot/indieauth. " );
throw IndieAuthException :: create ( IndieAuthException :: REQUEST_MISSING_PARAMETER , $request );
2021-06-09 00:21:33 +02:00
// @codeCoverageIgnoreEnd
2021-06-09 00:06:35 +02:00
}
if ( ! array_key_exists ( self :: HASH_QUERY_STRING_KEY , $queryParams )) {
$this -> logger -> warning ( " An authorization approval request did not have a " . self :: HASH_QUERY_STRING_KEY . " parameter. " );
throw IndieAuthException :: create ( IndieAuthException :: AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH , $request );
}
if ( ! hash_equals ( $expectedHash , $queryParams [ self :: HASH_QUERY_STRING_KEY ])) {
$this -> logger -> warning ( " The hash provided in the URL was invalid! " , [
'expected' => $expectedHash ,
'actual' => $queryParams [ self :: HASH_QUERY_STRING_KEY ]
]);
throw IndieAuthException :: create ( IndieAuthException :: AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH , $request );
}
// Assemble the data for the authorization code, store it somewhere persistent.
$code = array_merge ( $authenticationResult , [
'client_id' => $queryParams [ 'client_id' ],
'redirect_uri' => $queryParams [ 'redirect_uri' ],
'state' => $queryParams [ 'state' ],
'code_challenge' => $queryParams [ 'code_challenge' ],
'code_challenge_method' => $queryParams [ 'code_challenge_method' ],
'requested_scope' => $queryParams [ 'scope' ] ? ? '' ,
]);
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
// Pass it to the auth code customisation callback.
$code = $this -> authorizationForm -> transformAuthorizationCode ( $request , $code );
// Store the authorization code.
2021-06-10 14:11:58 +02:00
$authCode = $this -> tokenStorage -> createAuthCode ( $code );
if ( is_null ( $authCode )) {
2021-06-09 00:06:35 +02:00
// If saving the authorization code failed silently, there isn’ t much we can do about it,
// but should at least log and return an error.
$this -> logger -> error ( " Saving the authorization code failed and returned false without raising an exception. " );
2021-06-10 20:46:01 +02:00
throw IndieAuthException :: create ( IndieAuthException :: INTERNAL_ERROR_REDIRECT , $request );
2021-06-09 00:06:35 +02:00
}
// Return a redirect to the client app.
return new Response ( 302 , [ 'Location' => appendQueryParams ( $queryParams [ 'redirect_uri' ], [
2021-06-10 14:11:58 +02:00
'code' => $authCode -> getKey (),
2021-06-09 00:06:35 +02:00
'state' => $code [ 'state' ]
])]);
2021-06-07 20:32:02 +02:00
}
2021-06-09 00:06:35 +02:00
// 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.
2021-06-11 01:21:39 +02:00
// 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.
2021-06-09 00:06:35 +02:00
try {
/** @var ResponseInterface $clientIdResponse */
list ( $clientIdResponse , $clientIdEffectiveUrl ) = call_user_func ( $this -> httpGetWithEffectiveUrl , $queryParams [ 'client_id' ]);
$clientIdMf2 = Mf2\parse (( string ) $clientIdResponse -> getBody (), $clientIdEffectiveUrl );
} catch ( ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e ) {
$this -> logger -> error ( " Caught an HTTP exception while trying to fetch the client_id. Returning an error response. " , [
'client_id' => $queryParams [ 'client_id' ],
'exception' => $e -> __toString ()
2021-06-07 20:32:02 +02:00
]);
2021-06-09 00:06:35 +02:00
throw IndieAuthException :: create ( IndieAuthException :: HTTP_EXCEPTION_FETCHING_CLIENT_ID , $request , $e );
} catch ( Exception $e ) {
$this -> logger -> error ( " Caught an unknown exception while trying to fetch the client_id. Returning an error response. " , [
'exception' => $e -> __toString ()
]);
throw IndieAuthException :: create ( IndieAuthException :: INTERNAL_EXCEPTION_FETCHING_CLIENT_ID , $request , $e );
}
2021-06-07 20:32:02 +02:00
2021-06-09 00:06:35 +02:00
// Search for an h-app with u-url matching the client_id.
$clientHApps = M\findMicroformatsByProperty ( M\findMicroformatsByType ( $clientIdMf2 , 'h-app' ), 'url' , $queryParams [ 'client_id' ]);
$clientHApp = empty ( $clientHApps ) ? null : $clientHApps [ 0 ];
// Search for all link@rel=redirect_uri at the client_id.
$clientIdRedirectUris = [];
if ( array_key_exists ( 'redirect_uri' , $clientIdMf2 [ 'rels' ])) {
$clientIdRedirectUris = array_merge ( $clientIdRedirectUris , $clientIdMf2 [ 'rels' ][ 'redirect_uri' ]);
}
foreach ( HeaderParser :: parse ( $clientIdResponse -> getHeader ( 'Link' )) as $link ) {
if ( array_key_exists ( 'rel' , $link ) && mb_strpos ( " { $link [ 'rel' ] } " , " redirect_uri " ) !== false ) {
// Strip off the < > which surround the link URL for some reason.
$clientIdRedirectUris [] = substr ( $link [ 0 ], 1 , strlen ( $link [ 0 ]) - 2 );
}
}
// If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error.
$clientIdMatchesRedirectUri = urlComponentsMatch ( $queryParams [ 'client_id' ], $queryParams [ 'redirect_uri' ], [ PHP_URL_SCHEME , PHP_URL_HOST , PHP_URL_PORT ]);
$redirectUriValid = $clientIdMatchesRedirectUri || in_array ( $queryParams [ 'redirect_uri' ], $clientIdRedirectUris );
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
if ( ! $redirectUriValid ) {
$this -> logger -> warning ( " The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs. " , [
'provided_redirect_uri' => $queryParams [ 'redirect_uri' ],
'provided_client_id' => $queryParams [ 'client_id' ],
'discovered_redirect_uris' => $clientIdRedirectUris
]);
throw IndieAuthException :: create ( IndieAuthException :: INVALID_REDIRECT_URI , $request );
}
2021-06-06 01:18:44 +02:00
2021-06-09 00:06:35 +02:00
// Present the authorization UI.
return $this -> authorizationForm -> showForm ( $request , $authenticationResult , $authenticationRedirect , $clientHApp );
}
2021-06-06 01:18:44 +02:00
}
2021-06-09 00:06:35 +02:00
// If the request isn’ t an IndieAuth Authorization or Code-redeeming request, it’ s either an invalid
// request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
$nonIndieAuthRequestResult = call_user_func ( $this -> handleNonIndieAuthRequest , $request );
if ( $nonIndieAuthRequestResult instanceof ResponseInterface ) {
return $nonIndieAuthRequestResult ;
} else {
throw IndieAuthException :: create ( IndieAuthException :: INTERNAL_ERROR , $request );
}
} catch ( IndieAuthException $e ) {
// All IndieAuthExceptions will already have been logged.
return $this -> handleException ( $e );
} catch ( Exception $e ) {
// Unknown exceptions will not have been logged; do so now.
$this -> logger -> error ( " Caught unknown exception: { $e } " );
return $this -> handleException ( IndieAuthException :: create ( 0 , $request , $e ));
2021-06-06 01:18:44 +02:00
}
2021-06-09 00:06:35 +02:00
}));
2021-06-06 01:18:44 +02:00
}
2021-06-11 01:21:39 +02:00
/**
* 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
*/
2021-06-06 01:18:44 +02:00
public function handleTokenEndpointRequest ( ServerRequestInterface $request ) : ResponseInterface {
2021-06-10 17:49:27 +02:00
if ( isIndieAuthAuthorizationCodeRedeemingRequest ( $request )) {
$this -> logger -> info ( 'Handling a request to redeem an authorization code for profile information.' );
$bodyParams = $request -> getParsedBody ();
// Verify that all required parameters are included.
$requiredParameters = [ 'client_id' , 'redirect_uri' , 'code' , 'code_verifier' ];
$missingRequiredParameters = array_filter ( $requiredParameters , function ( $p ) use ( $bodyParams ) {
return ! array_key_exists ( $p , $bodyParams ) || empty ( $bodyParams [ $p ]);
});
if ( ! empty ( $missingRequiredParameters )) {
$this -> logger -> warning ( 'The exchange request was missing required parameters. Returning an error response.' , [ 'missing' => $missingRequiredParameters ]);
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_request' ,
'error_description' => 'The following required parameters were missing or empty: ' . join ( ', ' , $missingRequiredParameters )
]));
}
2021-06-10 18:23:21 +02:00
// Attempt to internally exchange the provided auth code for an access token.
$token = $this -> tokenStorage -> exchangeAuthCodeForAccessToken ( $bodyParams [ 'code' ]);
if ( is_null ( $token )) {
$this -> logger -> error ( 'Attempting to exchange an auth code for a token resulted in null.' , $bodyParams );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
// Verify that it was issued for the same client_id and redirect_uri
if ( $token -> getData ()[ 'client_id' ] !== $bodyParams [ 'client_id' ]
|| $token -> getData ()[ 'redirect_uri' ] !== $bodyParams [ 'redirect_uri' ]) {
2021-06-10 20:05:26 +02:00
$this -> tokenStorage -> revokeAccessToken ( $token -> getKey ());
2021-06-10 18:23:21 +02:00
$this -> logger -> error ( " The provided client_id and/or redirect_uri did not match those stored in the token. " );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
// Check that the supplied code_verifier hashes to the stored code_challenge
// TODO: support method = plain as well as S256.
if ( ! hash_equals ( $token -> getData ()[ 'code_challenge' ], generatePKCECodeChallenge ( $bodyParams [ 'code_verifier' ]))) {
2021-06-10 20:05:26 +02:00
$this -> tokenStorage -> revokeAccessToken ( $token -> getKey ());
2021-06-10 18:23:21 +02:00
$this -> logger -> error ( " The provided code_verifier did not hash to the stored code_challenge " );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
2021-06-10 20:05:26 +02:00
// If the auth code was issued with no scope, return an error.
if ( empty ( $token -> getData ()[ 'scope' ])) {
$this -> tokenStorage -> revokeAccessToken ( $token -> getKey ());
$this -> logger -> error ( " Cannot issue an access token with no scopes. " );
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
'error' => 'invalid_grant' ,
'error_description' => 'The provided credentials were not valid.'
]));
}
// If everything checks out, generate an access token and return it.
return new Response ( 200 , [ 'content-type' => 'application/json' ], json_encode ( array_merge ([
'access_token' => $token -> getKey (),
'token_type' => 'Bearer'
], array_filter ( $token -> getData (), function ( $k ) {
return in_array ( $k , [ 'me' , 'profile' , 'scope' ]);
}, ARRAY_FILTER_USE_KEY ))));
2021-06-10 17:49:27 +02:00
}
2021-06-10 18:18:49 +02:00
return new Response ( 400 , [ 'content-type' => 'application/json' ], json_encode ([
2021-06-10 17:49:27 +02:00
'error' => 'invalid_request' ,
'error_description' => 'Request to token endpoint was not a valid code exchange request.'
]));
2021-06-06 01:18:44 +02:00
}
2021-06-09 00:06:35 +02:00
2021-06-11 01:21:39 +02:00
/**
* Handle Exception
*
* Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface` .
*/
2021-06-09 00:06:35 +02:00
protected function handleException ( IndieAuthException $exception ) : ResponseInterface {
2021-06-10 17:49:27 +02:00
$exceptionData = $exception -> getInfo ();
if ( $exceptionData [ 'statusCode' ] == 302 ) {
// This exception is handled by redirecting to the redirect_uri with error parameters.
$redirectQueryParams = [
'error' => $exceptionData [ 'error' ] ? ? 'invalid_request' ,
'error_description' => ( string ) $exception
];
// If the state parameter was valid, include it in the error redirect.
if ( $exception -> getCode () !== IndieAuthException :: INVALID_STATE ) {
$redirectQueryParams [ 'state' ] = $exception -> getRequest () -> getQueryParams ()[ 'state' ];
}
return new Response ( $exceptionData [ 'statusCode' ], [
'Location' => appendQueryParams (( string ) $exception -> getRequest () -> getQueryParams ()[ 'redirect_uri' ], $redirectQueryParams )
]);
} else {
// This exception should be shown to the user.
return new Response ( $exception -> getStatusCode (), [ 'content-type' => 'text/html' ], renderTemplate ( $this -> exceptionTemplatePath , [
'request' => $exception -> getRequest (),
'exception' => $exception
]));
}
2021-06-09 00:06:35 +02:00
}
2021-06-06 01:18:44 +02:00
}