diff --git a/.env b/.env index cd76e70..976acf0 100644 --- a/.env +++ b/.env @@ -46,3 +46,22 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +###> app/myep-oauth2 ### +# OAuth2 against myep, the EP identity provider. ephub is a confidential client using the +# authorization_code grant; it reads /api/userinfo once at login and then uses its own session, +# so the 10-minute access token is never stored or refreshed. +# +# Credentials come from `league:oauth2-server:create-client` ON MYEP. Register the callback +# https://ephub.ddev.site/auth/check for local dev, plus the production host. Leave the id and +# secret empty until then -- the login page detects it and says so. +# +# `email` is the identity claim. Do NOT use profile.communication.email: it comes from the CRM +# address record and is not necessarily the login email. +EP_OAUTH2_CLIENT_ID= +EP_OAUTH2_CLIENT_SECRET= +EP_OAUTH2_URL_AUTHORIZE=https://myep.ddev.site/authorize +EP_OAUTH2_URL_ACCESS_TOKEN=https://myep.ddev.site/token +EP_OAUTH2_URL_USERINFO=https://myep.ddev.site/api/userinfo +EP_OAUTH2_SCOPES=email,profile,roles +###< app/myep-oauth2 ### diff --git a/composer.json b/composer.json index 207be90..5f3c7b5 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "doctrine/doctrine-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.7", + "league/oauth2-client": "^2.9", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3", "symfony/apache-pack": "*", diff --git a/composer.lock b/composer.lock index bd14e33..88928bd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3e68f01587473077081d93ce2360e229", + "content-hash": "8f1bfc30d968cee3e1cb4b846d7b1e73", "packages": [ { "name": "composer/semver", @@ -1269,6 +1269,402 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" + }, + { + "name": "league/oauth2-client", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.6.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" + }, + "time": "2025-11-25T22:17:17+00:00" + }, { "name": "monolog/monolog", "version": "3.12.0", @@ -1797,6 +2193,166 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/link", "version": "2.0.1", @@ -1903,6 +2459,50 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "symfony/apache-pack", "version": "v1.0.1", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b4e1ef2..f787a6b 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,6 +1,6 @@ security: - # No password_hashers: there are no passwords here. Authentication is OAuth2 against myep - # (plan step 6); until that lands there is no way to log in outside dev. + # No password_hashers: there are no passwords here. Authentication is OAuth2 against myep, + # which owns the credentials; ephub only ever sees a userinfo response. providers: app_user_provider: @@ -15,23 +15,31 @@ security: main: lazy: true provider: app_user_provider + entry_point: App\Security\LoginEntryPoint + custom_authenticators: + - App\Security\EpAuthenticator logout: path: app_logout + target: app_login access_control: - # ROLE_EMPLOYEE is the gate, not ROLE_USER: myep authenticates more people than are staff, - # and only staff may book a desk here. + # The login surface itself has to stay reachable, or the entry point redirects in a loop. + - { path: ^/(login|auth|logout)$, roles: PUBLIC_ACCESS } + - { path: ^/auth/, roles: PUBLIC_ACCESS } - { path: ^/dev/, roles: PUBLIC_ACCESS } - - { path: ^/, roles: ROLE_EMPLOYEE } + # Either role opens the app -- access_control treats a list of roles as an OR. Not + # ROLE_USER: myep authenticates far more people than work here, and someone who holds + # neither of these is refused at login with an explanation rather than let in to an + # empty screen. + - { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN] } when@dev: security: firewalls: main: - # Real login is blocked on OAuth2 client registration on myep. Until then - # /dev/login lists the seeded users and logs one in without credentials. - # The entry point, the route and the controller are all dev-only, so this - # cannot leak into another environment by accident. - entry_point: App\Security\DevLoginEntryPoint + # Until a client is registered on myep, /dev/login lists the seeded users and + # logs one in without credentials. The route and the controller are both + # dev-only, so this cannot leak into another environment by accident. custom_authenticators: + - App\Security\EpAuthenticator - App\Security\DevLoginAuthenticator diff --git a/config/services.yaml b/config/services.yaml index 79b8ce2..f3f35f8 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -19,5 +19,19 @@ services: App\: resource: '../src/' + # The myep OAuth2 client. Endpoint URLs are configuration rather than constants (same as + # myep-team), so dev, staging and production differ by .env alone. Empty credentials are a + # valid state: the login page then says the client is not registered yet instead of bouncing + # the browser at a broken authorize URL. + App\Security\OAuth2\EpClient: + arguments: + $options: + client_id: '%env(string:EP_OAUTH2_CLIENT_ID)%' + client_secret: '%env(string:EP_OAUTH2_CLIENT_SECRET)%' + url_authorize: '%env(string:EP_OAUTH2_URL_AUTHORIZE)%' + url_access_token: '%env(string:EP_OAUTH2_URL_ACCESS_TOKEN)%' + url_userinfo: '%env(string:EP_OAUTH2_URL_USERINFO)%' + scopes: '%env(csv:EP_OAUTH2_SCOPES)%' + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index d75ad9b..ac3be66 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -9,6 +9,7 @@ use App\Entity\ResourceType; use App\Entity\User; use App\Repository\ResourceRepository; use App\Repository\UserRepository; +use App\Security\Role; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -51,14 +52,16 @@ final class SeedCommand extends Command $created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i)); } + // One administrator on purpose, so the dev environment exercises both roles that open + // the app. An admin here is also staff, which is the realistic case. foreach ([ - ['anna.berger@example.test', 'Anna Berger'], - ['ben.kuhn@example.test', 'Ben Kuhn'], - ['clara.dietz@example.test', 'Clara Dietz'], - ['david.roth@example.test', 'David Roth'], - ['elif.yilmaz@example.test', 'Elif Yılmaz'], - ] as [$email, $name]) { - $created += $this->ensureUser($email, $name); + ['anna.berger@example.test', 'Anna Berger', [Role::EMPLOYEE]], + ['ben.kuhn@example.test', 'Ben Kuhn', [Role::EMPLOYEE, Role::ADMIN]], + ['clara.dietz@example.test', 'Clara Dietz', [Role::EMPLOYEE]], + ['david.roth@example.test', 'David Roth', [Role::EMPLOYEE]], + ['elif.yilmaz@example.test', 'Elif Yılmaz', [Role::EMPLOYEE]], + ] as [$email, $name, $roles]) { + $created += $this->ensureUser($email, $name, $roles); } $this->em->flush(); @@ -79,16 +82,19 @@ final class SeedCommand extends Command return 1; } - private function ensureUser(string $email, string $name): int + /** + * @param list $roles + */ + private function ensureUser(string $email, string $name, array $roles): int { if (null !== $this->users->findOneBy(['email' => $email])) { return 0; } $user = new User($email, $name); - // Mirrors what the OAuth2 authenticator will do: the remote roles claim is filtered - // through a local whitelist, and ROLE_EMPLOYEE is what the access_control gate wants. - $user->setRoles(['ROLE_EMPLOYEE']); + // Mirrors what EpAuthenticator does: whatever myep claims is filtered through + // Role::ELIGIBLE, and only those roles are ever stored. + $user->setRoles(Role::eligibleOnly($roles)); $this->em->persist($user); return 1; diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index ae54aab..e4c7a70 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -4,14 +4,58 @@ declare(strict_types=1); namespace App\Controller; +use App\Security\OAuth2\EpClient; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -/** - * Gains /auth/init and /auth/check when OAuth2 against myep lands (plan step 6). - */ final class SecurityController extends AbstractController { + public function __construct(private readonly EpClient $client) + { + } + + #[Route('/login', name: 'app_login', methods: ['GET'])] + public function login(): Response + { + if ($this->getUser()) { + return $this->redirectToRoute('app_dashboard'); + } + + return $this->render('security/login.html.twig', [ + 'oauth2_configured' => $this->client->isConfigured(), + ]); + } + + /** + * Starts the authorization code flow: stores the state on the session and sends the browser + * to myep. + */ + #[Route('/auth/init', name: 'app_auth_init', methods: ['GET'])] + public function authInit(Request $request): Response + { + if (!$this->client->isConfigured()) { + $this->addFlash('error', 'Für ephub ist auf myep noch kein OAuth2-Client registriert.'); + + return $this->redirectToRoute('app_login'); + } + + return new RedirectResponse($this->client->authorizationUrl($request)); + } + + /** + * The registered callback URL. Never executed: EpAuthenticator::supports() matches this + * route and the firewall handles the request before it reaches a controller. The route has + * to exist all the same, because the redirect URI is generated from it. + */ + #[Route('/auth/check', name: 'app_auth_check', methods: ['GET'])] + public function authCheck(): never + { + throw new \LogicException('Intercepted by EpAuthenticator on the main firewall.'); + } + /** * Never executed: the firewall's logout listener intercepts this route. */ diff --git a/src/Security/EpAuthenticator.php b/src/Security/EpAuthenticator.php new file mode 100644 index 0000000..e8078d4 --- /dev/null +++ b/src/Security/EpAuthenticator.php @@ -0,0 +1,172 @@ +attributes->get('_route'); + } + + public function authenticate(Request $request): Passport + { + try { + $accessToken = $this->client->fetchAccessToken($request); + } catch (AuthorizationRequestException|IdentityProviderException $e) { + $this->logger->error('Login via myep failed: no access token', ['exception' => $e]); + + throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.'); + } + + try { + $userinfo = $this->client->fetchUserinfo($accessToken); + } catch (UserinfoUnavailableException $e) { + $this->logger->error('Login via myep failed: userinfo unreadable', ['exception' => $e]); + + throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.'); + } + + // The guide is explicit that profile.communication.email comes from the CRM address + // record and is NOT necessarily the login email -- one address can hold several + // contacts. The top-level claim is the identity, and it is what User is keyed on. + $email = $userinfo['email'] ?? null; + if (!\is_string($email) || '' === $email) { + $this->logger->error('Login via myep failed: userinfo carried no email claim'); + + throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.'); + } + + $roles = Role::eligibleOnly($this->claimedRoles($userinfo)); + if ([] === $roles) { + $this->logger->info('Login via myep refused: no eligible role', ['email' => $email]); + + throw new CustomUserMessageAuthenticationException( + 'Für ephub ist eine Mitarbeiter- oder Administrator-Rolle erforderlich.', + ); + } + + $user = $this->createOrUpdate($email, $this->nameFrom($userinfo, $email), $roles); + + // Resolved here rather than by the user provider: the account may have just been + // created in this very request, and handing the provider an email would mean a second + // lookup of a row we already hold. + return new SelfValidatingPassport( + new UserBadge($user->getUserIdentifier(), static fn (): User => $user), + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // Whatever they were trying to reach before the firewall intervened. + $targetPath = $this->getTargetPath($request->getSession(), $firewallName); + + return new RedirectResponse($targetPath ?? $this->urlGenerator->generate('app_dashboard')); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $request->getSession()->getFlashBag()->add('error', $exception->getMessageKey()); + + return new RedirectResponse($this->urlGenerator->generate('app_login')); + } + + /** + * @param array $userinfo + * + * @return list + */ + private function claimedRoles(array $userinfo): array + { + $claimed = $userinfo['roles'] ?? []; + + if (!\is_array($claimed)) { + return []; + } + + return array_values(array_filter($claimed, \is_string(...))); + } + + /** + * @param array $userinfo + */ + private function nameFrom(array $userinfo, string $fallback): string + { + $profile = $userinfo['profile'] ?? null; + + if (\is_array($profile)) { + $name = trim(\sprintf('%s %s', $profile['first_name'] ?? '', $profile['last_name'] ?? '')); + + if ('' !== $name) { + return mb_substr($name, 0, 100); + } + } + + // The profile scope may not have been granted, and a nameless account would render as a + // blank holder on the floor plan -- the email at least identifies somebody. + return mb_substr($fallback, 0, 100); + } + + /** + * @param list $roles + */ + private function createOrUpdate(string $email, string $name, array $roles): User + { + $user = $this->users->findOneBy(['email' => $email]); + + if (null === $user) { + $user = new User($email, $name); + $this->em->persist($user); + } else { + $user->setName($name); + } + + // myep leads: a role it no longer reports is withdrawn here, not kept because it was + // granted once. + $user->setRoles($roles); + $this->em->flush(); + + return $user; + } +} diff --git a/src/Security/DevLoginEntryPoint.php b/src/Security/LoginEntryPoint.php similarity index 59% rename from src/Security/DevLoginEntryPoint.php rename to src/Security/LoginEntryPoint.php index f4d1169..050e9e5 100644 --- a/src/Security/DevLoginEntryPoint.php +++ b/src/Security/LoginEntryPoint.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Security; -use Symfony\Component\DependencyInjection\Attribute\When; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,14 +12,12 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; /** - * Sends an unauthenticated visitor to the dev login picker. + * Sends an unauthenticated visitor to the login page. * - * Dev only -- it exists so UI work is not gated on the OAuth2 client registration on myep. - * When the real authenticator lands (plan step 6) the entry point for every environment becomes - * the OAuth2 init route and this class goes away. + * Deliberately the login *page* rather than straight into /auth/init: the page is where a failed + * login's flash message is shown, and bouncing there instead would swallow it. */ -#[When('dev')] -final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInterface +final readonly class LoginEntryPoint implements AuthenticationEntryPointInterface { public function __construct(private UrlGeneratorInterface $urlGenerator) { @@ -28,6 +25,6 @@ final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInter public function start(Request $request, ?AuthenticationException $authException = null): Response { - return new RedirectResponse($this->urlGenerator->generate('app_dev_login')); + return new RedirectResponse($this->urlGenerator->generate('app_login')); } } diff --git a/src/Security/OAuth2/AuthorizationRequestException.php b/src/Security/OAuth2/AuthorizationRequestException.php new file mode 100644 index 0000000..8cb7cd7 --- /dev/null +++ b/src/Security/OAuth2/AuthorizationRequestException.php @@ -0,0 +1,13 @@ + */ + private array $config; + + /** + * @param array $options + */ + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + array $options, + ) { + $this->config = $this->resolveConfig($options); + } + + /** + * Whether a client has actually been registered on myep yet. Until it has, the login page + * says so instead of bouncing the user to a broken authorize URL. + */ + public function isConfigured(): bool + { + return '' !== $this->config['client_id'] && '' !== $this->config['client_secret']; + } + + /** + * The URL to send the browser to, having stored the state this session expects back. + */ + public function authorizationUrl(Request $request): string + { + $provider = $this->getProvider(); + $url = $provider->getAuthorizationUrl(); + + $request->getSession()->set('oauth2state', $provider->getState()); + + return $url; + } + + /** + * Exchanges the callback's code for an access token. + * + * The state check is the CSRF protection of the authorization code flow: without it an + * attacker can hand a victim a callback URL carrying the attacker's code and have the + * victim's session bound to the attacker's identity. The stored state is consumed either + * way, so replaying the same callback cannot succeed twice. + * + * @throws AuthorizationRequestException + */ + public function fetchAccessToken(Request $request): AccessTokenInterface + { + $session = $request->getSession(); + $expectedState = $session->get('oauth2state'); + $session->remove('oauth2state'); + + $code = $request->query->get('code'); + if (null === $code || '' === $code) { + throw new AuthorizationRequestException('Die Antwort von myep enthielt keinen Code.'); + } + + if (null === $expectedState || $request->query->get('state') !== $expectedState) { + throw new AuthorizationRequestException('Der state-Parameter passt nicht zur Sitzung.'); + } + + return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]); + } + + /** + * Reads /api/userinfo with the freshly obtained token. + * + * Lives here rather than in the authenticator so that the authenticator depends on two plain + * method calls and can be tested without standing up a PSR-18 client -- and so that + * everything that knows about myep's wire format sits in one class. + * + * @return array + * + * @throws UserinfoUnavailableException + */ + public function fetchUserinfo(AccessTokenInterface $accessToken): array + { + $provider = $this->getProvider(); + + try { + $response = $provider->getHttpClient()->sendRequest($provider->getAuthenticatedRequest( + 'GET', + $provider->getResourceOwnerDetailsUrl($accessToken), + $accessToken, + ['headers' => ['Accept' => 'application/json']], + )); + } catch (ClientExceptionInterface $e) { + throw new UserinfoUnavailableException('userinfo konnte nicht abgerufen werden.', previous: $e); + } + + $userinfo = json_decode((string) $response->getBody(), true); + + if (!\is_array($userinfo)) { + throw new UserinfoUnavailableException('userinfo lieferte keine lesbare Antwort.'); + } + + return $userinfo; + } + + public function getProvider(): AbstractProvider + { + return new GenericProvider([ + 'clientId' => $this->config['client_id'], + 'clientSecret' => $this->config['client_secret'], + 'redirectUri' => $this->urlGenerator->generate('app_auth_check', [], UrlGeneratorInterface::ABSOLUTE_URL), + 'urlAuthorize' => $this->config['url_authorize'], + 'urlAccessToken' => $this->config['url_access_token'], + 'urlResourceOwnerDetails' => $this->config['url_userinfo'], + 'scopes' => $this->config['scopes'], + // myep's server expects space-separated scopes, not the library's default comma. + 'scopeSeparator' => ' ', + ]); + } + + /** + * @param array $options + * + * @return array + */ + private function resolveConfig(array $options): array + { + $resolver = new OptionsResolver(); + $resolver->setRequired(['client_id', 'client_secret', 'url_authorize', 'url_access_token', 'url_userinfo', 'scopes']); + $resolver->setAllowedTypes('client_id', 'string'); + $resolver->setAllowedTypes('client_secret', 'string'); + $resolver->setAllowedTypes('url_authorize', 'string'); + $resolver->setAllowedTypes('url_access_token', 'string'); + $resolver->setAllowedTypes('url_userinfo', 'string'); + $resolver->setAllowedTypes('scopes', 'string[]'); + + return $resolver->resolve($options); + } +} diff --git a/src/Security/OAuth2/UserinfoUnavailableException.php b/src/Security/OAuth2/UserinfoUnavailableException.php new file mode 100644 index 0000000..c5f62c7 --- /dev/null +++ b/src/Security/OAuth2/UserinfoUnavailableException.php @@ -0,0 +1,12 @@ + + */ + public const ELIGIBLE = [ + self::EMPLOYEE, + self::ADMIN, + ]; + + /** + * Whatever of a claimed role set this application recognises, in a stable order. + * + * @param list $claimed + * + * @return list + */ + public static function eligibleOnly(array $claimed): array + { + return array_values(array_filter( + self::ELIGIBLE, + static fn (string $role): bool => \in_array($role, $claimed, true), + )); + } +} diff --git a/templates/dev/login.html.twig b/templates/dev/login.html.twig index 39a05d0..1209765 100644 --- a/templates/dev/login.html.twig +++ b/templates/dev/login.html.twig @@ -12,6 +12,10 @@ Die echte Anmeldung läuft über myep (OAuth2) und wartet auf die Client-Registrierung dort. +

+ Zur regulären Anmeldung +

+ {% if users is empty %}

Keine Benutzer vorhanden. bin/console app:seed ausführen. diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 0000000..845b7a3 --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,35 @@ +{% extends 'base.html.twig' %} + +{% block title %}Anmelden · ephub{% endblock %} + +{% block body %} +

+
+ ephub + +

Anmelden

+

Schreibtische und Parkplätze buchen.

+ + {% if oauth2_configured %} + + Mit myep anmelden + + {% else %} +
+ Anmeldung noch nicht eingerichtet. + Für ephub muss auf myep erst ein OAuth2-Client registriert werden. +
+ {% endif %} + + {% if app.environment == 'dev' %} +

+ Entwickler-Login +

+ {% endif %} + +
+ {% include '_partials/_flashes.html.twig' %} +
+
+
+{% endblock %} diff --git a/tests/Security/EpAuthenticatorTest.php b/tests/Security/EpAuthenticatorTest.php new file mode 100644 index 0000000..a9c2211 --- /dev/null +++ b/tests/Security/EpAuthenticatorTest.php @@ -0,0 +1,251 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testAnEmployeeIsLoggedInAndCreatedLocally(): void + { + $user = $this->authenticate([ + 'email' => 'anna@ep-reisen.de', + 'roles' => ['ROLE_EMPLOYEE'], + 'profile' => ['first_name' => 'Anna', 'last_name' => 'Berger'], + ]); + + self::assertSame('anna@ep-reisen.de', $user->getEmail()); + self::assertSame('Anna Berger', $user->getName()); + self::assertContains(Role::EMPLOYEE, $user->getRoles()); + } + + public function testAnAdministratorIsLoggedInToo(): void + { + $user = $this->authenticate(['email' => 'chef@ep-reisen.de', 'roles' => ['ROLE_ADMIN']]); + + self::assertContains(Role::ADMIN, $user->getRoles()); + } + + public function testSomebodyWithNeitherRoleIsRefused(): void + { + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Mitarbeiter- oder Administrator-Rolle'); + + $this->authenticate(['email' => 'kunde@example.org', 'roles' => ['ROLE_CUSTOMER', 'ROLE_TEAMER']]); + } + + public function testARefusedLoginCreatesNoLocalAccount(): void + { + try { + $this->authenticate(['email' => 'kunde@example.org', 'roles' => ['ROLE_CUSTOMER']]); + } catch (CustomUserMessageAuthenticationException) { + } + + self::assertNull($this->em->getRepository(User::class)->findOneBy(['email' => 'kunde@example.org'])); + } + + /** + * The roles claim is the identity provider's word, and myep is not the only thing that could + * ever put a value in it. Only what this application recognises is stored. + */ + public function testOnlyWhitelistedRolesAreStored(): void + { + $user = $this->authenticate([ + 'email' => 'anna@ep-reisen.de', + 'roles' => ['ROLE_EMPLOYEE', 'ROLE_SUPER_ADMIN', 'ROLE_HOUSE_MANAGER'], + ]); + + self::assertSame([Role::EMPLOYEE, 'ROLE_USER'], $user->getRoles()); + } + + public function testALoginWithoutAnEmailClaimIsRefused(): void + { + $this->expectException(CustomUserMessageAuthenticationException::class); + + $this->authenticate(['roles' => ['ROLE_EMPLOYEE']]); + } + + /** + * The consumer guide is explicit: profile.communication.email comes from the CRM address + * record and can belong to a different contact entirely. Keying on it would log somebody in + * as their colleague. + */ + public function testIdentityComesFromTheTopLevelEmailNotTheProfileOne(): void + { + $user = $this->authenticate([ + 'email' => 'anna@ep-reisen.de', + 'roles' => ['ROLE_EMPLOYEE'], + 'profile' => [ + 'first_name' => 'Anna', + 'last_name' => 'Berger', + 'communication' => ['email' => 'zentrale@ep-reisen.de'], + ], + ]); + + self::assertSame('anna@ep-reisen.de', $user->getEmail()); + } + + public function testAnExistingAccountIsUpdatedRatherThanDuplicated(): void + { + $existing = new User('anna@ep-reisen.de', 'Anna Alt'); + $existing->setRoles([Role::EMPLOYEE]); + $this->em->persist($existing); + $this->em->flush(); + + $user = $this->authenticate([ + 'email' => 'anna@ep-reisen.de', + 'roles' => ['ROLE_EMPLOYEE'], + 'profile' => ['first_name' => 'Anna', 'last_name' => 'Neu'], + ]); + + self::assertSame($existing->getId(), $user->getId()); + self::assertSame('Anna Neu', $user->getName()); + self::assertCount(1, $this->em->getRepository(User::class)->findBy(['email' => 'anna@ep-reisen.de'])); + } + + /** + * myep leads: a role it stops reporting is withdrawn here, not kept because it was granted + * once. + */ + public function testARoleMyepNoLongerReportsIsWithdrawn(): void + { + $existing = new User('chef@ep-reisen.de', 'Chef'); + $existing->setRoles([Role::EMPLOYEE, Role::ADMIN]); + $this->em->persist($existing); + $this->em->flush(); + + $user = $this->authenticate(['email' => 'chef@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); + + self::assertNotContains(Role::ADMIN, $user->getRoles()); + } + + public function testTheEmailStandsInForAMissingProfileName(): void + { + // The profile scope may simply not have been granted. + $user = $this->authenticate(['email' => 'anna@ep-reisen.de', 'roles' => ['ROLE_EMPLOYEE']]); + + self::assertSame('anna@ep-reisen.de', $user->getName()); + } + + public function testAMalformedRolesClaimIsTreatedAsNoRoles(): void + { + $this->expectException(CustomUserMessageAuthenticationException::class); + + $this->authenticate(['email' => 'anna@ep-reisen.de', 'roles' => 'ROLE_EMPLOYEE']); + } + + public function testAFailedTokenExchangeBecomesAFriendlyFailure(): void + { + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.'); + + $this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch')) + ->authenticate(new Request()); + } + + public function testAnUnreachableUserinfoBecomesAFriendlyFailure(): void + { + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.'); + + $this->authenticator(throwOnUserinfo: new UserinfoUnavailableException('boom')) + ->authenticate(new Request()); + } + + public function testItOnlyFiresOnTheCallbackRoute(): void + { + $authenticator = $this->authenticator(); + + $callback = new Request(); + $callback->attributes->set('_route', 'app_auth_check'); + self::assertTrue($authenticator->supports($callback)); + + $dashboard = new Request(); + $dashboard->attributes->set('_route', 'app_dashboard'); + self::assertFalse($authenticator->supports($dashboard)); + } + + /** + * @param array $userinfo + */ + private function authenticate(array $userinfo): User + { + $passport = $this->authenticator($userinfo)->authenticate(new Request()); + + $badge = $passport->getBadge(UserBadge::class); + self::assertInstanceOf(UserBadge::class, $badge); + + $user = $badge->getUser(); + self::assertInstanceOf(User::class, $user); + + return $user; + } + + /** + * @param array $userinfo + */ + private function authenticator( + array $userinfo = [], + ?\Throwable $throwOnToken = null, + ?\Throwable $throwOnUserinfo = null, + ): EpAuthenticator { + $client = $this->createStub(EpClient::class); + + if (null !== $throwOnToken) { + $client->method('fetchAccessToken')->willThrowException($throwOnToken); + } else { + $client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token'])); + } + + if (null !== $throwOnUserinfo) { + $client->method('fetchUserinfo')->willThrowException($throwOnUserinfo); + } else { + $client->method('fetchUserinfo')->willReturn($userinfo); + } + + return new EpAuthenticator( + $client, + self::getContainer()->get(UserRepository::class), + $this->em, + self::getContainer()->get(UrlGeneratorInterface::class), + new NullLogger(), + ); + } +} diff --git a/tests/Security/EpClientTest.php b/tests/Security/EpClientTest.php new file mode 100644 index 0000000..318b2be --- /dev/null +++ b/tests/Security/EpClientTest.php @@ -0,0 +1,130 @@ +client(clientId: '', clientSecret: '')->isConfigured()); + self::assertFalse($this->client(clientId: 'ephub', clientSecret: '')->isConfigured()); + self::assertTrue($this->client()->isConfigured()); + } + + public function testTheAuthorizationUrlCarriesTheClientAndScopes(): void + { + $request = $this->request(); + + $url = $this->client()->authorizationUrl($request); + + self::assertStringStartsWith('https://myep.test/authorize?', $url); + parse_str((string) parse_url($url, \PHP_URL_QUERY), $query); + + self::assertSame('ephub', $query['client_id']); + self::assertSame('code', $query['response_type']); + // myep's server wants space-separated scopes; the library's default is a comma. + self::assertSame('email profile roles', $query['scope']); + self::assertStringEndsWith('/auth/check', $query['redirect_uri']); + } + + public function testTheStateIsRememberedOnTheSession(): void + { + $request = $this->request(); + + $url = $this->client()->authorizationUrl($request); + parse_str((string) parse_url($url, \PHP_URL_QUERY), $query); + + self::assertNotEmpty($query['state']); + self::assertSame($query['state'], $request->getSession()->get('oauth2state')); + } + + public function testACallbackWithoutACodeIsRefused(): void + { + $request = $this->request(['state' => 'abc']); + $request->getSession()->set('oauth2state', 'abc'); + + $this->expectException(AuthorizationRequestException::class); + $this->expectExceptionMessage('keinen Code'); + + $this->client()->fetchAccessToken($request); + } + + /** + * Without this check an attacker can hand a victim a callback URL carrying the attacker's + * code and bind the victim's session to the attacker's identity. + */ + public function testACallbackWhoseStateDoesNotMatchIsRefused(): void + { + $request = $this->request(['code' => 'xyz', 'state' => 'forged']); + $request->getSession()->set('oauth2state', 'expected'); + + $this->expectException(AuthorizationRequestException::class); + $this->expectExceptionMessage('state-Parameter'); + + $this->client()->fetchAccessToken($request); + } + + public function testACallbackWithNoStateOnTheSessionIsRefused(): void + { + $request = $this->request(['code' => 'xyz', 'state' => 'anything']); + + $this->expectException(AuthorizationRequestException::class); + + $this->client()->fetchAccessToken($request); + } + + /** + * The stored state is consumed whether or not the check passed, so the same callback URL + * cannot be replayed. + */ + public function testTheStoredStateIsConsumedEvenOnFailure(): void + { + $request = $this->request(['code' => 'xyz', 'state' => 'forged']); + $session = $request->getSession(); + $session->set('oauth2state', 'expected'); + + try { + $this->client()->fetchAccessToken($request); + } catch (AuthorizationRequestException) { + } + + self::assertNull($session->get('oauth2state')); + } + + /** + * @param array $query + */ + private function request(array $query = []): Request + { + $request = new Request($query); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + private function client(string $clientId = 'ephub', string $clientSecret = 'secret'): EpClient + { + self::bootKernel(); + + return new EpClient(self::getContainer()->get(UrlGeneratorInterface::class), [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'url_authorize' => 'https://myep.test/authorize', + 'url_access_token' => 'https://myep.test/token', + 'url_userinfo' => 'https://myep.test/api/userinfo', + 'scopes' => ['email', 'profile', 'roles'], + ]); + } +} diff --git a/tests/Security/RoleTest.php b/tests/Security/RoleTest.php new file mode 100644 index 0000000..b8dff02 --- /dev/null +++ b/tests/Security/RoleTest.php @@ -0,0 +1,53 @@ +