Make OAuth connection grants configurable

This commit is contained in:
Jan Böhmer 2026-07-27 17:46:18 +02:00
parent 28023c9828
commit aa5edae4d9
15 changed files with 1370 additions and 13 deletions

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20260727143622 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add oauth_client_grant_preferences (per user+client OAuth2 consent preferences: granted scope level, friendly name, refresh token TTL)';
}
public function mySQLUp(Schema $schema): void
{
$this->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');
}
}

View file

@ -0,0 +1,81 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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);
}
}

View file

@ -0,0 +1,144 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}
}

View file

@ -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<int|null>
*/
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];
}
}

View file

@ -0,0 +1,126 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 <form> (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);
}
}

View file

@ -0,0 +1,91 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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);
}
}
}

View file

@ -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;
}

View file

@ -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<array{client: ClientInterface, expiry: \DateTimeInterface}>
* @return list<array{client: ClientInterface, expiry: \DateTimeInterface, friendlyName: ?string, scopeLevel: ?ApiTokenLevel, lastUsedAt: ?\DateTimeImmutable}>
*/
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;

View file

@ -0,0 +1,125 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<string> $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<string> $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];
}
}

View file

@ -2,10 +2,9 @@
{% block title %}{% trans %}oauth.authorize.title{% endtrans %}{% endblock %}
{% block card_title %}<h5>
{% block card_title %}
<i class="fa-solid fa-key fa-fw" aria-hidden="true"></i>
{% trans %}oauth.authorize.title{% endtrans %}
</h5>
{% trans %}oauth.authorize.title{% endtrans %}: <i>{{ client.name }}</i>
{% endblock %}
{% block card_content %}
@ -25,6 +24,48 @@
<form method="post" action="{{ app.request.requestUri }}" data-turbo="false" data-turbo-frame="_top">
<input type="hidden" name="_csrf_token" value="{{ csrf_token(csrf_token_id) }}">
{% if available_levels|length > 1 %}
<div class="mb-3">
<label class="form-label">{% trans %}oauth.authorize.scope_level.label{% endtrans %}</label>
<div class="form-text mb-1">{% trans %}oauth.authorize.scope_level.help{% endtrans %}</div>
{% for level in available_levels %}
<div class="form-check">
<input class="form-check-input" type="radio" name="oauth_scope_level"
id="oauth_scope_level_{{ level.name|lower }}" value="{{ level.name|lower }}"
{% if level == selected_level %}checked{% endif %}>
<label class="form-check-label" for="oauth_scope_level_{{ level.name|lower }}">
{{ level.translationKey|trans }}
</label>
</div>
{% endfor %}
</div>
{% else %}
<input type="hidden" name="oauth_scope_level" value="{{ available_levels[0].name|lower }}">
{% endif %}
<div class="mb-3">
<label class="form-label" for="oauth_friendly_name">{% trans %}oauth.authorize.friendly_name.label{% endtrans %}</label>
<input type="text" class="form-control" id="oauth_friendly_name" name="oauth_friendly_name"
maxlength="255" placeholder="{{ client.name }}" value="{{ friendly_name }}">
<div class="form-text">{% trans %}oauth.authorize.friendly_name.help{% endtrans %}</div>
</div>
<div class="mb-3">
<label class="form-label" for="oauth_ttl_days">{% trans %}oauth.authorize.ttl.label{% endtrans %}</label>
<select class="form-select" id="oauth_ttl_days" name="oauth_ttl_days">
{% for days in ttl_presets_days %}
<option value="{{ days }}" {% if days == selected_ttl_days %}selected{% endif %}>
{% if days is null %}
{% trans %}oauth.authorize.ttl.default{% endtrans %}
{% else %}
{% trans with {'%days%': days} %}oauth.authorize.ttl.days{% endtrans %}
{% endif %}
</option>
{% endfor %}
</select>
<div class="form-text">{% trans %}oauth.authorize.ttl.help{% endtrans %}</div>
</div>
<button type="submit" name="oauth_decision" value="deny" class="btn btn-secondary">
{% trans %}oauth.authorize.deny{% endtrans %}
</button>

View file

@ -1,5 +1,5 @@
{# @var user \App\Entity\UserSystem\User #}
{# @var connected_apps array<array{client: \League\Bundle\OAuth2ServerBundle\Model\ClientInterface, expiry: \DateTimeInterface}> #}
{# @var connected_apps array<array{client: \League\Bundle\OAuth2ServerBundle\Model\ClientInterface, expiry: \DateTimeInterface, friendlyName: ?string, scopeLevel: ?\App\Entity\UserSystem\ApiTokenLevel, lastUsedAt: ?\DateTimeImmutable}> #}
{% import "helper.twig" as helper %}
@ -26,6 +26,8 @@
<thead>
<tr>
<th>{% trans %}user.settings.oauth_connected_apps.app_name{% endtrans %}</th>
<th>{% trans %}user.settings.oauth_connected_apps.scope_level{% endtrans %}</th>
<th>{% trans %}user.settings.oauth_connected_apps.last_used{% endtrans %}</th>
<th>{% trans %}user.settings.oauth_connected_apps.access_until{% endtrans %}</th>
<th></th>
</tr>
@ -34,7 +36,14 @@
<tbody>
{% for connected_app in connected_apps %}
<tr>
<td>{{ connected_app.client.name }}</td>
<td>
{{ connected_app.friendlyName ?? connected_app.client.name }}
{% if connected_app.friendlyName is not empty %}
<br><span class="text-muted small">{{ connected_app.client.name }}</span>
{% endif %}
</td>
<td>{{ connected_app.scopeLevel ? connected_app.scopeLevel.translationKey|trans : '-' }}</td>
<td>{{ connected_app.lastUsedAt ? helper.format_date_nullable(connected_app.lastUsedAt) : '-' }}</td>
<td>{{ helper.format_date_nullable(connected_app.expiry) }}</td>
<td>
<button type="submit" class="btn btn-danger btn-sm" name="client_id"

View file

@ -22,7 +22,9 @@ declare(strict_types=1);
namespace App\Tests\EventListener\OAuth;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\User;
use App\Services\OAuth\OAuthClientGrantPreferenceManager;
use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
@ -39,12 +41,15 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
*/
final class AuthorizationConsentListenerTest extends WebTestCase
{
private function createTestClient(\Symfony\Bundle\FrameworkBundle\KernelBrowser $httpClient, string $redirectUri): Client
/**
* @param string[] $scopes
*/
private function createTestClient(\Symfony\Bundle\FrameworkBundle\KernelBrowser $httpClient, string $redirectUri, array $scopes = ['read_only']): Client
{
$client = new Client('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null);
$client->setRedirectUris(new RedirectUri($redirectUri));
$client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
$client->setScopes(new Scope('read_only'));
$client->setScopes(...array_map(static fn (string $s) => new Scope($s), $scopes));
$client->setActive(true);
static::getContainer()->get(ClientManagerInterface::class)->save($client);
@ -52,7 +57,10 @@ final class AuthorizationConsentListenerTest extends WebTestCase
return $client;
}
private function authorizeUrl(Client $client, string $redirectUri): string
/**
* @param string[] $scopes
*/
private function authorizeUrl(Client $client, string $redirectUri, array $scopes = ['read_only']): string
{
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
@ -63,7 +71,7 @@ final class AuthorizationConsentListenerTest extends WebTestCase
'redirect_uri' => $redirectUri,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
'scope' => 'read_only',
'scope' => implode(' ', $scopes),
]);
}
@ -150,4 +158,120 @@ final class AuthorizationConsentListenerTest extends WebTestCase
$httpClient->request('POST', $url, ['oauth_decision' => 'approve', '_csrf_token' => 'invalid']);
self::assertResponseStatusCodeSame(403);
}
public function testNarrowerScopeLevelFriendlyNameAndTtlArePersisted(): void
{
$httpClient = static::createClient();
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
self::assertInstanceOf(User::class, $admin);
$httpClient->loginUser($admin);
$redirectUri = 'https://client.example.invalid/callback';
$client = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit', 'full']);
$crawler = $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri, ['read_only', 'edit', 'full']));
self::assertResponseIsSuccessful();
// Cumulative choices up to the highest requested level (full) are offered - read_only, edit,
// admin, full - even though "admin" itself wasn't individually requested (see
// App\EventListener\OAuth\AuthorizationConsentListener's cumulative-level design).
self::assertCount(4, $crawler->filter('input[name="oauth_scope_level"]'));
$form = $crawler->selectButton('Approve')->form([
'oauth_scope_level' => 'edit',
'oauth_friendly_name' => 'My Laptop',
'oauth_ttl_days' => '7',
]);
$httpClient->submit($form);
self::assertResponseRedirects();
$preferences = static::getContainer()->get(OAuthClientGrantPreferenceManager::class);
$preference = $preferences->find($admin->getUserIdentifier(), $client->getIdentifier());
self::assertNotNull($preference);
self::assertSame(ApiTokenLevel::EDIT, $preference->getScopeLevel());
self::assertSame('My Laptop', $preference->getFriendlyName());
self::assertSame(7, $preference->getRefreshTokenTtlDays());
}
public function testConsentScreenPrefillsPreviouslySavedPreference(): void
{
$httpClient = static::createClient();
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
self::assertInstanceOf(User::class, $admin);
$httpClient->loginUser($admin);
$redirectUri = 'https://client.example.invalid/callback';
$client = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit']);
static::getContainer()->get(OAuthClientGrantPreferenceManager::class)->save(
$admin->getUserIdentifier(),
$client->getIdentifier(),
ApiTokenLevel::READ_ONLY,
'Existing Name',
30,
);
$crawler = $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri, ['read_only', 'edit']));
self::assertResponseIsSuccessful();
self::assertSame('Existing Name', $crawler->filter('input[name="oauth_friendly_name"]')->attr('value'));
self::assertCount(1, $crawler->filter('input[name="oauth_scope_level"][value="read_only"][checked]'));
self::assertCount(1, $crawler->filter('select[name="oauth_ttl_days"] option[value="30"][selected]'));
}
public function testTamperedScopeLevelIsRejected(): void
{
$httpClient = static::createClient();
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
self::assertInstanceOf(User::class, $admin);
$httpClient->loginUser($admin);
$redirectUri = 'https://client.example.invalid/callback';
// Client only registered/requested "read_only" - "full" must not be an acceptable choice.
$client = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$url = $this->authorizeUrl($client, $redirectUri, ['read_only']);
$crawler = $httpClient->request('GET', $url);
self::assertResponseIsSuccessful();
$token = $crawler->filter('input[name="_csrf_token"]')->attr('value');
$httpClient->request('POST', $url, [
'oauth_decision' => 'approve',
'_csrf_token' => $token,
'oauth_scope_level' => 'full',
'oauth_friendly_name' => '',
'oauth_ttl_days' => '',
]);
self::assertResponseStatusCodeSame(400);
}
public function testTamperedTtlIsRejected(): void
{
$httpClient = static::createClient();
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
self::assertInstanceOf(User::class, $admin);
$httpClient->loginUser($admin);
$redirectUri = 'https://client.example.invalid/callback';
$client = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$url = $this->authorizeUrl($client, $redirectUri, ['read_only']);
$crawler = $httpClient->request('GET', $url);
self::assertResponseIsSuccessful();
$token = $crawler->filter('input[name="_csrf_token"]')->attr('value');
$httpClient->request('POST', $url, [
'oauth_decision' => 'approve',
'_csrf_token' => $token,
'oauth_scope_level' => 'read_only',
'oauth_friendly_name' => '',
'oauth_ttl_days' => '5000',
]);
self::assertResponseStatusCodeSame(400);
}
}

View file

@ -0,0 +1,235 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Security\OAuth;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Services\OAuth\OAuthClientGrantPreferenceManager;
use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Entity\User as LeagueUser;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
use League\Bundle\OAuth2ServerBundle\Model\Client as OAuthClientModel;
use League\Bundle\OAuth2ServerBundle\Model\RefreshToken;
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
use League\OAuth2\Server\AuthorizationServer;
use Nyholm\Psr7\Factory\Psr17Factory;
/**
* Covers the two things that only take effect at token-issuance time, below the consent screen already
* covered by App\Tests\EventListener\OAuth\AuthorizationConsentListenerTest:
* - App\EventListener\OAuth\OAuthScopeResolveListener actually narrowing the granted scope to whatever
* App\Services\OAuth\OAuthClientGrantPreferenceManager has on file for the (user, client) pair, and
* - App\Doctrine\OAuth\RefreshTokenTtlRepositoryDecorator actually applying a configured refresh token TTL.
*
* Drives AuthorizationServer directly (bypassing the consent-screen UI, like
* App\Tests\Security\OAuth\OAuthBearerAuthenticationTest already does), since both mechanisms are keyed off
* a preference row that would normally be written by the consent screen - here it's written directly to
* isolate these two mechanisms from the HTML form/CSRF plumbing already covered elsewhere.
*/
final class OAuthScopeNarrowingAndTtlTest extends ApiTestCase
{
private function createTestClient(Client $client, string $redirectUri, array $scopes): OAuthClientModel
{
$oauthClient = new OAuthClientModel('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null);
$oauthClient->setRedirectUris(new RedirectUri($redirectUri));
$oauthClient->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
$oauthClient->setScopes(...array_map(static fn (string $s) => new Scope($s), $scopes));
$oauthClient->setActive(true);
$client->getContainer()->get(ClientManagerInterface::class)->save($oauthClient);
return $oauthClient;
}
/**
* @return array{0: string, 1: string} [code_verifier, code_challenge]
*/
private function generatePkcePair(): array
{
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return [$verifier, $challenge];
}
/**
* @param string[] $scopes
* @return array<string, mixed>
*/
private function issueTokenForScopes(Client $client, OAuthClientModel $oauthClient, string $redirectUri, array $scopes): array
{
$authServer = $client->getContainer()->get(AuthorizationServer::class);
$psr17 = new Psr17Factory();
[$verifier, $challenge] = $this->generatePkcePair();
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $oauthClient->getIdentifier(),
'redirect_uri' => $redirectUri,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
'scope' => implode(' ', $scopes),
]));
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
$leagueUser = new LeagueUser();
$leagueUser->setIdentifier('admin');
$authRequest->setUser($leagueUser);
$authRequest->setAuthorizationApproved(true);
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect.');
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
->withParsedBody([
'grant_type' => 'authorization_code',
'client_id' => $oauthClient->getIdentifier(),
'redirect_uri' => $redirectUri,
'code' => $query['code'],
'code_verifier' => $verifier,
]);
$tokenResponse = $authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
return json_decode((string) $tokenResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
}
private function findAccessTokenForClient(Client $httpClient, OAuthClientModel $oauthClient): AccessToken
{
$entityManager = $httpClient->getContainer()->get(EntityManagerInterface::class);
/** @var ?AccessToken $accessToken */
$accessToken = $entityManager->createQueryBuilder()
->select('at')
->from(AccessToken::class, 'at')
->where('at.client = :client')
->setParameter('client', $oauthClient->getIdentifier())
->getQuery()
->getOneOrNullResult();
self::assertNotNull($accessToken, 'Expected exactly one access token to have been issued for this client.');
return $accessToken;
}
private function findRefreshTokenForClient(Client $httpClient, OAuthClientModel $oauthClient): RefreshToken
{
$entityManager = $httpClient->getContainer()->get(EntityManagerInterface::class);
/** @var ?RefreshToken $refreshToken */
$refreshToken = $entityManager->createQueryBuilder()
->select('rt')
->from(RefreshToken::class, 'rt')
->join('rt.accessToken', 'at')
->where('at.client = :client')
->setParameter('client', $oauthClient->getIdentifier())
->getQuery()
->getOneOrNullResult();
self::assertNotNull($refreshToken, 'Expected exactly one refresh token to have been issued for this client.');
return $refreshToken;
}
public function testScopePreferenceNarrowsGrantedAccessToken(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit', 'full']);
$httpClient->getContainer()->get(OAuthClientGrantPreferenceManager::class)->save(
'admin',
$oauthClient->getIdentifier(),
ApiTokenLevel::EDIT,
null,
null,
);
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only', 'edit', 'full']);
self::assertArrayHasKey('access_token', $data);
$accessToken = $this->findAccessTokenForClient($httpClient, $oauthClient);
$scopeNames = array_map(static fn (Scope $s) => (string) $s, $accessToken->getScopes());
sort($scopeNames);
self::assertSame(['edit', 'read_only'], $scopeNames);
}
public function testNoPreferenceLeavesFullRequestedScopeGranted(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit']);
$this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only', 'edit']);
$accessToken = $this->findAccessTokenForClient($httpClient, $oauthClient);
$scopeNames = array_map(static fn (Scope $s) => (string) $s, $accessToken->getScopes());
sort($scopeNames);
self::assertSame(['edit', 'read_only'], $scopeNames);
}
public function testTtlPreferenceOverridesRefreshTokenExpiry(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$httpClient->getContainer()->get(OAuthClientGrantPreferenceManager::class)->save(
'admin',
$oauthClient->getIdentifier(),
ApiTokenLevel::READ_ONLY,
null,
1,
);
$this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
$refreshToken = $this->findRefreshTokenForClient($httpClient, $oauthClient);
$expectedExpiry = new \DateTimeImmutable('+1 day');
self::assertEqualsWithDelta($expectedExpiry->getTimestamp(), $refreshToken->getExpiry()->getTimestamp(), 120);
}
public function testDefaultRefreshTokenTtlIsUnaffectedWithoutPreference(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
$refreshToken = $this->findRefreshTokenForClient($httpClient, $oauthClient);
// league_oauth2_server.yaml's authorization_server.refresh_token_ttl is P30D.
$expectedExpiry = new \DateTimeImmutable('+30 days');
self::assertEqualsWithDelta($expectedExpiry->getTimestamp(), $refreshToken->getExpiry()->getTimestamp(), 120);
}
}

View file

@ -0,0 +1,100 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\OAuth;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\OAuthClientGrantPreference;
use App\Services\OAuth\OAuthClientGrantPreferenceManager;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class OAuthClientGrantPreferenceManagerTest extends KernelTestCase
{
public function testSaveUpsertsAllFields(): void
{
self::bootKernel();
$manager = static::getContainer()->get(OAuthClientGrantPreferenceManager::class);
$userIdentifier = 'test-user-'.bin2hex(random_bytes(8));
$clientIdentifier = 'test-client-'.bin2hex(random_bytes(8));
self::assertNull($manager->find($userIdentifier, $clientIdentifier));
$manager->save($userIdentifier, $clientIdentifier, ApiTokenLevel::EDIT, 'First Name', 7);
$preference = $manager->find($userIdentifier, $clientIdentifier);
self::assertInstanceOf(OAuthClientGrantPreference::class, $preference);
self::assertSame(ApiTokenLevel::EDIT, $preference->getScopeLevel());
self::assertSame('First Name', $preference->getFriendlyName());
self::assertSame(7, $preference->getRefreshTokenTtlDays());
// A second save() for the same (user, client) pair must update the existing row, not create
// a second one.
$manager->save($userIdentifier, $clientIdentifier, ApiTokenLevel::READ_ONLY, null, null);
$updated = $manager->find($userIdentifier, $clientIdentifier);
self::assertInstanceOf(OAuthClientGrantPreference::class, $updated);
self::assertSame($preference->getId(), $updated->getId());
self::assertSame(ApiTokenLevel::READ_ONLY, $updated->getScopeLevel());
self::assertNull($updated->getFriendlyName());
self::assertNull($updated->getRefreshTokenTtlDays());
}
public function testRecordUsageSetsLastUsedAndSelfHealsMissingPreference(): void
{
self::bootKernel();
$manager = static::getContainer()->get(OAuthClientGrantPreferenceManager::class);
$userIdentifier = 'test-user-'.bin2hex(random_bytes(8));
$clientIdentifier = 'test-client-'.bin2hex(random_bytes(8));
self::assertNull($manager->find($userIdentifier, $clientIdentifier));
// No preference exists yet - recordUsage() must create one, inferring the scope level from the
// token's own scopes rather than silently dropping the usage record.
$manager->recordUsage($userIdentifier, $clientIdentifier, ['read_only', 'edit']);
$preference = $manager->find($userIdentifier, $clientIdentifier);
self::assertInstanceOf(OAuthClientGrantPreference::class, $preference);
self::assertSame(ApiTokenLevel::EDIT, $preference->getScopeLevel());
self::assertNotNull($preference->getLastUsedAt());
}
public function testRecordUsageDoesNotOverwriteExistingPreferenceFields(): void
{
self::bootKernel();
$manager = static::getContainer()->get(OAuthClientGrantPreferenceManager::class);
$userIdentifier = 'test-user-'.bin2hex(random_bytes(8));
$clientIdentifier = 'test-client-'.bin2hex(random_bytes(8));
$manager->save($userIdentifier, $clientIdentifier, ApiTokenLevel::ADMIN, 'My Device', 30);
$manager->recordUsage($userIdentifier, $clientIdentifier, ['read_only']);
$preference = $manager->find($userIdentifier, $clientIdentifier);
self::assertInstanceOf(OAuthClientGrantPreference::class, $preference);
// Untouched by recordUsage() - only lastUsedAt changes.
self::assertSame(ApiTokenLevel::ADMIN, $preference->getScopeLevel());
self::assertSame('My Device', $preference->getFriendlyName());
self::assertSame(30, $preference->getRefreshTokenTtlDays());
self::assertNotNull($preference->getLastUsedAt());
}
}

View file

@ -13859,6 +13859,54 @@ Buerklin-API Authentication server:
<target>Deny</target>
</segment>
</unit>
<unit id="oaAuth07" name="oauth.authorize.scope_level.label">
<segment>
<source>oauth.authorize.scope_level.label</source>
<target>Access level to grant</target>
</segment>
</unit>
<unit id="oaAuth08" name="oauth.authorize.scope_level.help">
<segment>
<source>oauth.authorize.scope_level.help</source>
<target>You can grant a more restrictive access level than the application requested.</target>
</segment>
</unit>
<unit id="oaAuth09" name="oauth.authorize.friendly_name.label">
<segment>
<source>oauth.authorize.friendly_name.label</source>
<target>Friendly name (optional)</target>
</segment>
</unit>
<unit id="oaAuth10" name="oauth.authorize.friendly_name.help">
<segment>
<source>oauth.authorize.friendly_name.help</source>
<target>Give this connection a name to tell it apart from other devices using the same application.</target>
</segment>
</unit>
<unit id="oaAuth11" name="oauth.authorize.ttl.label">
<segment>
<source>oauth.authorize.ttl.label</source>
<target>Stay signed in for</target>
</segment>
</unit>
<unit id="oaAuth12" name="oauth.authorize.ttl.default">
<segment>
<source>oauth.authorize.ttl.default</source>
<target>Default</target>
</segment>
</unit>
<unit id="oaAuth13" name="oauth.authorize.ttl.days">
<segment>
<source>oauth.authorize.ttl.days</source>
<target>%days% days</target>
</segment>
</unit>
<unit id="oaAuth14" name="oauth.authorize.ttl.help">
<segment>
<source>oauth.authorize.ttl.help</source>
<target>After this period, the application will need you to sign in again.</target>
</segment>
</unit>
<unit id="oaConn01" name="user.settings.oauth_connected_apps">
<segment>
<source>user.settings.oauth_connected_apps</source>
@ -13913,6 +13961,18 @@ Buerklin-API Authentication server:
<target>Access revoked successfully!</target>
</segment>
</unit>
<unit id="oaConn10" name="user.settings.oauth_connected_apps.scope_level">
<segment>
<source>user.settings.oauth_connected_apps.scope_level</source>
<target>Access level</target>
</segment>
</unit>
<unit id="oaConn11" name="user.settings.oauth_connected_apps.last_used">
<segment>
<source>user.settings.oauth_connected_apps.last_used</source>
<target>Last used</target>
</segment>
</unit>
<unit id="oaAdm001" name="perm.system.manage_oauth_clients">
<segment>
<source>perm.system.manage_oauth_clients</source>