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
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
/**
* An OAuth2 client, extended with the roles an account must hold to authorize it.
*
* The bundle ships its own Client model and maps it for us. Replacing it (via
* league_oauth2_server.client.classname) is the supported way to add a column: the bundle
* maps AbstractClient as a mapped superclass and carries name, secret, redirectUris,
* grants, scopes, active and allowPlainTextPkce, then stops mapping its own Client, so
* this class only has to declare the table, the primary key and what it adds.
*
* Why the requirement lives here rather than in configuration: the alternative was a map
* in services.yaml keyed on the client identifier, and identifiers differ per environment,
* so that map would have needed an env var per client and would have drifted from the
* client table it describes. A column cannot drift from its own row.
*
* Nothing validates the contents. A misspelled role is not an error, it is a client that
* authorizes nobody, so AuthorizationCodeListener logs the requirement whenever it
* refuses — that is what makes the mistake visible without a database query.
*/
#[ORM\Entity]
#[ORM\Table(name: 'oauth2_client')]
class OAuth2Client extends AbstractClient
{
/**
* Declared protected on AbstractClient and mapped by the bundle only for its own
* Client, so the custom class has to map it itself. The type restates the parent's
* contract, which redeclaring the property would otherwise widen.
*
* @var non-empty-string
*/
#[ORM\Id]
#[ORM\Column(type: 'string', length: 32)]
protected string $identifier;
/**
* The roles that entitle an account to authorize this client. An empty list denies
* everyone: a row nobody has configured and a client that needs no roles are the same
* stored value, and of the two readings only "deny" is safe.
*
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $requiredRoles = [];
/**
* @return list<string>
*/
public function getRequiredRoles(): array
{
return $this->requiredRoles;
}
/**
* @param list<string> $requiredRoles
*/
public function setRequiredRoles(array $requiredRoles): self
{
$this->requiredRoles = $requiredRoles;
return $this;
}
}
+89 -11
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\EventListener;
use App\Entity\OAuth2Client;
use App\Entity\User;
use App\Security\Role;
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
use Psr\Log\LoggerInterface;
@@ -14,14 +16,21 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
/**
* Handles OAuth2 authorization code grant flow.
*
* Automatically approves authorization requests for authenticated users without
* consent prompts. Redirects anonymous users to login. For external OAuth2
* requests (flagged via session), logs out the user after authorization to
* prevent session hijacking.
* Redirects anonymous users to login, then decides whether the account may authorize the
* client that asked. Consent is deliberately not part of that decision: every client is
* one of our own applications, so there is nothing for the user to weigh up. What the user
* cannot be asked, though, is whether they are entitled to the application at all, and
* that is what this listener answers — a client declares the roles it requires in
* OAuth2Client::$requiredRoles and an account holding none of them is refused here rather
* than handed a token the client will reject for reasons it cannot explain.
*
* For external OAuth2 requests (flagged via session), logs out the user after a successful
* authorization to prevent session hijacking.
*/
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
class AuthorizationCodeListener
@@ -31,6 +40,7 @@ class AuthorizationCodeListener
private readonly RequestStack $requestStack,
private readonly Security $security,
private readonly LoggerInterface $authenticationLogger,
private readonly Environment $twig,
) {
}
@@ -53,13 +63,81 @@ class AuthorizationCodeListener
);
$event->setResponse($response);
$this->authenticationLogger->info('Authorization request without session');
} else {
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
// in case this authorization request has been flagged as external in login controller,
// immediately logout the current user (see App\Controller\Core\Security\LoginController).
if (true === $request->getSession()->get('_oauth2', false)) {
$this->security->logout(false);
}
return;
}
$client = $event->getClient();
// the event is typed to the bundle's ClientInterface, which knows nothing about the
// column below: anything that is not our entity is a client we cannot have configured
if (false === $client instanceof OAuth2Client) {
$this->deny($event, $user, $client->getIdentifier(), [], 'unexpected client class');
return;
}
$required = $client->getRequiredRoles();
// An empty requirement is not "no requirement": it is the state a client row starts
// in, so it means nobody has said who may use this client yet.
if ([] === $required) {
$this->deny($event, $user, $client->getName(), $required, 'client has no roles configured');
return;
}
// effectiveOnly(), never getRoles(): a *_PENDING marker is a CRM nomination that
// grants nothing until an administrator approves it, and matching the raw role set
// would let a nomination open a staff application on its own.
if ([] === array_intersect($required, Role::effectiveOnly($user->getRoles()))) {
$this->deny($event, $user, $client->getName(), $required, 'insufficient roles');
return;
}
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
// in case this authorization request has been flagged as external in login controller,
// immediately logout the current user (see App\Controller\SecurityController).
if (true === $request->getSession()->get('_oauth2', false)) {
$this->security->logout(false);
}
}
/**
* Refuses the request and explains it on our own page.
*
* The resolution is left at its AUTHORIZATION_DENIED default; setting a response is what
* keeps the user here instead of bouncing them back to a client that would only be able
* to report that no authorization code arrived. The session is deliberately left intact
* — the user has to still be signed in to read the page and navigate away from it.
*
* Logged with the client's requirement but never with the user's roles: the requirement
* is configuration, and it is what makes a misspelled entry diagnosable from the log,
* while the account's roles are personal data and the auth channel is database-backed.
*
* @param list<string> $required
*/
private function deny(
AuthorizationRequestResolveEvent $event,
User $user,
string $client,
array $required,
string $reason,
): void {
$this->authenticationLogger->info('Authorization request denied', [
'client' => $client,
'user_id' => $user->getId(),
'required_roles' => $required,
'reason' => $reason,
]);
$event->setResponse(new Response(
// oauth2: true drops hx-boost in base.html.twig -- this page is rendered
// mid-redirect-chain, where boosting breaks the navigation
$this->twig->render('security/oauth2_denied.html.twig', ['client' => $client, 'oauth2' => true]),
Response::HTTP_FORBIDDEN
));
}
}