Refactored Exception Handling, mostly tested authorization request handler
* Internal error conditions now raise IndieAuthException * Bubbled unknown exceptions converted to generic IndieAuthException * Exceptions passed to overridable handler, turned into response * Wrote many more tests, fixed a variety of problems
This commit is contained in:
parent
61bc3d7418
commit
6d5e93b07c
@ -33,6 +33,13 @@ class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwar
|
|||||||
$scopes[$s] = null; // Ideally there would be a description of the scope here, we don’t have one though.
|
$scopes[$s] = null; // Ideally there would be a description of the scope here, we don’t have one though.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_null($clientHApp)) {
|
||||||
|
$clientHApp = [
|
||||||
|
'type' => ['h-app'],
|
||||||
|
'properties' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$hApp = [
|
$hApp = [
|
||||||
'name' => M\getProp($clientHApp, 'name'),
|
'name' => M\getProp($clientHApp, 'name'),
|
||||||
'url' => M\getProp($clientHApp, 'url'),
|
'url' => M\getProp($clientHApp, 'url'),
|
||||||
@ -62,7 +69,6 @@ class DefaultAuthorizationForm implements AuthorizationFormInterface, LoggerAwar
|
|||||||
// You may wish to additionally make any other necessary changes to the the code based on
|
// You may wish to additionally make any other necessary changes to the the code based on
|
||||||
// the form submission, e.g. if the user set a custom token lifetime, or wanted extra data
|
// the form submission, e.g. if the user set a custom token lifetime, or wanted extra data
|
||||||
// stored on the token to affect how it behaves.
|
// stored on the token to affect how it behaves.
|
||||||
|
|
||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
60
src/IndieAuthException.php
Normal file
60
src/IndieAuthException.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Taproot\IndieAuth;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class IndieAuthException extends Exception {
|
||||||
|
const INTERNAL_ERROR = 0;
|
||||||
|
const REQUEST_MISSING_PARAMETER = 1;
|
||||||
|
const AUTHENTICATION_CALLBACK_MISSING_ME_PARAM = 2;
|
||||||
|
const AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH = 3;
|
||||||
|
const AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH = 4;
|
||||||
|
const HTTP_EXCEPTION_FETCHING_CLIENT_ID = 5;
|
||||||
|
const INTERNAL_EXCEPTION_FETCHING_CLIENT_ID = 6;
|
||||||
|
const INVALID_REDIRECT_URI = 7;
|
||||||
|
|
||||||
|
const EXC_INFO = [
|
||||||
|
self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'],
|
||||||
|
self::REQUEST_MISSING_PARAMETER => ['statusCode' => 400, 'name' => 'Request Missing Parameter', 'explanation' => 'The request was missing one or more required query string parameters.'],
|
||||||
|
self::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'The user data returned from handleAuthenticationRequestCallback was missing a “me” parameter.'],
|
||||||
|
self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH => ['statusCode' => 400, 'name' => 'Request Missing Hash', 'explanation' => 'An authentication form submission request was missing the hash parameter.'],
|
||||||
|
self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH => ['statusCode' => 400, 'name' => 'Request Hash Invalid', 'explanation' => 'The hash protecting the query string parameters from tampering was invalid. Your form submission may have been altered by malicious client-side code.'],
|
||||||
|
// TODO: should this one be a 500 because it’s an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care?
|
||||||
|
self::HTTP_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Error Fetching Client App URL', 'explanation' => 'Fetching the client app (client_id) failed.'],
|
||||||
|
self::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Internal Error fetching client app URI', 'explanation' => 'Fetching the client app (client_id) failed due to an internal error.'],
|
||||||
|
self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) did not sufficiently match client_id, or exactly match any redirect URIs parsed from fetching the client_id.']
|
||||||
|
];
|
||||||
|
|
||||||
|
protected ServerRequestInterface $request;
|
||||||
|
|
||||||
|
public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self {
|
||||||
|
// Only accept known codes. Default to 0 (generic internal error) on an unrecognised code.
|
||||||
|
if (!in_array($code, array_keys(self::EXC_INFO))) {
|
||||||
|
$code = 0;
|
||||||
|
}
|
||||||
|
$message = self::EXC_INFO[$code]['name'];
|
||||||
|
$e = new self($message, $code, $previous);
|
||||||
|
$e->request = $request;
|
||||||
|
return $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusCode() {
|
||||||
|
return self::EXC_INFO[$this->code]['statusCode'] ?? 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExplanation() {
|
||||||
|
return self::EXC_INFO[$this->code]['explanation'] ?? 'An unknown error occured.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trustQueryParams() {
|
||||||
|
return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH
|
||||||
|
|| $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequest() {
|
||||||
|
return $this->request;
|
||||||
|
}
|
||||||
|
}
|
350
src/Server.php
350
src/Server.php
@ -35,29 +35,31 @@ class Server {
|
|||||||
const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback';
|
const HANDLE_AUTHENTICATION_REQUEST = 'handleAuthenticationRequestCallback';
|
||||||
const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
|
const HASH_QUERY_STRING_KEY = 'taproot_indieauth_server_hash';
|
||||||
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
|
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
|
||||||
|
const APPROVE_ACTION_KEY = 'taproot_indieauth_action';
|
||||||
|
const APPROVE_ACTION_VALUE = 'approve';
|
||||||
|
|
||||||
public $callbacks;
|
protected Storage\TokenStorageInterface $authorizationCodeStorage;
|
||||||
|
|
||||||
public Storage\TokenStorageInterface $authorizationCodeStorage;
|
protected Storage\TokenStorageInterface $accessTokenStorage;
|
||||||
|
|
||||||
public Storage\TokenStorageInterface $accessTokenStorage;
|
protected AuthorizationFormInterface $authorizationForm;
|
||||||
|
|
||||||
public AuthorizationFormInterface $authorizationForm;
|
protected MiddlewareInterface $csrfMiddleware;
|
||||||
|
|
||||||
public MiddlewareInterface $csrfMiddleware;
|
protected LoggerInterface $logger;
|
||||||
|
|
||||||
public LoggerInterface $logger;
|
protected $httpGetWithEffectiveUrl;
|
||||||
|
|
||||||
public HttpClientInterface $httpClient;
|
protected $handleAuthenticationRequestCallback;
|
||||||
|
|
||||||
public $httpGetWithEffectiveUrl;
|
protected $handleNonIndieAuthRequest;
|
||||||
|
|
||||||
public $handleAuthenticationRequestCallback;
|
protected string $exceptionTemplatePath;
|
||||||
|
|
||||||
public $handleNonIndieAuthRequest;
|
|
||||||
|
|
||||||
protected string $secret;
|
protected string $secret;
|
||||||
|
|
||||||
|
protected int $tokenLength;
|
||||||
|
|
||||||
public function __construct(array $config) {
|
public function __construct(array $config) {
|
||||||
$config = array_merge([
|
$config = array_merge([
|
||||||
'csrfMiddleware' => null,
|
'csrfMiddleware' => null,
|
||||||
@ -67,8 +69,20 @@ class Server {
|
|||||||
'accessTokenStorage' => null,
|
'accessTokenStorage' => null,
|
||||||
'httpGetWithEffectiveUrl' => null,
|
'httpGetWithEffectiveUrl' => null,
|
||||||
'authorizationForm' => new DefaultAuthorizationForm(),
|
'authorizationForm' => new DefaultAuthorizationForm(),
|
||||||
|
'exceptionTemplatePath' => __DIR__ . '/templates/default_exception_response.html.php',
|
||||||
|
'tokenLength' => 64
|
||||||
], $config);
|
], $config);
|
||||||
|
|
||||||
|
if (!is_int($config['tokenLength'])) {
|
||||||
|
throw new Exception("\$config['tokenLength'] must be an int!");
|
||||||
|
}
|
||||||
|
$this->tokenLength = $config['tokenLength'];
|
||||||
|
|
||||||
|
if (!is_string($config['exceptionTemplatePath'])) {
|
||||||
|
throw new Exception("\$config['secret'] must be a string (path).");
|
||||||
|
}
|
||||||
|
$this->exceptionTemplatePath = $config['exceptionTemplatePath'];
|
||||||
|
|
||||||
$secret = $config['secret'] ?? '';
|
$secret = $config['secret'] ?? '';
|
||||||
if (!is_string($secret) || strlen($secret) < 64) {
|
if (!is_string($secret) || strlen($secret) < 64) {
|
||||||
throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters.");
|
throw new Exception("\$config['secret'] must be a string with a minimum length of 64 characters.");
|
||||||
@ -153,6 +167,12 @@ class Server {
|
|||||||
trySetLogger($this->authorizationForm, $this->logger);
|
trySetLogger($this->authorizationForm, $this->logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Authorization Endpoint Request
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
* @return ResponseInterface
|
||||||
|
*/
|
||||||
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
||||||
$this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
|
$this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
|
||||||
|
|
||||||
@ -175,163 +195,188 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
|
// Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
|
||||||
// gymnastics here to selectively-CSRF-protect requests which do need it.
|
// closure gymnastics here to selectively-CSRF-protect requests which do need it.
|
||||||
return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) {
|
return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) {
|
||||||
// If this is an authorization or approval request (allowing POST requests as well to accommodate
|
// Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it
|
||||||
// approval requests and custom auth form submission.
|
// to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a
|
||||||
if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
|
// response.
|
||||||
$this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]);
|
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();
|
$queryParams = $request->getQueryParams();
|
||||||
// Return an error if we’re missing required parameters.
|
// Return an error if we’re missing required parameters.
|
||||||
$requiredParameters = ['client_id', 'redirect_uri', 'state', 'code_challenge', 'code_challenge_method'];
|
$requiredParameters = ['client_id', 'redirect_uri', 'state', 'code_challenge', 'code_challenge_method'];
|
||||||
$missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($queryParams) {
|
$missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($queryParams) {
|
||||||
return !array_key_exists($p, $queryParams) || empty($queryParams[$p]);
|
return !array_key_exists($p, $queryParams) || empty($queryParams[$p]);
|
||||||
});
|
});
|
||||||
if (!empty($missingRequiredParameters)) {
|
if (!empty($missingRequiredParameters)) {
|
||||||
$this->logger->warning('The authorization request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
|
$this->logger->warning('The authorization request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
|
||||||
// TODO: return a better response, or at least allow the library consumer to configure a better response.
|
throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request);
|
||||||
return new Response(400, ['content-type' => 'text/plain'], 'The IndieAuth request was missing the following parameters: ' . join("\n", $missingRequiredParameters));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalise the me parameter, if it exists.
|
|
||||||
if (array_key_exists('me', $queryParams)) {
|
|
||||||
$queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
$queryParams[self::HASH_QUERY_STRING_KEY] = $hash;
|
|
||||||
$authenticationRedirect = $request->getUri()->withQuery(buildQueryString($queryParams))->__toString();
|
|
||||||
|
|
||||||
// User-facing requests always start by calling the authentication request callback.
|
|
||||||
$this->logger->info('Calling handle_authentication_request callback');
|
|
||||||
$authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect);
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the client_id URL to find information about the client to present to the user.
|
// Normalise the me parameter, if it exists.
|
||||||
try {
|
if (array_key_exists('me', $queryParams)) {
|
||||||
/** @var ResponseInterface $clientIdResponse */
|
$queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
|
||||||
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()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
|
|
||||||
'exception' => $e->__toString()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
// Search for an h-app with u-url matching the client_id.
|
// User-facing requests always start by calling the authentication request callback.
|
||||||
$clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client-id']);
|
$this->logger->info('Calling handle_authentication_request callback');
|
||||||
$clientHApp = empty($clientHApps) ? null : $clientHApps[0];
|
$authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect);
|
||||||
|
|
||||||
// Search for all link@rel=redirect_uri at the client_id.
|
// If the authentication handler returned a Response, return that as-is.
|
||||||
$clientIdRedirectUris = [];
|
if ($authenticationResult instanceof ResponseInterface) {
|
||||||
if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) {
|
return $authenticationResult;
|
||||||
$clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']);
|
} elseif (is_array($authenticationResult)) {
|
||||||
}
|
// Check the resulting array for errors.
|
||||||
foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) {
|
if (!array_key_exists('me', $authenticationResult)) {
|
||||||
if (array_key_exists('rel', $link) and str_contains(" {$link['rel']} ", " redirect_uri ")) {
|
$this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]);
|
||||||
// Strip off the < > which surround the link URL for some reason.
|
throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request);
|
||||||
$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.
|
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
|
||||||
$cidComponents = M\parseUrl($queryParams['client_id']);
|
if (isAuthorizationApprovalRequest($request)) {
|
||||||
$ruriComponents = M\parseUrl($queryParams['redirect_uri']);
|
// Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
|
||||||
$clientIdMatchesRedirectUri = $cidComponents['scheme'] == $ruriComponents['scheme']
|
// authorization request parameters from being changed, e.g. by a malicious script which
|
||||||
&& $cidComponents['host'] == $ruriComponents['host']
|
// found its way onto the authorization form.
|
||||||
&& $cidComponents['port'] == $ruriComponents['port'];
|
$expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
|
||||||
$redirectUriValid = $clientIdMatchesRedirectUri || in_array($queryParams['redirect_uri'], $clientIdRedirectUris);
|
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.
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!$redirectUriValid) {
|
if (!hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
|
||||||
$this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [
|
$this->logger->warning("The hash provided in the URL was invalid!", [
|
||||||
'provided_redirect_uri' => $queryParams['redirect_uri'],
|
'expected' => $expectedHash,
|
||||||
'provided_client_id' => $queryParams['client_id'],
|
'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
|
||||||
'discovered_redirect_uris' => $clientIdRedirectUris
|
]);
|
||||||
]);
|
throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request);
|
||||||
|
}
|
||||||
return new Response(500, ['content-type' => 'text/plain'], 'An internal error occurred.');
|
|
||||||
}
|
// Assemble the data for the authorization code, store it somewhere persistent.
|
||||||
|
$code = array_merge($authenticationResult, [
|
||||||
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
|
'client_id' => $queryParams['client_id'],
|
||||||
if (isAuthorizationApprovalRequest($request)) {
|
'redirect_uri' => $queryParams['redirect_uri'],
|
||||||
// Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
|
'state' => $queryParams['state'],
|
||||||
// authorization request parameters from being changed, e.g. by a malicious script which
|
'code_challenge' => $queryParams['code_challenge'],
|
||||||
// found its way onto the authorization form.
|
'code_challenge_method' => $queryParams['code_challenge_method'],
|
||||||
$expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
|
'requested_scope' => $queryParams['scope'] ?? '',
|
||||||
if (is_null($expectedHash)) {
|
'code' => generateRandomString($this->tokenLength)
|
||||||
$this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
|
|
||||||
return new Response(400, ['content-type' => 'text/plain'], 'The ' . self::HASH_QUERY_STRING_KEY . ' parameter was missing!');
|
|
||||||
}
|
|
||||||
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]
|
|
||||||
]);
|
]);
|
||||||
return new Response(400, ['content-type' => 'text/plain'], 'Invalid hash!');
|
|
||||||
|
// Pass it to the auth code customisation callback.
|
||||||
|
$code = $this->authorizationForm->transformAuthorizationCode($request, $code);
|
||||||
|
|
||||||
|
// Store the authorization code.
|
||||||
|
$success = $this->authorizationCodeStorage->put($code['code'], $code);
|
||||||
|
if (!$success) {
|
||||||
|
// 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.");
|
||||||
|
throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a redirect to the client app.
|
||||||
|
return new Response(302, ['Location' => appendQueryParams($queryParams['redirect_uri'], [
|
||||||
|
'code' => $code['code'],
|
||||||
|
'state' => $code['state']
|
||||||
|
])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble the data for the authorization code, store it somewhere persistent.
|
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
|
||||||
$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'] ?? '',
|
|
||||||
'code' => generateRandomString(256)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Pass it to the auth code customisation callback, if any.
|
// Fetch the client_id URL to find information about the client to present to the user.
|
||||||
|
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()
|
||||||
|
]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
$code = $this->authorizationForm->transformAuthorizationCode($request, $code);
|
// Search for an h-app with u-url matching the client_id.
|
||||||
// Store the authorization code.
|
$clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']);
|
||||||
$this->authorizationCodeStorage->put($code['code'], $code);
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return a redirect to the client app.
|
// If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error.
|
||||||
return new Response(302, ['Location' => appendQueryParams($queryParams['redirect_uri'], [
|
$clientIdMatchesRedirectUri = urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT]);
|
||||||
'code' => $code['code'],
|
$redirectUriValid = $clientIdMatchesRedirectUri || in_array($queryParams['redirect_uri'], $clientIdRedirectUris);
|
||||||
'state' => $code['state']
|
|
||||||
])]);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present the authorization UI.
|
||||||
|
return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
|
|
||||||
|
|
||||||
// Present the authorization UI.
|
|
||||||
return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
|
// 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.)
|
// 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);
|
$nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request);
|
||||||
if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
|
if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
|
||||||
return $nonIndieAuthRequestResult;
|
return $nonIndieAuthRequestResult;
|
||||||
} else {
|
} else {
|
||||||
return new Response(400, ['content-type' => 'text/plain'], 'Invalid request!');
|
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));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
|
||||||
@ -347,4 +392,11 @@ class Server {
|
|||||||
|
|
||||||
// If everything checks out, generate an access token and return it.
|
// If everything checks out, generate an access token and return it.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function handleException(IndieAuthException $exception): ResponseInterface {
|
||||||
|
return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [
|
||||||
|
'request' => $exception->getRequest(),
|
||||||
|
'exception' => $exception
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -53,7 +53,7 @@ class FilesystemJsonStorage implements TokenStorageInterface {
|
|||||||
public function put(string $key, array $data): bool {
|
public function put(string $key, array $data): bool {
|
||||||
// Ensure that the containing folder exists.
|
// Ensure that the containing folder exists.
|
||||||
@mkdir($this->path, 0777, true);
|
@mkdir($this->path, 0777, true);
|
||||||
|
|
||||||
return file_put_contents($this->getPath($key), json_encode($data)) !== false;
|
return file_put_contents($this->getPath($key), json_encode($data)) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace Taproot\IndieAuth;
|
namespace Taproot\IndieAuth;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use IndieAuth\Client;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Log\LoggerAwareInterface;
|
use Psr\Log\LoggerAwareInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
use function BarnabyWalters\Mf2\parseUrl;
|
||||||
|
|
||||||
// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
|
// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
|
||||||
function generateRandomString($numBytes) {
|
function generateRandomString($numBytes) {
|
||||||
if (function_exists('random_bytes')) {
|
if (function_exists('random_bytes')) {
|
||||||
@ -51,7 +55,7 @@ function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, $permi
|
|||||||
function isAuthorizationApprovalRequest(ServerRequestInterface $request) {
|
function isAuthorizationApprovalRequest(ServerRequestInterface $request) {
|
||||||
return strtolower($request->getMethod()) == 'post'
|
return strtolower($request->getMethod()) == 'post'
|
||||||
&& array_key_exists('taproot_indieauth_action', $request->getParsedBody())
|
&& array_key_exists('taproot_indieauth_action', $request->getParsedBody())
|
||||||
&& $request->getParsedBody()['taproot_indieauth_action'] == 'approve';
|
&& $request->getParsedBody()[Server::APPROVE_ACTION_KEY] == Server::APPROVE_ACTION_VALUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueryString(array $parameters) {
|
function buildQueryString(array $parameters) {
|
||||||
@ -62,6 +66,23 @@ function buildQueryString(array $parameters) {
|
|||||||
return join('&', $qs);
|
return join('&', $qs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function urlComponentsMatch($url1, $url2, ?array $components=null): bool {
|
||||||
|
$validComponents = [PHP_URL_HOST, PHP_URL_PASS, PHP_URL_PATH, PHP_URL_PORT, PHP_URL_USER, PHP_URL_QUERY, PHP_URL_SCHEME, PHP_URL_FRAGMENT];
|
||||||
|
$components = $components ?? $validComponents;
|
||||||
|
|
||||||
|
foreach ($components as $cmp) {
|
||||||
|
if (!in_array($cmp, $validComponents)) {
|
||||||
|
throw new Exception("Invalid parse_url() component passed: $cmp");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append Query Parameters
|
* Append Query Parameters
|
||||||
*
|
*
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Taproot\IndieAuth\Server;
|
||||||
|
|
||||||
/** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */
|
/** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */
|
||||||
/** @var Psr\Http\Message\ServerRequestInterface $request */
|
/** @var Psr\Http\Message\ServerRequestInterface $request */
|
||||||
/** @var array|null $clientHApp */
|
/** @var array|null $clientHApp */
|
||||||
@ -107,7 +110,7 @@
|
|||||||
<a class="cancel-link" href="<?= htmlentities($clientId) ?>">Cancel (back to <?= $clientHApp['name'] ?? 'app' ?>)</a>
|
<a class="cancel-link" href="<?= htmlentities($clientId) ?>">Cancel (back to <?= $clientHApp['name'] ?? 'app' ?>)</a>
|
||||||
|
|
||||||
<!-- Your form MUST be submitted with taproot_indieauth_action=approve for the approval submission to work. -->
|
<!-- Your form MUST be submitted with taproot_indieauth_action=approve for the approval submission to work. -->
|
||||||
<button type="submit" name="taproot_indieauth_action" value="approve">Authorize</button>
|
<button type="submit" name="<?= Server::APPROVE_ACTION_KEY ?>" value="<?= Server::APPROVE_ACTION_VALUE ?>">Authorize</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
32
src/templates/default_exception_response.html.php
Normal file
32
src/templates/default_exception_response.html.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
/** @var Taproot\IndieAuth\IndieAuthExcepton $exception */
|
||||||
|
/** @var Psr\Http\Message\ServerRequestInterface $request */
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>IndieAuth • Error!</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Error: <?= htmlentities($exception->getMessage()) ?></h1>
|
||||||
|
|
||||||
|
<p><?= htmlentities($exception->getExplanation()) ?></p>
|
||||||
|
|
||||||
|
<!-- If $exception->trustQueryParams() returns false, then the query parameters have been tampered with
|
||||||
|
and we shouldn’t offer the user a redirect back to the client_id! -->
|
||||||
|
<?php if ($exception->trustQueryParams()): ?>
|
||||||
|
<p><a href="<?= htmlentities($request->getQueryParams()['client_id']) ?>">Return to app (<?= htmlentities($request->getQueryParams()['client_id']) ?> ?>)</a>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<!-- You’ll probably want to offer the user a suitable link to leave the flow. -->
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<small>Powered by <a href="https://taprootproject.com">taproot/indieauth</a></small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -2,17 +2,30 @@
|
|||||||
|
|
||||||
namespace Taproot\IndieAuth\Test;
|
namespace Taproot\IndieAuth\Test;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
use Nyholm\Psr7\Response;
|
use Nyholm\Psr7\Response;
|
||||||
use Nyholm\Psr7\ServerRequest;
|
use Nyholm\Psr7\ServerRequest;
|
||||||
|
use Nyholm\Psr7\Request;
|
||||||
|
use PDO;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Message\RequestInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm;
|
||||||
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
|
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
|
||||||
|
use Taproot\IndieAuth\IndieAuthException;
|
||||||
|
use Taproot\IndieAuth\Middleware\NoOpMiddleware;
|
||||||
use Taproot\IndieAuth\Server;
|
use Taproot\IndieAuth\Server;
|
||||||
use Taproot\IndieAuth\Storage\FilesystemJsonStorage;
|
use Taproot\IndieAuth\Storage\FilesystemJsonStorage;
|
||||||
|
|
||||||
|
use function Taproot\IndieAuth\hashAuthorizationRequestParameters;
|
||||||
|
use function Taproot\IndieAuth\urlComponentsMatch;
|
||||||
|
|
||||||
const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111';
|
const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||||
const AUTH_CODE_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
|
const AUTH_CODE_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
|
||||||
const ACCESS_TOKEN_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
|
const ACCESS_TOKEN_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes';
|
||||||
|
const CODE_EXCEPTION_TEMPLATE_PATH = __DIR__ . '/templates/code_exception_response.txt.php';
|
||||||
|
const AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH = __DIR__ . '/templates/authorization_form_json_response.json.php';
|
||||||
const TMP_DIR = __DIR__ . '/tmp';
|
const TMP_DIR = __DIR__ . '/tmp';
|
||||||
|
|
||||||
class ServerTest extends TestCase {
|
class ServerTest extends TestCase {
|
||||||
@ -21,11 +34,15 @@ class ServerTest extends TestCase {
|
|||||||
'secret' => SERVER_SECRET,
|
'secret' => SERVER_SECRET,
|
||||||
'authorizationCodeStorage' => AUTH_CODE_STORAGE_PATH,
|
'authorizationCodeStorage' => AUTH_CODE_STORAGE_PATH,
|
||||||
'accessTokenStorage' => ACCESS_TOKEN_STORAGE_PATH,
|
'accessTokenStorage' => ACCESS_TOKEN_STORAGE_PATH,
|
||||||
Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT))
|
// With this template, IndieAuthException response bodies will contain only their IndieAuthException error code, for ease of comparison.
|
||||||
|
'exceptionTemplatePath' => CODE_EXCEPTION_TEMPLATE_PATH,
|
||||||
|
// Default to a simple single-user password authentication handler.
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback(['me' => 'https://example.com/'], password_hash('password', PASSWORD_DEFAULT), Server::DEFAULT_CSRF_KEY),
|
||||||
|
'authorizationForm' => new DefaultAuthorizationForm(AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH),
|
||||||
], $config));
|
], $config));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getIARequest(array $params=[]) {
|
protected function getIARequest(array $params=[]): ServerRequestInterface {
|
||||||
return (new ServerRequest('GET', 'https://example.com/'))->withQueryParams(array_merge([
|
return (new ServerRequest('GET', 'https://example.com/'))->withQueryParams(array_merge([
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
'client_id' => 'https://app.example.com/',
|
'client_id' => 'https://app.example.com/',
|
||||||
@ -36,6 +53,36 @@ class ServerTest extends TestCase {
|
|||||||
], $params));
|
], $params));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getApprovalRequest(bool $validCsrf=false, bool $addValidHash=false, ?array $queryParams=null, ?array $parsedBody=null): ServerRequestInterface {
|
||||||
|
$queryParams = $queryParams ?? [];
|
||||||
|
$parsedBody = $parsedBody ?? [];
|
||||||
|
$cookieParams = [];
|
||||||
|
|
||||||
|
$parsedBody[Server::APPROVE_ACTION_KEY] = Server::APPROVE_ACTION_VALUE;
|
||||||
|
|
||||||
|
// Assume Middleware\DoubleSubmitCookieCsrfMiddleware is being used.
|
||||||
|
$csrfVal = 'random_and_secure_csrf_value';
|
||||||
|
if ($validCsrf) {
|
||||||
|
$parsedBody[Server::DEFAULT_CSRF_KEY] = $csrfVal;
|
||||||
|
$cookieParams = [
|
||||||
|
Server::DEFAULT_CSRF_KEY => $csrfVal
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$req = $this->getIARequest($queryParams)
|
||||||
|
->withMethod('POST')
|
||||||
|
->withParsedBody($parsedBody)
|
||||||
|
->withCookieParams($cookieParams);
|
||||||
|
|
||||||
|
if ($addValidHash) {
|
||||||
|
$req = $req->withQueryParams(array_merge($req->getQueryParams(), [
|
||||||
|
Server::HASH_QUERY_STRING_KEY => hashAuthorizationRequestParameters($req, SERVER_SECRET)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $req;
|
||||||
|
}
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
// Clean up tmp folder.
|
// Clean up tmp folder.
|
||||||
new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH, -1, true);
|
new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH, -1, true);
|
||||||
@ -55,9 +102,11 @@ class ServerTest extends TestCase {
|
|||||||
public function testAuthorizationRequestMissingParametersReturnsError() {
|
public function testAuthorizationRequestMissingParametersReturnsError() {
|
||||||
$s = $this->getDefaultServer();
|
$s = $this->getDefaultServer();
|
||||||
|
|
||||||
$req = (new ServerRequest('GET', 'https://example.com/'));
|
$req = (new ServerRequest('GET', 'https://example.com/'))->withQueryParams([
|
||||||
|
'response_type' => 'code' // This param is required to identify the request as an IA authorization request.
|
||||||
|
]);
|
||||||
$res = $s->handleAuthorizationEndpointRequest($req);
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
$this->assertEquals(400, $res->getStatusCode());
|
$this->assertEquals((string) IndieAuthException::REQUEST_MISSING_PARAMETER, (string) $res->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnauthenticatedRequestReturnsAuthenticationResponse() {
|
public function testUnauthenticatedRequestReturnsAuthenticationResponse() {
|
||||||
@ -83,27 +132,311 @@ class ServerTest extends TestCase {
|
|||||||
|
|
||||||
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
|
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
|
||||||
|
|
||||||
$this->assertEquals(500, $res->getStatusCode());
|
$this->assertEquals((string) IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, (string) $res->getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testReturnServerErrorIfFetchingClientIdThrowsException() {
|
public function testReturnErrorIfFetchingClientIdThrowsException() {
|
||||||
$exceptionClasses = ['GuzzleHttp\Exception\ConnectException', 'GuzzleHttp\Exception\RequestException'];
|
$exceptionClasses = [
|
||||||
foreach ($exceptionClasses as $eClass) {
|
'GuzzleHttp\Exception\ConnectException' => (string) IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID,
|
||||||
$req = $this->getIARequest();
|
'GuzzleHttp\Exception\RequestException' => (string) IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID,
|
||||||
|
'Exception' => (string) IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID
|
||||||
|
];
|
||||||
|
foreach ($exceptionClasses as $eClass => $expectedResponse) {
|
||||||
$s = $this->getDefaultServer([
|
$s = $this->getDefaultServer([
|
||||||
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
|
||||||
return ['me' => 'https://example.com/'];
|
return ['me' => 'https://example.com/'];
|
||||||
},
|
},
|
||||||
'httpGetWithEffectiveUrl' => function ($url) use ($eClass, $req) {
|
'httpGetWithEffectiveUrl' => function ($url) use ($eClass) {
|
||||||
throw new $eClass($eClass, $req);
|
if ($eClass == 'Exception') { throw new Exception(); }
|
||||||
|
throw new $eClass($eClass, new Request('GET', $url));
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$res = $s->handleAuthorizationEndpointRequest($req);
|
|
||||||
|
|
||||||
$this->assertEquals(500, $res->getStatusCode());
|
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest());
|
||||||
|
|
||||||
|
$this->assertEquals($expectedResponse, (string) $res->getBody());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testReturnsErrorIfApprovalRequestHasNoHash() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
|
||||||
|
return ['me' => 'https://example.com'];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($this->getApprovalRequest(true, false));
|
||||||
|
|
||||||
|
$this->assertEquals((string) IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, (string) $res->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsErrorIfApprovalRequestHasInvalidHash() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
|
||||||
|
return ['me' => 'https://example.com'];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
$req = $this->getApprovalRequest(true, false);
|
||||||
|
$req = $req->withQueryParams(array_merge($req->getQueryParams(), [
|
||||||
|
Server::HASH_QUERY_STRING_KEY => 'clearly not a valid hash'
|
||||||
|
]));
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals((string) IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, (string) $res->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidApprovalRequestIsHandledCorrectly() {
|
||||||
|
// Make a valid authentication response with additional information, to make sure that it’s saved
|
||||||
|
// in the authorization code.
|
||||||
|
$authenticationResponse = [
|
||||||
|
'me' => 'https://me.example.com/',
|
||||||
|
'profile' => [
|
||||||
|
'name' => 'Example Name'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) use ($authenticationResponse) {
|
||||||
|
return $authenticationResponse;
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Make an approval request with valid CSRF tokens, a valid query parameter hash, one requested scope
|
||||||
|
// (different from the two granted scopes, so that we can test that requested and granted scopes are
|
||||||
|
// stored separately) and a redirect URI with a query string (so we can test that our IA query string
|
||||||
|
// parameters are appended correctly).
|
||||||
|
$grantedScopes = ['create', 'update'];
|
||||||
|
$req = $this->getApprovalRequest(true, true, [
|
||||||
|
'scope' => 'create',
|
||||||
|
'redirect_uri' => 'https://app.example.com/indieauth?client_redirect_query_string_param=value'
|
||||||
|
], [
|
||||||
|
'taproot_indieauth_server_scope[]' => $grantedScopes
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals(302, $res->getStatusCode(), 'The Response from a successful approval request must be a 302 redirect.');
|
||||||
|
|
||||||
|
$responseLocation = $res->getHeaderLine('location');
|
||||||
|
$queryParams = $req->getQueryParams();
|
||||||
|
parse_str(parse_url($responseLocation, PHP_URL_QUERY), $redirectUriQueryParams);
|
||||||
|
|
||||||
|
$this->assertTrue(urlComponentsMatch($responseLocation, $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_USER, PHP_URL_PORT, PHP_URL_HOST, PHP_URL_PORT, PHP_URL_PATH]), 'The successful redirect response location did not match the redirect URI up to the path.');
|
||||||
|
$this->assertEquals($redirectUriQueryParams['state'], $queryParams['state'], 'The redirect URI state parameter did not match the authorization request state parameter.');
|
||||||
|
$this->assertEquals('value', $redirectUriQueryParams['client_redirect_query_string_param'], 'Query string params in the client app redirect_uri were not correctly preserved.');
|
||||||
|
|
||||||
|
$storage = new FilesystemJsonStorage(AUTH_CODE_STORAGE_PATH);
|
||||||
|
$storedCode = $storage->get($redirectUriQueryParams['code']);
|
||||||
|
|
||||||
|
$this->assertNotNull($storedCode, 'An authorization code should be stored after a successful approval request.');
|
||||||
|
|
||||||
|
foreach (['client_id', 'redirect_uri', 'state', 'code_challenge', 'code_challenge_method'] as $p) {
|
||||||
|
$this->assertEquals($queryParams[$p], $storedCode[$p], "Parameter $p in the stored code ({$storedCode[$p]}) was not the same as the request parameter ($queryParams[$p]).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue(scopeEquals($queryParams['scope'], $storedCode['requested_scope']), "The requested scopes in the stored code ({$storedCode['requested_scope']}) did not match the scopes in the scope query parameter ({$queryParams['scope']}).");
|
||||||
|
$this->assertTrue(scopeEquals($grantedScopes, $storedCode['scope']), "The granted scopes in the stored code ({$storedCode['scope']}) did not match the granted scopes from the authorization form response (" . join(' ', $grantedScopes) . ").");
|
||||||
|
|
||||||
|
$this->assertEquals($authenticationResponse['me'], $storedCode['me'], "The “me” value in the stored code ({$storedCode['me']}) did not match the “me” value from the authentication response ({$authenticationResponse['me']}).");
|
||||||
|
$this->assertEquals($authenticationResponse['profile'], $storedCode['profile'], "The “profile” value in the stored code did not match the “profile” value from the authentication response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsErrorIfRedirectUriDoesntMatchClientIdWithNoParsedRedirectUris() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
|
||||||
|
return ['me' => 'https://me.example.com'];
|
||||||
|
},
|
||||||
|
'httpGetWithEffectiveUrl' => function ($url): array {
|
||||||
|
// An empty response suffices for this test.
|
||||||
|
return [
|
||||||
|
new Response(200, ['content-type' => 'text/html'], '' ),
|
||||||
|
$url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = $this->getIARequest([
|
||||||
|
'client_id' => 'https://client.example.com/',
|
||||||
|
'redirect_uri' => 'https://not-the-client.example.com/auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals((string) IndieAuthException::INVALID_REDIRECT_URI, (string) $res->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsErrorIfRedirectUriDoesntMatchClientIdOrParsedRedirectUris() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
|
||||||
|
return ['me' => 'https://me.example.com'];
|
||||||
|
},
|
||||||
|
'httpGetWithEffectiveUrl' => function ($url): array {
|
||||||
|
// Pass some tricky values to test for correct rel parsing.
|
||||||
|
return [
|
||||||
|
new Response(200, [
|
||||||
|
'content-type' => 'text/html',
|
||||||
|
'link' => [
|
||||||
|
'<https://not-the-client.example.com/auth>; rel="wrong_redirect_uri_rel"', // Matches redirect_uri but has wrong rel
|
||||||
|
'<https://invalid.example.com/redirect>; rel="redirect_uri"' // redirect_uri is correct but url is invalid.
|
||||||
|
]
|
||||||
|
],
|
||||||
|
<<<EOT
|
||||||
|
Rel correct, href not: <link rel="redirect_uri" href="https://yet-another-invalid.example.com/redirect" />
|
||||||
|
href matches redirect_uri, wrong rel: <link rel="another_incorrect_redirect_uri" href="https://not-the-client.example.com/auth" />
|
||||||
|
EOT
|
||||||
|
),
|
||||||
|
$url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = $this->getIARequest([
|
||||||
|
'client_id' => 'https://client.example.com/',
|
||||||
|
'redirect_uri' => 'https://not-the-client.example.com/auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals((string) IndieAuthException::INVALID_REDIRECT_URI, (string) $res->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsAuthorizationFormIfClientIdSufficientlyMatchesRedirectUri() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
|
||||||
|
return ['me' => 'https://me.example.com'];
|
||||||
|
},
|
||||||
|
'httpGetWithEffectiveUrl' => function ($url): array {
|
||||||
|
return [
|
||||||
|
new Response(200, ['content-type' => 'text/html'], ''), // An empty response suffices for this test.
|
||||||
|
$url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = $this->getIARequest([
|
||||||
|
'client_id' => 'https://client.example.com/',
|
||||||
|
'redirect_uri' => 'https://client.example.com/auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $res->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsAuthorizationFormIfClientIdExactlyMatchesParsedLinkHeaderRedirectUri() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
|
||||||
|
return ['me' => 'https://me.example.com'];
|
||||||
|
},
|
||||||
|
'httpGetWithEffectiveUrl' => function ($url): array {
|
||||||
|
return [
|
||||||
|
new Response(200, [
|
||||||
|
'content-type' => 'text/html',
|
||||||
|
'link' => '<https://link-header.example.com/auth>; rel="another_rel redirect_uri"'
|
||||||
|
],
|
||||||
|
''
|
||||||
|
),
|
||||||
|
$url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = $this->getIARequest([
|
||||||
|
'client_id' => 'https://client.example.com/',
|
||||||
|
'redirect_uri' => 'https://link-header.example.com/auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $res->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsAuthorizationFormIfClientIdExactlyMatchesParsedLinkElementRedirectUri() {
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
|
||||||
|
return ['me' => 'https://me.example.com'];
|
||||||
|
},
|
||||||
|
'httpGetWithEffectiveUrl' => function ($url): array {
|
||||||
|
return [
|
||||||
|
new Response(200, ['content-type' => 'text/html'],
|
||||||
|
'<link rel="redirect_uri another_rel" href="https://link-element.example.com/auth" />'
|
||||||
|
),
|
||||||
|
$url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = $this->getIARequest([
|
||||||
|
'client_id' => 'https://client.example.com/',
|
||||||
|
'redirect_uri' => 'https://link-element.example.com/auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $res->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFindsFirstHAppExactlyMatchingClientId() {
|
||||||
|
$correctHAppName = 'Correct h-app!';
|
||||||
|
$correctHAppUrl = 'https://client.example.com/';
|
||||||
|
$correctHAppPhoto = 'https://client.example.com/logo.png';
|
||||||
|
|
||||||
|
$s = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
|
||||||
|
return ['me' => 'https://me.example.com'];
|
||||||
|
},
|
||||||
|
'httpGetWithEffectiveUrl' => function ($url) use ($correctHAppPhoto, $correctHAppName, $correctHAppUrl) {
|
||||||
|
return [
|
||||||
|
new Response(200, ['content-type' => 'text/html'],
|
||||||
|
<<<EOT
|
||||||
|
<a class="h-app" href="https://not-the-client.example.com/">Wrong</a>
|
||||||
|
|
||||||
|
<a class="h-app" href="{$correctHAppUrl}"><img src="{$correctHAppPhoto}" alt="{$correctHAppName}" /></a>
|
||||||
|
EOT
|
||||||
|
),
|
||||||
|
$url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$req = $this->getIARequest([
|
||||||
|
'client_id' => $correctHAppUrl,
|
||||||
|
'redirect_uri' => 'https://client.example.com/auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $s->handleAuthorizationEndpointRequest($req);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $res->getStatusCode());
|
||||||
|
|
||||||
|
$parsedResponse = json_decode((string) $res->getBody(), true);
|
||||||
|
$flatHApp = $parsedResponse['clientHApp'];
|
||||||
|
$this->assertEquals($correctHAppUrl, $flatHApp['url']);
|
||||||
|
$this->assertEquals($correctHAppName, $flatHApp['name']);
|
||||||
|
$this->assertEquals($correctHAppPhoto, $flatHApp['photo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonIndieAuthRequestWithDefaultHandlerReturnsError() {
|
||||||
|
$res = $this->getDefaultServer()->handleAuthorizationEndpointRequest(new ServerRequest('GET', 'https://example.com'));
|
||||||
|
|
||||||
|
$this->assertEquals((string) IndieAuthException::INTERNAL_ERROR, (string) $res->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResponseReturnedFromNonIndieAuthRequestHandler() {
|
||||||
|
$responseBody = 'A response to a non-indieauth request.';
|
||||||
|
|
||||||
|
$res = $this->getDefaultServer([
|
||||||
|
Server::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) use ($responseBody) {
|
||||||
|
return new Response(200, ['content-type' => 'text/plain'], $responseBody);
|
||||||
|
}
|
||||||
|
])->handleAuthorizationEndpointRequest(new ServerRequest('GET', 'https://example.com'));
|
||||||
|
|
||||||
|
$this->assertEquals($responseBody, (string) $res->getBody());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeEquals($scope1, $scope2): bool {
|
||||||
|
$scope1 = is_string($scope1) ? explode(' ', $scope1) : $scope1;
|
||||||
|
$scope2 = is_string($scope2) ? explode(' ', $scope2) : $scope2;
|
||||||
|
sort($scope1);
|
||||||
|
sort($scope2);
|
||||||
|
return $scope1 == $scope2;
|
||||||
}
|
}
|
||||||
|
21
tests/templates/authorization_form_json_response.json.php
Normal file
21
tests/templates/authorization_form_json_response.json.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/** @var string $formAction The URL to POST to to authorize the app, or to set as the redirect URL for a logout action if the user wants to continue as a different user. */
|
||||||
|
/** @var Psr\Http\Message\ServerRequestInterface $request */
|
||||||
|
/** @var array|null $clientHApp */
|
||||||
|
/** @var array $user */
|
||||||
|
/** @var array $scopes */
|
||||||
|
/** @var string $clientId */
|
||||||
|
/** @var string $clientRedirectUri */
|
||||||
|
/** @var string $csrfFormElement A pre-rendered CSRF form element which must be output inside the authorization form. */
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'formAction' => $formAction,
|
||||||
|
'clientHApp' => $clientHApp,
|
||||||
|
'user' => $user,
|
||||||
|
'scopes' => $scopes,
|
||||||
|
'clientId' => $clientId,
|
||||||
|
'clientRedirectUri' => $clientRedirectUri,
|
||||||
|
'csrfFormElement' => $csrfFormElement
|
||||||
|
]);
|
||||||
|
?>
|
4
tests/templates/code_exception_response.txt.php
Normal file
4
tests/templates/code_exception_response.txt.php
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
/** @var Taproot\IndieAuth\IndieAuthExcepton $exception */
|
||||||
|
/** @var Psr\Http\Message\ServerRequestInterface $request */
|
||||||
|
?><?= $exception->getCode() ?>
|
Reference in New Issue
Block a user