Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

### Testing
- [ ] Unit tests added / updated
- [ ] New/changed code is covered by tests — SonarCloud Quality Gate (coverage on new code) passes on the `sonarcloud` CI job

### Security & Ops
- [ ] No sensitive data or secrets introduced
Expand Down
73 changes: 71 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,82 @@ jobs:
with:
symfony-versions: '["7.3"]'

# -----------------------------------------------------------------------
# COVERAGE — generates a Clover coverage report (PCOV) for SonarCloud to
# ingest. Kept separate from the reusable sylius_phpunit matrix (which
# runs with coverage: none) so coverage generation stays this repo's
# own concern, same as payplug/unified-plugin-core's ci.yml.
# -----------------------------------------------------------------------
coverage:
name: Coverage
if: github.base_ref == 'develop'
runs-on: ubuntu-latest
env:
APP_ENV: test
services:
mariadb:
image: 'mariadb:10.4.11'
ports:
- '3306:3306'
env:
MYSQL_ALLOW_EMPTY_PASSWORD: true
options: '--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3'
steps:
-
uses: actions/checkout@v4
-
name: 'Setup PHP 8.2'
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
ini-values: date.timezone=UTC
extensions: intl
tools: symfony
coverage: pcov
-
name: 'Setup Node 20.x'
uses: actions/setup-node@v4
with:
node-version: '20.x'
-
name: 'Composer - Get Cache Directory'
id: composer-cache
run: 'echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT'
-
name: 'Composer - Set cache'
uses: actions/cache@v4
with:
path: '${{ steps.composer-cache.outputs.dir }}'
key: 'php-8.2-sylius-2.1.0-symfony-7.3-coverage-composer-${{ hashFiles(''**/composer.json'') }}'
restore-keys: 'php-8.2-sylius-2.1.0-symfony-7.3-coverage-composer-'
-
name: 'Composer - Github Auth'
run: 'composer config -g github-oauth.github.com ${{ github.token }}'
-
name: 'Install Sylius-Standard and Plugin'
run: 'make install -e SYLIUS_VERSION=2.1.0 SYMFONY_VERSION=7.3'
id: end-of-setup-sylius
-
name: 'Run tests with coverage'
run: 'composer test-coverage'
if: 'always() && steps.end-of-setup-sylius.outcome == ''success'''
-
name: 'Upload coverage report'
uses: actions/upload-artifact@v4
with:
name: clover-coverage
path: build/logs/clover.xml
retention-days: 1

sonarcloud:
if: always() && !failure() && !cancelled() && github.base_ref == 'develop'
needs: [sylius-matrix]
uses: payplug/template-ci/.github/workflows/sonarcloud.yml@main
needs: [sylius-matrix, coverage]
uses: payplug/template-ci/.github/workflows/sonarcloud-coverage.yml@main
with:
project-name: 'github-payplug-payplug-syliuspayplugplugin'
src-folder: 'src/'
coverage-report-artifact: 'clover-coverage'
enforce-quality-gate: true
secrets:
sonar-orga: ${{ secrets.SONAR_ORGA }}
sonar-token: ${{ secrets.SONAR_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ CLAUDE.md
.claude
.review
.phpunit.result.cache
docs/
build/
38 changes: 38 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
FROM composer:2 AS composer

FROM php:8.2-cli

RUN apt-get update && apt-get install -y --no-install-recommends \
git \
unzip \
libicu-dev \
libzip-dev \
libonig-dev \
libxml2-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libsodium-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
intl \
gd \
sodium \
pdo_mysql \
mbstring \
xml \
dom \
simplexml \
xmlwriter \
zip \
&& pecl install pcov \
&& docker-php-ext-enable pcov \
&& rm -rf /var/lib/apt/lists/*

COPY --from=composer /usr/bin/composer /usr/local/bin/composer

RUN useradd --create-home --uid 1000 appuser

WORKDIR /app

USER appuser
14 changes: 14 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ SYLIUS_VERSION=2.1.0
SYMFONY_VERSION=6.4
PLUGIN_NAME=payplug/sylius-payplug-plugin

# Coverage runs inside Docker (PHP 8.2 + PCOV) instead of the host PHP, since the host's default
# `php`/`composer` may resolve to an unrelated version with no coverage driver installed. Assumes
# `vendor/` (and the Sylius test-application) is already installed on the host via `make install`.
IMAGE_DEV := sylius-payplug-plugin-dev
DOCKER_RUN := docker run --rm -v $(CURDIR):/app -w /app -u "$$(id -u):$$(id -g)" -e COMPOSER_HOME=/tmp/composer $(IMAGE_DEV)

###
### DEVELOPMENT
### ¯¯¯¯¯¯¯¯¯¯¯
Expand All @@ -23,6 +29,14 @@ phpunit: ## Run PHPUnit tests
./vendor/bin/phpunit
.PHONY: phpunit

build-dev: ## Build the Docker image used to run coverage
docker build -t $(IMAGE_DEV) .
.PHONY: build-dev

coverage: build-dev ## Run PHPUnit tests with a Clover coverage report (build/logs/clover.xml), via Docker
$(DOCKER_RUN) composer test-coverage
.PHONY: coverage

###
### OTHER
### ¯¯¯¯¯¯
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=alert_status&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=duplicated_lines_density&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=code_smells&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=coverage&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin)
[![Version](https://img.shields.io/packagist/v/payplug/sylius-payplug-plugin.svg)](https://packagist.org/packages/payplug/sylius-payplug-plugin)
[![Total Downloads](https://poser.pugx.org/payplug/sylius-payplug-plugin/downloads)](https://packagist.org/packages/payplug/sylius-payplug-plugin)

Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"ext-json": "*",
"giggsey/libphonenumber-for-php": "^8.12",
"payplug/payplug-php": "^4.0",
"payplug/unified-plugin-core": "0.0.7",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
Expand Down Expand Up @@ -85,6 +86,7 @@
"phpmd": "phpmd src ansi ruleset/.php_md.xml",
"phpstan": "phpstan analyse src -c ruleset/phpstan.neon",
"phpunit": "phpunit tests/PHPUnit --colors=always",
"test-coverage": "phpunit tests/PHPUnit --colors=always --coverage-clover=build/logs/clover.xml",
"tests": [
"@ecs",
"@phpmd",
Expand Down
10 changes: 10 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
parameters:
# Overridable via env vars for QA/staging testing; merchants installing the plugin normally
# never need to set these — the defaults below are used automatically.
payplug.oauth_base_url.default: 'https://api.payplug.com'
payplug.oauth_base_url: '%env(default:payplug.oauth_base_url.default:PAYPLUG_OAUTH_BASE_URL)%'
payplug.oauth_audience.default: 'https://www.payplug.com'
payplug.oauth_audience: '%env(default:payplug.oauth_audience.default:PAYPLUG_OAUTH_AUDIENCE)%'

services:
_defaults:
autowire: true
Expand All @@ -9,6 +17,8 @@ services:
exclude: '../src/{ApiClient,DependencyInjection,Entity,Exception,Model,Repository,PayPlugSyliusPayPlugPlugin.php}'
bind:
Psr\Log\LoggerInterface: '@monolog.logger.payplug'
$payplugOauthBaseUrl: '%payplug.oauth_base_url%'
$payplugOauthAudience: '%payplug.oauth_audience%'

PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface:
class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository
Expand Down
19 changes: 19 additions & 0 deletions config/services/client.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@
<service id="PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface"
class="PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory" />

<service id="PayplugUnifiedCore\Contracts\IOAuthHttpClient"
alias="PayPlug\SyliusPayPlugPlugin\Auth\SyliusOAuthHttpClient" />

<service id="PayplugUnifiedCore\Contracts\ITokenCache"
alias="PayPlug\SyliusPayPlugPlugin\Auth\SyliusTokenCache" />

<service id="PayplugUnifiedCore\Auth\OAuth2Client">
<!-- Only used for the client-credentials grant (background token refresh);
redirectUri/scope are irrelevant to that grant, left empty. -->
<!-- Must match the base URL UnifiedAuthenticationController uses — client_id/secret
minted by the interactive login are only valid against the same environment. -->
<argument key="$baseUrl">%payplug.oauth_base_url%</argument>
<argument key="$redirectUri" />
<argument key="$scope" />
<argument key="$audience">%payplug.oauth_audience%</argument>
</service>

<service id="PayplugUnifiedCore\Auth\TokenManager" />

<service id="payplug_sylius_payplug_plugin.api_client.payplug"
class="PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClient"
public="true"
Expand Down
6 changes: 6 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>

<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>
68 changes: 36 additions & 32 deletions src/Action/Admin/Auth/UnifiedAuthenticationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Payplug\Authentication;
use Payplug\Payplug;
use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator;
use PayplugUnifiedCore\Auth\OAuth2Client;
use PayplugUnifiedCore\Contracts\IOAuthHttpClient;
use Psr\Log\LoggerInterface;
use Sylius\Resource\Doctrine\Persistence\RepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
Expand All @@ -18,7 +20,6 @@
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Cache\CacheInterface;

/**
* This controller is used to authenticate the user with PayPlug
Expand All @@ -30,6 +31,9 @@
#[Route('/payplug/auth')]
final class UnifiedAuthenticationController extends AbstractController
{
// Matches the scope legacy Authentication::initiateOAuth() has always requested.
private const PKCE_SCOPE = 'openid offline profile email';

/**
* @param RepositoryInterface<\Sylius\Component\Core\Model\PaymentMethod> $paymentMethodRepository
*/
Expand All @@ -39,36 +43,33 @@ public function __construct(
private EntityManagerInterface $entityManager,
private PaymentMethodValidator $paymentMethodValidator,
private LoggerInterface $logger,
private CacheInterface $cache,
private IOAuthHttpClient $oauthHttpClient,
private string $payplugOauthBaseUrl,
private string $payplugOauthAudience,
) {
}

private function buildOAuth2Client(string $redirectUri): OAuth2Client
{
return new OAuth2Client($this->oauthHttpClient, $this->payplugOauthBaseUrl, $redirectUri, self::PKCE_SCOPE, $this->payplugOauthAudience);
}

#[Route('/setup-redirection', name: 'payplug_sylius_admin_auth_setup_redirection')]
public function setupRedirection(Request $request): Response
{
try {
$clientId = $request->query->get('client_id');
$companyId = $request->query->get('company_id');
$clientId = $request->query->getString('client_id');
$companyId = $request->query->getString('company_id');

$request->getSession()->set('payplug_client_id', $clientId);
$request->getSession()->set('payplug_company_id', $companyId);

$challenge = bin2hex(openssl_random_pseudo_bytes(50));
$request->getSession()->set('payplug_oauth_challenge', $challenge);

$callBackUrl = $this->router->generate('payplug_sylius_admin_auth_oauth_callback', [], RouterInterface::ABSOLUTE_URL);
$authorizationRequest = $this->buildOAuth2Client($callBackUrl)->buildAuthorizationUrl($clientId);
$request->getSession()->set('payplug_oauth_state', $authorizationRequest->state);
$request->getSession()->set('payplug_oauth_code_verifier', $authorizationRequest->codeVerifier);

// This method will redirect the user to PayPlug's oauth page via header('Location')'
Authentication::initiateOAuth($clientId, $callBackUrl, $challenge);
// Fetch the header Location the Sdk put and redirect the user to it
$headers = \headers_list();
foreach ($headers as $header) {
if (str_starts_with($header, 'Location:')) {
return new RedirectResponse(substr($header, 9));
}
}

throw new \LogicException('No location header found');
return new RedirectResponse($authorizationRequest->url);
} catch (\Throwable $e) {
$this->logger->critical('Error while perform Payplug OAuth Setup redirection', ['message' => $e->getMessage(), 'exception' => $e]);

Expand All @@ -81,16 +82,24 @@ public function oauthCallback(Request $request): Response
{
try {
$code = $request->query->getString('code');
$state = $request->query->getString('state');
/** @var string $clientId */
$clientId = $request->getSession()->get('payplug_client_id');
/** @var string $challenge */
$challenge = $request->getSession()->get('payplug_oauth_challenge');
$callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL);
/** @var string $expectedState */
$expectedState = $request->getSession()->get('payplug_oauth_state');
$codeVerifier = $request->getSession()->get('payplug_oauth_code_verifier');
Comment thread
adumont-payplug marked this conversation as resolved.

if ('' === $state || $state !== $expectedState) {
throw new BadRequestHttpException('OAuth state mismatch');
}
Comment thread
adumont-payplug marked this conversation as resolved.

$jwt = Authentication::generateJWTOneShot($code, $callback, $clientId, $challenge);
if ([] === $jwt || $jwt['httpStatus'] !== 200 || !\is_array($jwt['httpResponse'])) {
throw new BadRequestHttpException('Error while generating JWT');
if (!\is_string($codeVerifier) || '' === $codeVerifier) {
throw new BadRequestHttpException('OAuth code verifier missing from session');
}

$callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL);
$token = $this->buildOAuth2Client($callback)->exchangeAuthorizationCode($clientId, $code, $codeVerifier);

$paymentMethodId = $request->getSession()->get('payplug_sylius_oauth_payment_method_id');
if (null === $paymentMethodId) {
throw new BadRequestHttpException('No payment method id found in session');
Expand All @@ -105,7 +114,7 @@ public function oauthCallback(Request $request): Response
}

$companyId = $request->getSession()->get('payplug_company_id');
Payplug::init(['secretKey' => $jwt['httpResponse']['access_token']]);
Payplug::init(['secretKey' => $token->accessToken]);
$clientName = 'Sylius - ' . $paymentMethod->getName();
$testClientDataResult = Authentication::createClientIdAndSecret($companyId, $clientName, 'test');
$liveClientDataResult = Authentication::createClientIdAndSecret($companyId, $clientName, 'live');
Expand All @@ -119,12 +128,6 @@ public function oauthCallback(Request $request): Response
$this->cleanSession($request);

$request->getSession()->getFlashBag()->add('success', 'payplug_sylius_payplug_plugin.admin.oauth_callback_success');
// Clean previous cached client config
$cacheKeyLive = sprintf('payplug_%s_api_key_live', $gatewayConfig->getFactoryName());
$cacheKeyTest = sprintf('payplug_%s_api_key_test', $gatewayConfig->getFactoryName());
$this->cache->delete($cacheKeyLive);
$this->cache->delete($cacheKeyTest);

// Ensure that the payment method is well configured
$this->paymentMethodValidator->process($paymentMethod);

Expand Down Expand Up @@ -152,7 +155,8 @@ private function cleanSession(Request $request): void
$session = $request->getSession();
$session->remove('payplug_client_id');
$session->remove('payplug_company_id');
$session->remove('payplug_oauth_challenge');
$session->remove('payplug_oauth_state');
$session->remove('payplug_oauth_code_verifier');
$session->remove('payplug_sylius_oauth_payment_method_id');
}
}
Loading
Loading