mirror of
https://github.com/wallabag/wallabag.git
synced 2025-09-30 19:22:12 +00:00
Merge 173b317ff4
into bed6d09ef2
This commit is contained in:
commit
539c9d8685
21 changed files with 4989 additions and 2 deletions
|
@ -2,6 +2,7 @@ imports:
|
||||||
- { resource: parameters.yml }
|
- { resource: parameters.yml }
|
||||||
- { resource: security.yml }
|
- { resource: security.yml }
|
||||||
- { resource: services.yml }
|
- { resource: services.yml }
|
||||||
|
- { resource: services_oauth.yml }
|
||||||
- { resource: wallabag.yml }
|
- { resource: wallabag.yml }
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
|
|
|
@ -26,6 +26,7 @@ security:
|
||||||
pattern: ^/oauth/v2/token
|
pattern: ^/oauth/v2/token
|
||||||
security: false
|
security: false
|
||||||
|
|
||||||
|
|
||||||
api:
|
api:
|
||||||
pattern: /api/.*
|
pattern: /api/.*
|
||||||
fos_oauth: true
|
fos_oauth: true
|
||||||
|
|
32
app/config/services_oauth.yml
Normal file
32
app/config/services_oauth.yml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# OAuth and PKCE Services Configuration
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PKCE Service for code challenge generation and verification
|
||||||
|
wallabag.oauth.pkce_service:
|
||||||
|
class: Wallabag\Service\OAuth\PkceService
|
||||||
|
autowire: false
|
||||||
|
autoconfigure: false
|
||||||
|
public: true
|
||||||
|
|
||||||
|
# PkceOAuthStorage requires manual argument ordering for parent class compatibility
|
||||||
|
Wallabag\Service\OAuth\PkceOAuthStorage:
|
||||||
|
arguments:
|
||||||
|
- '@fos_oauth_server.client_manager'
|
||||||
|
- '@fos_oauth_server.access_token_manager'
|
||||||
|
- '@fos_oauth_server.refresh_token_manager'
|
||||||
|
- '@fos_oauth_server.auth_code_manager'
|
||||||
|
- '@fos_user.user_provider.username_email'
|
||||||
|
- '@security.encoder_factory'
|
||||||
|
- '@wallabag.oauth.pkce_service'
|
||||||
|
- '@request_stack'
|
||||||
|
|
||||||
|
# PKCE-enhanced OAuth Storage (replaces the default storage)
|
||||||
|
wallabag.oauth.storage:
|
||||||
|
alias: Wallabag\Service\OAuth\PkceOAuthStorage
|
||||||
|
public: true
|
||||||
|
|
||||||
|
# Override the default OAuth storage with our PKCE-enabled one
|
||||||
|
fos_oauth_server.storage:
|
||||||
|
alias: wallabag.oauth.storage
|
||||||
|
public: true
|
||||||
|
|
67
migrations/Version20250703140000.php
Normal file
67
migrations/Version20250703140000.php
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Application\Migrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Wallabag\Doctrine\WallabagMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add PKCE support to OAuth2 implementation.
|
||||||
|
*
|
||||||
|
* Adds code_challenge and code_challenge_method fields to oauth2_auth_codes table
|
||||||
|
* and client type fields to oauth2_clients table for OAuth 2.1 compliance.
|
||||||
|
*/
|
||||||
|
class Version20250703140000 extends WallabagMigration
|
||||||
|
{
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$authCodeTable = $schema->getTable($this->getTable('oauth2_auth_codes'));
|
||||||
|
$clientTable = $schema->getTable($this->getTable('oauth2_clients'));
|
||||||
|
|
||||||
|
// Add PKCE fields to auth_codes table
|
||||||
|
$this->skipIf($authCodeTable->hasColumn('code_challenge'), 'It seems that you already played this migration.');
|
||||||
|
|
||||||
|
$authCodeTable->addColumn('code_challenge', 'string', [
|
||||||
|
'length' => 128,
|
||||||
|
'notnull' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$authCodeTable->addColumn('code_challenge_method', 'string', [
|
||||||
|
'length' => 10,
|
||||||
|
'notnull' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add client type fields to clients table
|
||||||
|
$clientTable->addColumn('is_public', 'boolean', [
|
||||||
|
'default' => false,
|
||||||
|
'notnull' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$clientTable->addColumn('require_pkce', 'boolean', [
|
||||||
|
'default' => false,
|
||||||
|
'notnull' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$authCodeTable = $schema->getTable($this->getTable('oauth2_auth_codes'));
|
||||||
|
$clientTable = $schema->getTable($this->getTable('oauth2_clients'));
|
||||||
|
|
||||||
|
if ($authCodeTable->hasColumn('code_challenge')) {
|
||||||
|
$authCodeTable->dropColumn('code_challenge');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($authCodeTable->hasColumn('code_challenge_method')) {
|
||||||
|
$authCodeTable->dropColumn('code_challenge_method');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($clientTable->hasColumn('is_public')) {
|
||||||
|
$clientTable->dropColumn('is_public');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($clientTable->hasColumn('require_pkce')) {
|
||||||
|
$clientTable->dropColumn('require_pkce');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
458
src/Controller/Api/OAuthController.php
Normal file
458
src/Controller/Api/OAuthController.php
Normal file
|
@ -0,0 +1,458 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Wallabag\Controller\AbstractController;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
use Wallabag\Repository\Api\ClientRepository;
|
||||||
|
use Wallabag\Service\OAuth\PkceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 Authorization Controller.
|
||||||
|
*
|
||||||
|
* Handles the OAuth2 authorization flow with PKCE support.
|
||||||
|
* Implements the /oauth/v2/authorize endpoint for secure OAuth2 authorization.
|
||||||
|
*/
|
||||||
|
class OAuthController extends AbstractController
|
||||||
|
{
|
||||||
|
private PkceService $pkceService;
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
private ClientRepository $clientRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth Controller constructor.
|
||||||
|
*
|
||||||
|
* @param PkceService $pkceService Service for PKCE validation and generation
|
||||||
|
* @param EntityManagerInterface $entityManager Doctrine entity manager for database operations
|
||||||
|
* @param ClientRepository $clientRepository Repository for OAuth client entities
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
PkceService $pkceService,
|
||||||
|
EntityManagerInterface $entityManager,
|
||||||
|
ClientRepository $clientRepository,
|
||||||
|
) {
|
||||||
|
$this->pkceService = $pkceService;
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
|
$this->clientRepository = $clientRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 Authorization Endpoint.
|
||||||
|
*
|
||||||
|
* This endpoint handles the authorization request in the OAuth2 authorization code flow.
|
||||||
|
* It validates the request parameters, checks PKCE requirements, and either shows
|
||||||
|
* a login form or a consent screen.
|
||||||
|
*
|
||||||
|
* @Route("/oauth/v2/authorize", name="oauth2_authorize", methods={"GET"})
|
||||||
|
*
|
||||||
|
* @param Request $request The HTTP request containing OAuth2 parameters
|
||||||
|
*
|
||||||
|
* @throws BadRequestHttpException When required parameters are missing or invalid
|
||||||
|
* @return Response Either a redirect to login, consent page, or error redirect
|
||||||
|
*/
|
||||||
|
public function authorizeAction(Request $request): Response
|
||||||
|
{
|
||||||
|
// Extract and validate required parameters
|
||||||
|
$clientId = $request->query->get('client_id');
|
||||||
|
$redirectUri = $request->query->get('redirect_uri');
|
||||||
|
$responseType = $request->query->get('response_type', 'code');
|
||||||
|
$scope = $request->query->get('scope', 'read');
|
||||||
|
$state = $request->query->get('state');
|
||||||
|
|
||||||
|
// PKCE parameters
|
||||||
|
$codeChallenge = $request->query->get('code_challenge');
|
||||||
|
$codeChallengeMethod = $request->query->get('code_challenge_method', 'plain');
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!$clientId) {
|
||||||
|
throw new BadRequestHttpException('Missing required parameter: client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$redirectUri) {
|
||||||
|
throw new BadRequestHttpException('Missing required parameter: redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('code' !== $responseType) {
|
||||||
|
return $this->redirectWithError($redirectUri, 'unsupported_response_type',
|
||||||
|
'Only "code" response type is supported', $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and validate client
|
||||||
|
$clientValidation = $this->findAndValidateClient($clientId, $redirectUri, $codeChallenge, $codeChallengeMethod);
|
||||||
|
|
||||||
|
if (null === $clientValidation) {
|
||||||
|
throw new BadRequestHttpException('Invalid client_id or redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($clientValidation)) {
|
||||||
|
// PKCE validation failed - redirect with error (RFC compliant)
|
||||||
|
return $this->redirectWithError($redirectUri, $clientValidation['error'], $clientValidation['description'], $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $clientValidation;
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
// Store request parameters in session for after login
|
||||||
|
$session = $request->getSession();
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'scope' => $scope,
|
||||||
|
'state' => $state,
|
||||||
|
'code_challenge' => $codeChallenge,
|
||||||
|
'code_challenge_method' => $codeChallengeMethod,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Store the current URL to redirect back after login
|
||||||
|
$session->set('_security.main.target_path', $request->getUri());
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
return $this->redirectToRoute('fos_user_security_login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is authenticated - check if we have stored OAuth request from session
|
||||||
|
$session = $request->getSession();
|
||||||
|
$storedRequest = $session->get('oauth2_request');
|
||||||
|
if ($storedRequest) {
|
||||||
|
// We came back from login, use stored parameters
|
||||||
|
$clientId = $storedRequest['client_id'];
|
||||||
|
$redirectUri = $storedRequest['redirect_uri'];
|
||||||
|
$scope = $storedRequest['scope'];
|
||||||
|
$state = $storedRequest['state'];
|
||||||
|
$codeChallenge = $storedRequest['code_challenge'];
|
||||||
|
$codeChallengeMethod = $storedRequest['code_challenge_method'];
|
||||||
|
|
||||||
|
// Re-validate the client with stored parameters
|
||||||
|
$client = $this->findAndValidateClient($clientId, $redirectUri, $codeChallenge, $codeChallengeMethod);
|
||||||
|
if (!$client) {
|
||||||
|
throw new BadRequestHttpException('Invalid client_id or redirect_uri');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Store current request parameters for the consent form
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'scope' => $scope,
|
||||||
|
'state' => $state,
|
||||||
|
'code_challenge' => $codeChallenge,
|
||||||
|
'code_challenge_method' => $codeChallengeMethod,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is authenticated, show consent page
|
||||||
|
return $this->showConsentPage($client, $scope, $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 Authorization Consent Handler.
|
||||||
|
*
|
||||||
|
* Handles the user's consent decision and generates the authorization code.
|
||||||
|
* If the user approves, creates an authorization code and redirects back to the client.
|
||||||
|
* If denied, redirects with an access_denied error.
|
||||||
|
*
|
||||||
|
* @Route("/oauth/v2/authorize", name="oauth2_authorize_consent", methods={"POST"})
|
||||||
|
* @IsGranted("ROLE_USER")
|
||||||
|
*
|
||||||
|
* @param Request $request The HTTP request containing consent action and CSRF token
|
||||||
|
*
|
||||||
|
* @throws BadRequestHttpException When CSRF token is invalid or session state is invalid
|
||||||
|
* @return Response Redirect to client with authorization code or error
|
||||||
|
*/
|
||||||
|
public function consentAction(Request $request): Response
|
||||||
|
{
|
||||||
|
$session = $request->getSession();
|
||||||
|
$oauthRequest = $session->get('oauth2_request');
|
||||||
|
|
||||||
|
if (!$oauthRequest) {
|
||||||
|
throw new BadRequestHttpException('Invalid OAuth2 session state');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CSRF token
|
||||||
|
$token = $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid('oauth_consent', $token)) {
|
||||||
|
throw new BadRequestHttpException('Invalid CSRF token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $request->request->get('action');
|
||||||
|
|
||||||
|
if ('deny' === $action) {
|
||||||
|
// User denied access
|
||||||
|
$session->remove('oauth2_request');
|
||||||
|
|
||||||
|
return $this->redirectWithError(
|
||||||
|
$oauthRequest['redirect_uri'],
|
||||||
|
'access_denied',
|
||||||
|
'The user denied the request',
|
||||||
|
$oauthRequest['state']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('allow' !== $action) {
|
||||||
|
throw new BadRequestHttpException('Invalid action parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User granted access, generate authorization code
|
||||||
|
$client = $this->clientRepository->findOneBy(['id' => $oauthRequest['client_id']]);
|
||||||
|
if (!$client) {
|
||||||
|
throw new BadRequestHttpException('Invalid client');
|
||||||
|
}
|
||||||
|
|
||||||
|
$authCode = $this->generateAuthorizationCode($client, $this->getUser(), $oauthRequest);
|
||||||
|
|
||||||
|
// Clear the session
|
||||||
|
$session->remove('oauth2_request');
|
||||||
|
|
||||||
|
// Redirect back to client with authorization code
|
||||||
|
return $this->redirectWithCode(
|
||||||
|
$oauthRequest['redirect_uri'],
|
||||||
|
$authCode->getToken(),
|
||||||
|
$oauthRequest['state']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and validate the OAuth client.
|
||||||
|
*
|
||||||
|
* Validates the client_id, redirect_uri, and PKCE requirements.
|
||||||
|
* Returns the client object if valid, error array if PKCE validation fails,
|
||||||
|
* or null if client/redirect_uri is invalid.
|
||||||
|
*
|
||||||
|
* @param string $clientId The OAuth client identifier
|
||||||
|
* @param string $redirectUri The redirect URI to validate
|
||||||
|
* @param string|null $codeChallenge The PKCE code challenge (optional)
|
||||||
|
* @param string $codeChallengeMethod The PKCE challenge method
|
||||||
|
*
|
||||||
|
* @return Client|array|null Client object, error array for redirect, or null for invalid client/redirect_uri
|
||||||
|
*/
|
||||||
|
private function findAndValidateClient(
|
||||||
|
string $clientId,
|
||||||
|
string $redirectUri,
|
||||||
|
?string $codeChallenge,
|
||||||
|
string $codeChallengeMethod,
|
||||||
|
): Client|array|null {
|
||||||
|
// Extract the actual client ID from the composite client_id
|
||||||
|
$parts = explode('_', $clientId, 2);
|
||||||
|
if (2 !== \count($parts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $this->clientRepository->find((int) $parts[0]);
|
||||||
|
if (!$client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the random ID matches
|
||||||
|
if ($client->getRandomId() !== $parts[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirect URI
|
||||||
|
if (!$this->isValidRedirectUri($client, $redirectUri)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate PKCE requirements
|
||||||
|
$pkceValidation = $this->validatePkceRequirements($client, $codeChallenge, $codeChallengeMethod);
|
||||||
|
if (true !== $pkceValidation) {
|
||||||
|
return $pkceValidation; // Return error array for PKCE failures
|
||||||
|
}
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the redirect URI is allowed for this client.
|
||||||
|
*
|
||||||
|
* Performs exact match validation against the client's registered redirect URIs
|
||||||
|
* for security compliance with OAuth 2.1 specifications.
|
||||||
|
*
|
||||||
|
* @param Client $client The OAuth client to validate against
|
||||||
|
* @param string $redirectUri The redirect URI to validate
|
||||||
|
*
|
||||||
|
* @return bool True if the redirect URI is valid for this client
|
||||||
|
*/
|
||||||
|
private function isValidRedirectUri(Client $client, string $redirectUri): bool
|
||||||
|
{
|
||||||
|
$allowedUris = $client->getRedirectUris();
|
||||||
|
|
||||||
|
// Exact match required for security
|
||||||
|
return \in_array($redirectUri, $allowedUris, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate PKCE requirements based on client configuration.
|
||||||
|
*
|
||||||
|
* @return true|array True if valid, error array if invalid
|
||||||
|
*/
|
||||||
|
private function validatePkceRequirements(Client $client, ?string $codeChallenge, string $codeChallengeMethod): true|array
|
||||||
|
{
|
||||||
|
// If client requires PKCE, code_challenge must be present
|
||||||
|
if ($client->requiresPkce() && !$codeChallenge) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'description' => 'Client requires PKCE but no code_challenge provided',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If code_challenge is present, validate the method
|
||||||
|
if ($codeChallenge) {
|
||||||
|
try {
|
||||||
|
$this->pkceService->validateCodeChallengeMethod($codeChallengeMethod);
|
||||||
|
|
||||||
|
// For public clients, enforce S256 method for better security
|
||||||
|
if ($client->isPublic() && $this->pkceService->shouldEnforceS256(true) && PkceService::METHOD_S256 !== $codeChallengeMethod) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'description' => 'Public clients must use S256 code challenge method',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'description' => 'Invalid PKCE parameters: ' . $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the consent page to the user.
|
||||||
|
*
|
||||||
|
* @param Client $client The OAuth client requesting authorization
|
||||||
|
* @param string $scope The requested scope string
|
||||||
|
* @param string|null $state The state parameter for CSRF protection
|
||||||
|
*
|
||||||
|
* @return Response The rendered consent page
|
||||||
|
*/
|
||||||
|
private function showConsentPage(Client $client, string $scope, ?string $state): Response
|
||||||
|
{
|
||||||
|
$scopes = $this->parseScopes($scope);
|
||||||
|
|
||||||
|
return $this->render('OAuth/consent.html.twig', [
|
||||||
|
'client' => $client,
|
||||||
|
'scopes' => $scopes,
|
||||||
|
'state' => $state,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an authorization code for the authenticated user.
|
||||||
|
*
|
||||||
|
* Creates a new authorization code with PKCE data and saves it to the database.
|
||||||
|
*
|
||||||
|
* @param Client $client The OAuth client
|
||||||
|
* @param User $user The authenticated user
|
||||||
|
* @param array $oauthRequest The OAuth request data containing PKCE parameters
|
||||||
|
*
|
||||||
|
* @return AuthCode The created authorization code
|
||||||
|
*/
|
||||||
|
private function generateAuthorizationCode(Client $client, User $user, array $oauthRequest): AuthCode
|
||||||
|
{
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($client);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setRedirectUri($oauthRequest['redirect_uri']);
|
||||||
|
$authCode->setScope($oauthRequest['scope']);
|
||||||
|
|
||||||
|
// Set expiration to 10 minutes (RFC recommendation)
|
||||||
|
$authCode->setExpiresAt(time() + 600);
|
||||||
|
|
||||||
|
// Generate a secure authorization code token
|
||||||
|
$authCode->setToken($this->generateSecureToken());
|
||||||
|
|
||||||
|
// Set PKCE parameters if present
|
||||||
|
if ($oauthRequest['code_challenge']) {
|
||||||
|
$authCode->setCodeChallenge($oauthRequest['code_challenge']);
|
||||||
|
$authCode->setCodeChallengeMethod($oauthRequest['code_challenge_method']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($authCode);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure random token for authorization codes.
|
||||||
|
*/
|
||||||
|
private function generateSecureToken(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse scope string into array of individual scopes.
|
||||||
|
*/
|
||||||
|
private function parseScopes(string $scope): array
|
||||||
|
{
|
||||||
|
$scopes = array_filter(explode(' ', trim($scope)));
|
||||||
|
|
||||||
|
// Define available scopes with descriptions
|
||||||
|
$availableScopes = [
|
||||||
|
'read' => 'Read your entries and account information',
|
||||||
|
'write' => 'Create and modify entries',
|
||||||
|
'delete' => 'Delete entries',
|
||||||
|
];
|
||||||
|
|
||||||
|
$parsedScopes = [];
|
||||||
|
foreach ($scopes as $scopeName) {
|
||||||
|
if (isset($availableScopes[$scopeName])) {
|
||||||
|
$parsedScopes[$scopeName] = $availableScopes[$scopeName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parsedScopes ?: ['read' => $availableScopes['read']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to client with authorization code.
|
||||||
|
*/
|
||||||
|
private function redirectWithCode(string $redirectUri, string $code, ?string $state): RedirectResponse
|
||||||
|
{
|
||||||
|
$params = ['code' => $code];
|
||||||
|
|
||||||
|
if (null !== $state) {
|
||||||
|
$params['state'] = $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
$separator = parse_url($redirectUri, \PHP_URL_QUERY) ? '&' : '?';
|
||||||
|
$url = $redirectUri . $separator . http_build_query($params);
|
||||||
|
|
||||||
|
return new RedirectResponse($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to client with error.
|
||||||
|
*/
|
||||||
|
private function redirectWithError(string $redirectUri, string $error, string $description, ?string $state): RedirectResponse
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'error' => $error,
|
||||||
|
'error_description' => $description,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $state) {
|
||||||
|
$params['state'] = $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
$separator = parse_url($redirectUri, \PHP_URL_QUERY) ? '&' : '?';
|
||||||
|
$url = $redirectUri . $separator . http_build_query($params);
|
||||||
|
|
||||||
|
return new RedirectResponse($url);
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,4 +22,83 @@ class AuthCode extends BaseAuthCode
|
||||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
protected $user;
|
protected $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE code challenge.
|
||||||
|
* Base64-URL encoded SHA256 hash of the code verifier (for S256 method)
|
||||||
|
* or the code verifier itself (for plain method).
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'code_challenge', type: 'string', length: 128, nullable: true)]
|
||||||
|
private ?string $codeChallenge = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE code challenge method.
|
||||||
|
* Either 'S256' (recommended) or 'plain'.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'code_challenge_method', type: 'string', length: 10, nullable: true)]
|
||||||
|
private ?string $codeChallengeMethod = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PKCE code challenge value.
|
||||||
|
*
|
||||||
|
* @return string|null The code challenge or null if not set
|
||||||
|
*/
|
||||||
|
public function getCodeChallenge(): ?string
|
||||||
|
{
|
||||||
|
return $this->codeChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the PKCE code challenge value.
|
||||||
|
*
|
||||||
|
* @param string|null $codeChallenge The code challenge value
|
||||||
|
*/
|
||||||
|
public function setCodeChallenge(?string $codeChallenge): self
|
||||||
|
{
|
||||||
|
$this->codeChallenge = $codeChallenge;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PKCE code challenge method.
|
||||||
|
*
|
||||||
|
* @return string|null The challenge method ('S256' or 'plain') or null if not set
|
||||||
|
*/
|
||||||
|
public function getCodeChallengeMethod(): ?string
|
||||||
|
{
|
||||||
|
return $this->codeChallengeMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the PKCE code challenge method.
|
||||||
|
*
|
||||||
|
* @param string|null $codeChallengeMethod The challenge method ('S256' or 'plain')
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the method is not 'S256' or 'plain'
|
||||||
|
*/
|
||||||
|
public function setCodeChallengeMethod(?string $codeChallengeMethod): self
|
||||||
|
{
|
||||||
|
// Validate that the method is one of the allowed values
|
||||||
|
if (null !== $codeChallengeMethod && !\in_array($codeChallengeMethod, ['S256', 'plain'], true)) {
|
||||||
|
throw new \InvalidArgumentException('Code challenge method must be either "S256" or "plain"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->codeChallengeMethod = $codeChallengeMethod;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this authorization code has PKCE enabled.
|
||||||
|
*
|
||||||
|
* An authorization code has PKCE enabled if both code challenge
|
||||||
|
* and challenge method are set.
|
||||||
|
*
|
||||||
|
* @return bool True if PKCE is enabled for this authorization code
|
||||||
|
*/
|
||||||
|
public function hasPkce(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->codeChallenge && null !== $this->codeChallengeMethod;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,10 +55,26 @@ class Client extends BaseClient
|
||||||
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'clients')]
|
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'clients')]
|
||||||
private $user;
|
private $user;
|
||||||
|
|
||||||
public function __construct(User $user)
|
/**
|
||||||
|
* Whether this is a public client (mobile app, SPA) that cannot securely store credentials.
|
||||||
|
* Public clients MUST use PKCE for authorization code flow.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'is_public', type: 'boolean', options: ['default' => false])]
|
||||||
|
private bool $isPublic = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this client requires PKCE for authorization code flow.
|
||||||
|
* This should be true for public clients and can be optionally enabled for confidential clients.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'require_pkce', type: 'boolean', options: ['default' => false])]
|
||||||
|
private bool $requirePkce = false;
|
||||||
|
|
||||||
|
public function __construct(?User $user = null)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->user = $user;
|
if (null !== $user) {
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -107,4 +123,96 @@ class Client extends BaseClient
|
||||||
{
|
{
|
||||||
return $this->getId() . '_' . $this->getRandomId();
|
return $this->getId() . '_' . $this->getRandomId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a public client (mobile app, SPA, etc.).
|
||||||
|
*
|
||||||
|
* Public clients cannot securely store client secrets and must use PKCE.
|
||||||
|
*
|
||||||
|
* @return bool True if this is a public client
|
||||||
|
*/
|
||||||
|
public function isPublic(): bool
|
||||||
|
{
|
||||||
|
return $this->isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether this client is public or confidential.
|
||||||
|
*
|
||||||
|
* Public clients are automatically required to use PKCE for security.
|
||||||
|
*
|
||||||
|
* @param bool $isPublic True for public clients (mobile, SPA), false for confidential
|
||||||
|
*/
|
||||||
|
public function setIsPublic(bool $isPublic): self
|
||||||
|
{
|
||||||
|
$this->isPublic = $isPublic;
|
||||||
|
|
||||||
|
// Public clients should always require PKCE for security
|
||||||
|
if ($isPublic) {
|
||||||
|
$this->requirePkce = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this client requires PKCE for authorization code flow.
|
||||||
|
*
|
||||||
|
* @return bool True if PKCE is required for this client
|
||||||
|
*/
|
||||||
|
public function requiresPkce(): bool
|
||||||
|
{
|
||||||
|
return $this->requirePkce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether this client requires PKCE for authorization code flow.
|
||||||
|
*
|
||||||
|
* @param bool $requirePkce True to require PKCE, false to make it optional
|
||||||
|
*/
|
||||||
|
public function setRequirePkce(bool $requirePkce): self
|
||||||
|
{
|
||||||
|
$this->requirePkce = $requirePkce;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the checkSecret method to allow public clients without secrets.
|
||||||
|
*
|
||||||
|
* Public clients should not have or use client secrets according to OAuth 2.1.
|
||||||
|
* This method returns true for public clients regardless of the secret provided.
|
||||||
|
*
|
||||||
|
* @param string $secret The client secret to validate
|
||||||
|
*
|
||||||
|
* @return bool True if secret is valid or client is public
|
||||||
|
*/
|
||||||
|
public function checkSecret($secret): bool
|
||||||
|
{
|
||||||
|
if ($this->isPublic()) {
|
||||||
|
// Public clients should not use secrets
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::checkSecret($secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this client is allowed to use a specific grant type.
|
||||||
|
*
|
||||||
|
* Public clients are restricted from using the password grant for security reasons.
|
||||||
|
*
|
||||||
|
* @param string $grant The grant type to check (e.g., 'authorization_code', 'password')
|
||||||
|
*
|
||||||
|
* @return bool True if the grant type is supported by this client
|
||||||
|
*/
|
||||||
|
public function isGrantSupported(string $grant): bool
|
||||||
|
{
|
||||||
|
if ($this->isPublic() && 'password' === $grant) {
|
||||||
|
// Public clients should not use password grant for security reasons
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \in_array($grant, $this->getAllowedGrantTypes(), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
163
src/Service/OAuth/PkceAuthorizationCodeGrantHandler.php
Normal file
163
src/Service/OAuth/PkceAuthorizationCodeGrantHandler.php
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Service\OAuth;
|
||||||
|
|
||||||
|
use FOS\OAuthServerBundle\Model\AuthCodeManagerInterface;
|
||||||
|
use FOS\OAuthServerBundle\Storage\GrantExtensionInterface;
|
||||||
|
use OAuth2\Model\IOAuth2Client;
|
||||||
|
use OAuth2\OAuth2;
|
||||||
|
use OAuth2\OAuth2ServerException;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE-enabled Authorization Code Grant Handler.
|
||||||
|
*
|
||||||
|
* Extends the standard OAuth2 authorization code grant to support PKCE verification.
|
||||||
|
* This handler validates code_verifier against the stored code_challenge.
|
||||||
|
*/
|
||||||
|
class PkceAuthorizationCodeGrantHandler implements GrantExtensionInterface
|
||||||
|
{
|
||||||
|
private PkceService $pkceService;
|
||||||
|
private AuthCodeManagerInterface $authCodeManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE Authorization Code Grant Handler constructor.
|
||||||
|
*
|
||||||
|
* @param PkceService $pkceService Service for PKCE validation
|
||||||
|
* @param AuthCodeManagerInterface $authCodeManager Manager for authorization codes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
PkceService $pkceService,
|
||||||
|
AuthCodeManagerInterface $authCodeManager,
|
||||||
|
) {
|
||||||
|
$this->pkceService = $pkceService;
|
||||||
|
$this->authCodeManager = $authCodeManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this handler supports the given grant type.
|
||||||
|
*
|
||||||
|
* @param IOAuth2Client $client The OAuth client making the request
|
||||||
|
* @param array $inputData Request data containing grant_type
|
||||||
|
* @param array $authHeaders Authentication headers
|
||||||
|
*
|
||||||
|
* @return bool True if this handler supports the grant type
|
||||||
|
*/
|
||||||
|
public function checkGrantExtension(IOAuth2Client $client, array $inputData, array $authHeaders): bool
|
||||||
|
{
|
||||||
|
// This handler only supports authorization_code grant
|
||||||
|
return isset($inputData['grant_type']) && 'authorization_code' === $inputData['grant_type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the authorization code grant with PKCE validation.
|
||||||
|
*
|
||||||
|
* Validates the authorization code, performs PKCE verification if required,
|
||||||
|
* and returns the access token data.
|
||||||
|
*
|
||||||
|
* @param IOAuth2Client $client The OAuth client making the request
|
||||||
|
* @param array $inputData request data containing code, code_verifier, etc
|
||||||
|
* @param array $authHeaders Authentication headers
|
||||||
|
*
|
||||||
|
* @throws OAuth2ServerException When validation fails
|
||||||
|
* @return array Access token data with client_id, user_id, and scope
|
||||||
|
*/
|
||||||
|
public function getAccessTokenData(IOAuth2Client $client, array $inputData, array $authHeaders): array
|
||||||
|
{
|
||||||
|
// Validate required parameters
|
||||||
|
if (!isset($inputData['code'])) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'Missing parameter: "code" is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$authorizationCode = $inputData['code'];
|
||||||
|
$codeVerifier = $inputData['code_verifier'] ?? null;
|
||||||
|
|
||||||
|
// Find the authorization code
|
||||||
|
$authCode = $this->authCodeManager->findAuthCodeByToken($authorizationCode);
|
||||||
|
|
||||||
|
if (!$authCode instanceof AuthCode) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the authorization code has expired
|
||||||
|
if ($authCode->hasExpired()) {
|
||||||
|
$this->authCodeManager->deleteAuthCode($authCode);
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'The authorization code has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the client
|
||||||
|
if ($authCode->getClient()->getPublicId() !== $client->getPublicId()) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
$wallabagClient = $authCode->getClient();
|
||||||
|
if (!$wallabagClient instanceof Client) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_CLIENT, 'Invalid client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate PKCE requirements
|
||||||
|
if ($wallabagClient->requiresPkce() || $wallabagClient->isPublic()) {
|
||||||
|
// Public clients and clients explicitly requiring PKCE must always use PKCE
|
||||||
|
if (!$authCode->hasPkce()) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'PKCE is required for this client');
|
||||||
|
}
|
||||||
|
$this->validatePkce($authCode, $codeVerifier, $wallabagClient);
|
||||||
|
} elseif ($authCode->hasPkce()) {
|
||||||
|
// Optional PKCE - validate if present
|
||||||
|
$this->validatePkce($authCode, $codeVerifier, $wallabagClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify redirect URI - REQUIRED if one was stored during authorization
|
||||||
|
if ($authCode->getRedirectUri()) {
|
||||||
|
if (!isset($inputData['redirect_uri']) || $authCode->getRedirectUri() !== $inputData['redirect_uri']) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid redirect URI');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the access token data
|
||||||
|
$user = $authCode->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid user in authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenData = [
|
||||||
|
'client_id' => $client->getPublicId(),
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'scope' => $authCode->getScope(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Delete the authorization code (single use)
|
||||||
|
$this->authCodeManager->deleteAuthCode($authCode);
|
||||||
|
|
||||||
|
return $tokenData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate PKCE code verifier against the stored challenge.
|
||||||
|
*/
|
||||||
|
private function validatePkce(AuthCode $authCode, ?string $codeVerifier, Client $client): void
|
||||||
|
{
|
||||||
|
if (null === $codeVerifier) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'PKCE code_verifier is required for this authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
$codeChallenge = $authCode->getCodeChallenge();
|
||||||
|
$codeChallengeMethod = $authCode->getCodeChallengeMethod();
|
||||||
|
|
||||||
|
if (!$codeChallenge || !$codeChallengeMethod) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid PKCE data in authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the code verifier
|
||||||
|
if (!$this->pkceService->verifyCodeChallenge($codeVerifier, $codeChallenge, $codeChallengeMethod)) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid PKCE code_verifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional security check for public clients
|
||||||
|
if ($client->isPublic() && PkceService::METHOD_S256 !== $codeChallengeMethod) {
|
||||||
|
throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'Public clients must use S256 code challenge method');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
158
src/Service/OAuth/PkceOAuthStorage.php
Normal file
158
src/Service/OAuth/PkceOAuthStorage.php
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Service\OAuth;
|
||||||
|
|
||||||
|
use FOS\OAuthServerBundle\Model\AccessTokenManagerInterface;
|
||||||
|
use FOS\OAuthServerBundle\Model\AuthCodeManagerInterface;
|
||||||
|
use FOS\OAuthServerBundle\Model\ClientManagerInterface;
|
||||||
|
use FOS\OAuthServerBundle\Model\RefreshTokenManagerInterface;
|
||||||
|
use FOS\OAuthServerBundle\Storage\OAuthStorage;
|
||||||
|
use OAuth2\Model\IOAuth2AuthCode;
|
||||||
|
use OAuth2\OAuth2;
|
||||||
|
use OAuth2\OAuth2ServerException;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE-enhanced OAuth Storage.
|
||||||
|
*
|
||||||
|
* Extends the default FOSOAuthServerBundle storage to add PKCE validation
|
||||||
|
* for authorization code grants.
|
||||||
|
*/
|
||||||
|
class PkceOAuthStorage extends OAuthStorage
|
||||||
|
{
|
||||||
|
private PkceService $pkceService;
|
||||||
|
private RequestStack $requestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE OAuth Storage constructor.
|
||||||
|
*
|
||||||
|
* @param ClientManagerInterface $clientManager OAuth client manager
|
||||||
|
* @param AccessTokenManagerInterface $accessTokenManager Access token manager
|
||||||
|
* @param RefreshTokenManagerInterface $refreshTokenManager Refresh token manager
|
||||||
|
* @param AuthCodeManagerInterface $authCodeManager Authorization code manager
|
||||||
|
* @param UserProviderInterface $userProvider User provider for authentication
|
||||||
|
* @param EncoderFactoryInterface|null $encoderFactory Password encoder factory
|
||||||
|
* @param PkceService $pkceService Service for PKCE validation
|
||||||
|
* @param RequestStack $requestStack Symfony request stack
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
ClientManagerInterface $clientManager,
|
||||||
|
AccessTokenManagerInterface $accessTokenManager,
|
||||||
|
RefreshTokenManagerInterface $refreshTokenManager,
|
||||||
|
AuthCodeManagerInterface $authCodeManager,
|
||||||
|
UserProviderInterface $userProvider,
|
||||||
|
?EncoderFactoryInterface $encoderFactory,
|
||||||
|
PkceService $pkceService,
|
||||||
|
RequestStack $requestStack,
|
||||||
|
) {
|
||||||
|
parent::__construct(
|
||||||
|
$clientManager,
|
||||||
|
$accessTokenManager,
|
||||||
|
$refreshTokenManager,
|
||||||
|
$authCodeManager,
|
||||||
|
$userProvider,
|
||||||
|
$encoderFactory
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->pkceService = $pkceService;
|
||||||
|
$this->requestStack = $requestStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override getAuthCode to validate PKCE during authorization code validation.
|
||||||
|
*
|
||||||
|
* This is called by the OAuth2 library during token exchange for validation.
|
||||||
|
* Validates PKCE requirements and redirect URI matching for enhanced security.
|
||||||
|
*
|
||||||
|
* @param string $code The authorization code to validate
|
||||||
|
*
|
||||||
|
* @throws OAuth2ServerException When PKCE validation fails or redirect URI mismatches
|
||||||
|
* @return IOAuth2AuthCode|null The validated authorization code or null if invalid
|
||||||
|
*/
|
||||||
|
public function getAuthCode($code)
|
||||||
|
{
|
||||||
|
// Get the authorization code from parent storage
|
||||||
|
$authCodeEntity = parent::getAuthCode($code);
|
||||||
|
|
||||||
|
if ($authCodeEntity instanceof AuthCode) {
|
||||||
|
$client = $authCodeEntity->getClient();
|
||||||
|
if ($client instanceof Client) {
|
||||||
|
// Get code_verifier from current request
|
||||||
|
$currentRequest = $this->requestStack->getCurrentRequest();
|
||||||
|
$codeVerifier = null;
|
||||||
|
$requestRedirectUri = null;
|
||||||
|
|
||||||
|
if ($currentRequest) {
|
||||||
|
$codeVerifier = $currentRequest->request->get('code_verifier') ??
|
||||||
|
$currentRequest->query->get('code_verifier');
|
||||||
|
$requestRedirectUri = $currentRequest->request->get('redirect_uri') ??
|
||||||
|
$currentRequest->query->get('redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirect URI if provided in request
|
||||||
|
if (null !== $requestRedirectUri) {
|
||||||
|
if ($authCodeEntity->getRedirectUri() !== $requestRedirectUri) {
|
||||||
|
throw new OAuth2ServerException((string) Response::HTTP_BAD_REQUEST, OAuth2::ERROR_REDIRECT_URI_MISMATCH, 'The redirect URI provided does not match the one used during authorization');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate PKCE requirements during code validation
|
||||||
|
if ($client->requiresPkce() || $client->isPublic()) {
|
||||||
|
// Public clients and clients explicitly requiring PKCE must always use PKCE
|
||||||
|
if (!$authCodeEntity->hasPkce()) {
|
||||||
|
throw new OAuth2ServerException((string) Response::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'PKCE is required for this client');
|
||||||
|
}
|
||||||
|
$this->validatePkce($authCodeEntity, $codeVerifier, $client);
|
||||||
|
} elseif ($authCodeEntity->hasPkce()) {
|
||||||
|
// Optional PKCE - validate if present
|
||||||
|
$this->validatePkce($authCodeEntity, $codeVerifier, $client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the validated authorization code
|
||||||
|
return $authCodeEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate PKCE code verifier against the stored challenge.
|
||||||
|
*
|
||||||
|
* Performs RFC 7636 compliant PKCE validation including special security
|
||||||
|
* checks for public clients (S256 method enforcement).
|
||||||
|
*
|
||||||
|
* @param AuthCode $authCode The authorization code containing PKCE data
|
||||||
|
* @param string|null $codeVerifier The code verifier provided by the client
|
||||||
|
* @param Client $client The OAuth client making the request
|
||||||
|
*
|
||||||
|
* @throws OAuth2ServerException When PKCE validation fails
|
||||||
|
*/
|
||||||
|
private function validatePkce(AuthCode $authCode, ?string $codeVerifier, Client $client): void
|
||||||
|
{
|
||||||
|
if (null === $codeVerifier) {
|
||||||
|
throw new OAuth2ServerException((string) Response::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'PKCE code_verifier is required for this authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
$codeChallenge = $authCode->getCodeChallenge();
|
||||||
|
$codeChallengeMethod = $authCode->getCodeChallengeMethod();
|
||||||
|
|
||||||
|
if (!$codeChallenge || !$codeChallengeMethod) {
|
||||||
|
throw new OAuth2ServerException((string) Response::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid PKCE data in authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the code verifier
|
||||||
|
if (!$this->pkceService->verifyCodeChallenge($codeVerifier, $codeChallenge, $codeChallengeMethod)) {
|
||||||
|
throw new OAuth2ServerException((string) Response::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_GRANT, 'Invalid PKCE code_verifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional security check for public clients
|
||||||
|
if ($client->isPublic() && PkceService::METHOD_S256 !== $codeChallengeMethod) {
|
||||||
|
throw new OAuth2ServerException((string) Response::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'Public clients must use S256 code challenge method');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
154
src/Service/OAuth/PkceService.php
Normal file
154
src/Service/OAuth/PkceService.php
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Wallabag\Service\OAuth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE (Proof Key for Code Exchange) service implementation.
|
||||||
|
*
|
||||||
|
* This service handles the generation and verification of PKCE challenges
|
||||||
|
* according to RFC 7636 OAuth 2.0 Extension.
|
||||||
|
*
|
||||||
|
* @see https://tools.ietf.org/html/rfc7636
|
||||||
|
*/
|
||||||
|
class PkceService
|
||||||
|
{
|
||||||
|
public const METHOD_PLAIN = 'plain';
|
||||||
|
public const METHOD_S256 = 'S256';
|
||||||
|
|
||||||
|
// RFC 7636 specifications
|
||||||
|
public const MIN_VERIFIER_LENGTH = 43;
|
||||||
|
public const MAX_VERIFIER_LENGTH = 128;
|
||||||
|
public const VERIFIER_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure code verifier.
|
||||||
|
*
|
||||||
|
* The code verifier is a cryptographically random string using the characters
|
||||||
|
* [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a minimum length of 43
|
||||||
|
* characters and a maximum length of 128 characters.
|
||||||
|
*/
|
||||||
|
public function generateCodeVerifier(): string
|
||||||
|
{
|
||||||
|
$length = random_int(self::MIN_VERIFIER_LENGTH, self::MAX_VERIFIER_LENGTH);
|
||||||
|
$charactersLength = \strlen(self::VERIFIER_CHARACTERS);
|
||||||
|
$verifier = '';
|
||||||
|
|
||||||
|
for ($i = 0; $i < $length; ++$i) {
|
||||||
|
$verifier .= self::VERIFIER_CHARACTERS[random_int(0, $charactersLength - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $verifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a code challenge from a code verifier using the specified method.
|
||||||
|
*
|
||||||
|
* @param string $codeVerifier The code verifier string
|
||||||
|
* @param string $method Either 'plain' or 'S256' (default: 'S256')
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the method is not supported or verifier is invalid
|
||||||
|
*/
|
||||||
|
public function generateCodeChallenge(string $codeVerifier, string $method = self::METHOD_S256): string
|
||||||
|
{
|
||||||
|
$this->validateCodeVerifier($codeVerifier);
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case self::METHOD_PLAIN:
|
||||||
|
return $codeVerifier;
|
||||||
|
case self::METHOD_S256:
|
||||||
|
return $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Unsupported code challenge method "%s". Supported methods are: %s, %s', $method, self::METHOD_PLAIN, self::METHOD_S256));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that a code verifier matches the stored code challenge.
|
||||||
|
*
|
||||||
|
* @param string $codeVerifier The code verifier provided by the client
|
||||||
|
* @param string $codeChallenge The stored code challenge
|
||||||
|
* @param string $method The method used to generate the challenge
|
||||||
|
*
|
||||||
|
* @return bool True if the verifier is valid, false otherwise
|
||||||
|
*/
|
||||||
|
public function verifyCodeChallenge(string $codeVerifier, string $codeChallenge, string $method): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->validateCodeVerifier($codeVerifier);
|
||||||
|
$expectedChallenge = $this->generateCodeChallenge($codeVerifier, $method);
|
||||||
|
|
||||||
|
// Use hash_equals to prevent timing attacks
|
||||||
|
return hash_equals($codeChallenge, $expectedChallenge);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
// Invalid verifier or method
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a code verifier meets RFC 7636 requirements.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the verifier is invalid
|
||||||
|
*/
|
||||||
|
public function validateCodeVerifier(string $codeVerifier): void
|
||||||
|
{
|
||||||
|
$length = \strlen($codeVerifier);
|
||||||
|
|
||||||
|
if ($length < self::MIN_VERIFIER_LENGTH) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Code verifier must be at least %d characters long, got %d', self::MIN_VERIFIER_LENGTH, $length));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($length > self::MAX_VERIFIER_LENGTH) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Code verifier must be at most %d characters long, got %d', self::MAX_VERIFIER_LENGTH, $length));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that all characters are from the allowed set
|
||||||
|
if (!preg_match('/^[A-Za-z0-9\-._~]+$/', $codeVerifier)) {
|
||||||
|
throw new \InvalidArgumentException('Code verifier contains invalid characters. Only A-Z, a-z, 0-9, -, ., _, ~ are allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a code challenge method is supported.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the method is not supported
|
||||||
|
*/
|
||||||
|
public function validateCodeChallengeMethod(string $method): void
|
||||||
|
{
|
||||||
|
if (!\in_array($method, [self::METHOD_PLAIN, self::METHOD_S256], true)) {
|
||||||
|
throw new \InvalidArgumentException(\sprintf('Unsupported code challenge method "%s". Supported methods are: %s, %s', $method, self::METHOD_PLAIN, self::METHOD_S256));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of supported code challenge methods.
|
||||||
|
*
|
||||||
|
* @return string[] Array of supported method names
|
||||||
|
*/
|
||||||
|
public function getSupportedMethods(): array
|
||||||
|
{
|
||||||
|
return [self::METHOD_PLAIN, self::METHOD_S256];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if S256 method should be enforced for security.
|
||||||
|
*
|
||||||
|
* For production environments and public clients, S256 should be required.
|
||||||
|
* Plain method should only be used when S256 is not feasible.
|
||||||
|
*/
|
||||||
|
public function shouldEnforceS256(bool $isPublicClient = false): bool
|
||||||
|
{
|
||||||
|
// Always enforce S256 for public clients
|
||||||
|
return $isPublicClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64-URL encode a string (RFC 4648 Section 5).
|
||||||
|
*
|
||||||
|
* This is standard base64 encoding with URL-safe characters and no padding.
|
||||||
|
*/
|
||||||
|
private function base64UrlEncode(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
91
templates/OAuth/consent.html.twig
Normal file
91
templates/OAuth/consent.html.twig
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
{% extends "layout.html.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ 'oauth.consent.title'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<div class="card-panel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h4>{{ 'oauth.consent.authorize_application'|trans }}</h4>
|
||||||
|
|
||||||
|
<p class="flow-text">
|
||||||
|
<strong>{{ client.name ?? 'oauth.consent.unnamed_application'|trans }}</strong>
|
||||||
|
{{ 'oauth.consent.wants_access'|trans }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if scopes|length > 0 %}
|
||||||
|
<h5>{{ 'oauth.consent.requested_permissions'|trans }}</h5>
|
||||||
|
<ul class="collection">
|
||||||
|
{% for scope, description in scopes %}
|
||||||
|
<li class="collection-item">
|
||||||
|
<i class="material-icons left">check</i>
|
||||||
|
<strong>{{ ('oauth.scope.' ~ scope)|trans }}</strong><br>
|
||||||
|
<span class="grey-text text-darken-1">{{ description }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<div class="card blue-grey darken-1">
|
||||||
|
<div class="card-content white-text">
|
||||||
|
<span class="card-title">
|
||||||
|
<i class="material-icons left">security</i>
|
||||||
|
{{ 'oauth.consent.security_notice'|trans }}
|
||||||
|
</span>
|
||||||
|
<p>{{ 'oauth.consent.security_description'|trans }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ path('oauth2_authorize_consent') }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('oauth_consent') }}" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s6">
|
||||||
|
<button type="submit" name="action" value="deny" class="btn waves-effect waves-light red">
|
||||||
|
<i class="material-icons left">cancel</i>
|
||||||
|
{{ 'oauth.consent.deny'|trans }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col s6 right-align">
|
||||||
|
<button type="submit" name="action" value="allow" class="btn waves-effect waves-light green">
|
||||||
|
<i class="material-icons left">check</i>
|
||||||
|
{{ 'oauth.consent.allow'|trans }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<p class="grey-text text-darken-1">
|
||||||
|
<small>
|
||||||
|
<i class="material-icons tiny">info_outline</i>
|
||||||
|
{{ 'oauth.consent.revoke_info'|trans }}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script>
|
||||||
|
// Auto-focus the allow button for better UX
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const allowButton = document.querySelector('button[value="allow"]');
|
||||||
|
if (allowButton) {
|
||||||
|
allowButton.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
1031
tests/Controller/Api/OAuthAuthorizationSecurityTest.php
Normal file
1031
tests/Controller/Api/OAuthAuthorizationSecurityTest.php
Normal file
File diff suppressed because it is too large
Load diff
80
tests/Controller/Api/OAuthCodeTest.php
Normal file
80
tests/Controller/Api/OAuthCodeTest.php
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Tests\Wallabag\WallabagTestCase;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
class OAuthCodeTest extends WallabagTestCase
|
||||||
|
{
|
||||||
|
public function testAuthCodeSingleUse()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
|
||||||
|
// Create auth code manually
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($apiClient);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken(bin2hex(random_bytes(32)));
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback');
|
||||||
|
$authCode->setExpiresAt(time() + 600);
|
||||||
|
$authCode->setScope('read');
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// First use - should succeed
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('access_token', $data);
|
||||||
|
|
||||||
|
// Second use - should fail (code is single-use)
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('invalid_grant', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createApiClientForUser($username, $grantTypes = ['password'])
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$userManager = static::getContainer()->get('fos_user.user_manager');
|
||||||
|
|
||||||
|
$user = $userManager->findUserBy(['username' => $username]);
|
||||||
|
\assert($user instanceof User);
|
||||||
|
|
||||||
|
$apiClient = new Client($user);
|
||||||
|
$apiClient->setName('My app');
|
||||||
|
$apiClient->setAllowedGrantTypes($grantTypes);
|
||||||
|
$apiClient->setRedirectUris(['http://example.com/callback']);
|
||||||
|
$em->persist($apiClient);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $apiClient;
|
||||||
|
}
|
||||||
|
}
|
685
tests/Controller/Api/OAuthControllerTest.php
Normal file
685
tests/Controller/Api/OAuthControllerTest.php
Normal file
|
@ -0,0 +1,685 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Wallabag\Controller\Api\OAuthController;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
use Wallabag\Repository\Api\ClientRepository;
|
||||||
|
use Wallabag\Service\OAuth\PkceService;
|
||||||
|
|
||||||
|
class OAuthControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private OAuthController $controller;
|
||||||
|
private PkceService&MockObject $pkceService;
|
||||||
|
private EntityManagerInterface&MockObject $entityManager;
|
||||||
|
private ClientRepository&MockObject $clientRepository;
|
||||||
|
private TokenStorageInterface&MockObject $tokenStorage;
|
||||||
|
private AuthorizationCheckerInterface&MockObject $authorizationChecker;
|
||||||
|
private CsrfTokenManagerInterface&MockObject $csrfTokenManager;
|
||||||
|
private UrlGeneratorInterface&MockObject $urlGenerator;
|
||||||
|
private Environment&MockObject $twig;
|
||||||
|
private ContainerInterface&MockObject $container;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->pkceService = $this->createMock(PkceService::class);
|
||||||
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$this->clientRepository = $this->createMock(ClientRepository::class);
|
||||||
|
$this->tokenStorage = $this->createMock(TokenStorageInterface::class);
|
||||||
|
$this->authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||||
|
$this->csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
|
||||||
|
$this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||||
|
$this->twig = $this->createMock(Environment::class);
|
||||||
|
$this->container = $this->createMock(ContainerInterface::class);
|
||||||
|
|
||||||
|
$this->controller = new OAuthController(
|
||||||
|
$this->pkceService,
|
||||||
|
$this->entityManager,
|
||||||
|
$this->clientRepository
|
||||||
|
);
|
||||||
|
$this->controller->setContainer($this->container);
|
||||||
|
|
||||||
|
// Setup container services
|
||||||
|
// Setup CSRF token manager to validate our test token
|
||||||
|
$this->csrfTokenManager->method('isTokenValid')
|
||||||
|
->willReturnCallback(function ($csrfToken) {
|
||||||
|
return 'oauth_consent' === $csrfToken->getId() && 'valid_csrf_token' === $csrfToken->getValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup URL generator to return login route
|
||||||
|
$this->urlGenerator->method('generate')
|
||||||
|
->willReturnCallback(function ($route) {
|
||||||
|
return match ($route) {
|
||||||
|
'login' => '/login',
|
||||||
|
'fos_user_security_login' => '/fos_user_security_login',
|
||||||
|
default => '/' . $route,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->container->method('get')
|
||||||
|
->willReturnCallback(function ($service) {
|
||||||
|
return match ($service) {
|
||||||
|
'security.token_storage' => $this->tokenStorage,
|
||||||
|
'security.authorization_checker' => $this->authorizationChecker,
|
||||||
|
'security.csrf.token_manager' => $this->csrfTokenManager,
|
||||||
|
'router' => $this->urlGenerator,
|
||||||
|
'twig' => $this->twig,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->container->method('has')
|
||||||
|
->willReturnCallback(function ($service) {
|
||||||
|
return \in_array($service, [
|
||||||
|
'security.token_storage',
|
||||||
|
'security.authorization_checker',
|
||||||
|
'security.csrf.token_manager',
|
||||||
|
'router',
|
||||||
|
'twig',
|
||||||
|
], true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test successful authorization request with PKCE parameters.
|
||||||
|
*/
|
||||||
|
public function testAuthorizeWithValidPkceRequest(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
$request = $this->createAuthorizationRequest($client, true);
|
||||||
|
|
||||||
|
// Add session to request
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(123)
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$this->pkceService->expects($this->once())
|
||||||
|
->method('validateCodeChallengeMethod')
|
||||||
|
->with('S256');
|
||||||
|
|
||||||
|
$this->twig->expects($this->once())
|
||||||
|
->method('render')
|
||||||
|
->with('OAuth/consent.html.twig', $this->callback(function ($params) {
|
||||||
|
return $params['client'] instanceof Client
|
||||||
|
&& isset($params['scopes']['read'])
|
||||||
|
&& isset($params['scopes']['write'])
|
||||||
|
&& 'random_state' === $params['state'];
|
||||||
|
}))
|
||||||
|
->willReturn('<html>Consent page</html>');
|
||||||
|
|
||||||
|
$response = $this->controller->authorizeAction($request);
|
||||||
|
|
||||||
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
$this->assertStringContainsString('Consent page', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test authorization request without PKCE for public client.
|
||||||
|
*/
|
||||||
|
public function testAuthorizePublicClientWithoutPkce(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient(true); // Public client
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
$request = $this->createAuthorizationRequest($client, false); // No PKCE
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(123)
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$response = $this->controller->authorizeAction($request);
|
||||||
|
|
||||||
|
// Should redirect with error instead of throwing exception (RFC compliant)
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$this->assertSame(302, $response->getStatusCode());
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('error=invalid_request', $location);
|
||||||
|
$this->assertStringContainsString('Client+requires+PKCE+but+no+code_challenge+provided', $location);
|
||||||
|
$this->assertStringContainsString('state=random_state', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test authorization request with invalid client.
|
||||||
|
*/
|
||||||
|
public function testAuthorizeWithInvalidClient(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'GET', [
|
||||||
|
'client_id' => 'invalid_client',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For invalid client_id format, find() may be called with 0 (parsed from 'invalid_client')
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(0)
|
||||||
|
->willReturn(null);
|
||||||
|
|
||||||
|
$this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid client_id or redirect_uri');
|
||||||
|
|
||||||
|
$this->controller->authorizeAction($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test authorization request with mismatched redirect URI.
|
||||||
|
*/
|
||||||
|
public function testAuthorizeWithMismatchedRedirectUri(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$client->method('getRedirectUris')->willReturn(['http://example.com/callback']);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'GET', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://evil.com/steal',
|
||||||
|
'response_type' => 'code',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(123)
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid client_id or redirect_uri');
|
||||||
|
|
||||||
|
$this->controller->authorizeAction($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test authorization request without authentication.
|
||||||
|
*/
|
||||||
|
public function testAuthorizeWithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$request = $this->createAuthorizationRequest($client);
|
||||||
|
|
||||||
|
// Add session to request
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(123)
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$this->tokenStorage->expects($this->once())
|
||||||
|
->method('getToken')
|
||||||
|
->willReturn(null); // Not authenticated
|
||||||
|
|
||||||
|
$response = $this->controller->authorizeAction($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$this->assertSame(302, $response->getStatusCode());
|
||||||
|
$this->assertStringContainsString('/fos_user_security_login', $response->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test consent form submission - user approves.
|
||||||
|
*/
|
||||||
|
public function testConsentApproval(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'read write',
|
||||||
|
'state' => 'random_state',
|
||||||
|
'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'POST', [
|
||||||
|
'action' => 'allow',
|
||||||
|
'_token' => 'valid_csrf_token',
|
||||||
|
]);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('findOneBy')
|
||||||
|
->with(['id' => '123_abc'])
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
// The controller uses isCsrfTokenValid() which internally handles the token validation
|
||||||
|
// We need to ensure the container provides the CSRF token manager
|
||||||
|
$this->container->method('has')
|
||||||
|
->willReturnCallback(function ($service) {
|
||||||
|
return \in_array($service, [
|
||||||
|
'security.token_storage',
|
||||||
|
'security.authorization_checker',
|
||||||
|
'security.csrf.token_manager',
|
||||||
|
'twig',
|
||||||
|
], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AuthCode is created directly in controller
|
||||||
|
$this->entityManager->expects($this->once())
|
||||||
|
->method('persist')
|
||||||
|
->with($this->isInstanceOf(AuthCode::class));
|
||||||
|
$this->entityManager->expects($this->once())
|
||||||
|
->method('flush');
|
||||||
|
|
||||||
|
$response = $this->controller->consentAction($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$this->assertSame(302, $response->getStatusCode());
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('http://example.com/callback', $location);
|
||||||
|
$this->assertStringContainsString('code=', $location);
|
||||||
|
$this->assertStringContainsString('state=random_state', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test consent form submission - user denies.
|
||||||
|
*/
|
||||||
|
public function testConsentDenial(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'state' => 'random_state',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'POST', [
|
||||||
|
'action' => 'deny',
|
||||||
|
'_token' => 'valid_csrf_token',
|
||||||
|
]);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
// Note: For denial, no client repository call is made since user denied before validation
|
||||||
|
|
||||||
|
$response = $this->controller->consentAction($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$this->assertSame(302, $response->getStatusCode());
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('error=access_denied', $location);
|
||||||
|
$this->assertStringContainsString('state=random_state', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test consent form submission with CSRF token failure.
|
||||||
|
*/
|
||||||
|
public function testConsentWithInvalidCsrfToken(): void
|
||||||
|
{
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'state' => 'random_state',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'POST', [
|
||||||
|
'action' => 'allow',
|
||||||
|
'_token' => 'invalid_csrf_token',
|
||||||
|
]);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
// Override CSRF token manager to reject invalid token
|
||||||
|
$this->csrfTokenManager->method('isTokenValid')
|
||||||
|
->willReturnCallback(function ($csrfToken) {
|
||||||
|
return 'oauth_consent' === $csrfToken->getId() && 'valid_csrf_token' === $csrfToken->getValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid CSRF token');
|
||||||
|
|
||||||
|
$this->controller->consentAction($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that PKCE code_challenge is properly stored for later verification.
|
||||||
|
*/
|
||||||
|
public function testCodeChallengeStoredForVerification(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'read write',
|
||||||
|
'state' => 'random_state',
|
||||||
|
'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'POST', [
|
||||||
|
'action' => 'allow',
|
||||||
|
'_token' => 'valid_csrf_token',
|
||||||
|
]);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('findOneBy')
|
||||||
|
->with(['id' => '123_abc'])
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
// Capture the AuthCode to verify PKCE data is stored
|
||||||
|
$capturedAuthCode = null;
|
||||||
|
$this->entityManager->expects($this->once())
|
||||||
|
->method('persist')
|
||||||
|
->willReturnCallback(function ($entity) use (&$capturedAuthCode) {
|
||||||
|
if ($entity instanceof AuthCode) {
|
||||||
|
$capturedAuthCode = $entity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->entityManager->expects($this->once())
|
||||||
|
->method('flush');
|
||||||
|
|
||||||
|
$response = $this->controller->consentAction($request);
|
||||||
|
|
||||||
|
// Verify AuthCode has PKCE data stored for later verification
|
||||||
|
$this->assertNotNull($capturedAuthCode);
|
||||||
|
$this->assertSame('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', $capturedAuthCode->getCodeChallenge());
|
||||||
|
$this->assertSame('S256', $capturedAuthCode->getCodeChallengeMethod());
|
||||||
|
$this->assertTrue($capturedAuthCode->hasPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test PKCE validation for unsupported challenge method.
|
||||||
|
*/
|
||||||
|
public function testAuthorizeWithUnsupportedChallengeMethod(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'GET', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||||
|
'code_challenge_method' => 'MD5',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(123)
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$this->pkceService->expects($this->once())
|
||||||
|
->method('validateCodeChallengeMethod')
|
||||||
|
->with('MD5')
|
||||||
|
->willThrowException(new \InvalidArgumentException('Unsupported code_challenge_method'));
|
||||||
|
|
||||||
|
$response = $this->controller->authorizeAction($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('error=invalid_request', $location);
|
||||||
|
$this->assertStringContainsString('Unsupported+code_challenge_method', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test state parameter preservation.
|
||||||
|
*/
|
||||||
|
public function testStateParameterPreservation(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
$state = 'csrf_protection_' . bin2hex(random_bytes(16));
|
||||||
|
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'read write',
|
||||||
|
'state' => $state,
|
||||||
|
'code_challenge' => null,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'POST', [
|
||||||
|
'action' => 'allow',
|
||||||
|
'_token' => 'valid_csrf_token',
|
||||||
|
]);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('findOneBy')
|
||||||
|
->with(['id' => '123_abc'])
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$response = $this->controller->consentAction($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('state=' . $state, $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test scope validation and storage.
|
||||||
|
*/
|
||||||
|
public function testScopeHandling(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'GET', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'read write delete',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add session to request
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('find')
|
||||||
|
->with(123)
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$this->twig->expects($this->once())
|
||||||
|
->method('render')
|
||||||
|
->with('OAuth/consent.html.twig', $this->callback(function ($params) {
|
||||||
|
return isset($params['scopes']['read'])
|
||||||
|
&& isset($params['scopes']['write'])
|
||||||
|
&& isset($params['scopes']['delete']);
|
||||||
|
}))
|
||||||
|
->willReturn('<html>Consent page</html>');
|
||||||
|
|
||||||
|
$response = $this->controller->authorizeAction($request);
|
||||||
|
|
||||||
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test authorization code generation with correct expiration.
|
||||||
|
*/
|
||||||
|
public function testAuthorizationCodeExpiration(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMockClient();
|
||||||
|
$user = $this->createMockUser();
|
||||||
|
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('oauth2_request', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'read write',
|
||||||
|
'state' => 'test_state',
|
||||||
|
'code_challenge' => null,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'POST', [
|
||||||
|
'action' => 'allow',
|
||||||
|
'_token' => 'valid_csrf_token',
|
||||||
|
]);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$this->setupAuthenticatedUser($user);
|
||||||
|
$this->clientRepository->expects($this->once())
|
||||||
|
->method('findOneBy')
|
||||||
|
->with(['id' => '123_abc'])
|
||||||
|
->willReturn($client);
|
||||||
|
|
||||||
|
$capturedAuthCode = null;
|
||||||
|
$this->entityManager->expects($this->once())
|
||||||
|
->method('persist')
|
||||||
|
->willReturnCallback(function ($entity) use (&$capturedAuthCode) {
|
||||||
|
if ($entity instanceof AuthCode) {
|
||||||
|
$capturedAuthCode = $entity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->entityManager->expects($this->once())
|
||||||
|
->method('flush');
|
||||||
|
|
||||||
|
$this->controller->consentAction($request);
|
||||||
|
|
||||||
|
$this->assertNotNull($capturedAuthCode);
|
||||||
|
// Verify expiration is 10 minutes in the future
|
||||||
|
$expectedExpiration = time() + 600;
|
||||||
|
$actualExpiration = $capturedAuthCode->getExpiresAt();
|
||||||
|
$this->assertGreaterThanOrEqual($expectedExpiration - 5, $actualExpiration);
|
||||||
|
$this->assertLessThanOrEqual($expectedExpiration + 5, $actualExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test missing required parameters.
|
||||||
|
*/
|
||||||
|
public function testMissingRequiredParameters(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'GET', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
// Missing redirect_uri and response_type
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class);
|
||||||
|
$this->expectExceptionMessage('Missing required parameter: redirect_uri');
|
||||||
|
|
||||||
|
$this->controller->authorizeAction($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test unsupported response type.
|
||||||
|
*/
|
||||||
|
public function testUnsupportedResponseType(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/oauth/v2/authorize', 'GET', [
|
||||||
|
'client_id' => '123_abc',
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'token', // Implicit flow not supported
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify client lookup is NOT called - controller should return early
|
||||||
|
$this->clientRepository->expects($this->never())
|
||||||
|
->method('find');
|
||||||
|
|
||||||
|
$response = $this->controller->authorizeAction($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
$location = $response->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('error=unsupported_response_type', $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create a mock client.
|
||||||
|
*/
|
||||||
|
private function createMockClient(bool $isPublic = false): Client&MockObject
|
||||||
|
{
|
||||||
|
$client = $this->createMock(Client::class);
|
||||||
|
$client->method('getId')->willReturn(123);
|
||||||
|
$client->method('getPublicId')->willReturn('123_abc');
|
||||||
|
$client->method('getRandomId')->willReturn('abc');
|
||||||
|
$client->method('getRedirectUris')->willReturn(['http://example.com/callback']);
|
||||||
|
$client->method('isPublic')->willReturn($isPublic);
|
||||||
|
$client->method('requiresPkce')->willReturn($isPublic);
|
||||||
|
$client->method('getName')->willReturn('Test Client');
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create a mock user.
|
||||||
|
*/
|
||||||
|
private function createMockUser(): User&MockObject
|
||||||
|
{
|
||||||
|
$user = $this->createMock(User::class);
|
||||||
|
$user->method('getUsername')->willReturn('testuser');
|
||||||
|
$user->method('getId')->willReturn(1);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create an authorization request.
|
||||||
|
*/
|
||||||
|
private function createAuthorizationRequest(Client $client, bool $withPkce = false): Request
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'client_id' => $client->getPublicId(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'read write',
|
||||||
|
'state' => 'random_state',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($withPkce) {
|
||||||
|
$params['code_challenge'] = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||||
|
$params['code_challenge_method'] = 'S256';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Request::create('/oauth/v2/authorize', 'GET', $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to setup authenticated user.
|
||||||
|
*/
|
||||||
|
private function setupAuthenticatedUser(User $user): void
|
||||||
|
{
|
||||||
|
$token = $this->createMock(TokenInterface::class);
|
||||||
|
$token->method('getUser')->willReturn($user);
|
||||||
|
|
||||||
|
$this->tokenStorage->expects($this->any())
|
||||||
|
->method('getToken')
|
||||||
|
->willReturn($token);
|
||||||
|
}
|
||||||
|
}
|
318
tests/Controller/Api/OAuthSecurityTest.php
Normal file
318
tests/Controller/Api/OAuthSecurityTest.php
Normal file
|
@ -0,0 +1,318 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Tests\Wallabag\WallabagTestCase;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth Authorization Code Security Tests.
|
||||||
|
* Tests critical security aspects of OAuth implementation.
|
||||||
|
*/
|
||||||
|
class OAuthSecurityTest extends WallabagTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Test that authorization codes are single-use only.
|
||||||
|
* This prevents replay attacks.
|
||||||
|
*/
|
||||||
|
public function testAuthCodeSingleUse()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user, $em);
|
||||||
|
|
||||||
|
// First use - should succeed
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('access_token', $data);
|
||||||
|
|
||||||
|
// Second use - should fail (replay attack prevention)
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('invalid_grant', $data['error']);
|
||||||
|
|
||||||
|
// Verify code is deleted from database
|
||||||
|
$em->clear();
|
||||||
|
$deletedCode = $em->getRepository(AuthCode::class)->findOneBy(['token' => $authCode->getToken()]);
|
||||||
|
$this->assertNull($deletedCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that expired authorization codes are rejected.
|
||||||
|
* Codes should expire after 10 minutes per RFC recommendation.
|
||||||
|
*/
|
||||||
|
public function testAuthCodeExpiration()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
|
||||||
|
// Create expired auth code (11 minutes old)
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($apiClient);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken(bin2hex(random_bytes(32)));
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback');
|
||||||
|
$authCode->setExpiresAt(time() - 660); // 11 minutes ago
|
||||||
|
$authCode->setScope('read');
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// Try to use expired code
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('invalid_grant', $data['error']);
|
||||||
|
$this->assertStringContainsString('expired', $data['error_description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that authorization codes are bound to specific clients.
|
||||||
|
* A code issued to client A cannot be used by client B.
|
||||||
|
*/
|
||||||
|
public function testAuthCodeClientBinding()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$apiClient1 = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$apiClient2 = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient1, $user, $em);
|
||||||
|
|
||||||
|
// Try to use client1's code with client2 (should fail)
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient2->getPublicId(),
|
||||||
|
'client_secret' => $apiClient2->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('invalid_grant', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that authorization codes are bound to specific users.
|
||||||
|
* Verify the token exchange succeeds for the correct user.
|
||||||
|
*/
|
||||||
|
public function testAuthCodeUserBinding()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user, $em);
|
||||||
|
|
||||||
|
// Exchange code for token - should succeed
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('access_token', $data);
|
||||||
|
$this->assertArrayHasKey('token_type', $data);
|
||||||
|
$this->assertSame('bearer', $data['token_type']);
|
||||||
|
|
||||||
|
// Verify the auth code was properly bound to the user by checking
|
||||||
|
// that we got a valid token response (proves user binding worked)
|
||||||
|
$this->assertNotEmpty($data['access_token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test redirect URI validation prevents redirect attacks.
|
||||||
|
* Tests this at the token endpoint level to avoid authorization flow complexity.
|
||||||
|
*/
|
||||||
|
public function testRedirectUriValidation()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
// Set specific allowed redirect URIs
|
||||||
|
$apiClient->setRedirectUris(['http://example.com/callback']);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
|
||||||
|
// Create auth code with valid redirect URI
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($apiClient);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken(bin2hex(random_bytes(32)));
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback'); // Valid URI
|
||||||
|
$authCode->setExpiresAt(time() + 600);
|
||||||
|
$authCode->setScope('read');
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// Try to exchange with different redirect URI (should fail)
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://evil.com/steal-token', // Different from auth code
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('redirect_uri_mismatch', $data['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test PKCE requirement enforcement for public clients.
|
||||||
|
* Tests at token endpoint level - public clients must provide code_verifier.
|
||||||
|
*/
|
||||||
|
public function testPkceRequirementEnforcement()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
// Make it a public client requiring PKCE
|
||||||
|
$apiClient->setIsPublic(true);
|
||||||
|
$apiClient->setRequirePkce(true);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
|
||||||
|
// Create auth code WITHOUT PKCE data
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($apiClient);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken(bin2hex(random_bytes(32)));
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback');
|
||||||
|
$authCode->setExpiresAt(time() + 600);
|
||||||
|
$authCode->setScope('read');
|
||||||
|
// No PKCE data set
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// Try token exchange without code_verifier (should fail for public client)
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
// No client_secret for public client
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
// Missing code_verifier
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('invalid_request', $data['error']);
|
||||||
|
$this->assertStringContainsString('PKCE', $data['error_description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that wrong redirect URI in token request fails.
|
||||||
|
*/
|
||||||
|
public function testTokenRequestRedirectUriValidation()
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user, $em);
|
||||||
|
|
||||||
|
// Try to exchange code with wrong redirect URI
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://wrong.redirect.com/callback', // Different from auth code
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(400, $client->getResponse()->getStatusCode());
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame('redirect_uri_mismatch', $data['error']);
|
||||||
|
$this->assertStringContainsString('redirect URI', $data['error_description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create an OAuth client for testing.
|
||||||
|
*/
|
||||||
|
private function createApiClientForUser($username, $grantTypes = ['password'])
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$userManager = static::getContainer()->get('fos_user.user_manager');
|
||||||
|
|
||||||
|
$user = $userManager->findUserBy(['username' => $username]);
|
||||||
|
\assert($user instanceof User);
|
||||||
|
|
||||||
|
$apiClient = new Client($user);
|
||||||
|
$apiClient->setName('Test OAuth Client');
|
||||||
|
$apiClient->setAllowedGrantTypes($grantTypes);
|
||||||
|
$apiClient->setRedirectUris(['http://example.com/callback']);
|
||||||
|
$em->persist($apiClient);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create an authorization code for testing.
|
||||||
|
*/
|
||||||
|
private function createAuthCode(Client $client, User $user, EntityManagerInterface $em): AuthCode
|
||||||
|
{
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($client);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken(bin2hex(random_bytes(32)));
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback');
|
||||||
|
$authCode->setExpiresAt(time() + 600); // 10 minutes
|
||||||
|
$authCode->setScope('read write');
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $authCode;
|
||||||
|
}
|
||||||
|
}
|
13
tests/Controller/Api/OAuthSimpleTest.php
Normal file
13
tests/Controller/Api/OAuthSimpleTest.php
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Tests\Wallabag\WallabagTestCase;
|
||||||
|
|
||||||
|
class OAuthSimpleTest extends WallabagTestCase
|
||||||
|
{
|
||||||
|
public function testBasicAssertion(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
462
tests/Controller/Api/OAuthSqlInjectionTest.php
Normal file
462
tests/Controller/Api/OAuthSqlInjectionTest.php
Normal file
|
@ -0,0 +1,462 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Controller\Api;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Tests\Wallabag\WallabagTestCase;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL injection security tests for OAuth endpoints.
|
||||||
|
*
|
||||||
|
* Tests various SQL injection attack vectors against OAuth parameters
|
||||||
|
* to ensure proper parameter sanitization and prepared statement usage.
|
||||||
|
*/
|
||||||
|
class OAuthSqlInjectionTest extends WallabagTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Test SQL injection attempts in authorization code parameter.
|
||||||
|
*/
|
||||||
|
public function testSqlInjectionInAuthorizationCode(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
|
||||||
|
$sqlInjectionPayloads = [
|
||||||
|
// Basic SQL injection attempts
|
||||||
|
"'; DROP TABLE oauth2_access_tokens; --",
|
||||||
|
"' OR '1'='1",
|
||||||
|
"' UNION SELECT * FROM users --",
|
||||||
|
"'; DELETE FROM oauth2_clients WHERE id=1; --",
|
||||||
|
|
||||||
|
// Advanced SQL injection attempts
|
||||||
|
"' OR 1=1 UNION SELECT username, password FROM users --",
|
||||||
|
"'; INSERT INTO oauth2_access_tokens VALUES (1, 'malicious', 'token'); --",
|
||||||
|
"' AND (SELECT COUNT(*) FROM oauth2_clients) > 0 --",
|
||||||
|
"'; UPDATE oauth2_clients SET secret='hacked' WHERE id=1; --",
|
||||||
|
|
||||||
|
// Encoded SQL injection attempts
|
||||||
|
'%27%20OR%20%271%27%3D%271',
|
||||||
|
'%27%3B%20DROP%20TABLE%20users%3B%20--',
|
||||||
|
|
||||||
|
// Function-based SQL injection
|
||||||
|
"'; SELECT LOAD_FILE('/etc/passwd'); --",
|
||||||
|
"' OR SUBSTRING(password,1,1)='a' --",
|
||||||
|
|
||||||
|
// Time-based blind SQL injection
|
||||||
|
"' OR SLEEP(5) --",
|
||||||
|
"'; WAITFOR DELAY '00:00:05' --",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlInjectionPayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $payload,
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
|
||||||
|
// Should return proper error response, not crash or expose data
|
||||||
|
$this->assertSame(400, $response->getStatusCode(),
|
||||||
|
'SQL injection payload should return 400 error: ' . $payload);
|
||||||
|
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame('invalid_grant', $data['error']);
|
||||||
|
|
||||||
|
// Ensure no sensitive data is exposed in error message
|
||||||
|
$this->assertArrayHasKey('error_description', $data);
|
||||||
|
$errorDescription = strtolower($data['error_description']);
|
||||||
|
$this->assertStringNotContainsString('password', $errorDescription);
|
||||||
|
$this->assertStringNotContainsString('secret', $errorDescription);
|
||||||
|
$this->assertStringNotContainsString('token', $errorDescription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SQL injection attempts in client_id parameter.
|
||||||
|
*/
|
||||||
|
public function testSqlInjectionInClientId(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user);
|
||||||
|
|
||||||
|
$sqlInjectionPayloads = [
|
||||||
|
// Basic SQL injection attempts
|
||||||
|
"'; DROP TABLE oauth2_clients; --",
|
||||||
|
"' OR '1'='1",
|
||||||
|
"' UNION SELECT secret FROM oauth2_clients --",
|
||||||
|
"'; DELETE FROM users WHERE username='admin'; --",
|
||||||
|
|
||||||
|
// Advanced attempts targeting client validation
|
||||||
|
"' OR EXISTS(SELECT 1 FROM oauth2_clients WHERE secret='secret') --",
|
||||||
|
"'; INSERT INTO oauth2_clients VALUES (999, 'evil', 'client'); --",
|
||||||
|
"' AND (SELECT COUNT(*) FROM oauth2_access_tokens) > 0 --",
|
||||||
|
|
||||||
|
// Encoded attempts
|
||||||
|
'%27%20OR%20%271%27%3D%271',
|
||||||
|
'%27%3B%20DROP%20TABLE%20oauth2_clients%3B%20--',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlInjectionPayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $payload,
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
|
||||||
|
// Should return proper error response, not crash or expose data
|
||||||
|
$this->assertSame(400, $response->getStatusCode(),
|
||||||
|
'SQL injection in client_id should return 400 error: ' . $payload);
|
||||||
|
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
// Could be invalid_client or invalid_grant depending on validation order
|
||||||
|
$this->assertContains($data['error'], ['invalid_client', 'invalid_grant']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SQL injection attempts in client_secret parameter.
|
||||||
|
*/
|
||||||
|
public function testSqlInjectionInClientSecret(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user);
|
||||||
|
|
||||||
|
$sqlInjectionPayloads = [
|
||||||
|
// Basic SQL injection attempts
|
||||||
|
"'; DROP TABLE oauth2_clients; --",
|
||||||
|
"' OR '1'='1",
|
||||||
|
"' UNION SELECT id FROM oauth2_clients --",
|
||||||
|
"'; UPDATE oauth2_clients SET secret='hacked'; --",
|
||||||
|
|
||||||
|
// Advanced attempts targeting secret validation
|
||||||
|
"' OR LENGTH(secret) > 0 --",
|
||||||
|
"'; INSERT INTO oauth2_access_tokens VALUES (1, 'token', 'evil'); --",
|
||||||
|
"' AND (SELECT secret FROM oauth2_clients WHERE id=" . $apiClient->getId() . ') --',
|
||||||
|
|
||||||
|
// Encoded attempts
|
||||||
|
'%27%20OR%20%271%27%3D%271',
|
||||||
|
'%27%3B%20DROP%20TABLE%20users%3B%20--',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlInjectionPayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $payload,
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
|
||||||
|
// Should return proper error response, not crash or expose data
|
||||||
|
$this->assertSame(400, $response->getStatusCode(),
|
||||||
|
'SQL injection in client_secret should return 400 error: ' . $payload);
|
||||||
|
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame('invalid_client', $data['error']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SQL injection attempts in redirect_uri parameter.
|
||||||
|
*/
|
||||||
|
public function testSqlInjectionInRedirectUri(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user);
|
||||||
|
|
||||||
|
$sqlInjectionPayloads = [
|
||||||
|
// Basic SQL injection attempts
|
||||||
|
"http://example.com/callback'; DROP TABLE oauth2_auth_codes; --",
|
||||||
|
"http://example.com/callback' OR '1'='1",
|
||||||
|
"http://example.com/callback' UNION SELECT token FROM oauth2_auth_codes --",
|
||||||
|
"http://example.com/callback'; DELETE FROM oauth2_clients; --",
|
||||||
|
|
||||||
|
// Advanced attempts targeting redirect URI validation
|
||||||
|
"http://example.com/callback' OR redirect_uri LIKE '%callback%' --",
|
||||||
|
"http://example.com/callback'; INSERT INTO oauth2_access_tokens VALUES (1, 'evil'); --",
|
||||||
|
"http://example.com/callback' AND (SELECT COUNT(*) FROM oauth2_auth_codes) > 0 --",
|
||||||
|
|
||||||
|
// Encoded attempts
|
||||||
|
'http://example.com/callback%27%20OR%20%271%27%3D%271',
|
||||||
|
'http://example.com/callback%27%3B%20DROP%20TABLE%20users%3B%20--',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlInjectionPayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
|
||||||
|
// Should return proper error response, not crash or expose data
|
||||||
|
$this->assertSame(400, $response->getStatusCode(),
|
||||||
|
'SQL injection in redirect_uri should return 400 error: ' . $payload);
|
||||||
|
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame('redirect_uri_mismatch', $data['error']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SQL injection attempts in PKCE parameters.
|
||||||
|
*/
|
||||||
|
public function testSqlInjectionInPkceParameters(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$apiClient->setIsPublic(true); // Force PKCE requirement
|
||||||
|
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$em->persist($apiClient);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
|
||||||
|
// Create auth code with PKCE
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($apiClient);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken($this->generateToken());
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback');
|
||||||
|
$authCode->setExpiresAt(time() + 600);
|
||||||
|
$authCode->setScope('read');
|
||||||
|
$authCode->setCodeChallenge('test_challenge');
|
||||||
|
$authCode->setCodeChallengeMethod('S256');
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$sqlInjectionPayloads = [
|
||||||
|
// Basic SQL injection attempts
|
||||||
|
"'; DROP TABLE oauth2_auth_codes; --",
|
||||||
|
"' OR '1'='1",
|
||||||
|
"' UNION SELECT code_challenge FROM oauth2_auth_codes --",
|
||||||
|
"'; DELETE FROM oauth2_auth_codes WHERE client_id=" . $apiClient->getId() . '; --',
|
||||||
|
|
||||||
|
// Advanced attempts targeting PKCE validation
|
||||||
|
"' OR LENGTH(code_challenge) > 0 --",
|
||||||
|
"'; INSERT INTO oauth2_access_tokens VALUES (1, 'pkce_token'); --",
|
||||||
|
"' AND code_challenge_method='S256' --",
|
||||||
|
|
||||||
|
// Encoded attempts
|
||||||
|
'%27%20OR%20%271%27%3D%271',
|
||||||
|
'%27%3B%20DROP%20TABLE%20oauth2_auth_codes%3B%20--',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlInjectionPayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
'code_verifier' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
|
||||||
|
// Should return proper error response, not crash or expose data
|
||||||
|
$this->assertSame(400, $response->getStatusCode(),
|
||||||
|
'SQL injection in code_verifier should return 400 error: ' . $payload);
|
||||||
|
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame('invalid_grant', $data['error']);
|
||||||
|
|
||||||
|
// Ensure no sensitive data is exposed in error message
|
||||||
|
$this->assertArrayHasKey('error_description', $data);
|
||||||
|
$errorDescription = strtolower($data['error_description']);
|
||||||
|
$this->assertStringNotContainsString('challenge', $errorDescription);
|
||||||
|
$this->assertStringNotContainsString('secret', $errorDescription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SQL injection attempts in grant_type parameter.
|
||||||
|
*/
|
||||||
|
public function testSqlInjectionInGrantType(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $em->getRepository(User::class)->findOneByUsername('admin');
|
||||||
|
$authCode = $this->createAuthCode($apiClient, $user);
|
||||||
|
|
||||||
|
$sqlInjectionPayloads = [
|
||||||
|
// Basic SQL injection attempts
|
||||||
|
"authorization_code'; DROP TABLE oauth2_clients; --",
|
||||||
|
"authorization_code' OR '1'='1",
|
||||||
|
"authorization_code' UNION SELECT * FROM users --",
|
||||||
|
"authorization_code'; DELETE FROM oauth2_access_tokens; --",
|
||||||
|
|
||||||
|
// Advanced attempts
|
||||||
|
"authorization_code' OR grant_type='authorization_code' --",
|
||||||
|
"authorization_code'; INSERT INTO oauth2_access_tokens VALUES (1, 'evil'); --",
|
||||||
|
|
||||||
|
// Encoded attempts
|
||||||
|
'authorization_code%27%20OR%20%271%27%3D%271',
|
||||||
|
'authorization_code%27%3B%20DROP%20TABLE%20users%3B%20--',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlInjectionPayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => $payload,
|
||||||
|
'client_id' => $apiClient->getPublicId(),
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $authCode->getToken(),
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
|
||||||
|
// Should return proper error response, not crash or expose data
|
||||||
|
$this->assertSame(400, $response->getStatusCode(),
|
||||||
|
'SQL injection in grant_type should return 400 error: ' . $payload);
|
||||||
|
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
$this->assertArrayHasKey('error', $data);
|
||||||
|
$this->assertSame('invalid_request', $data['error']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that database queries are using prepared statements by ensuring
|
||||||
|
* SQL injection attempts don't affect the database state.
|
||||||
|
*/
|
||||||
|
public function testDatabaseStateIntegrityAfterSqlInjection(): void
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
// Record initial database state
|
||||||
|
$initialClientCount = $em->getRepository(Client::class)->count([]);
|
||||||
|
$initialAuthCodeCount = $em->getRepository(AuthCode::class)->count([]);
|
||||||
|
$initialUserCount = $em->getRepository(User::class)->count([]);
|
||||||
|
|
||||||
|
// Attempt various destructive SQL injection attacks
|
||||||
|
$destructivePayloads = [
|
||||||
|
"'; DROP TABLE oauth2_clients; --",
|
||||||
|
"'; DELETE FROM oauth2_auth_codes; --",
|
||||||
|
"'; UPDATE oauth2_clients SET secret='hacked'; --",
|
||||||
|
"'; INSERT INTO oauth2_access_tokens VALUES (999, 'evil', 'token'); --",
|
||||||
|
"'; TRUNCATE TABLE users; --",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($destructivePayloads as $payload) {
|
||||||
|
$client->request('POST', '/oauth/v2/token', [
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'client_id' => $payload,
|
||||||
|
'client_secret' => $apiClient->getSecret(),
|
||||||
|
'code' => $payload,
|
||||||
|
'redirect_uri' => 'http://example.com/callback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Clear entity manager to ensure fresh data from database
|
||||||
|
$em->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database state is unchanged
|
||||||
|
$finalClientCount = $em->getRepository(Client::class)->count([]);
|
||||||
|
$finalAuthCodeCount = $em->getRepository(AuthCode::class)->count([]);
|
||||||
|
$finalUserCount = $em->getRepository(User::class)->count([]);
|
||||||
|
|
||||||
|
$this->assertSame($initialClientCount, $finalClientCount,
|
||||||
|
'SQL injection should not affect oauth2_clients table');
|
||||||
|
$this->assertSame($initialAuthCodeCount, $finalAuthCodeCount,
|
||||||
|
'SQL injection should not affect oauth2_auth_codes table');
|
||||||
|
$this->assertSame($initialUserCount, $finalUserCount,
|
||||||
|
'SQL injection should not affect users table');
|
||||||
|
|
||||||
|
// Verify the test client still exists and is functional
|
||||||
|
$testClient = $em->getRepository(Client::class)->find($apiClient->getId());
|
||||||
|
$this->assertNotNull($testClient, 'Test client should still exist after SQL injection attempts');
|
||||||
|
$this->assertSame($apiClient->getSecret(), $testClient->getSecret(),
|
||||||
|
'Client secret should be unchanged after SQL injection attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create an OAuth client for testing.
|
||||||
|
* This follows the same pattern as OAuthSecurityTest for consistency.
|
||||||
|
*/
|
||||||
|
private function createApiClientForUser($username, $grantTypes = ['password'])
|
||||||
|
{
|
||||||
|
$client = $this->getTestClient();
|
||||||
|
$em = $client->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$userManager = static::getContainer()->get('fos_user.user_manager');
|
||||||
|
|
||||||
|
$user = $userManager->findUserBy(['username' => $username]);
|
||||||
|
\assert($user instanceof User);
|
||||||
|
|
||||||
|
$apiClient = new Client($user);
|
||||||
|
$apiClient->setName('Test OAuth Client');
|
||||||
|
$apiClient->setAllowedGrantTypes($grantTypes);
|
||||||
|
$apiClient->setRedirectUris(['http://example.com/callback']);
|
||||||
|
$em->persist($apiClient);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to create an authorization code.
|
||||||
|
*/
|
||||||
|
private function createAuthCode(Client $client, User $user): AuthCode
|
||||||
|
{
|
||||||
|
$em = $this->getTestClient()->getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$authCode = new AuthCode();
|
||||||
|
$authCode->setClient($client);
|
||||||
|
$authCode->setUser($user);
|
||||||
|
$authCode->setToken($this->generateToken());
|
||||||
|
$authCode->setRedirectUri('http://example.com/callback');
|
||||||
|
$authCode->setExpiresAt(time() + 600); // 10 minutes
|
||||||
|
$authCode->setScope('read write');
|
||||||
|
|
||||||
|
$em->persist($authCode);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to generate a secure token.
|
||||||
|
*/
|
||||||
|
private function generateToken(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
}
|
129
tests/Entity/Api/AuthCodeTest.php
Normal file
129
tests/Entity/Api/AuthCodeTest.php
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Entity\Api;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test suite for AuthCode entity with PKCE support.
|
||||||
|
*/
|
||||||
|
class AuthCodeTest extends TestCase
|
||||||
|
{
|
||||||
|
private AuthCode $authCode;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->authCode = new AuthCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetCodeChallenge(): void
|
||||||
|
{
|
||||||
|
$challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||||
|
|
||||||
|
$result = $this->authCode->setCodeChallenge($challenge);
|
||||||
|
|
||||||
|
$this->assertSame($this->authCode, $result);
|
||||||
|
$this->assertSame($challenge, $this->authCode->getCodeChallenge());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetCodeChallengeNull(): void
|
||||||
|
{
|
||||||
|
$this->authCode->setCodeChallenge('some_challenge');
|
||||||
|
$result = $this->authCode->setCodeChallenge(null);
|
||||||
|
|
||||||
|
$this->assertSame($this->authCode, $result);
|
||||||
|
$this->assertNull($this->authCode->getCodeChallenge());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetCodeChallengeMethodS256(): void
|
||||||
|
{
|
||||||
|
$result = $this->authCode->setCodeChallengeMethod('S256');
|
||||||
|
|
||||||
|
$this->assertSame($this->authCode, $result);
|
||||||
|
$this->assertSame('S256', $this->authCode->getCodeChallengeMethod());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetCodeChallengeMethodPlain(): void
|
||||||
|
{
|
||||||
|
$result = $this->authCode->setCodeChallengeMethod('plain');
|
||||||
|
|
||||||
|
$this->assertSame($this->authCode, $result);
|
||||||
|
$this->assertSame('plain', $this->authCode->getCodeChallengeMethod());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetCodeChallengeMethodNull(): void
|
||||||
|
{
|
||||||
|
$this->authCode->setCodeChallengeMethod('S256');
|
||||||
|
$result = $this->authCode->setCodeChallengeMethod(null);
|
||||||
|
|
||||||
|
$this->assertSame($this->authCode, $result);
|
||||||
|
$this->assertNull($this->authCode->getCodeChallengeMethod());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetCodeChallengeMethodInvalid(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Code challenge method must be either "S256" or "plain"');
|
||||||
|
|
||||||
|
$this->authCode->setCodeChallengeMethod('invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasPkceWithBothFields(): void
|
||||||
|
{
|
||||||
|
$this->authCode->setCodeChallenge('challenge');
|
||||||
|
$this->authCode->setCodeChallengeMethod('S256');
|
||||||
|
|
||||||
|
$this->assertTrue($this->authCode->hasPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasPkceWithoutChallenge(): void
|
||||||
|
{
|
||||||
|
$this->authCode->setCodeChallengeMethod('S256');
|
||||||
|
|
||||||
|
$this->assertFalse($this->authCode->hasPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasPkceWithoutMethod(): void
|
||||||
|
{
|
||||||
|
$this->authCode->setCodeChallenge('challenge');
|
||||||
|
|
||||||
|
$this->assertFalse($this->authCode->hasPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasPkceWithNeitherField(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->authCode->hasPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasPkceAfterClearingFields(): void
|
||||||
|
{
|
||||||
|
$this->authCode->setCodeChallenge('challenge');
|
||||||
|
$this->authCode->setCodeChallengeMethod('S256');
|
||||||
|
$this->assertTrue($this->authCode->hasPkce());
|
||||||
|
|
||||||
|
$this->authCode->setCodeChallenge(null);
|
||||||
|
$this->assertFalse($this->authCode->hasPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the complete PKCE workflow with AuthCode entity.
|
||||||
|
*/
|
||||||
|
public function testCompletePkceWorkflow(): void
|
||||||
|
{
|
||||||
|
$challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||||
|
$method = 'S256';
|
||||||
|
|
||||||
|
// Initially no PKCE
|
||||||
|
$this->assertFalse($this->authCode->hasPkce());
|
||||||
|
|
||||||
|
// Set PKCE parameters
|
||||||
|
$this->authCode->setCodeChallenge($challenge);
|
||||||
|
$this->authCode->setCodeChallengeMethod($method);
|
||||||
|
|
||||||
|
// Verify PKCE is enabled
|
||||||
|
$this->assertTrue($this->authCode->hasPkce());
|
||||||
|
$this->assertSame($challenge, $this->authCode->getCodeChallenge());
|
||||||
|
$this->assertSame($method, $this->authCode->getCodeChallengeMethod());
|
||||||
|
}
|
||||||
|
}
|
165
tests/Entity/Api/ClientTest.php
Normal file
165
tests/Entity/Api/ClientTest.php
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Entity\Api;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test suite for Client entity with PKCE and public client support.
|
||||||
|
*/
|
||||||
|
class ClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private Client $client;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->user = new User();
|
||||||
|
$this->client = new Client($this->user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsPublicDefault(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->client->isPublic());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetIsPublicTrue(): void
|
||||||
|
{
|
||||||
|
$result = $this->client->setIsPublic(true);
|
||||||
|
|
||||||
|
$this->assertSame($this->client, $result);
|
||||||
|
$this->assertTrue($this->client->isPublic());
|
||||||
|
// Public clients should automatically require PKCE
|
||||||
|
$this->assertTrue($this->client->requiresPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetIsPublicFalse(): void
|
||||||
|
{
|
||||||
|
$this->client->setIsPublic(true);
|
||||||
|
$result = $this->client->setIsPublic(false);
|
||||||
|
|
||||||
|
$this->assertSame($this->client, $result);
|
||||||
|
$this->assertFalse($this->client->isPublic());
|
||||||
|
// Should still require PKCE if it was set
|
||||||
|
$this->assertTrue($this->client->requiresPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequiresPkceDefault(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->client->requiresPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetRequirePkce(): void
|
||||||
|
{
|
||||||
|
$result = $this->client->setRequirePkce(true);
|
||||||
|
|
||||||
|
$this->assertSame($this->client, $result);
|
||||||
|
$this->assertTrue($this->client->requiresPkce());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCheckSecretForPublicClient(): void
|
||||||
|
{
|
||||||
|
$this->client->setIsPublic(true);
|
||||||
|
|
||||||
|
// Public clients should accept any secret (or no secret)
|
||||||
|
$this->assertTrue($this->client->checkSecret('any_secret'));
|
||||||
|
$this->assertTrue($this->client->checkSecret(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCheckSecretForConfidentialClient(): void
|
||||||
|
{
|
||||||
|
// Set a secret for testing
|
||||||
|
$this->client->setSecret('correct_secret');
|
||||||
|
|
||||||
|
// Should use parent implementation for confidential clients
|
||||||
|
$this->assertTrue($this->client->checkSecret('correct_secret'));
|
||||||
|
$this->assertFalse($this->client->checkSecret('wrong_secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsGrantSupportedPasswordForPublicClient(): void
|
||||||
|
{
|
||||||
|
$this->client->setIsPublic(true);
|
||||||
|
$this->client->setAllowedGrantTypes(['password', 'authorization_code']);
|
||||||
|
|
||||||
|
// Public clients should not be allowed to use password grant
|
||||||
|
$this->assertFalse($this->client->isGrantSupported('password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsGrantSupportedPasswordForConfidentialClient(): void
|
||||||
|
{
|
||||||
|
$this->client->setAllowedGrantTypes(['password', 'authorization_code']);
|
||||||
|
|
||||||
|
// Confidential clients can use password grant if configured
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsGrantSupportedAuthorizationCode(): void
|
||||||
|
{
|
||||||
|
$this->client->setAllowedGrantTypes(['authorization_code', 'refresh_token']);
|
||||||
|
|
||||||
|
// Both public and confidential clients can use authorization_code
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('authorization_code'));
|
||||||
|
|
||||||
|
$this->client->setIsPublic(true);
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('authorization_code'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsGrantSupportedUnsupportedGrant(): void
|
||||||
|
{
|
||||||
|
$this->client->setAllowedGrantTypes(['authorization_code']);
|
||||||
|
|
||||||
|
$this->assertFalse($this->client->isGrantSupported('unsupported_grant'));
|
||||||
|
$this->assertFalse($this->client->isGrantSupported('password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the complete public client workflow.
|
||||||
|
*/
|
||||||
|
public function testPublicClientWorkflow(): void
|
||||||
|
{
|
||||||
|
// Start as confidential client
|
||||||
|
$this->assertFalse($this->client->isPublic());
|
||||||
|
$this->assertFalse($this->client->requiresPkce());
|
||||||
|
|
||||||
|
// Configure as public client
|
||||||
|
$this->client->setIsPublic(true);
|
||||||
|
$this->client->setAllowedGrantTypes(['authorization_code', 'refresh_token']);
|
||||||
|
|
||||||
|
// Verify public client properties
|
||||||
|
$this->assertTrue($this->client->isPublic());
|
||||||
|
$this->assertTrue($this->client->requiresPkce());
|
||||||
|
|
||||||
|
// Verify grant restrictions
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('authorization_code'));
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('refresh_token'));
|
||||||
|
$this->assertFalse($this->client->isGrantSupported('password'));
|
||||||
|
|
||||||
|
// Verify secret handling
|
||||||
|
$this->assertTrue($this->client->checkSecret('any_secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test confidential client with optional PKCE.
|
||||||
|
*/
|
||||||
|
public function testConfidentialClientWithOptionalPkce(): void
|
||||||
|
{
|
||||||
|
$this->client->setAllowedGrantTypes(['authorization_code', 'password', 'refresh_token']);
|
||||||
|
$this->client->setRequirePkce(true);
|
||||||
|
$this->client->setSecret('confidential_secret');
|
||||||
|
|
||||||
|
// Verify confidential client properties
|
||||||
|
$this->assertFalse($this->client->isPublic());
|
||||||
|
$this->assertTrue($this->client->requiresPkce());
|
||||||
|
|
||||||
|
// Verify all grants are supported
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('authorization_code'));
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('password'));
|
||||||
|
$this->assertTrue($this->client->isGrantSupported('refresh_token'));
|
||||||
|
|
||||||
|
// Verify secret is required
|
||||||
|
$this->assertTrue($this->client->checkSecret('confidential_secret'));
|
||||||
|
$this->assertFalse($this->client->checkSecret('wrong_secret'));
|
||||||
|
}
|
||||||
|
}
|
531
tests/Service/OAuth/PkceAuthorizationCodeGrantHandlerTest.php
Normal file
531
tests/Service/OAuth/PkceAuthorizationCodeGrantHandlerTest.php
Normal file
|
@ -0,0 +1,531 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Service\OAuth;
|
||||||
|
|
||||||
|
use FOS\OAuthServerBundle\Model\AuthCodeManagerInterface;
|
||||||
|
use OAuth2\Model\IOAuth2Client;
|
||||||
|
use OAuth2\OAuth2;
|
||||||
|
use OAuth2\OAuth2ServerException;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Wallabag\Entity\Api\AuthCode;
|
||||||
|
use Wallabag\Entity\Api\Client;
|
||||||
|
use Wallabag\Entity\User;
|
||||||
|
use Wallabag\Service\OAuth\PkceAuthorizationCodeGrantHandler;
|
||||||
|
use Wallabag\Service\OAuth\PkceService;
|
||||||
|
|
||||||
|
class PkceAuthorizationCodeGrantHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private PkceAuthorizationCodeGrantHandler $handler;
|
||||||
|
private PkceService&MockObject $pkceService;
|
||||||
|
private AuthCodeManagerInterface&MockObject $authCodeManager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->pkceService = $this->createMock(PkceService::class);
|
||||||
|
$this->authCodeManager = $this->createMock(AuthCodeManagerInterface::class);
|
||||||
|
|
||||||
|
$this->handler = new PkceAuthorizationCodeGrantHandler(
|
||||||
|
$this->pkceService,
|
||||||
|
$this->authCodeManager
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that the PKCE handler correctly accepts authorization_code grants.
|
||||||
|
*
|
||||||
|
* This is the critical positive test case that ensures:
|
||||||
|
* - The handler declares support for authorization_code grants
|
||||||
|
* - PKCE validation will actually be invoked for authorization code flows
|
||||||
|
* - The handler integrates correctly with OAuth extension system
|
||||||
|
*
|
||||||
|
* This test is essential because if it fails, PKCE security would be
|
||||||
|
* completely bypassed - the handler would never run, allowing public
|
||||||
|
* clients to obtain tokens without PKCE validation.
|
||||||
|
*/
|
||||||
|
public function testCheckGrantExtensionSupportsAuthorizationCode(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$this->handler->checkGrantExtension($client, ['grant_type' => 'authorization_code'], [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that the PKCE handler only processes authorization_code grants.
|
||||||
|
*
|
||||||
|
* This is a critical security boundary test that ensures:
|
||||||
|
* - PKCE validation is not applied to inappropriate grant types
|
||||||
|
* - Grant type confusion attacks are prevented
|
||||||
|
* - The handler maintains proper OAuth security boundaries
|
||||||
|
*
|
||||||
|
* If this fails, PKCE logic might be applied to password or client_credentials
|
||||||
|
* grants, potentially causing security vulnerabilities or breaking functionality.
|
||||||
|
*/
|
||||||
|
public function testCheckGrantExtensionRejectsOtherGrants(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
|
||||||
|
// Should reject password grant (Resource Owner Password Credentials)
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->handler->checkGrantExtension($client, ['grant_type' => 'password'], [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should reject client credentials grant
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->handler->checkGrantExtension($client, ['grant_type' => 'client_credentials'], [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should reject malformed requests with no grant type
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->handler->checkGrantExtension($client, [], [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithoutCode(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, [], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_REQUEST, $e->getMessage());
|
||||||
|
$this->assertSame('Missing parameter: "code" is required', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithInvalidCode(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->with('invalid_code')
|
||||||
|
->willReturn(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, ['code' => 'invalid_code'], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid authorization code', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithExpiredCode(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
|
||||||
|
$authCode->expects($this->once())
|
||||||
|
->method('hasExpired')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->with('expired_code')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('deleteAuthCode')
|
||||||
|
->with($authCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, ['code' => 'expired_code'], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('The authorization code has expired', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithWrongClient(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('wrong_client_id');
|
||||||
|
|
||||||
|
$authCodeClient = $this->createMock(Client::class);
|
||||||
|
$authCodeClient->method('getPublicId')->willReturn('correct_client_id');
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($authCodeClient);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, ['code' => 'valid_code'], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid authorization code', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithPkceRequired(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(true);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(false);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, ['code' => 'valid_code'], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_REQUEST, $e->getMessage());
|
||||||
|
$this->assertSame('PKCE is required for this client', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithMissingCodeVerifier(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(false);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(true);
|
||||||
|
$authCode->method('getCodeChallenge')->willReturn('challenge');
|
||||||
|
$authCode->method('getCodeChallengeMethod')->willReturn('S256');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, ['code' => 'valid_code'], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_REQUEST, $e->getMessage());
|
||||||
|
$this->assertSame('PKCE code_verifier is required for this authorization code', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithInvalidCodeVerifier(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(false);
|
||||||
|
$wallabagClient->method('isPublic')->willReturn(false);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(true);
|
||||||
|
$authCode->method('getCodeChallenge')->willReturn('challenge');
|
||||||
|
$authCode->method('getCodeChallengeMethod')->willReturn('S256');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
$this->pkceService->expects($this->once())
|
||||||
|
->method('verifyCodeChallenge')
|
||||||
|
->with('wrong_verifier', 'challenge', 'S256')
|
||||||
|
->willReturn(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, [
|
||||||
|
'code' => 'valid_code',
|
||||||
|
'code_verifier' => 'wrong_verifier',
|
||||||
|
], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid PKCE code_verifier', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithPublicClientRequiresS256(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(true);
|
||||||
|
$wallabagClient->method('isPublic')->willReturn(true);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(true);
|
||||||
|
$authCode->method('getCodeChallenge')->willReturn('challenge');
|
||||||
|
$authCode->method('getCodeChallengeMethod')->willReturn('plain');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
$this->pkceService->expects($this->once())
|
||||||
|
->method('verifyCodeChallenge')
|
||||||
|
->with('verifier', 'challenge', 'plain')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, [
|
||||||
|
'code' => 'valid_code',
|
||||||
|
'code_verifier' => 'verifier',
|
||||||
|
], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_REQUEST, $e->getMessage());
|
||||||
|
$this->assertSame('Public clients must use S256 code challenge method', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithInvalidRedirectUri(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(false);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(false);
|
||||||
|
$authCode->method('getRedirectUri')->willReturn('http://correct.redirect');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, [
|
||||||
|
'code' => 'valid_code',
|
||||||
|
'redirect_uri' => 'http://wrong.redirect',
|
||||||
|
], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid redirect URI', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataSuccessWithoutPkce(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$user = $this->createMock(User::class);
|
||||||
|
$user->method('getId')->willReturn(123);
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(false);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(false);
|
||||||
|
$authCode->method('getUser')->willReturn($user);
|
||||||
|
$authCode->method('getScope')->willReturn('read write');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('deleteAuthCode')
|
||||||
|
->with($authCode);
|
||||||
|
|
||||||
|
$result = $this->handler->getAccessTokenData($client, ['code' => 'valid_code'], []);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'client_id' => 'client_id',
|
||||||
|
'user_id' => 123,
|
||||||
|
'scope' => 'read write',
|
||||||
|
], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataSuccessWithPkce(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$user = $this->createMock(User::class);
|
||||||
|
$user->method('getId')->willReturn(456);
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(true);
|
||||||
|
$wallabagClient->method('isPublic')->willReturn(true);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(true);
|
||||||
|
$authCode->method('getCodeChallenge')->willReturn('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||||
|
$authCode->method('getCodeChallengeMethod')->willReturn('S256');
|
||||||
|
$authCode->method('getUser')->willReturn($user);
|
||||||
|
$authCode->method('getScope')->willReturn('read');
|
||||||
|
$authCode->method('getRedirectUri')->willReturn('http://app.example/callback');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
$this->pkceService->expects($this->once())
|
||||||
|
->method('verifyCodeChallenge')
|
||||||
|
->with('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', 'S256')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('deleteAuthCode')
|
||||||
|
->with($authCode);
|
||||||
|
|
||||||
|
$result = $this->handler->getAccessTokenData($client, [
|
||||||
|
'code' => 'valid_code',
|
||||||
|
'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
|
||||||
|
'redirect_uri' => 'http://app.example/callback',
|
||||||
|
], []);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'client_id' => 'client_id',
|
||||||
|
'user_id' => 456,
|
||||||
|
'scope' => 'read',
|
||||||
|
], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAccessTokenDataWithCorruptedPkceData(): void
|
||||||
|
{
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(true);
|
||||||
|
$authCode->method('getCodeChallenge')->willReturn(null); // Corrupted - no challenge stored
|
||||||
|
$authCode->method('getCodeChallengeMethod')->willReturn('S256');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, [
|
||||||
|
'code' => 'valid_code',
|
||||||
|
'code_verifier' => 'verifier',
|
||||||
|
], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid PKCE data in authorization code', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplayAttackPrevention(): void
|
||||||
|
{
|
||||||
|
// Test that the same authorization code cannot be used twice
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$user = $this->createMock(User::class);
|
||||||
|
$user->method('getId')->willReturn(789);
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('requiresPkce')->willReturn(false);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(false);
|
||||||
|
$authCode->method('getUser')->willReturn($user);
|
||||||
|
$authCode->method('getScope')->willReturn('read');
|
||||||
|
|
||||||
|
// First use - should succeed
|
||||||
|
$this->authCodeManager->expects($this->exactly(2))
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->with('reusable_code')
|
||||||
|
->willReturnOnConsecutiveCalls($authCode, null);
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('deleteAuthCode')
|
||||||
|
->with($authCode);
|
||||||
|
|
||||||
|
// First use succeeds
|
||||||
|
$result = $this->handler->getAccessTokenData($client, ['code' => 'reusable_code'], []);
|
||||||
|
$this->assertArrayHasKey('user_id', $result);
|
||||||
|
|
||||||
|
// Second use should fail
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, ['code' => 'reusable_code'], []);
|
||||||
|
$this->fail('Expected OAuth2ServerException was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid authorization code', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTimingAttackResistance(): void
|
||||||
|
{
|
||||||
|
// Test that handler doesn't reveal information through timing differences
|
||||||
|
$client = $this->createMock(IOAuth2Client::class);
|
||||||
|
$client->method('getPublicId')->willReturn('client_id');
|
||||||
|
|
||||||
|
$wallabagClient = $this->createMock(Client::class);
|
||||||
|
$wallabagClient->method('getPublicId')->willReturn('client_id');
|
||||||
|
$wallabagClient->method('isPublic')->willReturn(false);
|
||||||
|
|
||||||
|
$authCode = $this->createMock(AuthCode::class);
|
||||||
|
$authCode->method('hasExpired')->willReturn(false);
|
||||||
|
$authCode->method('getClient')->willReturn($wallabagClient);
|
||||||
|
$authCode->method('hasPkce')->willReturn(true);
|
||||||
|
$authCode->method('getCodeChallenge')->willReturn('challenge');
|
||||||
|
$authCode->method('getCodeChallengeMethod')->willReturn('S256');
|
||||||
|
|
||||||
|
$this->authCodeManager->expects($this->once())
|
||||||
|
->method('findAuthCodeByToken')
|
||||||
|
->willReturn($authCode);
|
||||||
|
|
||||||
|
// Ensure the PKCE service is called even with invalid verifier
|
||||||
|
// This ensures timing consistency
|
||||||
|
$this->pkceService->expects($this->once())
|
||||||
|
->method('verifyCodeChallenge')
|
||||||
|
->with($this->anything(), 'challenge', 'S256')
|
||||||
|
->willReturn(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->handler->getAccessTokenData($client, [
|
||||||
|
'code' => 'valid_code',
|
||||||
|
'code_verifier' => 'wrong_verifier',
|
||||||
|
], []);
|
||||||
|
$this->fail('Expected exception was not thrown');
|
||||||
|
} catch (OAuth2ServerException $e) {
|
||||||
|
$this->assertSame(OAuth2::ERROR_INVALID_GRANT, $e->getMessage());
|
||||||
|
$this->assertSame('Invalid PKCE code_verifier', $e->getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
261
tests/Service/OAuth/PkceServiceTest.php
Normal file
261
tests/Service/OAuth/PkceServiceTest.php
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Wallabag\Service\OAuth;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Wallabag\Service\OAuth\PkceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive test suite for PKCE Service.
|
||||||
|
*
|
||||||
|
* Tests all aspects of PKCE code generation and verification according to RFC 7636.
|
||||||
|
*/
|
||||||
|
class PkceServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private PkceService $pkceService;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->pkceService = new PkceService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateCodeVerifierLength(): void
|
||||||
|
{
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
|
||||||
|
$this->assertGreaterThanOrEqual(PkceService::MIN_VERIFIER_LENGTH, \strlen($verifier));
|
||||||
|
$this->assertLessThanOrEqual(PkceService::MAX_VERIFIER_LENGTH, \strlen($verifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateCodeVerifierCharacters(): void
|
||||||
|
{
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
|
||||||
|
// Should only contain allowed characters: A-Z, a-z, 0-9, -, ., _, ~
|
||||||
|
$this->assertMatchesRegularExpression('/^[A-Za-z0-9\-._~]+$/', $verifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateCodeVerifierUniqueness(): void
|
||||||
|
{
|
||||||
|
$verifiers = [];
|
||||||
|
|
||||||
|
// Generate multiple verifiers and ensure they're unique
|
||||||
|
for ($i = 0; $i < 100; ++$i) {
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
$this->assertNotContains($verifier, $verifiers, 'Code verifiers should be unique');
|
||||||
|
$verifiers[] = $verifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateCodeChallengeS256(): void
|
||||||
|
{
|
||||||
|
$verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||||
|
$expectedChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||||
|
|
||||||
|
$challenge = $this->pkceService->generateCodeChallenge($verifier, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
$this->assertSame($expectedChallenge, $challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateCodeChallengePlain(): void
|
||||||
|
{
|
||||||
|
$verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||||
|
|
||||||
|
$challenge = $this->pkceService->generateCodeChallenge($verifier, PkceService::METHOD_PLAIN);
|
||||||
|
|
||||||
|
$this->assertSame($verifier, $challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateCodeChallengeInvalidMethod(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unsupported code challenge method "invalid"');
|
||||||
|
|
||||||
|
// Use a properly sized verifier (43+ characters) so method validation happens
|
||||||
|
$validVerifier = str_repeat('a', 43);
|
||||||
|
$this->pkceService->generateCodeChallenge($validVerifier, 'invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyCodeChallengeS256Valid(): void
|
||||||
|
{
|
||||||
|
$verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||||
|
$challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||||
|
|
||||||
|
$result = $this->pkceService->verifyCodeChallenge($verifier, $challenge, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyCodeChallengeS256Invalid(): void
|
||||||
|
{
|
||||||
|
$verifier = 'wrong_verifier';
|
||||||
|
$challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||||
|
|
||||||
|
$result = $this->pkceService->verifyCodeChallenge($verifier, $challenge, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyCodeChallengePlainValid(): void
|
||||||
|
{
|
||||||
|
$verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||||
|
$challenge = $verifier; // Plain method uses verifier as challenge
|
||||||
|
|
||||||
|
$result = $this->pkceService->verifyCodeChallenge($verifier, $challenge, PkceService::METHOD_PLAIN);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyCodeChallengePlainInvalid(): void
|
||||||
|
{
|
||||||
|
$verifier = 'correct_verifier';
|
||||||
|
$challenge = 'wrong_challenge';
|
||||||
|
|
||||||
|
$result = $this->pkceService->verifyCodeChallenge($verifier, $challenge, PkceService::METHOD_PLAIN);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyCodeChallengeInvalidVerifier(): void
|
||||||
|
{
|
||||||
|
$invalidVerifier = 'too_short'; // Less than 43 characters
|
||||||
|
$challenge = 'some_challenge';
|
||||||
|
|
||||||
|
$result = $this->pkceService->verifyCodeChallenge($invalidVerifier, $challenge, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateCodeVerifierTooShort(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Code verifier must be at least 43 characters long');
|
||||||
|
|
||||||
|
$this->pkceService->validateCodeVerifier('too_short');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateCodeVerifierTooLong(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Code verifier must be at most 128 characters long');
|
||||||
|
|
||||||
|
$longVerifier = str_repeat('a', 129);
|
||||||
|
$this->pkceService->validateCodeVerifier($longVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateCodeVerifierInvalidCharacters(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Code verifier contains invalid characters');
|
||||||
|
|
||||||
|
$invalidVerifier = str_repeat('a', 43) . '!@#$%'; // Invalid characters
|
||||||
|
$this->pkceService->validateCodeVerifier($invalidVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateCodeVerifierValid(): void
|
||||||
|
{
|
||||||
|
$validVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; // 43 chars, valid chars
|
||||||
|
|
||||||
|
// Should not throw exception
|
||||||
|
$this->pkceService->validateCodeVerifier($validVerifier);
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateCodeChallengeMethodValid(): void
|
||||||
|
{
|
||||||
|
// Should not throw exceptions for valid methods
|
||||||
|
$this->pkceService->validateCodeChallengeMethod(PkceService::METHOD_S256);
|
||||||
|
$this->pkceService->validateCodeChallengeMethod(PkceService::METHOD_PLAIN);
|
||||||
|
$this->addToAssertionCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateCodeChallengeMethodInvalid(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unsupported code challenge method "invalid"');
|
||||||
|
|
||||||
|
$this->pkceService->validateCodeChallengeMethod('invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetSupportedMethods(): void
|
||||||
|
{
|
||||||
|
$methods = $this->pkceService->getSupportedMethods();
|
||||||
|
|
||||||
|
$this->assertContains(PkceService::METHOD_S256, $methods);
|
||||||
|
$this->assertContains(PkceService::METHOD_PLAIN, $methods);
|
||||||
|
$this->assertCount(2, $methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShouldEnforceS256ForPublicClient(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue($this->pkceService->shouldEnforceS256(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShouldEnforceS256ForConfidentialClient(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->pkceService->shouldEnforceS256(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test full round-trip: generate verifier -> generate challenge -> verify.
|
||||||
|
*/
|
||||||
|
public function testFullRoundTripS256(): void
|
||||||
|
{
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
$challenge = $this->pkceService->generateCodeChallenge($verifier, PkceService::METHOD_S256);
|
||||||
|
$isValid = $this->pkceService->verifyCodeChallenge($verifier, $challenge, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
$this->assertTrue($isValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test full round-trip: generate verifier -> generate challenge -> verify (Plain method).
|
||||||
|
*/
|
||||||
|
public function testFullRoundTripPlain(): void
|
||||||
|
{
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
$challenge = $this->pkceService->generateCodeChallenge($verifier, PkceService::METHOD_PLAIN);
|
||||||
|
$isValid = $this->pkceService->verifyCodeChallenge($verifier, $challenge, PkceService::METHOD_PLAIN);
|
||||||
|
|
||||||
|
$this->assertTrue($isValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that verification fails when using wrong method.
|
||||||
|
*/
|
||||||
|
public function testVerificationFailsWithWrongMethod(): void
|
||||||
|
{
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
$challengeS256 = $this->pkceService->generateCodeChallenge($verifier, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
// Try to verify S256 challenge with plain method
|
||||||
|
$isValid = $this->pkceService->verifyCodeChallenge($verifier, $challengeS256, PkceService::METHOD_PLAIN);
|
||||||
|
|
||||||
|
$this->assertFalse($isValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test security: timing attack resistance
|
||||||
|
* This test ensures hash_equals is used for comparison.
|
||||||
|
*/
|
||||||
|
public function testTimingAttackResistance(): void
|
||||||
|
{
|
||||||
|
$verifier = $this->pkceService->generateCodeVerifier();
|
||||||
|
$correctChallenge = $this->pkceService->generateCodeChallenge($verifier, PkceService::METHOD_S256);
|
||||||
|
|
||||||
|
// Create a challenge that differs only in the last character
|
||||||
|
$almostCorrectChallenge = substr($correctChallenge, 0, -1) . 'X';
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$this->pkceService->verifyCodeChallenge($verifier, $almostCorrectChallenge, PkceService::METHOD_S256);
|
||||||
|
$time1 = microtime(true) - $startTime;
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$this->pkceService->verifyCodeChallenge($verifier, 'completely_wrong', PkceService::METHOD_S256);
|
||||||
|
$time2 = microtime(true) - $startTime;
|
||||||
|
|
||||||
|
// The timing difference should be minimal (less than 0.001 seconds)
|
||||||
|
// This is a basic test - in practice, timing attack resistance is provided by hash_equals()
|
||||||
|
$this->assertLessThan(0.001, abs($time1 - $time2));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue