Added auth request parameter validation, tests. Started work on exchange methods
This commit is contained in:
parent
3ae570809e
commit
e3c3d124bb
@ -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)) {
|
||||||
|
@ -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 it’s 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 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::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() {
|
||||||
|
106
src/Server.php
106
src/Server.php
@ -158,8 +158,29 @@ class Server {
|
|||||||
// If it’s a profile information request:
|
// If it’s 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, we’ll check it properly later if
|
||||||
|
// things go well.
|
||||||
|
if (false === filter_var($queryParams['redirect_uri'], FILTER_VALIDATE_URL)) {
|
||||||
|
$this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
|
||||||
|
throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the state parameter.
|
||||||
|
if (!isValidState($queryParams['state'])) {
|
||||||
|
$this->logger->warning("The state provided in an authorization request was not valid.", $queryParams);
|
||||||
|
throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate code_challenge parameter.
|
||||||
|
if (!isValidCodeChallenge($queryParams['code_challenge'])) {
|
||||||
|
$this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams);
|
||||||
|
throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the scope parameter, if provided.
|
||||||
|
if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) {
|
||||||
|
$this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams);
|
||||||
|
throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request);
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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 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(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 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(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'));
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user