├── .php-version ├── translations └── .gitignore ├── src ├── Controller │ ├── .gitignore │ └── ProfileController.php ├── Security │ ├── Jwt │ │ ├── IdTokenException.php │ │ ├── IdTokenData.php │ │ └── IdTokenDataExtractor.php │ ├── Exception │ │ ├── InvalidStateException.php │ │ └── OpenIdServerException.php │ ├── Dto │ │ └── TokensBag.php │ ├── User.php │ ├── Listener │ │ ├── LogoutListener.php │ │ └── JwtRefreshListener.php │ ├── OpenIdUserProvider.php │ ├── Client │ │ └── OpenIdClient.php │ └── OpenIdAuthenticator.php └── Kernel.php ├── config ├── packages │ ├── mailer.yaml │ ├── sensio_framework_extra.yaml │ ├── test │ │ ├── validator.yaml │ │ ├── web_profiler.yaml │ │ └── monolog.yaml │ ├── twig.yaml │ ├── dev │ │ ├── web_profiler.yaml │ │ ├── debug.yaml │ │ └── monolog.yaml │ ├── validator.yaml │ ├── prod │ │ ├── deprecations.yaml │ │ └── monolog.yaml │ ├── routing.yaml │ ├── translation.yaml │ ├── security.yaml │ ├── notifier.yaml │ ├── cache.yaml │ └── framework.yaml ├── routes │ ├── framework.yaml │ ├── annotations.yaml │ └── dev │ │ └── web_profiler.yaml ├── preload.php ├── routes.yaml ├── bundles.php └── services.yaml ├── .env.test ├── public └── index.php ├── tests └── bootstrap.php ├── templates ├── home │ └── index.html.twig ├── profile │ └── index.html.twig └── base.html.twig ├── .gitignore ├── bin ├── console └── phpunit ├── docker-compose.yml ├── LICENSE ├── .env ├── phpunit.xml.dist ├── composer.json ├── README.md └── symfony.lock /.php-version: -------------------------------------------------------------------------------- 1 | 8.0 2 | -------------------------------------------------------------------------------- /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Controller/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /config/packages/sensio_framework_extra.yaml: -------------------------------------------------------------------------------- 1 | sensio_framework_extra: 2 | router: 3 | annotations: false 4 | -------------------------------------------------------------------------------- /config/packages/test/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | not_compromised_password: false 4 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /config/routes/framework.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | _errors: 3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 4 | prefix: /_error 5 | -------------------------------------------------------------------------------- /config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { only_exceptions: false } 7 | -------------------------------------------------------------------------------- /config/routes/annotations.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: ../../src/Controller/ 3 | type: annotation 4 | 5 | kernel: 6 | resource: ../../src/Kernel.php 7 | type: annotation 8 | -------------------------------------------------------------------------------- /src/Security/Jwt/IdTokenException.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /config/packages/test/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | channels: ["!event"] 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: en 3 | translator: 4 | default_path: '%kernel.project_dir%/translations' 5 | fallbacks: 6 | - en 7 | # providers: 8 | # crowdin: 9 | # dsn: '%env(CROWDIN_DSN)%' 10 | # loco: 11 | # dsn: '%env(LOCO_DSN)%' 12 | # lokalise: 13 | # dsn: '%env(LOKALISE_DSN)%' 14 | -------------------------------------------------------------------------------- /templates/home/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Hello HomeController!{% endblock %} 4 | 5 | {% block body %} 6 |

Keycloak admin: {{ keycloakHome }} (admin / admin)

7 | {% if app.user is null %} 8 |

9 | Connect with Openid 10 |

11 | {% endif %} 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/profile/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Profile{% endblock %} 4 | 5 | {% block body %} 6 |
7 |

Profile

8 | 9 | 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ###> symfony/framework-bundle ### 3 | /.env.local 4 | /.env.local.php 5 | /.env.*.local 6 | /config/secrets/prod/prod.decrypt.private.php 7 | /public/bundles/ 8 | /var/ 9 | /vendor/ 10 | ###< symfony/framework-bundle ### 11 | 12 | ###> symfony/phpunit-bridge ### 13 | .phpunit.result.cache 14 | /phpunit.xml 15 | ###< symfony/phpunit-bridge ### 16 | 17 | ###> phpunit/phpunit ### 18 | /phpunit.xml 19 | .phpunit.result.cache 20 | ###< phpunit/phpunit ### 21 | 22 | ###> docker ### 23 | /docker-compose.override.yml 24 | ###< docker ### 25 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | render('profile/index.html.twig', [ 15 | 'controller_name' => 'ProfileController', 16 | ]); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /config/packages/notifier.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | notifier: 3 | #chatter_transports: 4 | # slack: '%env(SLACK_DSN)%' 5 | # telegram: '%env(TELEGRAM_DSN)%' 6 | #texter_transports: 7 | # twilio: '%env(TWILIO_DSN)%' 8 | # nexmo: '%env(NEXMO_DSN)%' 9 | channel_policy: 10 | # use chat/slack, chat/telegram, sms/twilio or sms/nexmo 11 | urgent: ['email'] 12 | high: ['email'] 13 | medium: ['email'] 14 | low: ['email'] 15 | admin_recipients: 16 | - { email: admin@example.com } 17 | -------------------------------------------------------------------------------- /config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 9 | nested: 10 | type: stream 11 | path: php://stderr 12 | level: debug 13 | formatter: monolog.formatter.json 14 | console: 15 | type: console 16 | process_psr_3_messages: false 17 | channels: ["!event", "!doctrine"] 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | keycloak: 5 | image: keycloak/keycloak:25.0 6 | ports: ['52957:8080'] 7 | environment: 8 | KEYCLOAK_ADMIN: admin 9 | KEYCLOAK_ADMIN_PASSWORD: admin 10 | DB_VENDOR: postgres 11 | DB_ADDR: postgres 12 | DB_USER: keycloak 13 | DB_PASSWORD: keycloak 14 | command: ['start-dev'] 15 | 16 | postgres: 17 | image: postgres:14.2 18 | environment: 19 | POSTGRES_DB: keycloak 20 | POSTGRES_USER: keycloak 21 | POSTGRES_PASSWORD: keycloak 22 | volumes: 23 | - keycloak_data:/var/lib/postgresql/data 24 | 25 | volumes: 26 | keycloak_data: ~ -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: stream 5 | path: "%kernel.logs_dir%/%kernel.environment%.log" 6 | level: debug 7 | channels: ["!event"] 8 | # uncomment to get logging in your browser 9 | # you may have to allow bigger header sizes in your Web server configuration 10 | #firephp: 11 | # type: firephp 12 | # level: info 13 | #chromephp: 14 | # type: chromephp 15 | # level: info 16 | console: 17 | type: console 18 | process_psr_3_messages: false 19 | channels: ["!event", "!doctrine", "!console"] 20 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | ['all' => true], 5 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], 6 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 7 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 8 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 9 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 10 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 11 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 12 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 13 | ]; 14 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | http_method_override: false 6 | 7 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 8 | # Remove or comment this section to explicitly disable session support. 9 | session: 10 | handler_id: null 11 | cookie_secure: auto 12 | cookie_samesite: lax 13 | storage_factory_id: session.storage.factory.native 14 | 15 | #esi: true 16 | #fragments: true 17 | php_errors: 18 | log: true 19 | 20 | when@test: 21 | framework: 22 | test: true 23 | session: 24 | storage_factory_id: session.storage.factory.mock_file 25 | -------------------------------------------------------------------------------- /src/Security/Dto/TokensBag.php: -------------------------------------------------------------------------------- 1 | accessToken; 16 | } 17 | 18 | public function getJwtExpires(): int 19 | { 20 | if (null === $this->jwtExpires) { 21 | throw new \LogicException('JWT expiration time is not set'); 22 | } 23 | 24 | return $this->jwtExpires; 25 | } 26 | 27 | public function getRefreshToken(): string 28 | { 29 | return $this->refreshToken; 30 | } 31 | 32 | public function withExpiration(int $jwtExpires): static 33 | { 34 | $static = new static($this->accessToken, $this->refreshToken); 35 | $static->jwtExpires = $jwtExpires; 36 | 37 | return $static; 38 | } 39 | } -------------------------------------------------------------------------------- /src/Security/Jwt/IdTokenData.php: -------------------------------------------------------------------------------- 1 | exp; 21 | } 22 | 23 | public function getSubject(): string 24 | { 25 | return $this->subject; 26 | } 27 | 28 | public function getEmail(): string 29 | { 30 | return $this->email; 31 | } 32 | 33 | public function getUsername(): string 34 | { 35 | return $this->username; 36 | } 37 | 38 | public function getName(): string 39 | { 40 | return $this->name; 41 | } 42 | 43 | public function getRoles(): array 44 | { 45 | return $this->roles; 46 | } 47 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Laurent VOULLEMIER 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # 13 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 14 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 15 | 16 | ###> symfony/framework-bundle ### 17 | APP_ENV=dev 18 | APP_SECRET=6a7f172a8976663105d1801f2438235b 19 | ###< symfony/framework-bundle ### 20 | 21 | KEYCLOAK_HOME=http://localhost:52957 22 | KEYCLOAK_BASE=http://localhost:52957/realms/master 23 | KEYCLOAK_ALGO=RS256 24 | KEYCLOAK_CLIENDID=symfony-app 25 | KEYCLOAK_CLIENTSECRET= 26 | KEYCLOAK_PK= 27 | KEYCLOAK_VERIFY_PEER=false 28 | KEYCLOAK_VERIFY_HOST=false -------------------------------------------------------------------------------- /src/Security/User.php: -------------------------------------------------------------------------------- 1 | roles; 20 | } 21 | 22 | public function getPassword(): ?string 23 | { 24 | return null; 25 | } 26 | 27 | public function getSalt(): ?string 28 | { 29 | return null; 30 | } 31 | 32 | public function eraseCredentials(): void 33 | { 34 | } 35 | 36 | public function getUserIdentifier(): string 37 | { 38 | return $this->userIdentifier; 39 | } 40 | 41 | public function getUsername(): string 42 | { 43 | throw new \BadMethodCallException('Deprecated, should not be called'); 44 | } 45 | 46 | public function getUuid(): string 47 | { 48 | return $this->uuid; 49 | } 50 | 51 | public function getEmail(): string 52 | { 53 | return $this->email; 54 | } 55 | 56 | public function getFullname(): string 57 | { 58 | return $this->fullname; 59 | } 60 | } -------------------------------------------------------------------------------- /src/Security/Listener/LogoutListener.php: -------------------------------------------------------------------------------- 1 | tokenStorage->getToken(); 22 | 23 | $user = $token->getUser(); 24 | if (!$user instanceof User) { 25 | return; 26 | } 27 | 28 | $tokens = $token->getAttribute(TokensBag::class); 29 | if (null === $tokens) { 30 | throw new \LogicException(sprintf('%s token attribute is empty', TokensBag::class)); 31 | } 32 | 33 | $this->openIdClient->logout($tokens->getAccessToken(), $tokens->getRefreshToken()); 34 | } 35 | 36 | public static function getSubscribedEvents(): array 37 | { 38 | return [LogoutEvent::class => 'logoutFromOpenidProvider']; 39 | } 40 | } -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | import('../config/{packages}/*.yaml'); 17 | $container->import('../config/{packages}/'.$this->environment.'/*.yaml'); 18 | 19 | if (is_file(\dirname(__DIR__).'/config/services.yaml')) { 20 | $container->import('../config/services.yaml'); 21 | $container->import('../config/{services}_'.$this->environment.'.yaml'); 22 | } else { 23 | $container->import('../config/{services}.php'); 24 | } 25 | } 26 | 27 | protected function configureRoutes(RoutingConfigurator $routes): void 28 | { 29 | $routes->import('../config/{routes}/'.$this->environment.'/*.yaml'); 30 | $routes->import('../config/{routes}/*.yaml'); 31 | 32 | if (is_file(\dirname(__DIR__).'/config/routes.yaml')) { 33 | $routes->import('../config/routes.yaml'); 34 | } else { 35 | $routes->import('../config/{routes}.php'); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests 23 | 24 | 25 | 26 | 27 | 28 | src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 42 | 43 | -------------------------------------------------------------------------------- /src/Security/OpenIdUserProvider.php: -------------------------------------------------------------------------------- 1 | idTokenDataExtractor->extract($idToken); 36 | 37 | $currentRequest = $this->requestStack->getCurrentRequest(); 38 | if (null === $currentRequest) { 39 | throw new LogicException(sprintf('%s can only be used in an http context', __CLASS__)); 40 | } 41 | $currentRequest->attributes->set('_app_jwt_expires', $idTokenData->getExpires()); 42 | 43 | // Extra user information from local database can also be added here 44 | return new User( 45 | $idTokenData->getSubject(), 46 | $idTokenData->getUsername(), 47 | $idTokenData->getEmail(), 48 | $idTokenData->getEmail(), 49 | $idTokenData->getRoles(), 50 | ); 51 | } 52 | } -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Let's play with Symfony authenticators: Openid connect with Keycloak 6 | 7 | 8 | 9 | 27 | {% for type, messages in app.flashes %} 28 | {% if type is same as 'error' %} 29 | {% for message in messages %} 30 | 31 | {% endfor %} 32 | {% endif %} 33 | {% endfor %} 34 |
35 | {% block body %}{% endblock %} 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration 6 | parameters: 7 | app.keycloak_home: '%env(KEYCLOAK_HOME)%' 8 | app.keycloak_base: '%env(KEYCLOAK_BASE)%' 9 | app.keycloak_authorization: '%app.keycloak_base%/protocol/openid-connect/auth' 10 | app.keycloak_token_endpoint: '%app.keycloak_base%/protocol/openid-connect/token' 11 | app.keycloak_logout_endpoint: '%app.keycloak_base%/protocol/openid-connect/logout' 12 | app.keycloak_clientid: '%env(KEYCLOAK_CLIENDID)%' 13 | app.keycloak_clientsecret: '%env(KEYCLOAK_CLIENTSECRET)%' 14 | app.keycloak_verify_peer: '%env(bool:KEYCLOAK_VERIFY_PEER)%' 15 | app.keycloak_verify_host: '%env(bool:KEYCLOAK_VERIFY_HOST)%' 16 | 17 | services: 18 | # default configuration for services in *this* file 19 | _defaults: 20 | autowire: true # Automatically injects dependencies in your services. 21 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 22 | 23 | # makes classes in src/ available to be used as services 24 | # this creates a service per class whose id is the fully-qualified class name 25 | App\: 26 | resource: '../src/' 27 | exclude: 28 | - '../src/DependencyInjection/' 29 | - '../src/Entity/' 30 | - '../src/Kernel.php' 31 | - '../src/Tests/' 32 | 33 | App\Security\OpenIdAuthenticator: 34 | $authorizationEndpoint: '%app.keycloak_authorization%' 35 | $clientId: '%app.keycloak_clientid%' 36 | 37 | App\Security\Client\OpenIdClient: 38 | $clientId: '%app.keycloak_clientid%' 39 | $clientSecret: '%app.keycloak_clientsecret%' 40 | $tokenEndpoint: '%app.keycloak_token_endpoint%' 41 | $logoutEndpoint: '%app.keycloak_logout_endpoint%' 42 | $verifyPeer: '%app.keycloak_verify_peer%' 43 | $verifyHost: '%app.keycloak_verify_host%' 44 | 45 | App\Security\Jwt\IdTokenDataExtractor: 46 | $keycloakBase: '%app.keycloak_base%' 47 | $keycloakClientId: '%app.keycloak_clientid%' 48 | $algo: '%env(KEYCLOAK_ALGO)%' 49 | $publicKey: '%env(KEYCLOAK_PK)%' -------------------------------------------------------------------------------- /src/Security/Jwt/IdTokenDataExtractor.php: -------------------------------------------------------------------------------- 1 | publicKey, $this->algo)); 20 | 21 | $iat = $decoded->iat ?? PHP_INT_MAX; 22 | if (time() > $iat) { 23 | throw new IdTokenException(sprintf('IdToken iat (%d) must be greater than current time (%d)', $iat, time())); 24 | } 25 | 26 | $exp = $decoded->exp ?? 0; 27 | if ($exp < time()) { 28 | throw new IdTokenException(sprintf('IdToken exp (%d) must be lower than current time (%d)', $exp, time())); 29 | } 30 | 31 | $sub = $decoded->sub ?? ''; 32 | if (!$sub) { 33 | throw new IdTokenException(sprintf('IdToken sub (%s) must not be empty', $sub)); 34 | } 35 | 36 | $iss = $decoded->iss ?? null; 37 | if ($this->keycloakBase !== $iss) { 38 | throw new IdTokenException(sprintf('IdToken iss (%s) must be the same as %s', $iss, $this->keycloakBase)); 39 | } 40 | 41 | $aud = $decoded->aud ?? null; 42 | if ($this->keycloakClientId !== $aud) { 43 | throw new IdTokenException(sprintf('IdToken aud (%s) must be the same as %s', $aud, $this->keycloakClientId)); 44 | } 45 | 46 | $azp = $decoded->azp ?? null; 47 | if ($this->keycloakClientId !== $azp) { 48 | throw new IdTokenException(sprintf('IdToken azp (%s) must be the same as %s', $azp, $this->keycloakClientId)); 49 | } 50 | 51 | if (!isset($decoded->email, $decoded->preferred_username, $decoded->name, $decoded->realm_access->roles)) { 52 | throw new IdTokenException(sprintf('email, username, name, or roles is missing; content: %s', json_encode($decoded))); 53 | } 54 | 55 | return new IdTokenData( 56 | $exp, 57 | $sub, 58 | $decoded->email, 59 | $decoded->preferred_username, 60 | $decoded->name, 61 | $decoded->realm_access->roles, 62 | ); 63 | } 64 | } -------------------------------------------------------------------------------- /src/Security/Client/OpenIdClient.php: -------------------------------------------------------------------------------- 1 | callTokenEntryPoint([ 26 | 'client_id' => $this->clientId, 27 | 'client_secret' => $this->clientSecret, 28 | 'grant_type' => 'authorization_code', 29 | // Force http since working on localhost 30 | 'redirect_uri' => 'http:' . $this->urlGenerator->generate('openid_redirecturi', [], UrlGeneratorInterface::NETWORK_PATH), 31 | 'code' => $authorizationCode, 32 | ]); 33 | } 34 | 35 | public function getTokenFromRefreshToken(string $refreshToken): string 36 | { 37 | return $this->callTokenEntryPoint([ 38 | 'client_id' => $this->clientId, 39 | 'client_secret' => $this->clientSecret, 40 | 'grant_type' => 'refresh_token', 41 | 'refresh_token' => $refreshToken, 42 | ]); 43 | } 44 | 45 | private function callTokenEntryPoint(array $body): string 46 | { 47 | $response = $this->httpClient->request('POST', $this->tokenEndpoint, [ 48 | 'body' => $body, 49 | 'verify_peer' => $this->verifyPeer, 50 | 'verify_host' => $this->verifyHost 51 | ]); 52 | 53 | return $response->getContent(); 54 | } 55 | 56 | public function logout(string $jwtToken, string $refreshToken): void 57 | { 58 | $this->httpClient->request('POST', $this->logoutEndpoint, [ 59 | 'headers' => ['Authorization' => sprintf('Bearer %s', $jwtToken)], 60 | 'body' => [ 61 | 'client_id' => $this->clientId, 62 | 'client_secret' => $this->clientSecret, 63 | 'refresh_token' => $refreshToken, 64 | ], 65 | 'verify_peer' => $this->verifyPeer, 66 | 'verify_host' => $this->verifyHost 67 | ]); 68 | } 69 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "minimum-stability": "stable", 5 | "prefer-stable": true, 6 | "require": { 7 | "php": "^8.0", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "composer/package-versions-deprecated": "1.11.99.4", 11 | "doctrine/annotations": "^1.0", 12 | "firebase/php-jwt": "^5.5", 13 | "phpdocumentor/reflection-docblock": "^5.3", 14 | "sensio/framework-extra-bundle": "^6.1", 15 | "symfony/asset": "5.4.*", 16 | "symfony/console": "5.4.*", 17 | "symfony/dotenv": "5.4.*", 18 | "symfony/expression-language": "5.4.*", 19 | "symfony/flex": "^1.3.1", 20 | "symfony/form": "5.4.*", 21 | "symfony/framework-bundle": "5.4.*", 22 | "symfony/http-client": "5.4.*", 23 | "symfony/intl": "5.4.*", 24 | "symfony/mailer": "5.4.*", 25 | "symfony/mime": "5.4.*", 26 | "symfony/monolog-bundle": "^3.1", 27 | "symfony/notifier": "5.4.*", 28 | "symfony/process": "5.4.*", 29 | "symfony/property-access": "5.4.*", 30 | "symfony/property-info": "5.4.*", 31 | "symfony/proxy-manager-bridge": "5.4.*", 32 | "symfony/runtime": "5.4.*", 33 | "symfony/security-bundle": "5.4.*", 34 | "symfony/serializer": "5.4.*", 35 | "symfony/string": "5.4.*", 36 | "symfony/translation": "5.4.*", 37 | "symfony/twig-bundle": "5.4.*", 38 | "symfony/uid": "5.4.*", 39 | "symfony/validator": "5.4.*", 40 | "symfony/web-link": "5.4.*", 41 | "symfony/yaml": "5.4.*", 42 | "twig/extra-bundle": "^2.12|^3.0", 43 | "twig/twig": "^2.12|^3.0" 44 | }, 45 | "require-dev": { 46 | "phpunit/phpunit": "^9.5", 47 | "symfony/browser-kit": "5.4.*", 48 | "symfony/css-selector": "5.4.*", 49 | "symfony/debug-bundle": "5.4.*", 50 | "symfony/maker-bundle": "^1.0", 51 | "symfony/phpunit-bridge": "^5.4", 52 | "symfony/stopwatch": "5.4.*", 53 | "symfony/web-profiler-bundle": "5.4.*" 54 | }, 55 | "config": { 56 | "optimize-autoloader": true, 57 | "preferred-install": { 58 | "*": "dist" 59 | }, 60 | "sort-packages": true, 61 | "allow-plugins": { 62 | "symfony/flex": true, 63 | "symfony/runtime": true 64 | } 65 | }, 66 | "autoload": { 67 | "psr-4": { 68 | "App\\": "src/" 69 | } 70 | }, 71 | "autoload-dev": { 72 | "psr-4": { 73 | "App\\Tests\\": "tests/" 74 | } 75 | }, 76 | "replace": { 77 | "symfony/polyfill-ctype": "*", 78 | "symfony/polyfill-iconv": "*", 79 | "symfony/polyfill-php72": "*" 80 | }, 81 | "scripts": { 82 | "auto-scripts": { 83 | "cache:clear": "symfony-cmd", 84 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 85 | }, 86 | "post-install-cmd": [ 87 | "@auto-scripts" 88 | ], 89 | "post-update-cmd": [ 90 | "@auto-scripts" 91 | ] 92 | }, 93 | "conflict": { 94 | "symfony/symfony": "*" 95 | }, 96 | "extra": { 97 | "symfony": { 98 | "allow-contrib": false, 99 | "require": "5.4.*" 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Security/Listener/JwtRefreshListener.php: -------------------------------------------------------------------------------- 1 | tokenStorage->getToken(); 28 | if (null === $token) { 29 | return; 30 | } 31 | 32 | $tokens = $token->getAttribute(TokensBag::class); 33 | if (null === $tokens) { 34 | throw new \LogicException(sprintf('%s token attribute is empty', TokensBag::class)); 35 | } 36 | 37 | if (time() < $tokens->getJwtExpires()) { 38 | return; 39 | } 40 | 41 | $refreshToken = $tokens->getRefreshToken(); 42 | 43 | try { 44 | $response = $this->openIdClient->getTokenFromRefreshToken($refreshToken); 45 | } catch (HttpExceptionInterface $e) { 46 | $response = $e->getResponse(); 47 | if (400 === $response->getStatusCode() && 'invalid_grant' === ($response->toArray(false)['error'] ?? null)) { 48 | // Logout when SSO session idle is reached 49 | $this->tokenStorage->setToken(null); 50 | $event->setResponse(new RedirectResponse($this->urlGenerator->generate('home'))); 51 | 52 | return; 53 | } 54 | 55 | throw new RuntimeException( 56 | sprintf('Bad status code returned by openID server (%s)', $e->getResponse()->getStatusCode()), 57 | previous: $e, 58 | ); 59 | } 60 | 61 | $responseData = json_decode($response, true); 62 | if (false === $responseData) { 63 | throw new RuntimeException(sprintf('Can\'t parse json in response: %s', $response->getContent())); 64 | } 65 | 66 | $jwtToken = $responseData['id_token'] ?? null; 67 | if (null === $jwtToken) { 68 | throw new RuntimeException(sprintf('No access token found in response %s', $response->getContent())); 69 | } 70 | 71 | $refreshToken = $responseData['refresh_token'] ?? null; 72 | if (null === $refreshToken) { 73 | throw new RuntimeException(sprintf('No refresh token found in response %s', $response->getContent())); 74 | } 75 | 76 | $user = $this->userProvider->loadUserByIdentifier($jwtToken); 77 | 78 | $request = $event->getRequest(); 79 | $jwtExpires = $request->attributes->get('_app_jwt_expires'); 80 | if (null === $jwtExpires) { 81 | throw new \LogicException('Missing _app_jwt_expires in the session'); 82 | } 83 | $request->attributes->remove('_app_jwt_expires'); 84 | 85 | $token->setAttribute(TokensBag::class, new TokensBag( 86 | $responseData['access_token'] ?? null, 87 | $refreshToken, 88 | $jwtExpires, 89 | )); 90 | 91 | $token->setUser($user); 92 | } 93 | 94 | public static function getSubscribedEvents(): array 95 | { 96 | return [RequestEvent::class => 'onKernelRequest']; 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example of Symfony authentication with Keycloak server as SSO 2 | 3 | ## Start Keycloak server 4 | 5 | The application is intended to be used with a Keycloak server in a Docker container. To start it: 6 | 7 | ```bash 8 | $ docker compose up -d 9 | ``` 10 | Keycloak now runs on the arbitrary chosen port `52957`. In your browser, go to `http://localhost:52957/` and follow *Administration Console* link. The credentials are **admin**/**admin**. 11 | 12 | ## Keycloak configuration 13 | 14 | ### Client configuration 15 | 16 | First, let's create a new OpenId client. Go to the *Clients* link in the menu and use the *Create client* button. Use `symfony-app` as *Client ID* and keep `OpenID Connect` as *Client type*. Then, click on the *Next* button. 17 | 18 | On the *Capability config* screen, switch on the *Client authentication* toggle. Let the other settings unchanged and click on the *Next* button. On the *Login settings screen*, type `http://localhost:8000/redirect-uri` in *Valid Redirect URIs*. You can now save the configuration. 19 | 20 | You should now see all the `symfony-app` client settings. Go to the *Credentials* tab and copy the *Client Secret* field content somewhere. You are going to need it for the Symfony application configuration. 21 | 22 | ### Add Symfony specific roles 23 | We are going to add a specific role for the application. Go to *Realm roles* on the left menu, click on the *Create role* button, type `ROLE_USER` as *Role name* (case matters) and save your modification. 24 | 25 | ### User creation 26 | Let's create an user for logging into our Symfony application. Go to the *Users* link from the left menu and click on the *Add user* button. Fill the *username*, *email*, *first name* and *last name* fields. Then create the user. 27 | 28 | Some extra configuration options are now available. Go to the *Credentials* tab, click on the *set password* button. On the displayed modal, choose a password, confirm it and disable the *Temporary* feature. Then save your modifications (a confirmation modal should appear, you can confirm your modifications clicking on *Save password*). 29 | 30 | You also need to add the `ROLE_USER` previously created to your user to be allowed to access to the profile page in the Symfony application. Go to *Role Mapping*, click on *Assign role*. In the top left select box, choose *Filter by realm roles*, tick `ROLE_USER` and click on the *Assign* button. 31 | 32 | ### Add roles to ID token 33 | By default, role are not present in the ID token. To be allowed to get roles from the ID token, go to *Client Scopes* (left menu) and click on the *roles* scope. Then chose the *Mappers* tab, edit the *realm roles* line and set the *Add to ID token* toggle to `ON`. Save your modification. For a sake of transparency, in the settings tab, switch on the *Include in token Scope* toggle and save the modification. Otherwise roles won't be displayed in the scope list of keycloak responses. 34 | 35 | ### Public key 36 | Keycloak configuration is done. But you need the public key to check JWT signature. Go to the left menu entry *Realm Settings* and chose the *Keys* tab. Click on the *Public key* button of the `RS256` algorithm for a signing (`SIG`) usage. Copy the displayed value somewhere. 37 | 38 | ### Disconnect from admin account 39 | 40 | Don't forget to logout, you can't use admin to login in the Symfony application since admin has no email (having an email is only a requirement for our implementation, not a general rule). 41 | 42 | ## Symfony application configuration 43 | 44 | ## Environment variables 45 | 46 | You need to create a `.env.local` file at the root of the project. Add the following content: 47 | ```env 48 | KEYCLOAK_CLIENTSECRET=624e2565-a612-4255-9522-35d27636e8c7 49 | KEYCLOAK_PK="-----BEGIN PUBLIC KEY----- 50 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhHUOz9Fwkx9TFR07flcEmn2aVCxKM9dLhTBvHwOYLzCSETWk3/lf/xwg/f2sicrsY2W/EZLrpDyKZSCuSzwbPp7DLSN9Ww8DnLJNLxFWL+LXgSY+IqoUZSKq/lPS/2N4bW61kz7clVgOMI1iWt2I+FAs6oRLfDRbOjIVWgMyT1W/pSrX5Y6nR8Q1VE+MfCE0QAlsYLpb9vxuh4jiOkpY+P+RqSj1ciTxuqic/k0HOvAaI1vJmIdJe3iQlVK/lxzHlaB+nY20WdVV2LVlFthvCVO6pH+I+pbHk1NkgYmXoKsm+on7epazT7Bg1K8eVpumcBG2sPX9R04RL5hz4WmWwwIDAQAB 51 | -----END PUBLIC KEY-----" 52 | ``` 53 | Replace `KEYCLOAK_CLIENTSECRET` and `KEYCLOAK_PK` contents by your own values you have previously copied. 54 | 55 | Add `KEYCLOAK_VERIFY_PEER=true` and `KEYCLOAK_VERIFY_HOST=true` by true if you want to verify the peer/host when calling the Keyclock server. 56 | 57 | ## Start the Symfony application 58 | 59 | For the sake of simplicity, we use the [Symfony local web server](https://symfony.com/doc/5.4/setup/symfony_server.html). At least PHP 8.0 is needed to run the application. Start the server: 60 | 61 | ```bash 62 | $ symfony serve -d --no-tls 63 | ``` 64 | 65 | Then, install the dependencies: 66 | 67 | ```bash 68 | $ symfony composer install 69 | ``` 70 | 71 | You can now go to `http://localhost:8000` in your browser and try to login into the application with the user account you previously created :) -------------------------------------------------------------------------------- /src/Security/OpenIdAuthenticator.php: -------------------------------------------------------------------------------- 1 | attributes->get('_route'); 44 | } 45 | 46 | public function authenticate(Request $request): Passport 47 | { 48 | $sessionState = $request->getSession()->get(self::STATE_SESSION_KEY); 49 | $queryState = $request->get(self::STATE_QUERY_KEY); 50 | if ($queryState === null || $queryState !== $sessionState) { 51 | throw new InvalidStateException(sprintf( 52 | 'query state (%s) is not the same as session state (%s)', 53 | $queryState ?? 'NULL', 54 | $sessionState ?? 'NULL', 55 | )); 56 | } 57 | 58 | $request->getSession()->remove(self::STATE_SESSION_KEY); 59 | 60 | try { 61 | $response = $this->openIdClient->getTokenFromAuthorizationCode($request->query->get('code', '')); 62 | } catch (HttpExceptionInterface $e) { 63 | throw new OpenIdServerException(sprintf( 64 | 'Bad status code returned by openID server (%s)', 65 | $e->getResponse()->getStatusCode(), 66 | ), previous: $e); 67 | } 68 | 69 | $responseData = json_decode($response, true); 70 | if (false === $responseData) { 71 | throw new OpenIdServerException(sprintf('Can\'t parse json in response: %s', $response->getContent())); 72 | } 73 | 74 | $jwtToken = $responseData['id_token'] ?? null; 75 | if (null === $jwtToken) { 76 | throw new OpenIdServerException(sprintf('No access token found in response %s', $response->getContent())); 77 | } 78 | 79 | $refreshToken = $responseData['refresh_token'] ?? null; 80 | if (null === $refreshToken) { 81 | throw new RuntimeException(sprintf('No refresh token found in response %s', $response->getContent())); 82 | } 83 | 84 | $userBadge = new UserBadge($jwtToken); 85 | 86 | $passport = new SelfValidatingPassport($userBadge, [new PreAuthenticatedUserBadge()]); 87 | 88 | $passport->setAttribute(TokensBag::class, new TokensBag($responseData['access_token'] ?? null, $refreshToken)); 89 | 90 | return $passport; 91 | } 92 | 93 | public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response 94 | { 95 | return new RedirectResponse($this->urlGenerator->generate('profile')); 96 | } 97 | 98 | public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response 99 | { 100 | $request->getSession()->getFlashBag()->add( 101 | 'error', 102 | 'An authentication error occured', 103 | ); 104 | 105 | return new RedirectResponse($this->urlGenerator->generate('home')); 106 | } 107 | 108 | public function start(Request $request, AuthenticationException $authException = null): Response 109 | { 110 | $state = (string)Uuid::v4(); 111 | $request->getSession()->set(self::STATE_SESSION_KEY, $state); 112 | 113 | $qs = http_build_query([ 114 | 'client_id' => $this->clientId, 115 | 'response_type' => 'code', 116 | 'state' => $state, 117 | 'scope' => 'openid roles profile email', 118 | // Force http since working on localhost 119 | 'redirect_uri' => 'http:'.$this->urlGenerator->generate('openid_redirecturi', [], UrlGeneratorInterface::NETWORK_PATH), 120 | ]); 121 | 122 | return new RedirectResponse(sprintf('%s?%s', $this->authorizationEndpoint, $qs)); 123 | } 124 | 125 | public function createToken(Passport $passport, string $firewallName): TokenInterface 126 | { 127 | $token = parent::createToken($passport, $firewallName); 128 | 129 | if (!$passport instanceof Passport) { 130 | throw new \LogicException(sprintf('Passport must be a subclass of %s, %s given', Passport::class, get_class($passport))); 131 | } 132 | 133 | $currentRequest = $this->requestStack->getCurrentRequest(); 134 | if (null === $currentRequest) { 135 | throw new LogicException(sprintf('%s can only be used in an http context', __CLASS__)); 136 | } 137 | $jwtExpires = $currentRequest->attributes->get('_app_jwt_expires'); 138 | if (null === $jwtExpires) { 139 | throw new \LogicException('Missing _app_jwt_expires in the session'); 140 | } 141 | $currentRequest->attributes->remove('_app_jwt_expires'); 142 | 143 | $tokens = $passport->getAttribute(TokensBag::class); 144 | if (null === $tokens) { 145 | throw new \LogicException(sprintf('Can\'t find %s in passport attributes', TokensBag::class)); 146 | } 147 | $token->setAttribute(TokensBag::class, $tokens->withExpiration($jwtExpires)); 148 | 149 | return $token; 150 | } 151 | 152 | public function isInteractive(): bool 153 | { 154 | return true; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "composer/package-versions-deprecated": { 3 | "version": "1.11.99.4" 4 | }, 5 | "doctrine/annotations": { 6 | "version": "1.13", 7 | "recipe": { 8 | "repo": "github.com/symfony/recipes", 9 | "branch": "master", 10 | "version": "1.0", 11 | "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457" 12 | }, 13 | "files": [ 14 | "config/routes/annotations.yaml" 15 | ] 16 | }, 17 | "doctrine/inflector": { 18 | "version": "2.0.4" 19 | }, 20 | "doctrine/instantiator": { 21 | "version": "1.4.0" 22 | }, 23 | "doctrine/lexer": { 24 | "version": "1.2.1" 25 | }, 26 | "egulias/email-validator": { 27 | "version": "3.1.2" 28 | }, 29 | "firebase/php-jwt": { 30 | "version": "v5.5.1" 31 | }, 32 | "friendsofphp/proxy-manager-lts": { 33 | "version": "v1.0.5" 34 | }, 35 | "laminas/laminas-code": { 36 | "version": "4.4.3" 37 | }, 38 | "monolog/monolog": { 39 | "version": "2.3.5" 40 | }, 41 | "myclabs/deep-copy": { 42 | "version": "1.10.2" 43 | }, 44 | "nikic/php-parser": { 45 | "version": "v4.13.1" 46 | }, 47 | "phar-io/manifest": { 48 | "version": "2.0.3" 49 | }, 50 | "phar-io/version": { 51 | "version": "3.1.0" 52 | }, 53 | "phpdocumentor/reflection-common": { 54 | "version": "2.2.0" 55 | }, 56 | "phpdocumentor/reflection-docblock": { 57 | "version": "5.3.0" 58 | }, 59 | "phpdocumentor/type-resolver": { 60 | "version": "1.5.1" 61 | }, 62 | "phpspec/prophecy": { 63 | "version": "1.14.0" 64 | }, 65 | "phpunit/php-code-coverage": { 66 | "version": "9.2.8" 67 | }, 68 | "phpunit/php-file-iterator": { 69 | "version": "3.0.5" 70 | }, 71 | "phpunit/php-invoker": { 72 | "version": "3.1.1" 73 | }, 74 | "phpunit/php-text-template": { 75 | "version": "2.0.4" 76 | }, 77 | "phpunit/php-timer": { 78 | "version": "5.0.3" 79 | }, 80 | "phpunit/phpunit": { 81 | "version": "9.5", 82 | "recipe": { 83 | "repo": "github.com/symfony/recipes", 84 | "branch": "master", 85 | "version": "9.3", 86 | "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6" 87 | }, 88 | "files": [ 89 | ".env.test", 90 | "phpunit.xml.dist", 91 | "tests/bootstrap.php" 92 | ] 93 | }, 94 | "psr/cache": { 95 | "version": "2.0.0" 96 | }, 97 | "psr/container": { 98 | "version": "1.1.2" 99 | }, 100 | "psr/event-dispatcher": { 101 | "version": "1.0.0" 102 | }, 103 | "psr/link": { 104 | "version": "1.1.1" 105 | }, 106 | "psr/log": { 107 | "version": "2.0.0" 108 | }, 109 | "sebastian/cli-parser": { 110 | "version": "1.0.1" 111 | }, 112 | "sebastian/code-unit": { 113 | "version": "1.0.8" 114 | }, 115 | "sebastian/code-unit-reverse-lookup": { 116 | "version": "2.0.3" 117 | }, 118 | "sebastian/comparator": { 119 | "version": "4.0.6" 120 | }, 121 | "sebastian/complexity": { 122 | "version": "2.0.2" 123 | }, 124 | "sebastian/diff": { 125 | "version": "4.0.4" 126 | }, 127 | "sebastian/environment": { 128 | "version": "5.1.3" 129 | }, 130 | "sebastian/exporter": { 131 | "version": "4.0.3" 132 | }, 133 | "sebastian/global-state": { 134 | "version": "5.0.3" 135 | }, 136 | "sebastian/lines-of-code": { 137 | "version": "1.0.3" 138 | }, 139 | "sebastian/object-enumerator": { 140 | "version": "4.0.4" 141 | }, 142 | "sebastian/object-reflector": { 143 | "version": "2.0.4" 144 | }, 145 | "sebastian/recursion-context": { 146 | "version": "4.0.4" 147 | }, 148 | "sebastian/resource-operations": { 149 | "version": "3.0.3" 150 | }, 151 | "sebastian/type": { 152 | "version": "2.3.4" 153 | }, 154 | "sebastian/version": { 155 | "version": "3.0.2" 156 | }, 157 | "sensio/framework-extra-bundle": { 158 | "version": "6.2", 159 | "recipe": { 160 | "repo": "github.com/symfony/recipes", 161 | "branch": "master", 162 | "version": "5.2", 163 | "ref": "fb7e19da7f013d0d422fa9bce16f5c510e27609b" 164 | }, 165 | "files": [ 166 | "config/packages/sensio_framework_extra.yaml" 167 | ] 168 | }, 169 | "symfony/asset": { 170 | "version": "v5.3.4" 171 | }, 172 | "symfony/browser-kit": { 173 | "version": "v5.3.4" 174 | }, 175 | "symfony/cache": { 176 | "version": "v5.3.10" 177 | }, 178 | "symfony/cache-contracts": { 179 | "version": "v2.4.0" 180 | }, 181 | "symfony/config": { 182 | "version": "v5.3.10" 183 | }, 184 | "symfony/console": { 185 | "version": "5.3", 186 | "recipe": { 187 | "repo": "github.com/symfony/recipes", 188 | "branch": "master", 189 | "version": "5.3", 190 | "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047" 191 | }, 192 | "files": [ 193 | "bin/console" 194 | ] 195 | }, 196 | "symfony/css-selector": { 197 | "version": "v5.3.4" 198 | }, 199 | "symfony/debug-bundle": { 200 | "version": "5.3", 201 | "recipe": { 202 | "repo": "github.com/symfony/recipes", 203 | "branch": "master", 204 | "version": "4.1", 205 | "ref": "0ce7a032d344fb7b661cd25d31914cd703ad445b" 206 | }, 207 | "files": [ 208 | "config/packages/dev/debug.yaml" 209 | ] 210 | }, 211 | "symfony/dependency-injection": { 212 | "version": "v5.3.10" 213 | }, 214 | "symfony/deprecation-contracts": { 215 | "version": "v2.4.0" 216 | }, 217 | "symfony/dom-crawler": { 218 | "version": "v5.3.7" 219 | }, 220 | "symfony/dotenv": { 221 | "version": "v5.3.10" 222 | }, 223 | "symfony/error-handler": { 224 | "version": "v5.3.7" 225 | }, 226 | "symfony/event-dispatcher": { 227 | "version": "v5.3.7" 228 | }, 229 | "symfony/event-dispatcher-contracts": { 230 | "version": "v2.4.0" 231 | }, 232 | "symfony/expression-language": { 233 | "version": "v5.3.7" 234 | }, 235 | "symfony/filesystem": { 236 | "version": "v5.3.4" 237 | }, 238 | "symfony/finder": { 239 | "version": "v5.3.7" 240 | }, 241 | "symfony/flex": { 242 | "version": "1.17", 243 | "recipe": { 244 | "repo": "github.com/symfony/recipes", 245 | "branch": "master", 246 | "version": "1.0", 247 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 248 | }, 249 | "files": [ 250 | ".env" 251 | ] 252 | }, 253 | "symfony/form": { 254 | "version": "v5.3.10" 255 | }, 256 | "symfony/framework-bundle": { 257 | "version": "5.3", 258 | "recipe": { 259 | "repo": "github.com/symfony/recipes", 260 | "branch": "master", 261 | "version": "5.3", 262 | "ref": "414ba00ad43fa71be42c7906a551f1831716b03c" 263 | }, 264 | "files": [ 265 | "config/services.yaml", 266 | "config/routes/framework.yaml", 267 | "config/preload.php", 268 | "config/packages/cache.yaml", 269 | "config/packages/framework.yaml", 270 | "public/index.php", 271 | "src/Kernel.php", 272 | "src/Controller/.gitignore" 273 | ] 274 | }, 275 | "symfony/http-client": { 276 | "version": "v5.3.10" 277 | }, 278 | "symfony/http-client-contracts": { 279 | "version": "v2.4.0" 280 | }, 281 | "symfony/http-foundation": { 282 | "version": "v5.3.10" 283 | }, 284 | "symfony/http-kernel": { 285 | "version": "v5.3.10" 286 | }, 287 | "symfony/intl": { 288 | "version": "v5.3.8" 289 | }, 290 | "symfony/mailer": { 291 | "version": "5.3", 292 | "recipe": { 293 | "repo": "github.com/symfony/recipes", 294 | "branch": "master", 295 | "version": "4.3", 296 | "ref": "bbfc7e27257d3a3f12a6fb0a42540a42d9623a37" 297 | }, 298 | "files": [ 299 | "config/packages/mailer.yaml" 300 | ] 301 | }, 302 | "symfony/maker-bundle": { 303 | "version": "1.34", 304 | "recipe": { 305 | "repo": "github.com/symfony/recipes", 306 | "branch": "master", 307 | "version": "1.0", 308 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" 309 | } 310 | }, 311 | "symfony/mime": { 312 | "version": "v5.3.8" 313 | }, 314 | "symfony/monolog-bridge": { 315 | "version": "v5.3.7" 316 | }, 317 | "symfony/monolog-bundle": { 318 | "version": "3.7", 319 | "recipe": { 320 | "repo": "github.com/symfony/recipes", 321 | "branch": "master", 322 | "version": "3.7", 323 | "ref": "a7bace7dbc5a7ed5608dbe2165e0774c87175fe6" 324 | }, 325 | "files": [ 326 | "config/packages/test/monolog.yaml", 327 | "config/packages/prod/monolog.yaml", 328 | "config/packages/prod/deprecations.yaml", 329 | "config/packages/dev/monolog.yaml" 330 | ] 331 | }, 332 | "symfony/notifier": { 333 | "version": "5.3", 334 | "recipe": { 335 | "repo": "github.com/symfony/recipes", 336 | "branch": "master", 337 | "version": "5.0", 338 | "ref": "c31585e252b32fe0e1f30b1f256af553f4a06eb9" 339 | }, 340 | "files": [ 341 | "config/packages/notifier.yaml" 342 | ] 343 | }, 344 | "symfony/options-resolver": { 345 | "version": "v5.3.7" 346 | }, 347 | "symfony/password-hasher": { 348 | "version": "v5.3.8" 349 | }, 350 | "symfony/phpunit-bridge": { 351 | "version": "5.3", 352 | "recipe": { 353 | "repo": "github.com/symfony/recipes", 354 | "branch": "master", 355 | "version": "5.3", 356 | "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96" 357 | }, 358 | "files": [ 359 | ".env.test", 360 | "bin/phpunit", 361 | "phpunit.xml.dist", 362 | "tests/bootstrap.php" 363 | ] 364 | }, 365 | "symfony/polyfill-intl-grapheme": { 366 | "version": "v1.23.1" 367 | }, 368 | "symfony/polyfill-intl-icu": { 369 | "version": "v1.23.0" 370 | }, 371 | "symfony/polyfill-intl-idn": { 372 | "version": "v1.23.0" 373 | }, 374 | "symfony/polyfill-intl-normalizer": { 375 | "version": "v1.23.0" 376 | }, 377 | "symfony/polyfill-mbstring": { 378 | "version": "v1.23.1" 379 | }, 380 | "symfony/polyfill-php73": { 381 | "version": "v1.23.0" 382 | }, 383 | "symfony/polyfill-php80": { 384 | "version": "v1.23.1" 385 | }, 386 | "symfony/polyfill-php81": { 387 | "version": "v1.23.0" 388 | }, 389 | "symfony/polyfill-uuid": { 390 | "version": "v1.23.0" 391 | }, 392 | "symfony/process": { 393 | "version": "v5.3.7" 394 | }, 395 | "symfony/property-access": { 396 | "version": "v5.3.8" 397 | }, 398 | "symfony/property-info": { 399 | "version": "v5.3.8" 400 | }, 401 | "symfony/proxy-manager-bridge": { 402 | "version": "v5.3.4" 403 | }, 404 | "symfony/routing": { 405 | "version": "5.3", 406 | "recipe": { 407 | "repo": "github.com/symfony/recipes", 408 | "branch": "master", 409 | "version": "5.3", 410 | "ref": "44633353926a0382d7dfb0530922c5c0b30fae11" 411 | }, 412 | "files": [ 413 | "config/routes.yaml", 414 | "config/packages/routing.yaml" 415 | ] 416 | }, 417 | "symfony/runtime": { 418 | "version": "v5.3.10" 419 | }, 420 | "symfony/security-bundle": { 421 | "version": "5.3", 422 | "recipe": { 423 | "repo": "github.com/symfony/recipes", 424 | "branch": "master", 425 | "version": "5.3", 426 | "ref": "3307d76caa2d12fb10ade57975beb3d8975df396" 427 | }, 428 | "files": [ 429 | "config/packages/security.yaml" 430 | ] 431 | }, 432 | "symfony/security-core": { 433 | "version": "v5.3.10" 434 | }, 435 | "symfony/security-csrf": { 436 | "version": "v5.3.4" 437 | }, 438 | "symfony/security-guard": { 439 | "version": "v5.3.7" 440 | }, 441 | "symfony/security-http": { 442 | "version": "v5.3.10" 443 | }, 444 | "symfony/serializer": { 445 | "version": "v5.3.10" 446 | }, 447 | "symfony/service-contracts": { 448 | "version": "v2.4.0" 449 | }, 450 | "symfony/stopwatch": { 451 | "version": "v5.3.4" 452 | }, 453 | "symfony/string": { 454 | "version": "v5.3.10" 455 | }, 456 | "symfony/translation": { 457 | "version": "5.3", 458 | "recipe": { 459 | "repo": "github.com/symfony/recipes", 460 | "branch": "master", 461 | "version": "5.3", 462 | "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43" 463 | }, 464 | "files": [ 465 | "config/packages/translation.yaml", 466 | "translations/.gitignore" 467 | ] 468 | }, 469 | "symfony/translation-contracts": { 470 | "version": "v2.4.0" 471 | }, 472 | "symfony/twig-bridge": { 473 | "version": "v5.3.7" 474 | }, 475 | "symfony/twig-bundle": { 476 | "version": "5.3", 477 | "recipe": { 478 | "repo": "github.com/symfony/recipes", 479 | "branch": "master", 480 | "version": "5.3", 481 | "ref": "3dd530739a4284e3272274c128dbb7a8140a66f1" 482 | }, 483 | "files": [ 484 | "config/packages/twig.yaml", 485 | "templates/base.html.twig" 486 | ] 487 | }, 488 | "symfony/uid": { 489 | "version": "v5.3.10" 490 | }, 491 | "symfony/validator": { 492 | "version": "5.3", 493 | "recipe": { 494 | "repo": "github.com/symfony/recipes", 495 | "branch": "master", 496 | "version": "4.3", 497 | "ref": "3eb8df139ec05414489d55b97603c5f6ca0c44cb" 498 | }, 499 | "files": [ 500 | "config/packages/validator.yaml", 501 | "config/packages/test/validator.yaml" 502 | ] 503 | }, 504 | "symfony/var-dumper": { 505 | "version": "v5.3.10" 506 | }, 507 | "symfony/var-exporter": { 508 | "version": "v5.3.8" 509 | }, 510 | "symfony/web-link": { 511 | "version": "v5.3.4" 512 | }, 513 | "symfony/web-profiler-bundle": { 514 | "version": "5.3", 515 | "recipe": { 516 | "repo": "github.com/symfony/recipes", 517 | "branch": "master", 518 | "version": "3.3", 519 | "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" 520 | }, 521 | "files": [ 522 | "config/routes/dev/web_profiler.yaml", 523 | "config/packages/test/web_profiler.yaml", 524 | "config/packages/dev/web_profiler.yaml" 525 | ] 526 | }, 527 | "symfony/yaml": { 528 | "version": "v5.3.6" 529 | }, 530 | "theseer/tokenizer": { 531 | "version": "1.2.1" 532 | }, 533 | "twig/extra-bundle": { 534 | "version": "v3.3.3" 535 | }, 536 | "twig/twig": { 537 | "version": "v3.3.3" 538 | }, 539 | "webmozart/assert": { 540 | "version": "1.10.0" 541 | } 542 | } 543 | --------------------------------------------------------------------------------