149 lines
5.1 KiB
PHP
149 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Security\OAuth2;
|
|
|
|
use App\Security\OAuth2\AuthorizationDeniedException;
|
|
use App\Security\OAuth2\AuthorizationRequestException;
|
|
use App\Security\OAuth2\EpClient;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Session\Session;
|
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
|
|
/**
|
|
* The two legs of the authorization code flow that do not need myep to answer.
|
|
*
|
|
* Everything asserted here happens before a single HTTP request goes out: what the browser is
|
|
* sent to myep with, and which callbacks are refused without an exchange being attempted.
|
|
*/
|
|
class EpClientTest extends TestCase
|
|
{
|
|
public function testTheAuthorizationUrlCarriesAnS256Challenge(): void
|
|
{
|
|
$request = $this->request();
|
|
$url = $this->client()->authorizationUrl($request);
|
|
|
|
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
|
|
|
|
self::assertSame('S256', $query['code_challenge_method'] ?? null);
|
|
self::assertNotEmpty($query['code_challenge'] ?? '');
|
|
|
|
// The verifier itself never leaves the session -- what went out is its hash.
|
|
$verifier = $request->getSession()->get('oauth2pkce');
|
|
self::assertIsString($verifier);
|
|
self::assertSame(
|
|
rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
|
|
$query['code_challenge'],
|
|
);
|
|
self::assertSame($query['state'] ?? null, $request->getSession()->get('oauth2state'));
|
|
}
|
|
|
|
/**
|
|
* A callback that myep explained. Reporting it as a missing code would tell the person
|
|
* something untrue and lose the reason.
|
|
*/
|
|
public function testARefusedAuthorizationIsReportedAsSuch(): void
|
|
{
|
|
$request = $this->callbackRequest(['error' => 'access_denied', 'error_description' => 'Nope.']);
|
|
|
|
try {
|
|
$this->client()->fetchAccessToken($request);
|
|
self::fail('Expected the callback to be refused.');
|
|
} catch (AuthorizationDeniedException $e) {
|
|
self::assertSame('access_denied', $e->error);
|
|
self::assertSame('Nope.', $e->errorDescription);
|
|
}
|
|
}
|
|
|
|
public function testAMismatchedStateIsRefused(): void
|
|
{
|
|
$this->expectException(AuthorizationRequestException::class);
|
|
$this->expectExceptionMessage('state parameter');
|
|
|
|
$this->client()->fetchAccessToken($this->callbackRequest(['code' => 'c', 'state' => 'somebody else\'s']));
|
|
}
|
|
|
|
/**
|
|
* The classic "no state in session means pass" bug.
|
|
*/
|
|
public function testACallbackIntoASessionThatStartedNoFlowIsRefused(): void
|
|
{
|
|
$this->expectException(AuthorizationRequestException::class);
|
|
|
|
$request = $this->callbackRequest(['code' => 'c', 'state' => 'anything'], state: null);
|
|
|
|
$this->client()->fetchAccessToken($request);
|
|
}
|
|
|
|
public function testACallbackWithoutTheVerifierIsRefusedRatherThanExchanged(): void
|
|
{
|
|
$this->expectException(AuthorizationRequestException::class);
|
|
$this->expectExceptionMessage('PKCE verifier');
|
|
|
|
$this->client()->fetchAccessToken($this->callbackRequest(['code' => 'c', 'state' => 'st'], verifier: null));
|
|
}
|
|
|
|
/**
|
|
* Both are consumed whatever the outcome, so the same callback cannot be replayed.
|
|
*/
|
|
public function testTheStateAndTheVerifierAreConsumedEvenOnAFailedCallback(): void
|
|
{
|
|
$request = $this->callbackRequest(['state' => 'st']);
|
|
|
|
try {
|
|
$this->client()->fetchAccessToken($request);
|
|
} catch (AuthorizationRequestException) {
|
|
}
|
|
|
|
self::assertFalse($request->getSession()->has('oauth2state'));
|
|
self::assertFalse($request->getSession()->has('oauth2pkce'));
|
|
}
|
|
|
|
private function client(): EpClient
|
|
{
|
|
$urls = $this->createStub(UrlGeneratorInterface::class);
|
|
$urls->method('generate')->willReturn('https://ephub.test/auth/check');
|
|
|
|
return new EpClient($urls, [
|
|
'client_id' => 'id',
|
|
'client_secret' => 'secret',
|
|
'url_authorize' => 'https://myep.test/authorize',
|
|
'url_access_token' => 'https://myep.test/token',
|
|
'url_userinfo' => 'https://myep.test/api/userinfo',
|
|
'scopes' => ['email', 'roles'],
|
|
]);
|
|
}
|
|
|
|
private function request(): Request
|
|
{
|
|
$request = new Request();
|
|
$request->setSession(new Session(new MockArraySessionStorage()));
|
|
|
|
return $request;
|
|
}
|
|
|
|
/**
|
|
* A callback as it arrives from myep, into a session that started the flow.
|
|
*
|
|
* @param array<string, string> $query
|
|
*/
|
|
private function callbackRequest(array $query, ?string $state = 'st', ?string $verifier = 'verifier'): Request
|
|
{
|
|
$request = $this->request();
|
|
$request->query->replace($query);
|
|
|
|
if (null !== $state) {
|
|
$request->getSession()->set('oauth2state', $state);
|
|
}
|
|
|
|
if (null !== $verifier) {
|
|
$request->getSession()->set('oauth2pkce', $verifier);
|
|
}
|
|
|
|
return $request;
|
|
}
|
|
}
|