├── .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 |
10 | - Uuid: {{ app.user.uuid }}
11 | - Username: {{ app.user.userIdentifier }}
12 | - Full name: {{ app.user.fullname }}
13 | - Email: {{ app.user.email }}
14 |
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 | {{ message }}
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 |
--------------------------------------------------------------------------------