Files
myep-team/tests/Security/OAuth2/MyEpClientTest.php
T

167 lines
6.7 KiB
PHP

<?php
namespace App\Tests\Security\OAuth2;
use App\Security\OAuth2\AuthorizationDeniedException;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\MyEpClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MyEpClientTest extends TestCase
{
public function testAuthorizationUrlCarriesAnS256ChallengeAndStoresItsVerifier(): void
{
$request = $this->createRequest();
$url = $this->createClient()->createAuthorizationUrl($request);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
$this->assertSame('S256', $query['code_challenge_method']);
$session = $request->getSession();
$verifier = $session->get('oauth2pkce');
$this->assertIsString($verifier);
$this->assertSame($session->get('oauth2state'), $query['state']);
// the challenge is what MyE&P stores; the verifier replayed on the token request
// has to hash to it, or the exchange is refused
$expectedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$this->assertSame($expectedChallenge, $query['code_challenge']);
}
public function testDeniedCallbackIsReportedAsADenialAndConsumesTheStoredState(): void
{
$request = $this->createRequest([
'state' => 'expected-state',
'error' => 'access_denied',
'error_description' => 'The user denied the request',
]);
$request->getSession()->set('oauth2state', 'expected-state');
$request->getSession()->set('oauth2pkce', 'verifier');
try {
$this->createClient()->fetchAccessToken($request);
$this->fail('Expected an AuthorizationDeniedException');
} catch (AuthorizationDeniedException $e) {
// MyE&P has named the cause; reporting this as a missing code would not
$this->assertSame('access_denied', $e->getError());
$this->assertSame('The user denied the request', $e->getErrorDescription());
}
$this->assertFalse($request->getSession()->has('oauth2state'));
$this->assertFalse($request->getSession()->has('oauth2pkce'));
}
public function testDeniedCallbackWithAForeignStateIsRefusedAsAMismatch(): void
{
$request = $this->createRequest([
'state' => 'other-state',
'error' => 'access_denied',
]);
$request->getSession()->set('oauth2state', 'expected-state');
$request->getSession()->set('oauth2pkce', 'verifier');
// the state is checked before the error parameter: a callback that cannot prove it
// belongs to this flow must not be able to report a denial into it. Otherwise a
// link to this route would consume the pending state of whoever follows it - the
// session cookie is SameSite=lax, so it rides along on a top-level navigation.
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('Missing state or mismatch');
$this->createClient()->fetchAccessToken($request);
}
public function testCallbackIsRefusedWhenTheSessionHoldsNoVerifier(): void
{
$request = $this->createRequest(['code' => 'a-code', 'state' => 'expected-state']);
$request->getSession()->set('oauth2state', 'expected-state');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('Missing PKCE verifier');
$this->createClient()->fetchAccessToken($request);
}
public function testCallbackWithoutCodeConsumesTheStoredState(): void
{
$request = $this->createRequest(['state' => 'expected-state']);
$request->getSession()->set('oauth2state', 'expected-state');
$request->getSession()->set('oauth2pkce', 'verifier');
try {
$this->createClient()->fetchAccessToken($request);
$this->fail('Expected an AuthorizationRequestException');
} catch (AuthorizationRequestException $e) {
$this->assertSame('Missing code', $e->getMessage());
}
// the state is single-use: a callback that fails must not leave one behind that a
// later attempt could still replay
$this->assertFalse($request->getSession()->has('oauth2state'));
$this->assertFalse($request->getSession()->has('oauth2pkce'));
}
public function testCallbackWithAMismatchedStateConsumesTheStoredState(): void
{
$request = $this->createRequest(['code' => 'a-code', 'state' => 'other-state']);
$request->getSession()->set('oauth2state', 'expected-state');
$request->getSession()->set('oauth2pkce', 'verifier');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('Missing state or mismatch');
try {
$this->createClient()->fetchAccessToken($request);
} finally {
$this->assertFalse($request->getSession()->has('oauth2state'));
$this->assertFalse($request->getSession()->has('oauth2pkce'));
}
}
public function testCallbackIsRefusedWhenTheSessionHoldsNoState(): void
{
$request = $this->createRequest(['code' => 'a-code', 'state' => 'any-state']);
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('Missing state or mismatch');
$this->createClient()->fetchAccessToken($request);
}
private function createClient(): MyEpClient
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->method('generate')
->willReturn('https://myep-team.example.org/myep-auth/check')
;
return new MyEpClient($urlGenerator, $this->createMock(LoggerInterface::class), [
'myep_oauth2_client_id' => 'client-id',
'myep_oauth2_client_secret' => 'client-secret',
'myep_oauth2_url_authorize' => 'https://my.example.org/authorize',
'myep_oauth2_url_access_token' => 'https://my.example.org/token',
'myep_oauth2_url_resource_owner_details' => 'https://my.example.org/api/userinfo',
'myep_oauth2_scopes' => ['email', 'id', 'roles', 'profile'],
]);
}
/**
* @param array<string, string> $query
*/
private function createRequest(array $query = []): Request
{
$request = new Request($query);
$request->setSession(new Session(new MockArraySessionStorage()));
return $request;
}
}