diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index 75c1239..60c3dc9 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -176,7 +176,13 @@ jobs:
openssl genrsa -out private.key 2048
openssl rsa -in private.key -pubout -out public.key
chmod 660 *.key
- sed "s/KEY/$(../../vendor/bin/generate-defuse-key)/" -i encryption-key.php
+ DEFUSE_KEY=$(../../vendor/bin/generate-defuse-key)
+ cat > encryption-key.php << 'EOFKEY'
+ > encryption-key.php
+ cat encryption-key.php
- name: Init for PostgreSQL
env:
@@ -185,7 +191,8 @@ jobs:
run: |
cd galette-core
bin/console galette:install -v --dbtype=pgsql --dbhost=localhost --dbname=galette_tests --dbuser=galette_tests --dbpass=g@l3tte --admin=admin --password=admin --no-interaction -w
- bin/console galette:plugins:install-db --all
+ bin/console galette:plugins:enable --all
+ bin/console galette:plugins:list --enabled --complete
if: env.skip != 'true' && startsWith(matrix.db-image, 'postgres')
- name: Init for MariaDB
@@ -193,13 +200,179 @@ jobs:
cd galette-core
mysql -e 'create database IF NOT EXISTS galette_tests;' -u galette_tests --password=g@l3tte -h 127.0.0.1 -P 3306
bin/console galette:install -v --dbtype=mysql --dbhost=127.0.0.1 --dbname=galette_tests --dbuser=galette_tests --dbpass=g@l3tte --admin=admin --password=admin --no-interaction -w
- bin/console galette:plugins:install-db --all
+ bin/console galette:plugins:enable --all
+ bin/console galette:plugins:list --enabled --complete
if: env.skip != 'true' && (startsWith(matrix.db-image, 'mysql') || startsWith(matrix.db-image, 'mariadb'))
- name: Unit tests
if: env.skip != 'true'
run: |
cd galette-core/galette/webroot
- php -S localhost:8888 ../plugins/plugin-oauth2/tests/router.php &
+ DB=${{ env.DB }} php -S localhost:8888 ../plugins/plugin-oauth2/tests/router.php &
cd ../plugins/plugin-oauth2
../../vendor/bin/phpunit --test-suffix=.php --bootstrap tests/TestsBootstrap.php --no-coverage --process-isolation tests/GaletteOAuth2/
+
+ - name: Sanitize ref name for artifact upload
+ if: failure()
+ id: sanitize
+ run: |
+ db_image=$(echo -n "${{ matrix.db-image }}" | sed 's/[\\\/'"'"':<>|*?]/-/g')
+ echo "db_image=$db_image" >> $GITHUB_OUTPUT
+
+ - name: Upload test data (logs, photos, etc.)
+ uses: actions/upload-artifact@v7
+ if: failure()
+ with:
+ name: test-data-${{ matrix.php-version }}-${{ steps.sanitize.outputs.db_image }}
+ path: |
+ galette-core/tests/tests-data/
+ galette-core/galette/data/logs/
+ galette-core/galette/data/photos/
+ retention-days: 7
+ if-no-files-found: ignore
+
+ e2e-tests:
+ runs-on: ubuntu-latest
+ name: "E2E Playwright — ${{ github.event_name == 'schedule' && 'all browsers' || 'chromium' }} (postgres:17)"
+ env:
+ DB: pgsql
+ GALETTE_TESTS: 1
+
+ services:
+ db:
+ image: postgres:17
+ env:
+ POSTGRES_USER: galette_tests
+ POSTGRES_PASSWORD: g@l3tte
+ POSTGRES_DB: galette_tests
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd="pg_isready"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+
+ steps:
+ - name: PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.5"
+ tools: composer, pecl
+ coverage: none
+ extensions: apcu, pdo_pgsql
+ ini-values: apc.enable_cli=1
+
+ - name: Build Galette
+ uses: galette/.github/actions/build-galette@main
+ with:
+ php-version: "8.5"
+ enable-assets-cache: true
+
+ - name: Checkout plugin
+ uses: actions/checkout@v6
+ with:
+ path: galette-core/galette/plugins/plugin-oauth2
+
+ - name: Install plugin dependencies
+ run: |
+ cd galette-core/galette/plugins/plugin-oauth2
+ composer install --ignore-platform-reqs
+
+ - name: Generate OAuth2 keys
+ run: |
+ cd galette-core/galette/plugins/plugin-oauth2
+ # Generate keys for production config path (used by E2E tests)
+ openssl genrsa -out config/private.key 2048
+ openssl rsa -in config/private.key -pubout -out config/public.key
+ chmod 660 config/*.key
+ # Generate encryption key
+ DEFUSE_KEY=$(vendor/bin/generate-defuse-key)
+ cat > config/encryption-key.php << 'EOFKEY'
+ > config/encryption-key.php
+ # Create a minimal config.yml for E2E tests
+ cp tests/config/config.yml config/config.yml
+ cat config/encryption-key.php
+
+ - name: Initialize test data
+ run: |
+ cd galette-core
+ php tests/init_test_data.php
+
+ - name: Initialize Galette database
+ run: |
+ cd galette-core
+ bin/console galette:install -v \
+ --dbtype=pgsql \
+ --dbhost=localhost \
+ --dbname=galette_tests \
+ --dbuser=galette_tests \
+ --dbpass=g@l3tte \
+ --admin=admin \
+ --password=admin \
+ --no-interaction
+ env:
+ POSTGRES_HOST: localhost
+ POSTGRES_PORT: 5432
+
+ - name: Seed E2E fixtures
+ run: |
+ cd galette-core
+ bin/console galette:seed-fixtures -v
+
+ - name: Start PHP built-in server
+ run: |
+ cd galette-core
+ GALETTE_PLUGINS_PATH=$(pwd)/galette/plugins DB=${{ env.DB }} GALETTE_TESTS=1 php -S 0.0.0.0:8090 -t galette/webroot tests/router_e2e.php &
+ timeout 15 bash -c 'until curl -sf http://127.0.0.1:8090/login > /dev/null; do sleep 1; done'
+ CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8090/plugins/oauth2/authorize?response_type=code&client_id=galette_cli&redirect_uri=http://127.0.0.1:8090/plugins/oauth2/test-callback&scope=member&state=ci-check")
+ if [ "$CODE" = "404" ]; then
+ echo "OAuth2 route returned 404 (plugin not initialized/active)."
+ exit 1
+ fi
+ echo "PHP server started"
+
+ - name: Install Playwright browsers
+ run: |
+ cd galette-core
+ if [ "${{ github.event_name }}" == "schedule" ]; then
+ npx playwright install --with-deps chromium firefox
+ else
+ npx playwright install --with-deps chromium
+ fi
+
+ - name: Run OAuth2 E2E tests
+ run: |
+ cd galette-core
+ if [ "${{ github.event_name }}" == "schedule" ]; then
+ npm run test:plugins:all
+ else
+ npm run test:plugins
+ fi
+ env:
+ E2E_BASE_URL: http://127.0.0.1:8090
+ E2E_ADMIN_USER: admin
+ E2E_ADMIN_PASS: admin
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v7
+ if: failure()
+ with:
+ name: playwright-report-oauth2
+ path: galette-core/playwright-report/
+ retention-days: 7
+
+ - name: Upload test data (logs, photos, etc.)
+ uses: actions/upload-artifact@v7
+ if: failure()
+ with:
+ name: test-data-e2e-oauth2
+ path: |
+ galette-core/tests/tests-data/
+ galette-core/galette/data/logs/
+ galette-core/galette/data/photos/
+ retention-days: 7
+ if-no-files-found: ignore
diff --git a/_dependencies.php b/_dependencies.php
index f786e72..a71bc10 100644
--- a/_dependencies.php
+++ b/_dependencies.php
@@ -95,7 +95,14 @@ static function (ContainerInterface $container) {
$container->set(
AuthorizationServer::class,
function (ContainerInterface $container) {
- include OAUTH2_CONFIGPATH . '/encryption-key.php';
+ $encryptionKey = $container->get(Config::class)->get('global.encryption_key', 'NONE');
+ if ($encryptionKey === 'NONE' && file_exists(OAUTH2_CONFIGPATH . '/encryption-key.php')) {
+ include OAUTH2_CONFIGPATH . '/encryption-key.php';
+ }
+
+ if (empty($encryptionKey) || $encryptionKey === 'NONE') {
+ throw new RuntimeException('Encryption key not found!');
+ }
// Setup the authorization server
$server = new AuthorizationServer(
diff --git a/_routes.php b/_routes.php
index 6db4f80..892f16a 100644
--- a/_routes.php
+++ b/_routes.php
@@ -58,6 +58,11 @@
[LoginController::class, 'logout']
)->setName(OAUTH2_PREFIX . '_logout');
+$app->get(
+ '/error',
+ [LoginController::class, 'error']
+)->setName(OAUTH2_PREFIX . '_error');
+
$app->get(
'/authorize',
[AuthorizationController::class, 'authorize']
@@ -77,3 +82,18 @@
'/user',
[ApiController::class, 'user']
)->setName(OAUTH2_PREFIX . '_user');
+
+// Test callback route for E2E tests (only in test environment)
+if (getenv('GALETTE_TESTS') !== false || defined('GALETTE_TESTS')) {
+ $app->get(
+ '/test-callback',
+ function ($request, $response) {
+ $params = $request->getQueryParams();
+ $html = '
OAuth2 Test Callback
';
+ $html .= '' . htmlspecialchars(print_r($params, true)) . '
';
+ $html .= '';
+ $response->getBody()->write($html);
+ return $response->withHeader('Content-Type', 'text/html');
+ }
+ )->setName(OAUTH2_PREFIX . '_test_callback');
+}
diff --git a/config/config.yml.dist b/config/config.yml.dist
index c7bf656..0c31f6b 100644
--- a/config/config.yml.dist
+++ b/config/config.yml.dist
@@ -1,6 +1,7 @@
global:
title: 'Galette'
password: abc123
+ encryption_key: 'your-encryption-key-here' # Replace with a secure random string
galette_flarum:
title: 'Forum Flarum'
redirect_logout: 'http://192.168.1.99/flarum/public'
diff --git a/lang/oauth2_en_US.po b/lang/oauth2_en_US.po
index 8013537..42dad7f 100644
--- a/lang/oauth2_en_US.po
+++ b/lang/oauth2_en_US.po
@@ -126,3 +126,18 @@ msgstr "Cancel"
#: ../tempcache/oauth2_authorize.html.twig:146
msgid "Allow"
msgstr "Allow"
+
+#: ../lib/GaletteOAuth2/Controllers/LoginController.php
+#: ../lib/GaletteOAuth2/Middleware/Authentication.php
+msgid "Unknown client application"
+msgstr "Unknown client application"
+
+#: ../lib/GaletteOAuth2/Controllers/LoginController.php
+#: ../templates/default/oauth2_error.html.twig
+msgid "OAuth2 error"
+msgstr "OAuth2 error"
+
+#: ../lib/GaletteOAuth2/Controllers/LoginController.php
+msgid "An error occurred"
+msgstr "An error occurred"
+
diff --git a/lang/oauth2_fr_FR.utf8.po b/lang/oauth2_fr_FR.utf8.po
index d549cac..48d2050 100644
--- a/lang/oauth2_fr_FR.utf8.po
+++ b/lang/oauth2_fr_FR.utf8.po
@@ -129,6 +129,20 @@ msgstr "Annuler"
msgid "Allow"
msgstr "Autoriser"
+#: ../lib/GaletteOAuth2/Controllers/LoginController.php
+#: ../lib/GaletteOAuth2/Middleware/Authentication.php
+msgid "Unknown client application"
+msgstr "Application cliente inconnue"
+
+#: ../lib/GaletteOAuth2/Controllers/LoginController.php
+#: ../templates/default/oauth2_error.html.twig
+msgid "OAuth2 error"
+msgstr "Erreur OAuth2"
+
+#: ../lib/GaletteOAuth2/Controllers/LoginController.php
+msgid "An error occurred"
+msgstr "Une erreur est survenue"
+
#, php-format
#~ msgid "Please sign in for %s"
#~ msgstr "Veuillez vous connecter pour %s"
diff --git a/lib/GaletteOAuth2/Controllers/LoginController.php b/lib/GaletteOAuth2/Controllers/LoginController.php
index 044b705..39adeca 100755
--- a/lib/GaletteOAuth2/Controllers/LoginController.php
+++ b/lib/GaletteOAuth2/Controllers/LoginController.php
@@ -23,11 +23,13 @@
namespace GaletteOAuth2\Controllers;
+use Analog\Analog;
use DI\Attribute\Inject;
use DI\Container;
use Galette\Controllers\AbstractPluginController;
use GaletteOAuth2\Authorization\UserAuthorizationException;
use GaletteOAuth2\Authorization\UserHelper;
+use GaletteOAuth2\Repositories\ClientRepository;
use GaletteOAuth2\Tools\Config;
use GaletteOAuth2\Tools\Debug;
use RKA\Session;
@@ -79,11 +81,26 @@ public function login(Request $request, Response $response): Response
Debug::log('GET _SESSION = ' . Debug::printVar($this->session));
}
+ // Validate client_id before displaying login form
+ $vars = $this->prepareVarsForm();
+ if ($vars === null) {
+ return $response
+ ->withStatus(302)
+ ->withHeader(
+ 'Location',
+ $this->routeparser->urlFor(
+ OAUTH2_PREFIX . '_error',
+ [],
+ ['message' => _T('Unknown client application', 'oauth2')]
+ )
+ );
+ }
+
// display page
$this->view->render(
$response,
$this->getTemplate(OAUTH2_PREFIX . '_login'),
- $this->prepareVarsForm()
+ $vars
);
return $response;
}
@@ -193,15 +210,65 @@ public function logout(Request $request, Response $response): Response
return $response->withHeader('Location', $redirect_logout)->withStatus(302);
}
- private function prepareVarsForm()
+ /**
+ * Display error page
+ *
+ * @param Request $request Received request
+ * @param Response $response Response instance
+ */
+ public function error(Request $request, Response $response): Response
+ {
+ Debug::logRequest('error()', $request);
+
+ $error_message = $request->getQueryParams()['message'] ?? _T('An error occurred', 'oauth2');
+
+ $this->view->render(
+ $response,
+ $this->getTemplate(OAUTH2_PREFIX . '_error'),
+ [
+ 'page_title' => _T('OAuth2 error', 'oauth2'),
+ 'error_message' => $error_message
+ ]
+ );
+ return $response;
+ }
+
+ private function prepareVarsForm(): ?array
{
- $client_id = $this->session->request_args['client_id'];
+ $client_id = $this->session->request_args['client_id'] ?? null;
+
+ // Validate client_id exists
+ if ($client_id === null || $client_id === '') {
+ Analog::log(
+ sprintf(
+ 'OAuth2: Missing client_id in request from IP %s',
+ $_SERVER['REMOTE_ADDR'] ?? 'unknown'
+ ),
+ Analog::WARNING
+ );
+ return null;
+ }
+
+ // Check if client exists in configuration
+ $clientRepository = new ClientRepository($this->container);
+ if (!$clientRepository->clientExists($client_id)) {
+ Analog::log(
+ sprintf(
+ 'OAuth2: Invalid client_id "%s" in request from IP %s',
+ $client_id,
+ $_SERVER['REMOTE_ADDR'] ?? 'unknown'
+ ),
+ Analog::WARNING
+ );
+ return null;
+ }
+
$server_title = $this->config->get('global.title', 'Galette');
$sign_in_with = sprintf(
_T('Sign in with %s', 'oauth2'),
$server_title
);
- $application = $this->config->get("{$client_id}.title", 'noname');
+ $application = $this->config->get("{$client_id}.title", '');
$page_title = sprintf(
_T('Sign in %s', 'oauth2'),
$application
diff --git a/lib/GaletteOAuth2/Middleware/Authentication.php b/lib/GaletteOAuth2/Middleware/Authentication.php
index ba6eadc..e70b231 100755
--- a/lib/GaletteOAuth2/Middleware/Authentication.php
+++ b/lib/GaletteOAuth2/Middleware/Authentication.php
@@ -23,6 +23,8 @@
namespace GaletteOAuth2\Middleware;
+use Analog\Analog;
+use GaletteOAuth2\Repositories\ClientRepository;
use GaletteOAuth2\Tools\Debug;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
@@ -39,11 +41,13 @@
*/
final class Authentication
{
+ private Container $container;
private RouteParser $routeparser;
private Session $session;
public function __construct(Container $container)
{
+ $this->container = $container;
$this->routeparser = $container->get(RouteParser::class);
$this->session = $container->get('oauth_session');
}
@@ -56,6 +60,30 @@ public function __construct(Container $container)
*/
public function __invoke(Request $request, RequestHandler $handler): Response
{
+ // Validate client_id before proceeding
+ $queryParams = $request->getQueryParams();
+ $client_id = $queryParams['client_id'] ?? null;
+
+ $clientRepository = new ClientRepository($this->container);
+ if (!$clientRepository->clientExists($client_id)) {
+ Analog::log(
+ sprintf(
+ 'OAuth2: Invalid or missing client_id "%s" in authorization request from IP %s',
+ $client_id ?? 'null',
+ $_SERVER['REMOTE_ADDR'] ?? 'unknown'
+ ),
+ Analog::WARNING
+ );
+
+ $response = new \Slim\Psr7\Response();
+ $url = $this->routeparser->urlFor(
+ OAUTH2_PREFIX . '_error',
+ [],
+ ['message' => _T('Unknown client application', 'oauth2')]
+ );
+ return $response->withHeader('Location', $url)->withStatus(302);
+ }
+
$loggedIn = $this->session->isLoggedIn ?? '';
if ('yes' !== $loggedIn) {
diff --git a/lib/GaletteOAuth2/Repositories/ClientRepository.php b/lib/GaletteOAuth2/Repositories/ClientRepository.php
index 54c8652..1084f6e 100755
--- a/lib/GaletteOAuth2/Repositories/ClientRepository.php
+++ b/lib/GaletteOAuth2/Repositories/ClientRepository.php
@@ -50,6 +50,17 @@ public function __construct(Container $container)
$this->session = $this->container->get('oauth_session');
}
+ /**
+ * Check if a client exists in the configuration
+ */
+ public function clientExists(?string $client_id): bool
+ {
+ if (empty($client_id)) {
+ return false;
+ }
+ return $this->config->get($client_id) !== '';
+ }
+
public function getClientEntity($client_id): ClientEntityInterface
{
$client = new ClientEntity();
diff --git a/templates/default/oauth2_error.html.twig b/templates/default/oauth2_error.html.twig
new file mode 100644
index 0000000..97292ce
--- /dev/null
+++ b/templates/default/oauth2_error.html.twig
@@ -0,0 +1,41 @@
+{#
+/**
+ * Copyright © 2021-2026 The Galette Team
+ *
+ * This file is part of Galette OAuth2 plugin (https://galette-community.github.io/plugin-oauth2/).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette OAuth2 plugin. If not, see .
+ */
+#}
+{% extends "public_page.html.twig" %}
+
+{% set ext_auth = true %}
+
+{% block content %}
+
+
+
{{ error_message }}
+
+
+
+{% endblock %}
+
diff --git a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php
index cb521ed..86c7611 100644
--- a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php
+++ b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php
@@ -21,7 +21,9 @@
namespace GaletteOAuth2\Controllers\tests\units;
+use Analog\Analog;
use Galette\Tests\GaletteRoutingTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
/**
* Login controller tests
@@ -47,6 +49,20 @@ public function setUp(): void
$session = $this->session;
}
+ /**
+ * Data provider for invalid client IDs
+ *
+ * @return array>
+ */
+ public static function invalidClientIdsProvider(): array
+ {
+ return [
+ 'unknown client' => ['unknown_client'],
+ 'galette_unknown' => ['galette_unknown'],
+ 'empty string' => [''],
+ ];
+ }
+
/**
* Test display login page
*
@@ -122,7 +138,7 @@ public function testDoLogin(): void
$this->assertSame(['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], $test_response->getHeaders());
$this->assertSame(301, $test_response->getStatusCode());
$this->expectLogEntry(
- \Analog::WARNING,
+ Analog::WARNING,
'No entry found for login `jdoe`'
);
//flash data are stored in plugin specific session... No way from tests to check them, including on redirected page
@@ -135,9 +151,129 @@ public function testDoLogin(): void
$this->assertSame(['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], $test_response->getHeaders());
$this->assertSame(301, $test_response->getStatusCode());
$this->expectLogEntry(
- \Analog::WARNING,
+ Analog::WARNING,
'OAuth login attempt from superadmin account'
);
//flash data are stored in plugin specific session... No way from tests to check them, including on redirected page
}
+
+ /**
+ * Test login with invalid client_id redirects to error page
+ *
+ * @param string $client_id Invalid client ID to test
+ * @return void
+ */
+ #[DataProvider('invalidClientIdsProvider')]
+ public function testLoginWithInvalidClientId(string $client_id): void
+ {
+ $route_name = OAUTH2_PREFIX . '_login';
+ $query_params = [
+ 'redirect_url' => $this->routeparser->urlFor(
+ OAUTH2_PREFIX . '_authorize',
+ [],
+ [
+ 'scope' => 'member',
+ 'state' => '7d627422092a7a5ac413ac597312b9b4',
+ 'response_type' => 'code',
+ 'approval_prompt' => 'auto',
+ 'redirect_uri' => 'http://example.com/auth',
+ 'client_id' => $client_id
+ ]
+ )
+ ];
+ $request = $this->createRequest(
+ route_name: $route_name,
+ query_params: $query_params
+ );
+
+ $test_response = $this->app->handle($request);
+
+ // Should redirect to error page
+ $this->assertSame(302, $test_response->getStatusCode());
+ $location = $test_response->getHeader('Location')[0] ?? '';
+ $this->assertStringContainsString(OAUTH2_PREFIX . '/error', $location);
+
+ // Should log a warning with the invalid client_id
+ if ($client_id === '') {
+ $this->expectLogEntry(
+ Analog::WARNING,
+ 'OAuth2: Missing client_id in request'
+ );
+ } else {
+ $this->expectLogEntry(
+ Analog::WARNING,
+ 'OAuth2: Invalid client_id "' . $client_id . '"'
+ );
+ }
+ }
+
+ /**
+ * Test login without redirect_url (no client_id in session)
+ *
+ * @return void
+ */
+ public function testLoginWithoutRedirectUrl(): void
+ {
+ $route_name = OAUTH2_PREFIX . '_login';
+ $request = $this->createRequest(
+ route_name: $route_name,
+ query_params: []
+ );
+
+ $test_response = $this->app->handle($request);
+
+ // Should redirect to error page because no client_id
+ $this->assertSame(302, $test_response->getStatusCode());
+ $location = $test_response->getHeader('Location')[0] ?? '';
+ $this->assertStringContainsString(OAUTH2_PREFIX . '/error', $location);
+
+ $this->expectLogEntry(
+ Analog::WARNING,
+ 'OAuth2: Missing client_id in request'
+ );
+ }
+
+ /**
+ * Test error page displays correctly
+ *
+ * @return void
+ */
+ public function testErrorPage(): void
+ {
+ $route_name = OAUTH2_PREFIX . '_error';
+ $query_params = [
+ 'message' => 'Test error message'
+ ];
+ $request = $this->createRequest(
+ route_name: $route_name,
+ query_params: $query_params
+ );
+
+ $test_response = $this->app->handle($request);
+ $this->expectOK($test_response);
+
+ $body = (string)$test_response->getBody();
+ $this->assertStringContainsString('Test error message', $body);
+ $this->assertStringContainsString('OAuth2 error', $body);
+ }
+
+ /**
+ * Test error page with default message
+ *
+ * @return void
+ */
+ public function testErrorPageDefaultMessage(): void
+ {
+ $route_name = OAUTH2_PREFIX . '_error';
+ $request = $this->createRequest(
+ route_name: $route_name,
+ query_params: []
+ );
+
+ $test_response = $this->app->handle($request);
+ $this->expectOK($test_response);
+
+ $body = (string)$test_response->getBody();
+ $this->assertStringContainsString('OAuth2 error', $body);
+ }
}
diff --git a/tests/GaletteOAuth2/GaletteOAuth2.php b/tests/GaletteOAuth2/GaletteOAuth2.php
index b67f4c3..145c195 100644
--- a/tests/GaletteOAuth2/GaletteOAuth2.php
+++ b/tests/GaletteOAuth2/GaletteOAuth2.php
@@ -64,7 +64,7 @@ public function testFlow(): void
$provider = new \Galette\OAuth2\Client\Provider\Galette([
//information related to the app where you will use galette-oauth2
'clientId' => 'galette_cli', // The client ID assigned to you
- 'clientSecret' => '4567zyx', // The client password assigned to you
+ 'clientSecret' => 'abc123', // The client password assigned to you
'redirectUri' => 'http://localhost:8888', // The return URL you specified for your app
//information related to the galette instance you want to connect to
'instance' => 'http://localhost:8888', // The instance of Galette you want to connect to
diff --git a/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php b/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php
new file mode 100644
index 0000000..0832d81
--- /dev/null
+++ b/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php
@@ -0,0 +1,112 @@
+.
+ */
+
+namespace GaletteOAuth2\Repositories\tests\units;
+
+use Galette\Tests\GaletteTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * ClientRepository tests
+ *
+ * @author Johan Cwiklinski
+ */
+class ClientRepository extends GaletteTestCase
+{
+ protected int $seed = 20260413100000;
+ protected bool $load_plugins = true;
+
+ /**
+ * Set up tests
+ *
+ * @return void
+ */
+ public function setUp(): void
+ {
+ global $session;
+
+ parent::setUp();
+ $this->session = $this->container->get('oauth_session');
+ $session = $this->session;
+ }
+
+ /**
+ * Data provider for valid client IDs
+ *
+ * @return array>
+ */
+ public static function validClientIdsProvider(): array
+ {
+ return [
+ 'galette_flarum' => ['galette_flarum'],
+ 'galette_nc' => ['galette_nc'],
+ 'galette_cli' => ['galette_cli'],
+ ];
+ }
+
+ /**
+ * Data provider for invalid client IDs
+ *
+ * @return array>
+ */
+ public static function invalidClientIdsProvider(): array
+ {
+ return [
+ 'null' => [null],
+ 'empty string' => [''],
+ 'unknown client' => ['unknown_client'],
+ 'galette_unknown' => ['galette_unknown'],
+ 'random string' => ['some_random_string'],
+ ];
+ }
+
+ /**
+ * Test clientExists with valid client IDs
+ *
+ * @param string $client_id Client ID to test
+ * @return void
+ */
+ #[DataProvider('validClientIdsProvider')]
+ public function testClientExistsWithValidClients(string $client_id): void
+ {
+ $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container);
+ $this->assertTrue(
+ $clientRepository->clientExists($client_id),
+ "Client '$client_id' should exist in configuration"
+ );
+ }
+
+ /**
+ * Test clientExists with invalid client IDs
+ *
+ * @param string|null $client_id Client ID to test
+ * @return void
+ */
+ #[DataProvider('invalidClientIdsProvider')]
+ public function testClientExistsWithInvalidClients(?string $client_id): void
+ {
+ $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container);
+ $this->assertFalse(
+ $clientRepository->clientExists($client_id),
+ "Client '$client_id' should not exist in configuration"
+ );
+ }
+}
diff --git a/tests/config/config.yml b/tests/config/config.yml
index e9d3abf..0362614 100644
--- a/tests/config/config.yml
+++ b/tests/config/config.yml
@@ -14,7 +14,7 @@ galette_nc:
- member:phones
- member:groups
galette_cli:
- password: 4567zyx
+ password: abc123
title: CLI for testing
redirect_logout: 'http://localhost'
authorize: teamonly
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000..28cbc09
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1,85 @@
+# OAuth2 Plugin E2E Tests
+
+## Prerequisites
+
+- Galette installed and configured
+- Test database initialized (`bin/console galette:install`), fixture data added
+- Playwright installed (`npm install` from Galette root)
+
+You can find useful information on Playwright setup in the `tests/e2e/README.md` file from Galette.
+
+## Running tests
+
+### From Galette root directory:
+
+```bash
+# Run OAuth2 E2E tests only
+npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/
+
+# Run with visible browser (headed mode)
+npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ --headed
+
+# Debug mode (step-by-step)
+npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ --debug
+
+# UI mode
+npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ --ui
+
+# Run specific test
+npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/oauth2-flow.spec.ts
+```
+
+### Start test server manually:
+
+```bash
+# Terminal 1: Start PHP server
+cd /path/to/galette
+DB=pgsql GALETTE_TESTS=1 php -S 0.0.0.0:8090 -t galette/webroot tests/router_e2e.php
+
+# Terminal 2: Run tests
+E2E_BASE_URL=http://127.0.0.1:8090 npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/
+```
+
+## Using Galette shared fixtures
+
+Plugin specs can import Galette E2E fixtures via the `@e2e` alias defined in
+`tsconfig.json`:
+
+```typescript
+import { test as base, expect } from '@playwright/test';
+import { test } from '@e2e/fixtures/auth.fixture';
+
+// Use `base(...)` for tests with a plain page (no auth)
+base('my test', async ({ page }) => { ... });
+
+// Use `test(...)` for tests with a pre-authenticated page
+test('my test', async ({ loggedInPage }) => { ... });
+```
+
+Available fixtures:
+- **`@e2e/fixtures/auth.fixture`** — provides `loggedInPage` (logged in as admin)
+- **`@e2e/fixtures/a11y.fixture`** — provides `axeBuilder()` and `formatViolations()` for accessibility audits
+
+## Test Coverage
+
+The E2E tests cover:
+
+1. **Complete OAuth2 Authorization Code Flow**
+ - Client redirects user to Galette OAuth2
+ - User logs in on OAuth2 login page
+ - User approves authorization
+ - Client receives authorization code
+ - Client exchanges code for access token
+ - Client retrieves user information with access token
+
+2. **Error Handling**
+ - Invalid client_id shows error
+
+3. **UI Validation**
+ - OAuth2 login page displays correctly
+ - All form elements are visible
+
+## Notes
+
+- Tests require data to be committed to database
+- Screenshots and traces are captured on failure
diff --git a/tests/e2e/specs/oauth2-flow.spec.ts b/tests/e2e/specs/oauth2-flow.spec.ts
new file mode 100644
index 0000000..01dd246
--- /dev/null
+++ b/tests/e2e/specs/oauth2-flow.spec.ts
@@ -0,0 +1,244 @@
+/*!
+ * Copyright © 2021-2026 The Galette Team
+ *
+ * This file is part of Galette OAuth2 plugin (https://galette-community.github.io/plugin-oauth2/).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette OAuth2 plugin. If not, see .
+ *
+ * @author Johan Cwiklinski
+ */
+
+import { test as base, expect } from '@playwright/test';
+import { test } from '@e2e/fixtures/auth.fixture';
+
+/**
+ * OAuth2 Flow E2E Tests
+ *
+ * These tests verify the complete OAuth2 authorization code flow:
+ * 1. Client initiates OAuth2 authorization
+ * 2. User is redirected to Galette OAuth2 login page
+ * 3. User authenticates
+ * 4. User approves the authorization request
+ * 5. User is redirected back to client with authorization code
+ * 6. Client exchanges code for access token
+ * 7. Client uses access token to get user information
+ *
+ * Galette shared fixtures are available via the @e2e alias:
+ * import { test } from '@e2e/fixtures/auth.fixture';
+ * test('my test', async ({ loggedInPage }) => { ... });
+ */
+test.describe('OAuth2 Plugin', () => {
+
+ test.describe('Login Page UI', () => {
+
+ base('OAuth2 login page displays correctly', async ({ page }) => {
+ const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090';
+ const clientId = 'galette_cli';
+
+ const authParams = new URLSearchParams({
+ response_type: 'code',
+ client_id: clientId,
+ redirect_uri: `${baseUrl}/callback`,
+ scope: 'member',
+ state: 'test-ui',
+ });
+ const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`;
+
+ await page.goto(authorizationUrl);
+
+ // Should redirect to OAuth2 login page
+ await expect(page).toHaveURL(/\/plugins\/oauth2\/login/);
+
+ // Check page elements
+ await expect(page.locator('input[name="login"]')).toBeVisible();
+ await expect(page.locator('input[name="password"]')).toBeVisible();
+ await expect(page.locator('button[type="submit"], input[type="submit"]')).toBeVisible();
+
+ // Page should have a title
+ await expect(page).toHaveTitle(/.+/);
+ });
+
+ });
+
+ test.describe('Authorization Flow', () => {
+
+ base('OAuth2 flow superadmin cannot login', async ({ page }) => {
+ const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090';
+ const clientId = 'galette_cli';
+ const redirectUri = `${baseUrl}/oauth2-test-callback`;
+ const scope = 'member member:localization';
+
+ // Step 1: Build authorization URL (simulating client application)
+ const state = 'test-state-' + Date.now();
+ const authParams = new URLSearchParams({
+ response_type: 'code',
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ scope: scope,
+ state: state,
+ });
+ const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`;
+
+ // Step 2: Navigate to authorization URL
+ await page.goto(authorizationUrl);
+
+ // Should be redirected to login page with redirect_url
+ await expect(page).toHaveURL(/\/plugins\/oauth2\/login.*redirect_url=/);
+ await expect(page.locator('input[name="login"]')).toBeVisible();
+ await expect(page.locator('input[name="password"]')).toBeVisible();
+
+ // Step 3: Fill login form
+ const login = 'admin';
+ const password = 'admin';
+
+ await page.locator('input[name="login"]').fill(login);
+ await page.locator('input[name="password"]').fill(password);
+ await page.locator('button[type="submit"], input[type="submit"]').click();
+
+ // Step 4: Should be redirected to login page
+ await expect(page).toHaveURL(/\/plugins\/oauth2\/login/);
+
+ // Wait for the error toast to appear. It can take some time in CI.
+ await expect(page.locator('.ui.toast.error')).toBeVisible({ timeout: 10000 });
+ });
+
+ base('Complete OAuth2 authorization code flow', async ({ page }) => {
+ const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090';
+ const clientId = 'galette_cli';
+ const redirectUri = `${baseUrl}/plugins/oauth2/test-callback`;
+ const scope = 'member member:localization';
+
+ // Step 1: Build authorization URL (simulating client application)
+ const state = 'test-state-' + Date.now();
+ const authParams = new URLSearchParams({
+ response_type: 'code',
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ scope: scope,
+ state: state,
+ });
+ const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`;
+
+ // Step 2: Navigate to authorization URL
+ await page.goto(authorizationUrl);
+
+ // Should be redirected to login page with redirect_url
+ await expect(page).toHaveURL(/\/plugins\/oauth2\/login.*redirect_url=/);
+ await expect(page.locator('input[name="login"]')).toBeVisible();
+ await expect(page.locator('input[name="password"]')).toBeVisible();
+
+ // Step 3: Fill login form
+ const login = 'leia.organa';
+ const password = 'G@l3tte-E2E!';
+
+ await page.locator('input[name="login"]').fill(login);
+ await page.locator('input[name="password"]').fill(password);
+ await page.locator('button[type="submit"], input[type="submit"]').click();
+
+ // Step 4: Should be redirected to authorization page
+ await expect(page).toHaveURL(/\/plugins\/oauth2\/authorize/);
+
+ // Check that authorization page displays correctly
+ await expect(page.locator('button[name="approve"], input[name="approve"]')).toBeVisible({ timeout: 10000 });
+
+ // Step 5: Approve the authorization
+ await page.locator('button[name="approve"], input[name="approve"]').click();
+
+ // Step 6: Should be redirected to test callback with code
+ await page.waitForURL(/\/plugins\/oauth2\/test-callback/, { timeout: 10000 });
+
+ // Verify the URL contains the authorization code and state
+ const finalUrl = page.url();
+ const urlParams = new URLSearchParams(new URL(finalUrl).search);
+
+ expect(urlParams.has('code')).toBeTruthy();
+ expect(urlParams.has('state')).toBeTruthy();
+ expect(urlParams.get('state')).toBe(state);
+
+ const authorizationCode = urlParams.get('code');
+ expect(authorizationCode).toBeTruthy();
+ expect(authorizationCode!.length).toBeGreaterThan(10);
+
+ // Step 7: Exchange code for access token (API call)
+ const tokenResponse = await page.request.post(`${baseUrl}/plugins/oauth2/access_token`, {
+ form: {
+ grant_type: 'authorization_code',
+ code: authorizationCode!,
+ redirect_uri: redirectUri,
+ client_id: clientId,
+ client_secret: 'abc123',
+ },
+ });
+
+ // Debug: log response if not OK
+ if (!tokenResponse.ok()) {
+ console.error('Token exchange failed:', tokenResponse.status(), await tokenResponse.text());
+ }
+ expect(tokenResponse.ok()).toBeTruthy();
+ const tokenData = await tokenResponse.json();
+
+ expect(tokenData).toHaveProperty('access_token');
+ expect(tokenData).toHaveProperty('token_type');
+ expect(tokenData.token_type).toBe('Bearer');
+ expect(tokenData).toHaveProperty('expires_in');
+
+ // Step 8: Use access token to get user info
+ const userInfoResponse = await page.request.get(`${baseUrl}/plugins/oauth2/user`, {
+ headers: {
+ 'Authorization': `Bearer ${tokenData.access_token}`,
+ },
+ });
+
+ // Debug: log response if not OK
+ if (!userInfoResponse.ok()) {
+ console.error('User info failed:', userInfoResponse.status(), await userInfoResponse.text());
+ }
+ expect(userInfoResponse.ok()).toBeTruthy();
+ const userInfo = await userInfoResponse.json();
+
+ // Verify user information
+ expect(userInfo).toHaveProperty('id');
+ expect(userInfo).toHaveProperty('username');
+ expect(userInfo.username).toBe(login);
+ });
+
+ base('Shows error for invalid client_id', async ({ page }) => {
+ const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090';
+
+ const authParams = new URLSearchParams({
+ response_type: 'code',
+ client_id: 'invalid_client_does_not_exist',
+ redirect_uri: `${baseUrl}/callback`,
+ scope: 'member',
+ state: 'test-invalid',
+ });
+ const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`;
+
+ await page.goto(authorizationUrl);
+
+ // Should show an error
+ await page.waitForTimeout(2000); // Give time for error to appear
+
+ // Wait for the error to appear. It can take some time in CI.
+ await expect(page.locator('.ui.red.message')).toBeVisible({ timeout: 10000 });
+ await page.getByText('OAuth2 error Unknown client');
+ const hasErrorOnPage = await page.locator('.ui.red.message').isVisible().catch(() => false);
+
+ expect(hasErrorOnPage).toBeTruthy();
+ });
+
+ });
+
+});
+