. */ 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 */ 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); } }