feat: harden the myep OAuth2 login

This commit is contained in:
fromme committed 2026-09-23 15:18:43 +02:00
1 parent 4517b84023
commit 91419294b8
25 files changed
+1013 -35

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
<?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;
}
}