Added auth request parameter validation, tests. Started work on exchange methods

This commit is contained in:
Barnaby Walters 2021-06-10 17:49:27 +02:00
parent 3ae570809e
commit e3c3d124bb
5 changed files with 319 additions and 94 deletions

View File

@ -26,7 +26,7 @@ class SingleUserPasswordAuthenticationCallback {
$this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY; $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
} }
public function __invoke(ServerRequestInterface $request, string $formAction) { public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl) {
// If the request is a form submission with a matching password, return the corresponding // If the request is a form submission with a matching password, return the corresponding
// user data. // user data.
if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) { if ($request->getMethod() == 'POST' && password_verify($request->getParsedBody()[self::PASSWORD_FORM_PARAMETER] ?? '', $this->hashedPassword)) {

View File

@ -15,6 +15,10 @@ class IndieAuthException extends Exception {
const HTTP_EXCEPTION_FETCHING_CLIENT_ID = 5; const HTTP_EXCEPTION_FETCHING_CLIENT_ID = 5;
const INTERNAL_EXCEPTION_FETCHING_CLIENT_ID = 6; const INTERNAL_EXCEPTION_FETCHING_CLIENT_ID = 6;
const INVALID_REDIRECT_URI = 7; const INVALID_REDIRECT_URI = 7;
const INVALID_CLIENT_ID = 8;
const INVALID_STATE = 9;
const INVALID_CODE_CHALLENGE = 10;
const INVALID_SCOPE = 11;
const EXC_INFO = [ const EXC_INFO = [
self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'], self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'],
@ -25,7 +29,11 @@ class IndieAuthException extends Exception {
// TODO: should this one be a 500 because its an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care? // TODO: should this one be a 500 because its 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::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::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.'] self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) either was not a valid URI, did not sufficiently match client_id, or did not exactly match any redirect URIs parsed from fetching the client_id.'],
self::INVALID_CLIENT_ID => ['statusCode' => 400, 'name' => 'Invalid Client Identifier URI', 'explanation' => 'The Client Identifier was not valid.'],
self::INVALID_STATE => ['statusCode' => 302, 'name' => 'Invalid state Parameter', 'error' => 'invalid_request'],
self::INVALID_CODE_CHALLENGE => ['statusCode' => 302, 'name' => 'Invalid code_challenge Parameter', 'error' => 'invalid_request'],
self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'],
]; ];
protected ServerRequestInterface $request; protected ServerRequestInterface $request;
@ -42,11 +50,15 @@ class IndieAuthException extends Exception {
} }
public function getStatusCode() { public function getStatusCode() {
return self::EXC_INFO[$this->code]['statusCode'] ?? 500; return $this->getInfo()['statusCode'] ?? 500;
} }
public function getExplanation() { public function getExplanation() {
return self::EXC_INFO[$this->code]['explanation'] ?? 'An unknown error occured.'; return $this->getInfo()['explanation'] ?? 'An unknown error occured.';
}
public function getInfo() {
return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR];
} }
public function trustQueryParams() { public function trustQueryParams() {

View File

@ -158,8 +158,29 @@ class Server {
// If its a profile information request: // If its a profile information request:
if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) { if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
$this->logger->info('Handling a request to redeem an authorization code for profile information.'); $this->logger->info('Handling a request to redeem an authorization code for profile information.');
// Verify that the authorization code is valid and has not yet been used.
$this->authorizationCodeStorage->get($request->getParsedBody()['code']); $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);
}
// Verify that it was issued for the same client_id and redirect_uri // Verify that it was issued for the same client_id and redirect_uri
@ -196,9 +217,44 @@ class Server {
throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request); throw IndieAuthException::create(IndieAuthException::REQUEST_MISSING_PARAMETER, $request);
} }
// 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, well 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);
}
// Normalise the me parameter, if it exists. // Normalise the me parameter, if it exists.
if (array_key_exists('me', $queryParams)) { if (array_key_exists('me', $queryParams)) {
$queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']); $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
// If the me parameter is not a valid profile URL, ignore it.
if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) {
$queryParams['me'] = null;
}
} }
// Build a URL containing the indieauth authorization request parameters, hashing them // Build a URL containing the indieauth authorization request parameters, hashing them
@ -212,7 +268,7 @@ class Server {
// User-facing requests always start by calling the authentication request callback. // User-facing requests always start by calling the authentication request callback.
$this->logger->info('Calling handle_authentication_request callback'); $this->logger->info('Calling handle_authentication_request callback');
$authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect); $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null);
// If the authentication handler returned a Response, return that as-is. // If the authentication handler returned a Response, return that as-is.
if ($authenticationResult instanceof ResponseInterface) { if ($authenticationResult instanceof ResponseInterface) {
@ -360,6 +416,30 @@ class Server {
} }
public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface { public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
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)
]));
}
}
return new Response(400, ['contet-type' => 'application/json'], json_encode([
'error' => 'invalid_request',
'error_description' => 'Request to token endpoint was not a valid code exchange request.'
]));
// This is a request to redeem an authorization_code for an access_token. // This is a request to redeem an authorization_code for an access_token.
// Verify that the authorization code is valid and has not yet been used. // Verify that the authorization code is valid and has not yet been used.
@ -374,9 +454,29 @@ class Server {
} }
protected function handleException(IndieAuthException $exception): ResponseInterface { protected function handleException(IndieAuthException $exception): ResponseInterface {
$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, [ return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [
'request' => $exception->getRequest(), 'request' => $exception->getRequest(),
'exception' => $exception 'exception' => $exception
])); ]));
} }
}
} }

View File

@ -107,7 +107,8 @@ class FunctionTest extends TestCase {
public function testIsValidState() { public function testIsValidState() {
$testCases = [ $testCases = [
'hisdfbusdgiueryb@#$%^&*(' => true 'hisdfbusdgiueryb@#$%^&*(' => true,
"\x19" => false
]; ];
foreach ($testCases as $test => $expected) { foreach ($testCases as $test => $expected) {

View File

@ -14,6 +14,7 @@ use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback;
use Taproot\IndieAuth\IndieAuthException; use Taproot\IndieAuth\IndieAuthException;
use Taproot\IndieAuth\Server; use Taproot\IndieAuth\Server;
use Taproot\IndieAuth\Storage\FilesystemJsonStorage; use Taproot\IndieAuth\Storage\FilesystemJsonStorage;
use Taproot\IndieAuth\Storage\TokenStorageInterface;
use function Taproot\IndieAuth\hashAuthorizationRequestParameters; use function Taproot\IndieAuth\hashAuthorizationRequestParameters;
use function Taproot\IndieAuth\urlComponentsMatch; use function Taproot\IndieAuth\urlComponentsMatch;
@ -25,6 +26,11 @@ const AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH = __DIR__ . '/templates/aut
const TMP_DIR = __DIR__ . '/tmp'; const TMP_DIR = __DIR__ . '/tmp';
class ServerTest extends TestCase { class ServerTest extends TestCase {
/**
* Utility Methods
*/
protected function getDefaultServer(array $config=[]) { protected function getDefaultServer(array $config=[]) {
return new Server(array_merge([ return new Server(array_merge([
'secret' => SERVER_SECRET, 'secret' => SERVER_SECRET,
@ -100,6 +106,10 @@ class ServerTest extends TestCase {
@rmdir(TOKEN_STORAGE_PATH); @rmdir(TOKEN_STORAGE_PATH);
} }
/**
* Authorization Request Tests
*/
public function testAuthorizationRequestMissingParametersReturnsError() { public function testAuthorizationRequestMissingParametersReturnsError() {
$s = $this->getDefaultServer(); $s = $this->getDefaultServer();
@ -110,6 +120,78 @@ class ServerTest extends TestCase {
$this->assertEquals((string) IndieAuthException::REQUEST_MISSING_PARAMETER, (string) $res->getBody()); $this->assertEquals((string) IndieAuthException::REQUEST_MISSING_PARAMETER, (string) $res->getBody());
} }
public function testAuthorizationRequestWithInvalidClientIdOrRedirectUriShowsErrorToUser() {
$testCases = [
'client_id not a URI' => [
'expectedError' => IndieAuthException::INVALID_CLIENT_ID,
'queryParams' => ['client_id' => 'this string is definitely not a valid URI']
],
'client_id host was an IP address' => [
'expectedError' => IndieAuthException::INVALID_CLIENT_ID,
'queryParams' => ['client_id' => 'https://12.56.12.5/']
],
'redirect_uri not a URI' => [
'expectedError' => IndieAuthException::INVALID_REDIRECT_URI,
'queryParams' => ['redirect_uri' => 'again, definitely not a valid URI.']
]
];
foreach ($testCases as $testName => $testData) {
$s = $this->getDefaultServer();
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest($testData['queryParams']));
$this->assertEquals((string) $testData['expectedError'], (string) $res->getBody(), "Case “{$testName}” did not result in expected error {$testData['expectedError']}.");
}
}
public function testInvalidStateCodeChallengeOrScopeReturnErrorRedirects() {
$testCases = [
'Invalid state' => [
'expectedError' => IndieAuthException::INVALID_STATE,
'queryParams' => ['state' => "This unprintable ASCII character is not allowed in state: \x19"]
],
'Invalid code_challenge' => [
'expectedError' => IndieAuthException::INVALID_CODE_CHALLENGE,
'queryParams' => ['code_challenge' => 'has_bad_characters_in_*%#ü____']
],
'Invalid scope' => [
'expectedError' => IndieAuthException::INVALID_SCOPE,
'queryParams' => ['scope' => '" is not a permitted scope character']
]
];
foreach ($testCases as $testName => $testData) {
$s = $this->getDefaultServer();
$res = $s->handleAuthorizationEndpointRequest($this->getIARequest($testData['queryParams']));
$this->assertEquals(302, $res->getStatusCode(), "Case “{$testName}” should result in a redirect error.");
$expectedErrorName = IndieAuthException::EXC_INFO[IndieAuthException::INVALID_STATE]['error'];
parse_str(parse_url($res->getHeaderLine('location'), PHP_URL_QUERY), $redirectQueryParams);
$this->assertEquals($expectedErrorName, $redirectQueryParams['error']);
}
}
public function testHandlesValidAndInvalidMeUrlsCorrectly() {
$testCases = [
'example.com' => 'http://example.com/',
'https://example.com' => 'https://example.com/',
'https://example.com/path?query' => 'https://example.com/path?query',
'invalid URL' => null,
'https://example.com/foo/../bar' => null,
'https://example.com/#me' => null,
'https://user:pass@example.com/' => null,
'https://example.com:8443/' => null,
'https://172.28.92.51/' => null
];
foreach ($testCases as $meUrl => $expected) {
$s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl) use ($expected) {
$this->assertEquals($expected, $normalizedMeUrl);
}
]);
$s->handleAuthorizationEndpointRequest($this->getIARequest(['me' => $meUrl]));
}
}
public function testUnauthenticatedRequestReturnsAuthenticationResponse() { public function testUnauthenticatedRequestReturnsAuthenticationResponse() {
$expectedResponse = 'You need to authenticate before continuing!'; $expectedResponse = 'You need to authenticate before continuing!';
$s = $this->getDefaultServer([ $s = $this->getDefaultServer([
@ -159,88 +241,6 @@ class ServerTest extends TestCase {
} }
} }
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 its 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(TOKEN_STORAGE_PATH, SECRET);
$storedCode = $storage->get(hash_hmac('sha256', $redirectUriQueryParams['code'], SECRET));
$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() { public function testReturnsErrorIfRedirectUriDoesntMatchClientIdWithNoParsedRedirectUris() {
$s = $this->getDefaultServer([ $s = $this->getDefaultServer([
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array { Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
@ -415,6 +415,118 @@ EOT
$this->assertEquals($correctHAppPhoto, $flatHApp['photo']); $this->assertEquals($correctHAppPhoto, $flatHApp['photo']);
} }
/**
* Test Authorization Approval Requests
*/
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 its 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(TOKEN_STORAGE_PATH, SECRET);
$storedCode = $storage->get(hash_hmac('sha256', $redirectUriQueryParams['code'], SECRET));
$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.");
}
/**
* Test Authorization Token Exchange Requests
*/
public function testBothExchangePathsReturnErrorsIfParametersAreMissing() {
$s = $this->getDefaultServer();
$req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([
'grant_type' => 'authorization_code'
]);
$authEndpointResponse = $s->handleAuthorizationEndpointRequest($req);
$this->assertEquals(400, $authEndpointResponse->getStatusCode());
$authEndpointJson = json_decode((string) $authEndpointResponse->getBody(), true);
$this->assertEquals('invalid_request', $authEndpointJson['error']);
$tokenEndpointResponse = $s->handleTokenEndpointRequest($req);
$this->assertEquals(400, $tokenEndpointResponse->getStatusCode());
$tokenEndpointJson = json_decode((string) $tokenEndpointResponse->getBody(), true);
$this->assertEquals('invalid_request', $tokenEndpointJson['error']);
}
/**
* Test Non-Indieauth Requests.
*/
public function testNonIndieAuthRequestWithDefaultHandlerReturnsError() { public function testNonIndieAuthRequestWithDefaultHandlerReturnsError() {
$res = $this->getDefaultServer()->handleAuthorizationEndpointRequest(new ServerRequest('GET', 'https://example.com')); $res = $this->getDefaultServer()->handleAuthorizationEndpointRequest(new ServerRequest('GET', 'https://example.com'));