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
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue