feat: gate oauth2 authorization on per-client roles

This commit is contained in:
2026-09-23 13:46:56 +02:00
parent 79709e3a3e
commit cc3d32fcbb
6 changed files with 436 additions and 11 deletions
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Entity\OAuth2Client;
use App\Entity\User;
use App\EventListener\AuthorizationCodeListener;
use App\Security\Role;
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use League\Bundle\OAuth2ServerBundle\Model\ClientInterface;
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Twig\Environment;
class AuthorizationCodeListenerTest extends TestCase
{
public function testAnonymousRequestIsRedirectedToLoginWithoutResolving(): void
{
$event = $this->dispatch(null, $this->client(['ROLE_EMPLOYEE']));
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame(Response::HTTP_TEMPORARY_REDIRECT, $response->getStatusCode());
self::assertFalse($event->getAuthorizationResolution());
}
public function testAccountHoldingOneOfTheRequiredRolesIsApproved(): void
{
$event = $this->dispatch(
$this->user([Role::EMPLOYEE]),
$this->client([Role::EMPLOYEE, Role::ADMIN])
);
self::assertTrue($event->getAuthorizationResolution());
self::assertNull($event->getResponse());
}
public function testAccountHoldingNoneOfTheRequiredRolesIsDenied(): void
{
$event = $this->dispatch(
$this->user([Role::CUSTOMER]),
$this->client([Role::EMPLOYEE, Role::ADMIN])
);
self::assertFalse($event->getAuthorizationResolution());
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
}
/**
* A CRM nomination grants nothing until an administrator approves it, so it must not
* open a client either.
*/
public function testPendingMarkerDoesNotSatisfyTheRequirement(): void
{
$event = $this->dispatch(
$this->user([Role::ADMIN.Role::PENDING_SUFFIX]),
$this->client([Role::ADMIN])
);
self::assertFalse($event->getAuthorizationResolution());
}
/**
* The column starts empty, so "nobody configured this client" and "this client needs
* no roles" are the same value. Only one of the two readings is safe.
*/
public function testClientWithoutConfiguredRolesIsDenied(): void
{
$event = $this->dispatch($this->user([Role::ADMIN]), $this->client([]));
self::assertFalse($event->getAuthorizationResolution());
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
}
public function testClientOfAnotherImplementationIsDenied(): void
{
$foreign = $this->createStub(ClientInterface::class);
$foreign->method('getIdentifier')->willReturn('f3f10f8807e191c127d74cc9a247bf45');
$event = $this->dispatch($this->user([Role::ADMIN]), $foreign);
self::assertFalse($event->getAuthorizationResolution());
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
}
public function testExternalRequestLogsTheUserOutAfterApproval(): void
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($this->user([Role::EMPLOYEE]));
$security->expects(self::once())->method('logout')->with(false);
$this->dispatch(
$this->user([Role::EMPLOYEE]),
$this->client([Role::EMPLOYEE]),
external: true,
security: $security
);
}
/**
* A denied user has to stay signed in: they need a session to read the explanation and
* navigate away from it.
*/
public function testExternalRequestDoesNotLogTheUserOutAfterDenial(): void
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($this->user([Role::CUSTOMER]));
$security->expects(self::never())->method('logout');
$this->dispatch(
$this->user([Role::CUSTOMER]),
$this->client([Role::EMPLOYEE]),
external: true,
security: $security
);
}
/**
* @param list<string> $requiredRoles
*/
private function client(array $requiredRoles): OAuth2Client
{
return (new OAuth2Client('EPHub', 'f3f10f8807e191c127d74cc9a247bf45', 'secret'))
->setRequiredRoles($requiredRoles);
}
/**
* @param list<string> $roles
*/
private function user(array $roles): User
{
return (new User('[email protected]'))->setRoles($roles);
}
private function dispatch(
?UserInterface $user,
ClientInterface $client,
bool $external = false,
?Security $security = null,
): AuthorizationRequestResolveEvent {
$request = new Request();
$session = new Session(new MockArraySessionStorage());
$session->set('_oauth2', $external);
$request->setSession($session);
$requestStack = new RequestStack();
$requestStack->push($request);
if (null === $security) {
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
}
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
$urlGenerator->method('generate')->willReturn('/');
$twig = $this->createStub(Environment::class);
$twig->method('render')->willReturn('<html lang="de"></html>');
$listener = new AuthorizationCodeListener(
$urlGenerator,
$requestStack,
$security,
$this->createStub(LoggerInterface::class),
$twig,
);
$event = new AuthorizationRequestResolveEvent(
$this->createStub(AuthorizationRequestInterface::class),
[],
$client,
$user ?? new User('[email protected]'),
);
$listener->onAuthorizationRequestResolve($event);
return $event;
}
}