167 lines
6.7 KiB
PHP
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;
|
|
}
|
|
}
|