Files
ephub/tests/Security/EpClientTest.php

131 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
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 EpClientTest extends KernelTestCase
{
public function testAnUnregisteredClientIsReportedAsSuch(): void
{
// The state the project is in until somebody runs create-client on myep. The login page
// checks this so it can explain itself instead of redirecting into a broken flow.
self::assertFalse($this->client(clientId: '', clientSecret: '')->isConfigured());
self::assertFalse($this->client(clientId: 'ephub', clientSecret: '')->isConfigured());
self::assertTrue($this->client()->isConfigured());
}
public function testTheAuthorizationUrlCarriesTheClientAndScopes(): void
{
$request = $this->request();
$url = $this->client()->authorizationUrl($request);
self::assertStringStartsWith('https://myep.test/authorize?', $url);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
self::assertSame('ephub', $query['client_id']);
self::assertSame('code', $query['response_type']);
// myep's server wants space-separated scopes; the library's default is a comma.
self::assertSame('email profile roles', $query['scope']);
self::assertStringEndsWith('/auth/check', $query['redirect_uri']);
}
public function testTheStateIsRememberedOnTheSession(): void
{
$request = $this->request();
$url = $this->client()->authorizationUrl($request);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
self::assertNotEmpty($query['state']);
self::assertSame($query['state'], $request->getSession()->get('oauth2state'));
}
public function testACallbackWithoutACodeIsRefused(): void
{
$request = $this->request(['state' => 'abc']);
$request->getSession()->set('oauth2state', 'abc');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('no authorization code');
$this->client()->fetchAccessToken($request);
}
/**
* Without this check an attacker can hand a victim a callback URL carrying the attacker's
* code and bind the victim's session to the attacker's identity.
*/
public function testACallbackWhoseStateDoesNotMatchIsRefused(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'forged']);
$request->getSession()->set('oauth2state', 'expected');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('state parameter does not match');
$this->client()->fetchAccessToken($request);
}
public function testACallbackWithNoStateOnTheSessionIsRefused(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'anything']);
$this->expectException(AuthorizationRequestException::class);
$this->client()->fetchAccessToken($request);
}
/**
* The stored state is consumed whether or not the check passed, so the same callback URL
* cannot be replayed.
*/
public function testTheStoredStateIsConsumedEvenOnFailure(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'forged']);
$session = $request->getSession();
$session->set('oauth2state', 'expected');
try {
$this->client()->fetchAccessToken($request);
} catch (AuthorizationRequestException) {
}
self::assertNull($session->get('oauth2state'));
}
/**
* @param array<string, string> $query
*/
private function request(array $query = []): Request
{
$request = new Request($query);
$request->setSession(new Session(new MockArraySessionStorage()));
return $request;
}
private function client(string $clientId = 'ephub', string $clientSecret = 'secret'): EpClient
{
self::bootKernel();
return new EpClient(self::getContainer()->get(UrlGeneratorInterface::class), [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'url_authorize' => 'https://myep.test/authorize',
'url_access_token' => 'https://myep.test/token',
'url_userinfo' => 'https://myep.test/api/userinfo',
'scopes' => ['email', 'profile', 'roles'],
]);
}
}