1
0
Fork 0
mirror of https://github.com/wallabag/wallabag.git synced 2025-09-30 19:22:12 +00:00
This commit is contained in:
skn 2025-09-24 16:44:58 +02:00 committed by GitHub
commit 539c9d8685
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 4989 additions and 2 deletions

View file

@ -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:

View file

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

View 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

View 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');
}
}
}

View 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);
}
}

View file

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

View file

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

View 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');
}
}
}

View 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');
}
}
}

View 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), '+/', '-_'), '=');
}
}

View 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 %}

File diff suppressed because it is too large Load diff

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

View 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);
}
}

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

View 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);
}
}

View 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));
}
}

View 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());
}
}

View 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'));
}
}

View 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());
}
}
}

View 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));
}
}