From aa5edae4d9315fa774b72bcbee16277da9d14565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 27 Jul 2026 17:46:18 +0200 Subject: [PATCH] Make OAuth connection grants configurable --- migrations/Version20260727143622.php | 81 ++++++ .../RefreshTokenTtlRepositoryDecorator.php | 81 ++++++ .../UserSystem/OAuthClientGrantPreference.php | 144 +++++++++++ .../OAuth/AuthorizationConsentListener.php | 119 ++++++++- .../OAuthAuthorizeFormActionCspListener.php | 126 ++++++++++ .../OAuth/OAuthScopeResolveListener.php | 91 +++++++ .../OAuth/OAuthBearerAuthenticator.php | 16 ++ src/Services/OAuth/ConnectedAppManager.php | 13 +- .../OAuthClientGrantPreferenceManager.php | 125 ++++++++++ templates/oauth/authorize.html.twig | 47 +++- .../users/_oauth_connected_apps.html.twig | 13 +- .../AuthorizationConsentListenerTest.php | 132 +++++++++- .../OAuth/OAuthScopeNarrowingAndTtlTest.php | 235 ++++++++++++++++++ .../OAuthClientGrantPreferenceManagerTest.php | 100 ++++++++ translations/messages.en.xlf | 60 +++++ 15 files changed, 1370 insertions(+), 13 deletions(-) create mode 100644 migrations/Version20260727143622.php create mode 100644 src/Doctrine/OAuth/RefreshTokenTtlRepositoryDecorator.php create mode 100644 src/Entity/UserSystem/OAuthClientGrantPreference.php create mode 100644 src/EventListener/OAuth/OAuthAuthorizeFormActionCspListener.php create mode 100644 src/EventListener/OAuth/OAuthScopeResolveListener.php create mode 100644 src/Services/OAuth/OAuthClientGrantPreferenceManager.php create mode 100644 tests/Security/OAuth/OAuthScopeNarrowingAndTtlTest.php create mode 100644 tests/Services/OAuth/OAuthClientGrantPreferenceManagerTest.php diff --git a/migrations/Version20260727143622.php b/migrations/Version20260727143622.php new file mode 100644 index 00000000..dc5def7a --- /dev/null +++ b/migrations/Version20260727143622.php @@ -0,0 +1,81 @@ +addSql(<<<'SQL' + CREATE TABLE oauth_client_grant_preferences ( + id INT AUTO_INCREMENT NOT NULL, + user_identifier VARCHAR(128) NOT NULL, + client_identifier VARCHAR(32) NOT NULL, + scope_level SMALLINT NOT NULL, + friendly_name VARCHAR(255) DEFAULT NULL, + refresh_token_ttl_days INT DEFAULT NULL, + last_used_at DATETIME DEFAULT NULL, + UNIQUE INDEX oauth_client_grant_pref_user_client (user_identifier, client_identifier), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE oauth_client_grant_preferences'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE oauth_client_grant_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + user_identifier VARCHAR(128) NOT NULL, + client_identifier VARCHAR(32) NOT NULL, + scope_level SMALLINT NOT NULL, + friendly_name VARCHAR(255) DEFAULT NULL, + refresh_token_ttl_days INTEGER DEFAULT NULL, + last_used_at DATETIME DEFAULT NULL + ) + SQL); + $this->addSql('CREATE UNIQUE INDEX oauth_client_grant_pref_user_client ON oauth_client_grant_preferences (user_identifier, client_identifier)'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('DROP TABLE oauth_client_grant_preferences'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE oauth_client_grant_preferences ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + user_identifier VARCHAR(128) NOT NULL, + client_identifier VARCHAR(32) NOT NULL, + scope_level SMALLINT NOT NULL, + friendly_name VARCHAR(255) DEFAULT NULL, + refresh_token_ttl_days INT DEFAULT NULL, + last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE UNIQUE INDEX oauth_client_grant_pref_user_client ON oauth_client_grant_preferences (user_identifier, client_identifier)'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('DROP TABLE oauth_client_grant_preferences'); + } +} diff --git a/src/Doctrine/OAuth/RefreshTokenTtlRepositoryDecorator.php b/src/Doctrine/OAuth/RefreshTokenTtlRepositoryDecorator.php new file mode 100644 index 00000000..76658eb3 --- /dev/null +++ b/src/Doctrine/OAuth/RefreshTokenTtlRepositoryDecorator.php @@ -0,0 +1,81 @@ +. + */ + +declare(strict_types=1); + +namespace App\Doctrine\OAuth; + +use App\Services\OAuth\OAuthClientGrantPreferenceManager; +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; + +/** + * Applies the refresh token TTL the user picked on the consent screen + * (App\EventListener\OAuth\AuthorizationConsentListener) - league_oauth2_server.yaml's + * authorization_server.refresh_token_ttl (currently P30D) is a single fixed value for the whole server, + * with no per-request hook to override it, so this decorates the bundle's own + * League\Bundle\OAuth2ServerBundle\Repository\RefreshTokenRepository and overrides the expiry date on the + * refresh token entity right before it's persisted - every time one is minted (initial authorization_code + * exchange and every subsequent refresh rotation, since revoke_refresh_tokens is true), not just once. + */ +#[AsDecorator('league.oauth2_server.repository.refresh_token')] +class RefreshTokenTtlRepositoryDecorator implements RefreshTokenRepositoryInterface +{ + public function __construct( + #[AutowireDecorated] + private readonly RefreshTokenRepositoryInterface $decorated, + private readonly OAuthClientGrantPreferenceManager $grantPreferences, + ) { + } + + public function getNewRefreshToken(): ?RefreshTokenEntityInterface + { + return $this->decorated->getNewRefreshToken(); + } + + public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void + { + $userIdentifier = $refreshTokenEntity->getAccessToken()->getUserIdentifier(); + + if (null !== $userIdentifier) { + $clientIdentifier = $refreshTokenEntity->getAccessToken()->getClient()->getIdentifier(); + $preference = $this->grantPreferences->find($userIdentifier, $clientIdentifier); + $ttlDays = $preference?->getRefreshTokenTtlDays(); + + if (null !== $ttlDays) { + $refreshTokenEntity->setExpiryDateTime(new \DateTimeImmutable(\sprintf('+%d days', $ttlDays))); + } + } + + $this->decorated->persistNewRefreshToken($refreshTokenEntity); + } + + public function revokeRefreshToken(string $tokenId): void + { + $this->decorated->revokeRefreshToken($tokenId); + } + + public function isRefreshTokenRevoked(string $tokenId): bool + { + return $this->decorated->isRefreshTokenRevoked($tokenId); + } +} diff --git a/src/Entity/UserSystem/OAuthClientGrantPreference.php b/src/Entity/UserSystem/OAuthClientGrantPreference.php new file mode 100644 index 00000000..8005bff0 --- /dev/null +++ b/src/Entity/UserSystem/OAuthClientGrantPreference.php @@ -0,0 +1,144 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\UserSystem; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +/** + * A single user's preferences for one OAuth2 client (League\Bundle\OAuth2ServerBundle) they have + * authorized at /authorize - the scope level they granted, an optional friendly name to tell apart + * multiple connections to the same self-registered (RFC 7591) client, and how long the resulting + * refresh token should stay valid before requiring re-authorization. + * + * Deliberately keyed by plain userIdentifier/clientIdentifier strings, not Doctrine relations to + * App\Entity\UserSystem\User or League\Bundle\OAuth2ServerBundle\Model\Client - this mirrors how the + * bundle's own AccessToken/RefreshToken/AuthorizationCode models already reference their user (see + * App\Services\OAuth\ConnectedAppManager), and lets App\EventListener\OAuth\OAuthScopeResolveListener / + * App\ApiPlatform... (App\Doctrine\OAuth\RefreshTokenTtlDecorator) look this up on every token mint + * without needing an extra User entity fetch. + */ +#[ORM\Entity] +#[ORM\Table(name: 'oauth_client_grant_preferences')] +#[ORM\UniqueConstraint(name: 'oauth_client_grant_pref_user_client', columns: ['user_identifier', 'client_identifier'])] +class OAuthClientGrantPreference +{ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + #[ORM\GeneratedValue] + private int $id; + + #[ORM\Column(type: Types::STRING, length: 128, name: 'user_identifier')] + private string $userIdentifier; + + #[ORM\Column(type: Types::STRING, length: 32, name: 'client_identifier')] + private string $clientIdentifier; + + #[ORM\Column(type: Types::SMALLINT, enumType: ApiTokenLevel::class)] + private ApiTokenLevel $scopeLevel; + + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + private ?string $friendlyName = null; + + /** + * Refresh token lifetime chosen by the user, in days - null means "use the server default" + * (league_oauth2_server.yaml's authorization_server.refresh_token_ttl, currently P30D). + */ + #[ORM\Column(type: Types::INTEGER, nullable: true)] + private ?int $refreshTokenTtlDays = null; + + /** + * Last time an OAuth2 access token for this user+client pair was used to authenticate a request - + * the OAuth equivalent of App\Entity\UserSystem\ApiToken::$last_time_used. Updated by + * App\Security\OAuth\OAuthBearerAuthenticator on every successful authentication (throttled the same + * way ApiTokenAuthenticator throttles its own flush, to avoid a DB write on every single API request). + */ + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $lastUsedAt = null; + + public function __construct(string $userIdentifier, string $clientIdentifier, ApiTokenLevel $scopeLevel) + { + $this->userIdentifier = $userIdentifier; + $this->clientIdentifier = $clientIdentifier; + $this->scopeLevel = $scopeLevel; + } + + public function getId(): int + { + return $this->id; + } + + public function getUserIdentifier(): string + { + return $this->userIdentifier; + } + + public function getClientIdentifier(): string + { + return $this->clientIdentifier; + } + + public function getScopeLevel(): ApiTokenLevel + { + return $this->scopeLevel; + } + + public function setScopeLevel(ApiTokenLevel $scopeLevel): self + { + $this->scopeLevel = $scopeLevel; + return $this; + } + + public function getFriendlyName(): ?string + { + return $this->friendlyName; + } + + public function setFriendlyName(?string $friendlyName): self + { + $this->friendlyName = ('' === $friendlyName) ? null : $friendlyName; + return $this; + } + + public function getRefreshTokenTtlDays(): ?int + { + return $this->refreshTokenTtlDays; + } + + public function setRefreshTokenTtlDays(?int $refreshTokenTtlDays): self + { + $this->refreshTokenTtlDays = $refreshTokenTtlDays; + return $this; + } + + public function getLastUsedAt(): ?\DateTimeImmutable + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?\DateTimeImmutable $lastUsedAt): self + { + $this->lastUsedAt = $lastUsedAt; + return $this; + } +} diff --git a/src/EventListener/OAuth/AuthorizationConsentListener.php b/src/EventListener/OAuth/AuthorizationConsentListener.php index 738e861c..0766543f 100644 --- a/src/EventListener/OAuth/AuthorizationConsentListener.php +++ b/src/EventListener/OAuth/AuthorizationConsentListener.php @@ -23,12 +23,14 @@ declare(strict_types=1); namespace App\EventListener\OAuth; use App\Entity\UserSystem\ApiTokenLevel; +use App\Services\OAuth\OAuthClientGrantPreferenceManager; use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent; use League\Bundle\OAuth2ServerBundle\OAuth2Events; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Twig\Environment; @@ -46,22 +48,47 @@ use Twig\Environment; * The consent form resubmits to the exact same URL (same query string, so * league/oauth2-server re-derives an identical AuthorizationRequest - it only reads request params from * the query string, never the POST body), with the actual decision + a CSRF token in the POST body. + * + * The user also picks a scope level, an optional friendly name, and a refresh token TTL, all persisted + * via App\Services\OAuth\OAuthClientGrantPreferenceManager - see App\EventListener\OAuth\OAuthScopeResolveListener + * (which is what actually narrows the granted scope, at token-issuance time - not this listener; the + * league/oauth2-server-bundle 1.2 AuthorizationRequestResolveEvent has no way to mutate the underlying + * AuthorizationRequest's scopes) and App\Doctrine\OAuth\RefreshTokenTtlRepositoryDecorator (which applies + * the chosen TTL). */ #[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE)] class AuthorizationConsentListener { public const CSRF_TOKEN_ID = 'oauth_authorize'; + /** + * Preset choices offered on the consent screen for the refresh token TTL, in days. `null` means + * "use the server default" (league_oauth2_server.yaml's authorization_server.refresh_token_ttl). + * + * @var list + */ + public const TTL_PRESETS_DAYS = [null, 1, 7, 30, 90, 365]; + public function __construct( private readonly RequestStack $requestStack, private readonly Environment $twig, private readonly CsrfTokenManagerInterface $csrfTokenManager, + private readonly OAuthClientGrantPreferenceManager $grantPreferences, ) { } public function __invoke(AuthorizationRequestResolveEvent $event): void { $request = $this->requestStack->getMainRequest(); + $userIdentifier = $event->getUser()->getUserIdentifier(); + $clientIdentifier = $event->getClient()->getIdentifier(); + + $requestedLevels = $this->scopesToLevels($event->getScopes()); + $maxLevel = $this->highestLevel($requestedLevels) ?? ApiTokenLevel::READ_ONLY; + $availableLevels = array_values(array_filter( + ApiTokenLevel::cases(), + static fn (ApiTokenLevel $level) => $level->value <= $maxLevel->value + )); if ($request?->isMethod('POST') && $request->request->has('oauth_decision')) { $token = new CsrfToken(self::CSRF_TOKEN_ID, (string) $request->request->get('_csrf_token')); @@ -69,20 +96,94 @@ class AuthorizationConsentListener throw new AccessDeniedHttpException('Invalid CSRF token.'); } - $event->resolveAuthorization('approve' === $request->request->get('oauth_decision')); + $approved = 'approve' === $request->request->get('oauth_decision'); + + if ($approved) { + $selectedLevel = $this->parseSelectedLevel($request->request->get('oauth_scope_level'), $availableLevels); + $friendlyName = $this->parseFriendlyName($request->request->get('oauth_friendly_name')); + $ttlDays = $this->parseTtlDays($request->request->get('oauth_ttl_days')); + + $this->grantPreferences->save($userIdentifier, $clientIdentifier, $selectedLevel, $friendlyName, $ttlDays); + } + + $event->resolveAuthorization($approved); return; } + $existingPreference = $this->grantPreferences->find($userIdentifier, $clientIdentifier); + + // Stashed for App\EventListener\OAuth\OAuthAuthorizeFormActionCspListener (kernel.response), which + // needs the already-validated redirect_uri to scope-exempt this one response's CSP form-action + // directive (nelmio_security.yaml's csp has no form-action, so it falls back to default-src + // 'self', which would otherwise block the browser from following the post-decision redirect to + // this client's external redirect_uri). + $request?->attributes->set('oauth_authorize_redirect_uri', $event->getRedirectUri()); + $response = new Response($this->twig->render('oauth/authorize.html.twig', [ 'client' => $event->getClient(), - 'levels' => $this->scopesToLevels($event->getScopes()), + 'levels' => $requestedLevels, + 'available_levels' => $availableLevels, + 'selected_level' => $existingPreference?->getScopeLevel() ?? $maxLevel, + 'friendly_name' => $existingPreference?->getFriendlyName(), + 'ttl_presets_days' => self::TTL_PRESETS_DAYS, + 'selected_ttl_days' => $existingPreference?->getRefreshTokenTtlDays(), 'csrf_token_id' => self::CSRF_TOKEN_ID, ])); $event->setResponse($response); } + /** + * @param ApiTokenLevel[] $availableLevels + */ + private function parseSelectedLevel(mixed $rawLevel, array $availableLevels): ApiTokenLevel + { + if (!\is_string($rawLevel)) { + throw new BadRequestHttpException('Missing scope level.'); + } + + foreach ($availableLevels as $level) { + if (strtolower($level->name) === $rawLevel) { + return $level; + } + } + + throw new BadRequestHttpException('Invalid scope level.'); + } + + private function parseFriendlyName(mixed $rawName): ?string + { + if (!\is_string($rawName)) { + return null; + } + + $trimmed = trim($rawName); + if ('' === $trimmed) { + return null; + } + + return mb_substr($trimmed, 0, 255); + } + + private function parseTtlDays(mixed $rawTtlDays): ?int + { + if (!\is_string($rawTtlDays) || '' === $rawTtlDays) { + return null; + } + + if (!ctype_digit($rawTtlDays)) { + throw new BadRequestHttpException('Invalid refresh token TTL.'); + } + + $days = (int) $rawTtlDays; + if (!\in_array($days, self::TTL_PRESETS_DAYS, true)) { + throw new BadRequestHttpException('Invalid refresh token TTL.'); + } + + return $days; + } + /** * Scopes are just App\Entity\UserSystem\ApiTokenLevel case names lowercased (see * App\Security\OAuth\OAuthBearerAuthenticator and config/packages/league_oauth2_server.yaml's @@ -100,4 +201,18 @@ class AuthorizationConsentListener static fn (ApiTokenLevel $level) => \in_array(strtolower($level->name), $requested, true) )); } + + /** + * @param ApiTokenLevel[] $levels + */ + private function highestLevel(array $levels): ?ApiTokenLevel + { + if ([] === $levels) { + return null; + } + + usort($levels, static fn (ApiTokenLevel $a, ApiTokenLevel $b) => $b->value <=> $a->value); + + return $levels[0]; + } } diff --git a/src/EventListener/OAuth/OAuthAuthorizeFormActionCspListener.php b/src/EventListener/OAuth/OAuthAuthorizeFormActionCspListener.php new file mode 100644 index 00000000..7443ea65 --- /dev/null +++ b/src/EventListener/OAuth/OAuthAuthorizeFormActionCspListener.php @@ -0,0 +1,126 @@ +. + */ + +declare(strict_types=1); + +namespace App\EventListener\OAuth; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpKernel\Event\ResponseEvent; + +/** + * Scope-exempts the /authorize consent screen's CSP header so the browser is allowed to follow the + * post-decision redirect to the OAuth client's (already-validated) redirect_uri. + * + * config/packages/nelmio_security.yaml's csp.enforce has no form-action directive, so browsers fall back + * to default-src 'self' for it - which blocks the consent form (App\EventListener\OAuth\AuthorizationConsentListener, + * templates/oauth/authorize.html.twig) from ever navigating anywhere but this app's own host, even though + * the form itself posts back to /authorize (same-origin) and it's only the *server-side* redirect that + * follows (to the client's redirect_uri) that needs to leave the site. form-action is evaluated against + * the CSP delivered with the document containing the
(this GET response), not the response that + * performs the redirect - and per spec/browser behaviour, it also covers the eventual redirect target, not + * just the form's own "action" attribute. + * + * Safe for the same reason as App\EventListener\OAuth\OAuthExternalRedirectListener: league/oauth2-server + * has already strictly validated redirect_uri against the client's own pre-registered list + * (AuthCodeGrant::validateRedirectUri()) by the time AuthorizationConsentListener renders this page - we + * just don't have that value here directly, so AuthorizationConsentListener stashes it onto the request as + * the "oauth_authorize_redirect_uri" attribute for us to pick up. + * + * Must run *after* Nelmio\SecurityBundle\EventListener\ContentSecurityPolicyListener (default kernel.response + * priority 0, no explicit priority) has already added its header - that listener only adds a + * Content-Security-Policy header `if (!$response->headers->has('Content-Security-Policy'))`, so running + * any earlier would just get silently skipped by it instead of amended. + */ +#[AsEventListener(event: 'kernel.response', priority: -8)] +class OAuthAuthorizeFormActionCspListener +{ + public function __invoke(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + if ('/authorize' !== $event->getRequest()->getPathInfo()) { + return; + } + + $redirectUri = $event->getRequest()->attributes->get('oauth_authorize_redirect_uri'); + if (!\is_string($redirectUri) || '' === $redirectUri) { + return; + } + + $source = $this->formActionSource($redirectUri); + if (null === $source) { + return; + } + + $response = $event->getResponse(); + foreach (['Content-Security-Policy', 'X-Content-Security-Policy'] as $headerName) { + $header = $response->headers->get($headerName); + if (\is_string($header) && '' !== $header) { + $response->headers->set($headerName, $this->withFormAction($header, $source)); + } + } + } + + /** + * Builds a CSP source-expression that matches the given redirect_uri: a host-source with a wildcard + * port for http(s) URIs (native/CLI clients on a loopback interface use an OS-assigned, effectively + * random port each run - see App\Controller\OAuth\ClientRegistrationController::isAllowedRedirectUri()), + * or a scheme-source for RFC 8252 §7.1 private-use URI schemes (which have no host at all). + */ + private function formActionSource(string $redirectUri): ?string + { + $scheme = parse_url($redirectUri, PHP_URL_SCHEME); + if (!\is_string($scheme) || '' === $scheme) { + return null; + } + + $host = parse_url($redirectUri, PHP_URL_HOST); + if (\is_string($host) && '' !== $host) { + return \sprintf('%s://%s:*', $scheme, $host); + } + + return $scheme.':'; + } + + private function withFormAction(string $cspHeader, string $source): string + { + $directives = array_values(array_filter(array_map(trim(...), explode(';', $cspHeader)), static fn (string $d) => '' !== $d)); + + $found = false; + $directives = array_map(static function (string $directive) use ($source, &$found): string { + if (str_starts_with($directive, 'form-action')) { + $found = true; + + return $directive.' '.$source; + } + + return $directive; + }, $directives); + + if (!$found) { + $directives[] = "form-action 'self' ".$source; + } + + return implode('; ', $directives); + } +} diff --git a/src/EventListener/OAuth/OAuthScopeResolveListener.php b/src/EventListener/OAuth/OAuthScopeResolveListener.php new file mode 100644 index 00000000..3b45b709 --- /dev/null +++ b/src/EventListener/OAuth/OAuthScopeResolveListener.php @@ -0,0 +1,91 @@ +. + */ + +declare(strict_types=1); + +namespace App\EventListener\OAuth; + +use App\Entity\UserSystem\ApiTokenLevel; +use App\Services\OAuth\OAuthClientGrantPreferenceManager; +use League\Bundle\OAuth2ServerBundle\Event\ScopeResolveEvent; +use League\Bundle\OAuth2ServerBundle\OAuth2Events; +use League\Bundle\OAuth2ServerBundle\ValueObject\Scope; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Narrows the scope actually granted to an access/refresh token down to whatever level the user picked + * on the consent screen (App\EventListener\OAuth\AuthorizationConsentListener), instead of always + * granting everything the client requested. + * + * This is the *only* place that can actually narrow scopes: league/oauth2-server-bundle 1.2's + * AuthorizationRequestResolveEvent (dispatched at /authorize) has no setter to feed narrowed scopes back + * into the underlying League\OAuth2\Server\RequestTypes\AuthorizationRequest, and its controller + * (League\Bundle\OAuth2ServerBundle\Controller\AuthorizationController::indexAction()) only ever reads + * setUser()/setAuthorizationApproved() off it afterwards - so the authorization code itself always carries + * the client's originally-requested scopes. OAuth2Events::SCOPE_RESOLVE, on the other hand, is dispatched + * by ScopeRepository::finalizeScopes() every time a token is actually minted - for *both* the initial + * authorization_code exchange and every subsequent refresh_token grant (see + * League\OAuth2\Server\Grant\AuthCodeGrant and RefreshTokenGrant, both of which call finalizeScopes()) - + * so narrowing here applies consistently for the life of the grant, not just once. + */ +#[AsEventListener(event: OAuth2Events::SCOPE_RESOLVE)] +class OAuthScopeResolveListener +{ + public function __construct( + private readonly OAuthClientGrantPreferenceManager $grantPreferences, + ) { + } + + public function __invoke(ScopeResolveEvent $event): void + { + $userIdentifier = $event->getUserIdentifier(); + if (null === $userIdentifier) { + // e.g. a client_credentials-style grant with no end user - not used by this app + // (enable_client_credentials_grant is false), but guard against it regardless. + return; + } + + $preference = $this->grantPreferences->find((string) $userIdentifier, $event->getClient()->getIdentifier()); + if (null === $preference) { + // No consent-time preference recorded (e.g. a grant made before this feature existed) - + // leave the client's originally-requested scopes untouched rather than silently reducing them. + return; + } + + $allowedScopeNames = array_map( + static fn (ApiTokenLevel $level) => strtolower($level->name), + array_filter( + ApiTokenLevel::cases(), + static fn (ApiTokenLevel $level) => $level->value <= $preference->getScopeLevel()->value + ) + ); + + $narrowedScopes = array_values(array_filter( + $event->getScopes(), + static fn (Scope $scope) => \in_array((string) $scope, $allowedScopeNames, true) + )); + + // Never grant an *empty* scope set just because of a stale/mismatched preference - that would + // leave the token unable to do anything at all rather than merely narrower than requested. + if ([] !== $narrowedScopes) { + $event->setScopes(...$narrowedScopes); + } + } +} diff --git a/src/Security/OAuth/OAuthBearerAuthenticator.php b/src/Security/OAuth/OAuthBearerAuthenticator.php index f4390b9f..70971cbc 100644 --- a/src/Security/OAuth/OAuthBearerAuthenticator.php +++ b/src/Security/OAuth/OAuthBearerAuthenticator.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Security\OAuth; use App\Entity\UserSystem\ApiTokenType; +use App\Services\OAuth\OAuthClientGrantPreferenceManager; use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token; use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationException; use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationFailedException; @@ -72,6 +73,7 @@ class OAuthBearerAuthenticator implements AuthenticatorInterface, Authentication private readonly ResourceServer $resourceServer, #[Autowire(service: 'security.user.provider.concrete.app_user_provider')] private readonly UserProviderInterface $userProvider, + private readonly OAuthClientGrantPreferenceManager $grantPreferences, ) { } @@ -144,6 +146,20 @@ class OAuthBearerAuthenticator implements AuthenticatorInterface, Authentication public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { + if (!$token instanceof OAuth2Token) { + return null; + } + + $userIdentifier = $token->getUserIdentifier(); + $clientId = $token->getOAuthClientId(); + + // Skip the (currently unreachable, since enable_client_credentials_grant is false) + // ClientCredentialsUser case - see authenticate()'s $userLoader - there's no real end user to + // record a per-user preference/last-used timestamp for. + if ($userIdentifier !== $clientId) { + $this->grantPreferences->recordUsage($userIdentifier, $clientId, $token->getScopes()); + } + return null; } diff --git a/src/Services/OAuth/ConnectedAppManager.php b/src/Services/OAuth/ConnectedAppManager.php index 06f30c7d..3c223850 100644 --- a/src/Services/OAuth/ConnectedAppManager.php +++ b/src/Services/OAuth/ConnectedAppManager.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Services\OAuth; +use App\Entity\UserSystem\ApiTokenLevel; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; @@ -42,11 +43,12 @@ class ConnectedAppManager public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ClientManagerInterface $clientManager, + private readonly OAuthClientGrantPreferenceManager $grantPreferences, ) { } /** - * @return list + * @return list */ public function listConnectedClients(string $userIdentifier): array { @@ -93,7 +95,14 @@ class ConnectedAppManager if (null === $client) { continue; } - $result[] = ['client' => $client, 'expiry' => $expiry]; + $preference = $this->grantPreferences->find($userIdentifier, $clientId); + $result[] = [ + 'client' => $client, + 'expiry' => $expiry, + 'friendlyName' => $preference?->getFriendlyName(), + 'scopeLevel' => $preference?->getScopeLevel(), + 'lastUsedAt' => $preference?->getLastUsedAt(), + ]; } return $result; diff --git a/src/Services/OAuth/OAuthClientGrantPreferenceManager.php b/src/Services/OAuth/OAuthClientGrantPreferenceManager.php new file mode 100644 index 00000000..852b6aa3 --- /dev/null +++ b/src/Services/OAuth/OAuthClientGrantPreferenceManager.php @@ -0,0 +1,125 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\OAuth; + +use App\Entity\UserSystem\ApiTokenLevel; +use App\Entity\UserSystem\OAuthClientGrantPreference; +use Doctrine\ORM\EntityManagerInterface; + +/** + * Manages the per (user, OAuth2 client) preferences recorded on the /authorize consent screen + * (App\EventListener\OAuth\AuthorizationConsentListener): the granted scope level, an optional friendly + * name, and a refresh token TTL - plus last-use tracking used by App\Security\OAuth\OAuthBearerAuthenticator + * (the OAuth equivalent of App\Entity\UserSystem\ApiToken::$last_time_used). + */ +class OAuthClientGrantPreferenceManager +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + } + + public function find(string $userIdentifier, string $clientIdentifier): ?OAuthClientGrantPreference + { + return $this->entityManager->getRepository(OAuthClientGrantPreference::class) + ->findOneBy(['userIdentifier' => $userIdentifier, 'clientIdentifier' => $clientIdentifier]); + } + + public function save( + string $userIdentifier, + string $clientIdentifier, + ApiTokenLevel $scopeLevel, + ?string $friendlyName, + ?int $refreshTokenTtlDays, + ): OAuthClientGrantPreference { + $preference = $this->find($userIdentifier, $clientIdentifier); + + if (null === $preference) { + $preference = new OAuthClientGrantPreference($userIdentifier, $clientIdentifier, $scopeLevel); + $this->entityManager->persist($preference); + } else { + $preference->setScopeLevel($scopeLevel); + } + + $preference->setFriendlyName($friendlyName); + $preference->setRefreshTokenTtlDays($refreshTokenTtlDays); + + $this->entityManager->flush(); + + return $preference; + } + + /** + * Records that an access token for this user+client pair was just used to authenticate a request - + * throttled the same way App\Security\ApiTokenAuthenticator throttles ApiToken::$last_time_used, to + * avoid a DB write on every single API request. Self-heals a missing preference row (e.g. a grant + * made before this feature existed) by creating one from the token's own scopes, so usage is never + * silently dropped just because the user never re-consented. + * + * @param list $fallbackScopeNames lowercase ApiTokenLevel names (see + * App\Security\OAuth\OAuthBearerAuthenticator), used only if + * no preference row exists yet + */ + public function recordUsage(string $userIdentifier, string $clientIdentifier, array $fallbackScopeNames = []): void + { + $preference = $this->find($userIdentifier, $clientIdentifier); + + if (null === $preference) { + $preference = new OAuthClientGrantPreference( + $userIdentifier, + $clientIdentifier, + $this->highestLevelAmong($fallbackScopeNames), + ); + $this->entityManager->persist($preference); + } + + $lastUsedAt = $preference->getLastUsedAt(); + $now = new \DateTimeImmutable(); + $preference->setLastUsedAt($now); + + // Only flush if the last-used date actually changed by more than 10 minutes (or was never set) - + // otherwise every single API request would trigger a write. + if (null === $lastUsedAt || $lastUsedAt->diff($now)->i > 10 || $lastUsedAt->diff($now)->days > 0) { + $this->entityManager->flush(); + } + } + + /** + * @param list $scopeNames lowercase ApiTokenLevel names + */ + private function highestLevelAmong(array $scopeNames): ApiTokenLevel + { + $levels = array_values(array_filter( + ApiTokenLevel::cases(), + static fn (ApiTokenLevel $level) => \in_array(strtolower($level->name), $scopeNames, true) + )); + + if ([] === $levels) { + return ApiTokenLevel::READ_ONLY; + } + + usort($levels, static fn (ApiTokenLevel $a, ApiTokenLevel $b) => $b->value <=> $a->value); + + return $levels[0]; + } +} diff --git a/templates/oauth/authorize.html.twig b/templates/oauth/authorize.html.twig index 5ae2b201..05ce2d1f 100644 --- a/templates/oauth/authorize.html.twig +++ b/templates/oauth/authorize.html.twig @@ -2,10 +2,9 @@ {% block title %}{% trans %}oauth.authorize.title{% endtrans %}{% endblock %} -{% block card_title %}
+{% block card_title %} - {% trans %}oauth.authorize.title{% endtrans %} -
+ {% trans %}oauth.authorize.title{% endtrans %}: {{ client.name }} {% endblock %} {% block card_content %} @@ -25,6 +24,48 @@ + {% if available_levels|length > 1 %} +
+ +
{% trans %}oauth.authorize.scope_level.help{% endtrans %}
+ {% for level in available_levels %} +
+ + +
+ {% endfor %} +
+ {% else %} + + {% endif %} + +
+ + +
{% trans %}oauth.authorize.friendly_name.help{% endtrans %}
+
+ +
+ + +
{% trans %}oauth.authorize.ttl.help{% endtrans %}
+
+ diff --git a/templates/users/_oauth_connected_apps.html.twig b/templates/users/_oauth_connected_apps.html.twig index 7645b0fb..8a26b569 100644 --- a/templates/users/_oauth_connected_apps.html.twig +++ b/templates/users/_oauth_connected_apps.html.twig @@ -1,5 +1,5 @@ {# @var user \App\Entity\UserSystem\User #} -{# @var connected_apps array #} +{# @var connected_apps array #} {% import "helper.twig" as helper %} @@ -26,6 +26,8 @@ {% trans %}user.settings.oauth_connected_apps.app_name{% endtrans %} + {% trans %}user.settings.oauth_connected_apps.scope_level{% endtrans %} + {% trans %}user.settings.oauth_connected_apps.last_used{% endtrans %} {% trans %}user.settings.oauth_connected_apps.access_until{% endtrans %} @@ -34,7 +36,14 @@ {% for connected_app in connected_apps %} - {{ connected_app.client.name }} + + {{ connected_app.friendlyName ?? connected_app.client.name }} + {% if connected_app.friendlyName is not empty %} +
{{ connected_app.client.name }} + {% endif %} + + {{ connected_app.scopeLevel ? connected_app.scopeLevel.translationKey|trans : '-' }} + {{ connected_app.lastUsedAt ? helper.format_date_nullable(connected_app.lastUsedAt) : '-' }} {{ helper.format_date_nullable(connected_app.expiry) }}