mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-01 06:01:42 +00:00
Make OAuth connection grants configurable
This commit is contained in:
parent
28023c9828
commit
aa5edae4d9
15 changed files with 1370 additions and 13 deletions
81
migrations/Version20260727143622.php
Normal file
81
migrations/Version20260727143622.php
Normal 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');
|
||||
}
|
||||
}
|
||||
81
src/Doctrine/OAuth/RefreshTokenTtlRepositoryDecorator.php
Normal file
81
src/Doctrine/OAuth/RefreshTokenTtlRepositoryDecorator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
144
src/Entity/UserSystem/OAuthClientGrantPreference.php
Normal file
144
src/Entity/UserSystem/OAuthClientGrantPreference.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
src/EventListener/OAuth/OAuthAuthorizeFormActionCspListener.php
Normal file
126
src/EventListener/OAuth/OAuthAuthorizeFormActionCspListener.php
Normal 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);
|
||||
}
|
||||
}
|
||||
91
src/EventListener/OAuth/OAuthScopeResolveListener.php
Normal file
91
src/EventListener/OAuth/OAuthScopeResolveListener.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
125
src/Services/OAuth/OAuthClientGrantPreferenceManager.php
Normal file
125
src/Services/OAuth/OAuthClientGrantPreferenceManager.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
tests/Security/OAuth/OAuthScopeNarrowingAndTtlTest.php
Normal file
235
tests/Security/OAuth/OAuthScopeNarrowingAndTtlTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
100
tests/Services/OAuth/OAuthClientGrantPreferenceManagerTest.php
Normal file
100
tests/Services/OAuth/OAuthClientGrantPreferenceManagerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue