├── migrations ├── .gitignore └── Version20240109130406.php ├── src ├── Entity │ ├── .gitignore │ └── User.php ├── Controller │ ├── .gitignore │ ├── IndexController.php │ ├── ProfileController.php │ ├── UserListController.php │ └── LoginController.php ├── Repository │ ├── .gitignore │ └── UserRepository.php ├── Kernel.php └── Security │ └── ZitadelUserProvider.php ├── translations └── .gitignore ├── assets ├── styles │ └── app.css ├── bootstrap.js ├── app.js ├── controllers.json └── controllers │ └── hello_controller.js ├── config ├── packages │ ├── mailer.yaml │ ├── twig.yaml │ ├── asset_mapper.yaml │ ├── debug.yaml │ ├── doctrine_migrations.yaml │ ├── routing.yaml │ ├── notifier.yaml │ ├── validator.yaml │ ├── web_profiler.yaml │ ├── translation.yaml │ ├── framework.yaml │ ├── cache.yaml │ ├── drenso_oidc.yaml │ ├── messenger.yaml │ ├── security.yaml │ ├── doctrine.yaml │ └── monolog.yaml ├── routes │ ├── security.yaml │ ├── framework.yaml │ └── web_profiler.yaml ├── routes.yaml ├── preload.php ├── bundles.php └── services.yaml ├── .env.test ├── public └── index.php ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── compose.override.yaml ├── tests └── bootstrap.php ├── bin └── console ├── templates ├── index.html.twig ├── base.html.twig ├── profile.html.twig └── user_list.html.twig ├── compose.yaml ├── importmap.php ├── phpunit.xml.dist ├── .gitignore ├── .vscode └── launch.json ├── .github └── workflows │ └── issues.yml ├── .env ├── README.md ├── composer.json ├── symfony.lock └── LICENSE /migrations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Entity/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Controller/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Repository/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: skyblue; 3 | } 4 | -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /config/routes/security.yaml: -------------------------------------------------------------------------------- 1 | _security_logout: 2 | resource: security.route_loader.logout 3 | type: service 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/asset_mapper.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | asset_mapper: 3 | # The paths to make available to the asset mapper. 4 | paths: 5 | - assets/ 6 | -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: 3 | path: ../src/Controller/ 4 | namespace: App\Controller 5 | type: attribute 6 | login_check: 7 | path: /login_check 8 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | doctrine/doctrine-bundle ### 5 | database: 6 | ports: 7 | - "5432" 8 | ###< doctrine/doctrine-bundle ### 9 | 10 | ###> symfony/mailer ### 11 | mailer: 12 | image: schickling/mailcatcher 13 | ports: ["1025", "1080"] 14 | ###< symfony/mailer ### 15 | -------------------------------------------------------------------------------- /assets/app.js: -------------------------------------------------------------------------------- 1 | import './bootstrap.js'; 2 | /* 3 | * Welcome to your app's main JavaScript file! 4 | * 5 | * This file will be included onto the page via the importmap() Twig function, 6 | * which should already be in your base.html.twig. 7 | */ 8 | import './styles/app.css' 9 | 10 | console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉') 11 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /assets/controllers.json: -------------------------------------------------------------------------------- 1 | { 2 | "controllers": { 3 | "@symfony/ux-turbo": { 4 | "turbo-core": { 5 | "enabled": true, 6 | "fetch": "eager" 7 | }, 8 | "mercure-turbo-stream": { 9 | "enabled": false, 10 | "fetch": "eager" 11 | } 12 | } 13 | }, 14 | "entrypoints": [] 15 | } 16 | -------------------------------------------------------------------------------- /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/notifier.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | notifier: 3 | chatter_transports: 4 | texter_transports: 5 | channel_policy: 6 | # use chat/slack, chat/telegram, sms/twilio or sms/nexmo 7 | urgent: ['email'] 8 | high: ['email'] 9 | medium: ['email'] 10 | low: ['email'] 11 | admin_recipients: 12 | - { email: admin@example.com } 13 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | #auto_mapping: 8 | # App\Entity\: [] 9 | 10 | when@test: 11 | framework: 12 | validation: 13 | not_compromised_password: false 14 | -------------------------------------------------------------------------------- /config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /src/Controller/IndexController.php: -------------------------------------------------------------------------------- 1 | render('index.html.twig', []); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Controller/ProfileController.php: -------------------------------------------------------------------------------- 1 | render('profile.html.twig', []); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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 | # phrase: 15 | # dsn: '%env(PHRASE_DSN)%' 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | "hello" 9 | * 10 | * Delete this file or adapt it for your use! 11 | */ 12 | export default class extends Controller { 13 | connect() { 14 | this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /templates/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | {% block body %} 3 |

Home

4 |

You are viewing the public homepage of the ZITADEL Symfony example web app.

5 | 6 |

Authenticated pages

7 |

8 | Browsing to one of the following pages will trigger OIDC authentication flow. 9 |

10 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | 7 | {% block stylesheets %} 8 | {{ ux_controller_link_tags() }} 9 | {% endblock %} 10 | 11 | {% block javascripts %} 12 | {% block importmap %}{{ importmap('app') }}{% endblock %} 13 | {% endblock %} 14 | 15 | 16 | {% block body %}{% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /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 | handle_all_throwables: true 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 | 14 | #esi: true 15 | #fragments: true 16 | php_errors: 17 | log: true 18 | 19 | when@test: 20 | framework: 21 | test: true 22 | session: 23 | storage_factory_id: session.storage.factory.mock_file 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | ###> doctrine/doctrine-bundle ### 5 | database: 6 | image: postgres:${POSTGRES_VERSION:-15}-alpine 7 | environment: 8 | POSTGRES_DB: ${POSTGRES_DB:-app} 9 | # You should definitely change the password in production 10 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} 11 | POSTGRES_USER: ${POSTGRES_USER:-app} 12 | volumes: 13 | - database_data:/var/lib/postgresql/data:rw 14 | # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! 15 | # - ./docker/db/data:/var/lib/postgresql/data:rw 16 | ###< doctrine/doctrine-bundle ### 17 | 18 | volumes: 19 | ###> doctrine/doctrine-bundle ### 20 | database_data: 21 | ###< doctrine/doctrine-bundle ### 22 | -------------------------------------------------------------------------------- /config/packages/drenso_oidc.yaml: -------------------------------------------------------------------------------- 1 | drenso_oidc: 2 | #default_client: default # The default client, will be aliased to OidcClientInterface 3 | clients: 4 | default: # The client name, each client will be aliased to its name (for example, $defaultOidcClient) 5 | # Required OIDC client configuration 6 | well_known_url: '%env(OIDC_WELL_KNOWN_URL)%' 7 | client_id: '%env(OIDC_CLIENT_ID)%' 8 | client_secret: '%env(OIDC_CLIENT_SECRET)%' 9 | 10 | # Extra configuration options 11 | #redirect_route: '/login_check' 12 | #custom_client_headers: [] 13 | 14 | # Add any extra client 15 | #link: # Will be accessible using $linkOidcClient 16 | #well_known_url: '%env(LINK_WELL_KNOWN_URL)%' 17 | #client_id: '%env(LINK_CLIENT_ID)%' 18 | #client_secret: '%env(LINK_CLIENT_SECRET)%' 19 | -------------------------------------------------------------------------------- /src/Controller/UserListController.php: -------------------------------------------------------------------------------- 1 | repo = $entityManager->getRepository(User::class); 20 | $this->em = $entityManager; 21 | } 22 | 23 | #[Route('/users')] 24 | public function index(): Response 25 | { 26 | $users = $this->repo->findAll(); 27 | return $this->render('user_list.html.twig', ['users' => $users]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/packages/messenger.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | messenger: 3 | failure_transport: failed 4 | 5 | transports: 6 | # https://symfony.com/doc/current/messenger.html#transport-configuration 7 | async: 8 | dsn: '%env(MESSENGER_TRANSPORT_DSN)%' 9 | options: 10 | use_notify: true 11 | check_delayed_interval: 60000 12 | retry_strategy: 13 | max_retries: 3 14 | multiplier: 2 15 | failed: 'doctrine://default?queue_name=failed' 16 | # sync: 'sync://' 17 | 18 | routing: 19 | Symfony\Component\Mailer\Messenger\SendEmailMessage: async 20 | Symfony\Component\Notifier\Message\ChatMessage: async 21 | Symfony\Component\Notifier\Message\SmsMessage: async 22 | 23 | # Route your messages to the transports 24 | # 'App\Message\YourMessage': async 25 | -------------------------------------------------------------------------------- /importmap.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'path' => './assets/app.js', 19 | 'entrypoint' => true, 20 | ], 21 | '@hotwired/stimulus' => [ 22 | 'version' => '3.2.2', 23 | ], 24 | '@symfony/stimulus-bundle' => [ 25 | 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', 26 | ], 27 | '@hotwired/turbo' => [ 28 | 'version' => '7.3.0', 29 | ], 30 | ]; 31 | -------------------------------------------------------------------------------- /src/Controller/LoginController.php: -------------------------------------------------------------------------------- 1 | generateAuthorizationRedirect(scopes: ZitadelUserProvider::SCOPES); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /templates/profile.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | {% block body %} 3 |

Welcome back, {{ app.user.fullName }}!

4 |

5 | You are viewing the authenticated profile page. 6 | This means you succesfully logged-in using OIDC! 7 |

8 |

9 | Click here to logout. 10 |

11 |

Profile information

12 | 17 |

Roles

18 |

19 | Role names are converted from ZITADEL to Symfony format. All upper-cased and prefixed with ROLE_. 20 | ROLE_USER is the default role set in Symfony, even if there are no ZITADEL roles returned. 21 |

22 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 8 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 9 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 10 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 11 | Symfony\UX\Turbo\TurboBundle::class => ['all' => true], 12 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 13 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 14 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 15 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 16 | Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true], 17 | ]; 18 | -------------------------------------------------------------------------------- /templates/user_list.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | {% block body %} 3 |

Welcome back, {{ app.user.fullName }}!

4 |

5 | You are viewing the registered users list page. 6 | This means you succesfully logged-in using OIDC! 7 | View your profile page. 8 |

9 |

10 | Click here to logout. 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for user in users %} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% endfor %} 35 |
idsubrolesdisplay nameemailemail verifiedcreated atupdated at
{{ user.id }}{{ user.sub }}{{ user.implodeRoles }}{{ user.displayName }}{{ user.email }}{{ user.emailVerified }}{{ user.formatCreatedAt }}{{ user.formatUpdatedAt }}
36 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /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 | 8 | services: 9 | # default configuration for services in *this* file 10 | _defaults: 11 | autowire: true # Automatically injects dependencies in your services. 12 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 13 | 14 | # makes classes in src/ available to be used as services 15 | # this creates a service per class whose id is the fully-qualified class name 16 | App\: 17 | resource: '../src/' 18 | exclude: 19 | - '../src/DependencyInjection/' 20 | - '../src/Entity/' 21 | - '../src/Kernel.php' 22 | 23 | # add more service definitions when explicit configuration is needed 24 | # please note that last definitions always *replace* previous ones 25 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider 3 | providers: 4 | # used to reload user from session & other features (e.g. switch_user) 5 | zitadel_user_provider: 6 | id: App\Security\ZitadelUserProvider 7 | firewalls: 8 | dev: 9 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 10 | security: false 11 | main: 12 | lazy: true 13 | provider: zitadel_user_provider 14 | pattern: ^/ 15 | oidc: 16 | enable_end_session_listener: true 17 | logout: 18 | path: /logout 19 | 20 | # activate different ways to authenticate 21 | # https://symfony.com/doc/current/security.html#the-firewall 22 | 23 | # https://symfony.com/doc/current/security/impersonating_user.html 24 | # switch_user: true 25 | 26 | # Easy way to control access for large sections of your site 27 | # Note: Only the *first* access control that matches will be used 28 | access_control: 29 | - { path: ^/users, roles: ROLE_ADMIN } 30 | - { path: ^/profile, roles: ROLE_USER } 31 | -------------------------------------------------------------------------------- /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 | 38 | 39 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/php 3 | { 4 | "name": "zitadel-example-symfony", 5 | // Use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "build": { 7 | // Path is relative to the devcontainer.json file. 8 | "dockerfile": "Dockerfile" 9 | }, 10 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 11 | "forwardPorts": [ 12 | 8000 13 | ], 14 | // Features to add to the dev container. More info: https://containers.dev/features. 15 | "features": { 16 | "ghcr.io/devcontainers-contrib/features/composer:1": {}, 17 | "ghcr.io/shyim/devcontainers-features/symfony-cli:0": {} 18 | }, 19 | // Configure tool-specific properties. 20 | "customizations": { 21 | "vscode": { 22 | "extensions": [ 23 | "ms-azuretools.vscode-docker", 24 | "rholdos.twig-language-support" 25 | ] 26 | } 27 | } 28 | 29 | // Use 'postCreateCommand' to run commands after the container is created. 30 | // "postCreateCommand": "sudo chmod a+x \"$(pwd)\" && sudo rm -rf /var/www/html && sudo ln -s \"$(pwd)\" /var/www/html" 31 | 32 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 33 | // "remoteUser": "root" 34 | } 35 | -------------------------------------------------------------------------------- /src/Repository/UserRepository.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @method User|null find($id, $lockMode = null, $lockVersion = null) 13 | * @method User|null findOneBy(array $criteria, array $orderBy = null) 14 | * @method User[] findAll() 15 | * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 16 | */ 17 | class UserRepository extends ServiceEntityRepository 18 | { 19 | public function __construct(ManagerRegistry $registry) 20 | { 21 | parent::__construct($registry, User::class); 22 | } 23 | 24 | // /** 25 | // * @return User[] Returns an array of User objects 26 | // */ 27 | // public function findByExampleField($value): array 28 | // { 29 | // return $this->createQueryBuilder('u') 30 | // ->andWhere('u.exampleField = :val') 31 | // ->setParameter('val', $value) 32 | // ->orderBy('u.id', 'ASC') 33 | // ->setMaxResults(10) 34 | // ->getQuery() 35 | // ->getResult() 36 | // ; 37 | // } 38 | 39 | public function findOneBySub($sub): ?User 40 | { 41 | return $this->createQueryBuilder('u') 42 | ->andWhere('u.sub = :sub') 43 | ->setParameter('sub', $sub) 44 | ->getQuery() 45 | ->getOneOrNullResult() 46 | ; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Cache and logs (Symfony2) 2 | /app/cache/* 3 | /app/logs/* 4 | !app/cache/.gitkeep 5 | !app/logs/.gitkeep 6 | 7 | # Email spool folder 8 | /app/spool/* 9 | 10 | # Cache, session files and logs (Symfony3) 11 | /var/cache/* 12 | /var/logs/* 13 | /var/sessions/* 14 | !var/cache/.gitkeep 15 | !var/logs/.gitkeep 16 | !var/sessions/.gitkeep 17 | 18 | # Logs (Symfony4) 19 | /var/log/* 20 | !var/log/.gitkeep 21 | 22 | # Parameters 23 | /app/config/parameters.yml 24 | /app/config/parameters.ini 25 | 26 | # Managed by Composer 27 | /app/bootstrap.php.cache 28 | /var/bootstrap.php.cache 29 | /bin/* 30 | !bin/console 31 | !bin/symfony_requirements 32 | /vendor/ 33 | 34 | # Assets and user uploads 35 | /web/bundles/ 36 | /web/uploads/ 37 | 38 | # PHPUnit 39 | /app/phpunit.xml 40 | /phpunit.xml 41 | 42 | # Build data 43 | /build/ 44 | 45 | # Composer PHAR 46 | /composer.phar 47 | 48 | # Backup entities generated with doctrine:generate:entities command 49 | **/Entity/*~ 50 | 51 | # Embedded web-server pid file 52 | /.web-server-pid 53 | 54 | ###> symfony/framework-bundle ### 55 | /.env.local 56 | /.env.local.php 57 | /.env.*.local 58 | /config/secrets/prod/prod.decrypt.private.php 59 | /public/bundles/ 60 | /var/ 61 | /vendor/ 62 | ###< symfony/framework-bundle ### 63 | 64 | ###> phpunit/phpunit ### 65 | /phpunit.xml 66 | .phpunit.result.cache 67 | ###< phpunit/phpunit ### 68 | 69 | ###> symfony/phpunit-bridge ### 70 | .phpunit.result.cache 71 | /phpunit.xml 72 | ###< symfony/phpunit-bridge ### 73 | 74 | ###> symfony/asset-mapper ### 75 | /public/assets/ 76 | /assets/vendor 77 | ###< symfony/asset-mapper ### -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Listen for Xdebug", 9 | "type": "php", 10 | "request": "launch", 11 | "port": 9000 12 | }, 13 | { 14 | "name": "Launch currently open script", 15 | "type": "php", 16 | "request": "launch", 17 | "program": "${file}", 18 | "cwd": "${fileDirname}", 19 | "port": 0, 20 | "runtimeArgs": [ 21 | "-dxdebug.start_with_request=yes" 22 | ], 23 | "env": { 24 | "XDEBUG_MODE": "debug,develop", 25 | "XDEBUG_CONFIG": "client_port=${port}" 26 | } 27 | }, 28 | { 29 | "name": "Launch Built-in web server", 30 | "type": "php", 31 | "request": "launch", 32 | "runtimeArgs": [ 33 | "-dxdebug.mode=debug", 34 | "-dxdebug.start_with_request=yes", 35 | "-S", 36 | "localhost:0" 37 | ], 38 | "program": "", 39 | "cwd": "${workspaceRoot}", 40 | "port": 9003, 41 | "serverReadyAction": { 42 | "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started", 43 | "uriFormat": "http://localhost:%s", 44 | "action": "openExternally" 45 | } 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '15' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | orm: 11 | auto_generate_proxy_classes: true 12 | enable_lazy_ghost_objects: true 13 | report_fields_where_declared: true 14 | validate_xml_mapping: true 15 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 16 | auto_mapping: true 17 | mappings: 18 | App: 19 | type: attribute 20 | is_bundle: false 21 | dir: '%kernel.project_dir%/src/Entity' 22 | prefix: 'App\Entity' 23 | alias: App 24 | 25 | when@test: 26 | doctrine: 27 | dbal: 28 | # "TEST_TOKEN" is typically set by ParaTest 29 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 30 | 31 | when@prod: 32 | doctrine: 33 | orm: 34 | auto_generate_proxy_classes: false 35 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 36 | query_cache_driver: 37 | type: pool 38 | pool: doctrine.system_cache_pool 39 | result_cache_driver: 40 | type: pool 41 | pool: doctrine.result_cache_pool 42 | 43 | framework: 44 | cache: 45 | pools: 46 | doctrine.result_cache_pool: 47 | adapter: cache.app 48 | doctrine.system_cache_pool: 49 | adapter: cache.system 50 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: Add new issues to product management project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request_target: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | name: Add issue and community pr to project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: add issue 17 | uses: actions/add-to-project@v0.5.0 18 | if: ${{ github.event_name == 'issues' }} 19 | with: 20 | # You can target a repository in a different organization 21 | # to the issue 22 | project-url: https://github.com/orgs/zitadel/projects/2 23 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 24 | - uses: tspascoal/get-user-teams-membership@v3 25 | id: checkUserMember 26 | if: github.actor != 'dependabot[bot]' 27 | with: 28 | username: ${{ github.actor }} 29 | GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} 30 | - name: add pr 31 | uses: actions/add-to-project@v0.5.0 32 | if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} 33 | with: 34 | # You can target a repository in a different organization 35 | # to the issue 36 | project-url: https://github.com/orgs/zitadel/projects/2 37 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 38 | - uses: actions-ecosystem/action-add-labels@v1.1.0 39 | if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} 40 | with: 41 | github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} 42 | labels: | 43 | os-contribution 44 | -------------------------------------------------------------------------------- /migrations/Version20240109130406.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE "user" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, sub VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json) 24 | , display_name VARCHAR(255) DEFAULT NULL, full_name VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, email_verified BOOLEAN DEFAULT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable) 25 | , updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable) 26 | )'); 27 | $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649580282DC ON "user" (sub)'); 28 | $this->addSql('CREATE TABLE messenger_messages (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, body CLOB NOT NULL, headers CLOB NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable) 29 | , available_at DATETIME NOT NULL --(DC2Type:datetime_immutable) 30 | , delivered_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable) 31 | )'); 32 | $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)'); 33 | $this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)'); 34 | $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); 35 | } 36 | 37 | public function down(Schema $schema): void 38 | { 39 | // this down() migration is auto-generated, please modify it to your needs 40 | $this->addSql('DROP TABLE "user"'); 41 | $this->addSql('DROP TABLE messenger_messages'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | -------------------------------------------------------------------------------- /.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 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=c20d01c4efbcba3f876e3fabb8a13b97 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> doctrine/doctrine-bundle ### 23 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 24 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 25 | # 26 | DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 27 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" 28 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 29 | # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8" 30 | ###< doctrine/doctrine-bundle ### 31 | 32 | ###> symfony/messenger ### 33 | # Choose one of the transports below 34 | # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages 35 | # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages 36 | MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 37 | ###< symfony/messenger ### 38 | 39 | ###> symfony/mailer ### 40 | # MAILER_DSN=null://null 41 | ###< symfony/mailer ### 42 | 43 | ###> drenso/symfony-oidc-bundle ### 44 | OIDC_WELL_KNOWN_URL="Enter the .well-known url for the OIDC provider" 45 | OIDC_CLIENT_ID="Enter your OIDC client id" 46 | OIDC_CLIENT_SECRET="Enter your OIDC client secret" 47 | ###< drenso/symfony-oidc-bundle ### 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example symfony OIDC 2 | 3 | This repository provides a Symfony framework example for ZITADEL using OpenID connect (OIDC) authentication. 4 | This example is provided as companion to our [guide](https://zitadel.com/docs/examples/login/symfony), 5 | which should produce this application when followed. 6 | 7 | ## Features 8 | 9 | - OIDC Code flow with User Info call after authentication. 10 | - Fully integrated with Symfony security and firewall. 11 | - User Role mapping 12 | - Persistent user data using local sqlite file. See `DATABASE_URL` in [.env](.env). 13 | - Public page at `/` 14 | - Authenticated `/profile` page for all users. 15 | - Authenticated `/admin` page for admin role users. 16 | 17 | ## Package structure 18 | 19 | The package structure follows a [Symfony boilerplate app](https://symfony.com/doc/current/setup.html#creating-symfony-applications) generated with: 20 | 21 | ```bash 22 | symfony new my_project_directory --version="7.0.*" --webapp 23 | ``` 24 | 25 | Code implementations live under [`src/`](/src/) with accompanying [templates](/templates/). 26 | 27 | ## Getting started 28 | 29 | If you want to run this example directly you can fork and clone it to your system. 30 | Be sure to [configure ZITADEL](https://docs-git-docs-example-symfony-zitadel.vercel.app/docs/examples/login/symfony#zitadel-setup) to accept requests from this app. 31 | 32 | ### Prerequisites 33 | 34 | You need a working PHP 8.2 or higher environment set up for use with Symfony. See more details in the [Symfony installation documentation](https://symfony.com/doc/current/setup.html#technical-requirements). 35 | 36 | Alternatively if you have a system with Docker and an IDE capable of running [Development Container](https://containers.dev/), 37 | definitions are provided with a complete PHP-8.2 environment, configuration and tools required for Symfony development. 38 | Use your IDE to build and launch the development environment or use GitHub code spaces from your browser. 39 | 40 | ### Symfony 41 | 42 | After setting up your system and repository, install the project dependencies locally. 43 | 44 | ```bash 45 | composer install 46 | ``` 47 | 48 | At this point you might want to start a [`xdebug` client](https://xdebug.org/docs/step_debug#clients). 49 | This is not required, but php might complain in the following steps that it can't connect to a `xdebug` client. 50 | An example [`launch.json``](.vscode/launch.json) file has been provided for VSCode. Use the "Listen for Xdebug" option. 51 | 52 | You can check the application environment with: 53 | 54 | ```bash 55 | bin/console about 56 | ``` 57 | 58 | Create a local sqlite database (stored in `./var`).: 59 | 60 | ```bash 61 | bin/console doctrine:database:create 62 | bin/console doctrine:migrations:migrate 63 | ``` 64 | 65 | And run the development server: 66 | 67 | ```bash 68 | symfony server:start --no-tls 69 | ``` 70 | 71 | Visit [http://localhost:8000] and click around. When you go to profile you will be redirected to login your user on ZITADEL. After login you should see some profile data of the current user. Upon clicking logout you are redirected to the homepage. Now you can click "users" and login with an account that has the admin role. 72 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "minimum-stability": "stable", 5 | "prefer-stable": true, 6 | "require": { 7 | "php": ">=8.2", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "doctrine/doctrine-bundle": "^2.11", 11 | "doctrine/doctrine-migrations-bundle": "^3.3", 12 | "doctrine/orm": "^2.17", 13 | "drenso/symfony-oidc-bundle": "^2.13", 14 | "phpdocumentor/reflection-docblock": "^5.3", 15 | "phpstan/phpdoc-parser": "^1.24", 16 | "symfony/asset": "7.1.*", 17 | "symfony/asset-mapper": "7.1.*", 18 | "symfony/console": "7.1.*", 19 | "symfony/doctrine-messenger": "7.1.*", 20 | "symfony/dotenv": "7.1.*", 21 | "symfony/expression-language": "7.1.*", 22 | "symfony/flex": "^2", 23 | "symfony/form": "7.1.*", 24 | "symfony/framework-bundle": "7.1.*", 25 | "symfony/http-client": "7.1.*", 26 | "symfony/intl": "7.1.*", 27 | "symfony/mailer": "7.1.*", 28 | "symfony/mime": "7.1.*", 29 | "symfony/monolog-bundle": "^3.0", 30 | "symfony/notifier": "7.1.*", 31 | "symfony/process": "7.1.*", 32 | "symfony/property-access": "7.1.*", 33 | "symfony/property-info": "7.1.*", 34 | "symfony/runtime": "7.1.*", 35 | "symfony/security-bundle": "7.1.*", 36 | "symfony/serializer": "7.1.*", 37 | "symfony/stimulus-bundle": "^2.13", 38 | "symfony/string": "7.1.*", 39 | "symfony/translation": "7.1.*", 40 | "symfony/twig-bundle": "7.1.*", 41 | "symfony/ux-turbo": "^2.13", 42 | "symfony/validator": "7.1.*", 43 | "symfony/web-link": "7.1.*", 44 | "symfony/yaml": "7.1.*", 45 | "twig/extra-bundle": "^2.12|^3.0", 46 | "twig/twig": "^2.12|^3.0" 47 | }, 48 | "config": { 49 | "allow-plugins": { 50 | "php-http/discovery": true, 51 | "symfony/flex": true, 52 | "symfony/runtime": true 53 | }, 54 | "sort-packages": true 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "App\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "App\\Tests\\": "tests/" 64 | } 65 | }, 66 | "replace": { 67 | "symfony/polyfill-ctype": "*", 68 | "symfony/polyfill-iconv": "*", 69 | "symfony/polyfill-php72": "*", 70 | "symfony/polyfill-php73": "*", 71 | "symfony/polyfill-php74": "*", 72 | "symfony/polyfill-php80": "*", 73 | "symfony/polyfill-php81": "*", 74 | "symfony/polyfill-php82": "*" 75 | }, 76 | "scripts": { 77 | "auto-scripts": { 78 | "cache:clear": "symfony-cmd", 79 | "assets:install %PUBLIC_DIR%": "symfony-cmd", 80 | "importmap:install": "symfony-cmd" 81 | }, 82 | "post-install-cmd": [ 83 | "@auto-scripts" 84 | ], 85 | "post-update-cmd": [ 86 | "@auto-scripts" 87 | ] 88 | }, 89 | "conflict": { 90 | "symfony/symfony": "*" 91 | }, 92 | "extra": { 93 | "symfony": { 94 | "allow-contrib": false, 95 | "require": "7.1.*" 96 | } 97 | }, 98 | "require-dev": { 99 | "phpunit/phpunit": "^9.5", 100 | "symfony/browser-kit": "7.1.*", 101 | "symfony/css-selector": "7.1.*", 102 | "symfony/debug-bundle": "7.1.*", 103 | "symfony/maker-bundle": "^1.0", 104 | "symfony/phpunit-bridge": "^7.0", 105 | "symfony/stopwatch": "7.1.*", 106 | "symfony/web-profiler-bundle": "7.1.*" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Entity/User.php: -------------------------------------------------------------------------------- 1 | id; 46 | } 47 | 48 | public function getSub(): ?string 49 | { 50 | return $this->sub; 51 | } 52 | 53 | public function setSub(string $sub): static 54 | { 55 | $this->sub = $sub; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * A visual identifier that represents this user. 62 | * 63 | * @see UserInterface 64 | */ 65 | public function getUserIdentifier(): string 66 | { 67 | return (string) $this->sub; 68 | } 69 | 70 | /** 71 | * @see UserInterface 72 | */ 73 | public function getRoles(): array 74 | { 75 | $roles = $this->roles; 76 | // guarantee every user at least has ROLE_USER 77 | $roles[] = 'ROLE_USER'; 78 | 79 | return array_unique($roles); 80 | } 81 | 82 | public function setRoles(array $roles): static 83 | { 84 | $this->roles = $roles; 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * @see UserInterface 91 | */ 92 | public function eraseCredentials(): void 93 | { 94 | // If you store any temporary, sensitive data on the user, clear it here 95 | // $this->plainPassword = null; 96 | } 97 | 98 | public function getDisplayName(): ?string 99 | { 100 | return $this->display_name; 101 | } 102 | 103 | public function setDisplayName(?string $display_name): static 104 | { 105 | $this->display_name = $display_name; 106 | 107 | return $this; 108 | } 109 | 110 | public function getFullName(): ?string 111 | { 112 | return $this->full_name; 113 | } 114 | 115 | public function setFullName(?string $full_name): static 116 | { 117 | $this->full_name = $full_name; 118 | 119 | return $this; 120 | } 121 | 122 | public function getEmail(): ?string 123 | { 124 | return $this->email; 125 | } 126 | 127 | public function setEmail(?string $email): static 128 | { 129 | $this->email = $email; 130 | 131 | return $this; 132 | } 133 | 134 | public function isEmailVerified(): ?bool 135 | { 136 | return $this->email_verified; 137 | } 138 | 139 | public function setEmailVerified(?bool $email_verified): static 140 | { 141 | $this->email_verified = $email_verified; 142 | 143 | return $this; 144 | } 145 | 146 | public function getCreatedAt(): ?\DateTimeImmutable 147 | { 148 | return $this->created_at; 149 | } 150 | 151 | public function setCreatedAt(\DateTimeImmutable $created_at): static 152 | { 153 | $this->created_at = $created_at; 154 | 155 | return $this; 156 | } 157 | 158 | public function getUpdatedAt(): ?\DateTimeImmutable 159 | { 160 | return $this->updated_at; 161 | } 162 | 163 | public function setUpdatedAt(\DateTimeImmutable $updated_at): static 164 | { 165 | $this->updated_at = $updated_at; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Convert roles to string for use in a template. 172 | */ 173 | public function implodeRoles(): string 174 | { 175 | return implode(', ', $this->getRoles()); 176 | } 177 | 178 | /** 179 | * Format timestamp for use in a template. 180 | */ 181 | public function formatCreatedAt(): string 182 | { 183 | return $this->created_at->format(DateTimeInterface::W3C); 184 | } 185 | 186 | /** 187 | * Format timestamp for use in a template. 188 | */ 189 | public function formatUpdatedAt(): string 190 | { 191 | return $this->updated_at->format(DateTimeInterface::W3C); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Security/ZitadelUserProvider.php: -------------------------------------------------------------------------------- 1 | repo = $entityManager->getRepository(User::class); 27 | $this->em = $entityManager; 28 | } 29 | 30 | private LoggerInterface $logger; 31 | 32 | /** 33 | * @see LoggerAwareInterface 34 | */ 35 | public function setLogger(LoggerInterface $logger): void 36 | { 37 | $this->logger = $logger; 38 | } 39 | 40 | /** 41 | * Symfony calls this method if you use features like switch_user 42 | * or remember_me. If you're not using these features, you do not 43 | * need to implement this method. 44 | * 45 | * @throws UserNotFoundException if the user is not found 46 | */ 47 | public function loadUserByIdentifier(string $identifier): UserInterface 48 | { 49 | // Load a User object from your data source or throw UserNotFoundException. 50 | // The $identifier argument is whatever value is being returned by the 51 | // getUserIdentifier() method in your User class. 52 | $user = $this->repo->findOneBySub($identifier); 53 | if (!$user) { 54 | throw new UserNotFoundException(sprintf('User with id "%s" not found')); 55 | } 56 | return $user; 57 | } 58 | 59 | /** 60 | * Refreshes the user after being reloaded from the session. 61 | * 62 | * When a user is logged in, at the beginning of each request, the 63 | * User object is loaded from the session and then this method is 64 | * called. Your job is to make sure the user's data is still fresh by, 65 | * for example, re-querying for fresh User data. 66 | * 67 | * If your firewall is "stateless: true" (for a pure API), this 68 | * method is not called. 69 | */ 70 | public function refreshUser(UserInterface $user): UserInterface 71 | { 72 | if (!$user instanceof User) { 73 | throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user))); 74 | } 75 | 76 | // Return a User object after making sure its data is "fresh". 77 | // Or throw a UserNotFoundException if the user no longer exists. 78 | $refreshedUser = $this->repo->find($user->getId()); 79 | if (!$user) { 80 | throw new UserNotFoundException(sprintf('User with id "%s" not found', $user->getId())); 81 | } 82 | return $refreshedUser; 83 | } 84 | 85 | /** 86 | * Tells Symfony to use this provider for this User class. 87 | */ 88 | public function supportsClass(string $class): bool 89 | { 90 | return User::class === $class || is_subclass_of($class, User::class); 91 | } 92 | 93 | /** 94 | * Parse lower case plain role names from zitadel to the 95 | * Symfony `ROLE_USER` format. 96 | */ 97 | private function parseZitadelRoles(array $roles): array 98 | { 99 | $symfonyRoles = []; 100 | foreach ($roles as $role => $data) { 101 | $role = strtoupper($role); 102 | if (!str_starts_with($role, 'ROLE_')) { 103 | $role = 'ROLE_' . $role; 104 | } 105 | array_push($symfonyRoles, $role); 106 | } 107 | return $symfonyRoles; 108 | } 109 | 110 | /** 111 | * Zitadel reserved roles claim. 112 | * See https://zitadel.com/docs/apis/openidoauth/claims#reserved-claims for other available claims. 113 | */ 114 | const ROLES_CLAIM = 'urn:zitadel:iam:org:project:roles'; 115 | 116 | /** 117 | * Requested scopes. Adjust to your application's needs. 118 | * See https://zitadel.com/docs/apis/openidoauth/scopes for all available scopes. 119 | */ 120 | const SCOPES = array('openid', 'profile', 'email', self::ROLES_CLAIM); 121 | 122 | /** 123 | * Copy Zitadel User Info to the Symfony User Entity. 124 | * The available info depends on the scopes defined in the SCOPES constant above. 125 | */ 126 | private function updateUserEntity(User &$user, OidcUserData $userData) 127 | { 128 | $user->setSub($userData->getSub()); 129 | $user->setRoles($this->parseZitadelRoles($userData->getUserDataArray(self::ROLES_CLAIM))); 130 | $user->setDisplayName($userData->getDisplayName()); 131 | $user->setFullName($userData->getFullName()); 132 | $user->setEmail($userData->getEmail()); 133 | $user->setEmailVerified($userData->getEmailVerified()); 134 | $user->setUpdatedAt(new DateTimeImmutable()); 135 | } 136 | 137 | /** 138 | * Create or update an user with User Info from Zitadel. 139 | * 140 | * @see OidcUserProviderInterface 141 | */ 142 | public function ensureUserExists(string $userIdentifier, OidcUserData $userData) 143 | { 144 | $this->logger->debug("OIDC User Data", [ 145 | 'sub' => $userData->getSub(), 146 | 'display_name' => $userData->getDisplayName(), 147 | 'full_name' => $userData->getFullName(), 148 | 'roles' => $userData->getUserData(self::ROLES_CLAIM), 149 | ]); 150 | 151 | try { 152 | $user = $this->repo->findOneBySub($userIdentifier); 153 | if (!$user) { 154 | $user = new User(); 155 | $user->setCreatedAt(new DateTimeImmutable()); 156 | $this->em->persist($user); 157 | } 158 | $this->updateUserEntity($user, $userData); 159 | $this->em->flush(); 160 | } catch (\Throwable $th) { 161 | throw new OidcException('cannot create user', previous: $th); 162 | } 163 | } 164 | 165 | /** 166 | * @see OidcUserProviderInterface 167 | */ 168 | public function loadOidcUser(string $userIdentifier): UserInterface 169 | { 170 | return $this->loadUserByIdentifier($userIdentifier); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "doctrine/doctrine-bundle": { 3 | "version": "2.11", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes", 6 | "branch": "main", 7 | "version": "2.10", 8 | "ref": "0b4a11ee7e60b36227502ed26874edd7e8b66353" 9 | }, 10 | "files": [ 11 | "config/packages/doctrine.yaml", 12 | "src/Entity/.gitignore", 13 | "src/Repository/.gitignore" 14 | ] 15 | }, 16 | "doctrine/doctrine-migrations-bundle": { 17 | "version": "3.3", 18 | "recipe": { 19 | "repo": "github.com/symfony/recipes", 20 | "branch": "main", 21 | "version": "3.1", 22 | "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" 23 | }, 24 | "files": [ 25 | "config/packages/doctrine_migrations.yaml", 26 | "migrations/.gitignore" 27 | ] 28 | }, 29 | "drenso/symfony-oidc-bundle": { 30 | "version": "2.13", 31 | "recipe": { 32 | "repo": "github.com/symfony/recipes-contrib", 33 | "branch": "main", 34 | "version": "2.0", 35 | "ref": "e2b975158d940a191f48e3ff2c59108a1d7225e6" 36 | }, 37 | "files": [ 38 | "config/packages/drenso_oidc.yaml" 39 | ] 40 | }, 41 | "phpunit/phpunit": { 42 | "version": "9.6", 43 | "recipe": { 44 | "repo": "github.com/symfony/recipes", 45 | "branch": "main", 46 | "version": "9.6", 47 | "ref": "7364a21d87e658eb363c5020c072ecfdc12e2326" 48 | }, 49 | "files": [ 50 | ".env.test", 51 | "phpunit.xml.dist", 52 | "tests/bootstrap.php" 53 | ] 54 | }, 55 | "symfony/asset-mapper": { 56 | "version": "7.0", 57 | "recipe": { 58 | "repo": "github.com/symfony/recipes", 59 | "branch": "main", 60 | "version": "6.4", 61 | "ref": "8a0d17c04c791b3d7bdd083e1e2b2a53439dc3b0" 62 | }, 63 | "files": [ 64 | "assets/app.js", 65 | "assets/styles/app.css", 66 | "config/packages/asset_mapper.yaml", 67 | "importmap.php" 68 | ] 69 | }, 70 | "symfony/console": { 71 | "version": "7.0", 72 | "recipe": { 73 | "repo": "github.com/symfony/recipes", 74 | "branch": "main", 75 | "version": "5.3", 76 | "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047" 77 | }, 78 | "files": [ 79 | "bin/console" 80 | ] 81 | }, 82 | "symfony/debug-bundle": { 83 | "version": "7.0", 84 | "recipe": { 85 | "repo": "github.com/symfony/recipes", 86 | "branch": "main", 87 | "version": "5.3", 88 | "ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b" 89 | }, 90 | "files": [ 91 | "config/packages/debug.yaml" 92 | ] 93 | }, 94 | "symfony/flex": { 95 | "version": "2.4", 96 | "recipe": { 97 | "repo": "github.com/symfony/recipes", 98 | "branch": "main", 99 | "version": "1.0", 100 | "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172" 101 | }, 102 | "files": [ 103 | ".env" 104 | ] 105 | }, 106 | "symfony/framework-bundle": { 107 | "version": "7.0", 108 | "recipe": { 109 | "repo": "github.com/symfony/recipes", 110 | "branch": "main", 111 | "version": "7.0", 112 | "ref": "de6e1b3e2bbbe69e36262d72c3f3db858b1ab391" 113 | }, 114 | "files": [ 115 | "config/packages/cache.yaml", 116 | "config/packages/framework.yaml", 117 | "config/preload.php", 118 | "config/routes/framework.yaml", 119 | "config/services.yaml", 120 | "public/index.php", 121 | "src/Controller/.gitignore", 122 | "src/Kernel.php" 123 | ] 124 | }, 125 | "symfony/mailer": { 126 | "version": "7.0", 127 | "recipe": { 128 | "repo": "github.com/symfony/recipes", 129 | "branch": "main", 130 | "version": "4.3", 131 | "ref": "2bf89438209656b85b9a49238c4467bff1b1f939" 132 | }, 133 | "files": [ 134 | "config/packages/mailer.yaml" 135 | ] 136 | }, 137 | "symfony/maker-bundle": { 138 | "version": "1.52", 139 | "recipe": { 140 | "repo": "github.com/symfony/recipes", 141 | "branch": "main", 142 | "version": "1.0", 143 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" 144 | } 145 | }, 146 | "symfony/messenger": { 147 | "version": "7.0", 148 | "recipe": { 149 | "repo": "github.com/symfony/recipes", 150 | "branch": "main", 151 | "version": "6.0", 152 | "ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee" 153 | }, 154 | "files": [ 155 | "config/packages/messenger.yaml" 156 | ] 157 | }, 158 | "symfony/monolog-bundle": { 159 | "version": "3.10", 160 | "recipe": { 161 | "repo": "github.com/symfony/recipes", 162 | "branch": "main", 163 | "version": "3.7", 164 | "ref": "213676c4ec929f046dfde5ea8e97625b81bc0578" 165 | }, 166 | "files": [ 167 | "config/packages/monolog.yaml" 168 | ] 169 | }, 170 | "symfony/notifier": { 171 | "version": "7.0", 172 | "recipe": { 173 | "repo": "github.com/symfony/recipes", 174 | "branch": "main", 175 | "version": "5.0", 176 | "ref": "178877daf79d2dbd62129dd03612cb1a2cb407cc" 177 | }, 178 | "files": [ 179 | "config/packages/notifier.yaml" 180 | ] 181 | }, 182 | "symfony/phpunit-bridge": { 183 | "version": "7.0", 184 | "recipe": { 185 | "repo": "github.com/symfony/recipes", 186 | "branch": "main", 187 | "version": "6.3", 188 | "ref": "1f5830c331065b6e4c9d5fa2105e322d29fcd573" 189 | }, 190 | "files": [ 191 | ".env.test", 192 | "bin/phpunit", 193 | "phpunit.xml.dist", 194 | "tests/bootstrap.php" 195 | ] 196 | }, 197 | "symfony/routing": { 198 | "version": "7.0", 199 | "recipe": { 200 | "repo": "github.com/symfony/recipes", 201 | "branch": "main", 202 | "version": "6.2", 203 | "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6" 204 | }, 205 | "files": [ 206 | "config/packages/routing.yaml", 207 | "config/routes.yaml" 208 | ] 209 | }, 210 | "symfony/security-bundle": { 211 | "version": "7.0", 212 | "recipe": { 213 | "repo": "github.com/symfony/recipes", 214 | "branch": "main", 215 | "version": "6.4", 216 | "ref": "2ae08430db28c8eb4476605894296c82a642028f" 217 | }, 218 | "files": [ 219 | "config/packages/security.yaml", 220 | "config/routes/security.yaml" 221 | ] 222 | }, 223 | "symfony/stimulus-bundle": { 224 | "version": "2.13", 225 | "recipe": { 226 | "repo": "github.com/symfony/recipes", 227 | "branch": "main", 228 | "version": "2.9", 229 | "ref": "05c45071c7ecacc1e48f94bc43c1f8d4405fb2b2" 230 | }, 231 | "files": [ 232 | "assets/bootstrap.js", 233 | "assets/controllers.json", 234 | "assets/controllers/hello_controller.js" 235 | ] 236 | }, 237 | "symfony/translation": { 238 | "version": "7.0", 239 | "recipe": { 240 | "repo": "github.com/symfony/recipes", 241 | "branch": "main", 242 | "version": "6.3", 243 | "ref": "64fe617084223633e1dedf9112935d8c95410d3e" 244 | }, 245 | "files": [ 246 | "config/packages/translation.yaml", 247 | "translations/.gitignore" 248 | ] 249 | }, 250 | "symfony/twig-bundle": { 251 | "version": "7.0", 252 | "recipe": { 253 | "repo": "github.com/symfony/recipes", 254 | "branch": "main", 255 | "version": "6.3", 256 | "ref": "b7772eb20e92f3fb4d4fe756e7505b4ba2ca1a2c" 257 | }, 258 | "files": [ 259 | "config/packages/twig.yaml", 260 | "templates/base.html.twig" 261 | ] 262 | }, 263 | "symfony/ux-turbo": { 264 | "version": "v2.13.2" 265 | }, 266 | "symfony/validator": { 267 | "version": "7.0", 268 | "recipe": { 269 | "repo": "github.com/symfony/recipes", 270 | "branch": "main", 271 | "version": "5.3", 272 | "ref": "c32cfd98f714894c4f128bb99aa2530c1227603c" 273 | }, 274 | "files": [ 275 | "config/packages/validator.yaml" 276 | ] 277 | }, 278 | "symfony/web-profiler-bundle": { 279 | "version": "7.0", 280 | "recipe": { 281 | "repo": "github.com/symfony/recipes", 282 | "branch": "main", 283 | "version": "6.1", 284 | "ref": "e42b3f0177df239add25373083a564e5ead4e13a" 285 | }, 286 | "files": [ 287 | "config/packages/web_profiler.yaml", 288 | "config/routes/web_profiler.yaml" 289 | ] 290 | }, 291 | "twig/extra-bundle": { 292 | "version": "v3.8.0" 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------