├── .github └── workflows │ └── running_the_tests.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── dependabot.yml ├── docs └── index.md ├── phpstan.dist.neon ├── phpunit.xml.dist ├── src ├── Annotation │ ├── ExcludeTokenValidationAttribute.php │ ├── Since.php │ └── Until.php ├── Controller │ └── KeycloakController.php ├── DTO │ ├── GroupRepresentationDTO.php │ ├── KeycloakAuthorizationCodeEnum.php │ ├── RoleRepresentationDTO.php │ ├── ScopeRepresentationDTO.php │ └── UserRepresentationDTO.php ├── DependencyInjection │ ├── Configuration.php │ └── MainickKeycloakClientExtension.php ├── EventSubscriber │ ├── ExceptionListener.php │ ├── LogoutAuthListener.php │ └── TokenAuthListener.php ├── Exception │ ├── KeycloakAuthenticationException.php │ ├── PropertyDoesNotExistException.php │ └── TokenDecoderException.php ├── Interface │ ├── AccessTokenInterface.php │ ├── IamAdminClientInterface.php │ ├── IamClientInterface.php │ ├── ResourceOwnerInterface.php │ └── TokenDecoderInterface.php ├── MainickKeycloakClientBundle.php ├── Provider │ ├── KeycloakAdminClient.php │ └── KeycloakClient.php ├── Representation │ ├── ClientRepresentation.php │ ├── ClientScopeRepresentation.php │ ├── Collection │ │ ├── ClientCollection.php │ │ ├── ClientScopeCollection.php │ │ ├── Collection.php │ │ ├── CredentialCollection.php │ │ ├── GroupCollection.php │ │ ├── ProtocolMapperCollection.php │ │ ├── RealmCollection.php │ │ ├── RoleCollection.php │ │ ├── UPAttributeCollection.php │ │ ├── UPGroupCollection.php │ │ ├── UserCollection.php │ │ ├── UserConsentCollection.php │ │ ├── UserProfileAttributeGroupMetadataCollection.php │ │ ├── UserProfileAttributeMetadataCollection.php │ │ └── UserSessionCollection.php │ ├── Composites.php │ ├── CredentialRepresentation.php │ ├── GroupRepresentation.php │ ├── ProtocolMapperRepresentation.php │ ├── RealmRepresentation.php │ ├── Representation.php │ ├── RoleRepresentation.php │ ├── RolesRepresentation.php │ ├── Type │ │ ├── Map.php │ │ └── Type.php │ ├── UPAttribute.php │ ├── UPAttributePermissions.php │ ├── UPAttributeRequired.php │ ├── UPAttributeSelector.php │ ├── UPConfig.php │ ├── UPGroup.php │ ├── UnmanagedAttributePolicyEnum.php │ ├── UserConsentRepresentation.php │ ├── UserProfileAttributeGroupMetadata.php │ ├── UserProfileAttributeMetadata.php │ ├── UserProfileMetadata.php │ ├── UserRepresentation.php │ └── UserSessionRepresentation.php ├── Resources │ └── config │ │ ├── routing.yaml │ │ ├── security.yaml │ │ └── services.yaml ├── Security │ ├── Authenticator │ │ └── KeycloakAuthenticator.php │ ├── EntryPoint │ │ └── KeycloakAuthenticationEntryPoint.php │ └── User │ │ └── KeycloakUserProvider.php ├── Serializer │ ├── AttributeNormalizer.php │ ├── CollectionDenormalizer.php │ ├── MapDenormalizer.php │ ├── MapNormalizer.php │ ├── RepresentationDenormalizer.php │ └── Serializer.php ├── Service │ ├── ClientsService.php │ ├── Criteria.php │ ├── GroupsService.php │ ├── HttpMethodEnum.php │ ├── RealmsService.php │ ├── RolesService.php │ ├── Service.php │ └── UsersService.php └── Token │ ├── AccessToken.php │ ├── HS256TokenDecoder.php │ ├── KeycloakResourceOwner.php │ ├── RS256TokenDecoder.php │ └── TokenDecoderFactory.php └── tests ├── EventSubscriber └── TokenAuthListenerTest.php ├── Provider └── KeycloakClientTest.php ├── Security └── KeycloakAuthenticatorTest.php ├── Serializer └── CollectionDenormalizerTest.php ├── Service ├── ClientsServiceTest.php ├── GroupsServiceTest.php ├── RealmsServiceTest.php ├── RolesServiceTest.php └── UsersServiceTest.php └── bootstrap.php /.github/workflows/running_the_tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Symfony 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | symfony-tests: 19 | runs-on: ubuntu-latest 20 | steps: 21 | # To automatically get bug fixes and new Php versions for shivammathur/setup-php, 22 | # change this to (see https://github.com/shivammathur/setup-php#bookmark-versioning): 23 | # uses: shivammathur/setup-php@v2 24 | - uses: shivammathur/setup-php@2cb9b829437ee246e9b3cac53555a39208ca6d28 25 | with: 26 | php-version: '8.3' 27 | - uses: actions/checkout@v3 28 | - name: Cache Composer packages 29 | id: composer-cache 30 | uses: actions/cache@v3 31 | with: 32 | path: vendor 33 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-php- 36 | - name: Install Dependencies 37 | run: composer update 38 | - name: Execute tests (Unit and Feature tests) via PHPUnit 39 | run: vendor/bin/phpunit 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # IntelliJ project files 3 | .idea 4 | *.iml 5 | out 6 | gen 7 | 8 | /vendor/ 9 | 10 | ###> friendsofphp/php-cs-fixer ### 11 | /.php-cs-fixer.php 12 | /.php-cs-fixer.cache 13 | ###< friendsofphp/php-cs-fixer ### 14 | 15 | ###> phpstan/phpstan ### 16 | phpstan.neon 17 | ###< phpstan/phpstan ### 18 | 19 | ###> phpunit/phpunit ### 20 | /phpunit.xml 21 | phpunit.xml.bak 22 | /.phpunit.cache/ 23 | .phpunit.result.cache 24 | ###< phpunit/phpunit ### 25 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude(['var', 'vendor', 'config/secrets', 'public', 'node_modules', 'tests/Fixtures']); 6 | 7 | return (new PhpCsFixer\Config()) 8 | ->setRules([ 9 | '@Symfony' => true, 10 | // 'strict_param' => true, 11 | // 'declare_strict_types' => true, 12 | 'no_leading_import_slash' => true, 13 | 'no_trailing_comma_in_singleline' => true, 14 | 'no_whitespace_in_blank_line' => true, 15 | 'no_trailing_whitespace' => true, 16 | 'no_space_around_double_colon' => true, 17 | 'multiline_whitespace_before_semicolons' => true, 18 | 'blank_lines_before_namespace' => true, 19 | 'single_blank_line_at_eof' => true, 20 | 'control_structure_continuation_position' => ['position' => 'next_line'], 21 | ]) 22 | ->setFinder($finder); 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome your contributions! If you wish to enhance this package or have found a bug, 4 | feel free to create a pull request or report an issue in the [issue tracker](https://github.com/mainick/KeycloakClientBundle/issues). 5 | 6 | We accept contributions via Pull Requests on [Github](https://github.com/mainick/KeycloakClientBundle). 7 | 8 | 9 | ## Pull Requests 10 | 11 | - **[PSR-12: Extended Coding Style](https://www.php-fig.org/psr/psr-12/)** - The easiest way to apply the conventions is to install [PHP Coding Standards Fixer](https://cs.symfony.com/). 12 | 13 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 14 | 15 | - **Document any change in behaviour** - Make sure the README and any other relevant documentation are kept up-to-date. 16 | 17 | - **Consider our release cycle** - We try to follow SemVer. Randomly breaking public APIs is not an option. 18 | 19 | - **Create topic branches** - Don't ask us to pull from your master branch. 20 | 21 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 22 | 23 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. 24 | 25 | - **Ensure tests pass!** - Please run the tests (see below) before submitting your pull request, and make sure they pass. We won't accept a patch until all tests pass. 26 | 27 | - **Ensure no coding standards violations** - Please run PHP Coding Standards Fixer using the PSR-12 standard (see below) before submitting your pull request. A violation will cause the build to fail, so please make sure there are no violations. We can't accept a patch if the build fails. 28 | 29 | ## Running Tests 30 | 31 | ``` bash 32 | composer test 33 | ``` 34 | 35 | ## Running PHP Coding Standards Fixer dry-run 36 | 37 | The rules usaged the PHP Coding Standards Fixer are defined in the `.php-cs-fixer.dist.php` file. 38 | 39 | ``` bash 40 | composer lint 41 | ``` 42 | 43 | ## Running PHP Coding Standards Fixer 44 | 45 | ``` bash 46 | composer lint-fix 47 | ``` 48 | 49 | **Happy coding**! 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mainick/keycloak-client-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Keycloak client bundle for Symfony, designed to simplify Keycloak integration into your application and provide additional functionality for token management and user information access", 5 | "license": "MIT", 6 | "keywords": [ 7 | "keycloak-client", 8 | "jwt-token", 9 | "oauth2-keycloak" 10 | ], 11 | "require": { 12 | "php": ">=8.2", 13 | "stevenmaguire/oauth2-keycloak": "^5.1", 14 | "symfony/routing": "^7.2", 15 | "symfony/security-bundle": "^7.2", 16 | "symfony/http-kernel": "^7.2", 17 | "symfony/framework-bundle": "^7.2", 18 | "symfony/serializer-pack": "^1.3" 19 | }, 20 | "require-dev": { 21 | "friendsofphp/php-cs-fixer": "^3.75", 22 | "phpunit/phpunit": "^11.2", 23 | "mockery/mockery": "^1.6", 24 | "phpstan/phpstan": "^2.1" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Mainick\\KeycloakClientBundle\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Mainick\\KeycloakClientBundle\\Tests\\": "tests/" 34 | } 35 | }, 36 | "scripts": { 37 | "lint": "php-cs-fixer fix --dry-run --diff --ansi --verbose --allow-risky=no", 38 | "lint-fix": "php-cs-fixer fix", 39 | "types": "phpstan analyse --ansi --memory-limit=256M", 40 | "test": "phpunit --testdox --colors=always" 41 | }, 42 | "authors": [ 43 | { 44 | "name": "Maico Orazio", 45 | "email": "maico.orazio@gmail.com", 46 | "homepage": "https://github.com/mainick" 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "composer" # Using Composer for PHP dependencies 4 | directory: "/" # The directory where your composer.json file is located 5 | schedule: 6 | interval: "daily" # Check for updates daily 7 | commit-message: 8 | prefix: "chore" # Prefix for commit messages 9 | open-pull-requests-limit: 10 # Limit pull requests opened by Dependabot 10 | groups: 11 | symfony: 12 | patterns: 13 | - "symfony/*" # Update all Symfony dependencies together 14 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainick/KeycloakClientBundle/a19121891e9eca0036ec732c7fb20c093a76c90d/docs/index.md -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src/ 5 | - tests/ 6 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | tests 17 | 18 | 19 | 20 | 25 | 26 | 31 | 32 | 33 | src 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Annotation/ExcludeTokenValidationAttribute.php: -------------------------------------------------------------------------------- 1 | iamClient->getAuthorizationUrl(); 27 | $this->keycloakClientLogger->info('KeycloakController::connect', [ 28 | 'authorizationUrl' => $authorizationUrl, 29 | ]); 30 | if ($request->hasSession()) { 31 | $request->getSession()->set(KeycloakAuthorizationCodeEnum::STATE_SESSION_KEY, $this->iamClient->getState()); 32 | } 33 | 34 | return $this->redirect($authorizationUrl); 35 | } 36 | 37 | #[Route('/auth/keycloak/check', name: 'mainick_keycloak_security_auth_connect_check', methods: ['GET'])] 38 | public function connectCheck(Request $request, string $defaultTargetRouteName): Response 39 | { 40 | $loginReferrer = null; 41 | if ($request->hasSession()) { 42 | $loginReferrer = $request->getSession()->remove(KeycloakAuthorizationCodeEnum::LOGIN_REFERRER); 43 | } 44 | $this->keycloakClientLogger->info('KeycloakController::connectCheck', [ 45 | 'defaultTargetRouteName' => $defaultTargetRouteName, 46 | 'loginReferrer' => $loginReferrer, 47 | ]); 48 | 49 | return $loginReferrer ? $this->redirect($loginReferrer) : $this->redirectToRoute($defaultTargetRouteName); 50 | } 51 | 52 | #[Route('/auth/keycloak/logout', name: 'mainick_keycloak_security_auth_logout', methods: ['GET'])] 53 | public function logout(string $defaultTargetRouteName): Response 54 | { 55 | $this->keycloakClientLogger->info('KeycloakController::logout', [ 56 | 'defaultTargetRouteName' => $defaultTargetRouteName, 57 | ]); 58 | 59 | return $this->redirectToRoute($defaultTargetRouteName); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/DTO/GroupRepresentationDTO.php: -------------------------------------------------------------------------------- 1 | $data 18 | */ 19 | public static function fromArray(array $data): self 20 | { 21 | return new self( 22 | name: $data['name'], 23 | id: $data['id'] ?? null, 24 | path: $data['path'] ?? null, 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/DTO/KeycloakAuthorizationCodeEnum.php: -------------------------------------------------------------------------------- 1 | $attributes 11 | */ 12 | public function __construct( 13 | public string $name, 14 | public ?string $id, 15 | public ?string $description, 16 | public ?bool $composite, 17 | public ?bool $clientRole, 18 | public ?array $attributes, 19 | ) { 20 | } 21 | 22 | /** 23 | * @param array $data 24 | */ 25 | public static function fromArray(array $data): self 26 | { 27 | return new self( 28 | name: $data['name'], 29 | id: $data['id'] ?? null, 30 | description: $data['description'] ?? null, 31 | composite: $data['composite'] ?? null, 32 | clientRole: $data['clientRole'] ?? null, 33 | attributes: $data['attributes'] ?? null, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/DTO/ScopeRepresentationDTO.php: -------------------------------------------------------------------------------- 1 | $data 17 | */ 18 | public static function fromArray(array $data): self 19 | { 20 | return new self( 21 | name: $data['name'], 22 | id: $data['id'] ?? null, 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DTO/UserRepresentationDTO.php: -------------------------------------------------------------------------------- 1 | $attributes 11 | * @param RoleRepresentationDTO[]|null $realmRoles 12 | * @param RoleRepresentationDTO[]|null $clientRoles 13 | * @param RoleRepresentationDTO[]|null $applicationRoles 14 | * @param GroupRepresentationDTO[]|null $groups 15 | * @param ScopeRepresentationDTO[]|null $scope 16 | */ 17 | public function __construct( 18 | public string $id, 19 | public string $username, 20 | public bool $emailVerified, 21 | public string $name, 22 | public string $firstName, 23 | public string $lastName, 24 | public string $email, 25 | public ?bool $enabled, 26 | public ?int $createdTimestamp, 27 | public ?int $updatedAt, 28 | public ?array $attributes, 29 | public ?array $realmRoles, 30 | public ?array $clientRoles, 31 | public ?array $applicationRoles, 32 | public ?array $groups, 33 | public ?array $scope, 34 | ) { 35 | } 36 | 37 | /** 38 | * @param array $data 39 | */ 40 | public static function fromArray(array $data, ?string $client_id = null): self 41 | { 42 | $realm_roles = []; 43 | if (isset($data['realm_access']['roles'])) { 44 | foreach ($data['realm_access']['roles'] as $role_name) { 45 | $dummy = ['name' => $role_name]; 46 | $realm_roles[] = RoleRepresentationDTO::fromArray($dummy); 47 | } 48 | } 49 | 50 | $client_roles = []; 51 | if (isset($data['resource_access'])) { 52 | foreach ($data['resource_access'] as $client_rif) { 53 | if (isset($client_rif['roles'])) { 54 | foreach ($client_rif['roles'] as $role_name) { 55 | $dummy = ['name' => $role_name]; 56 | $client_roles[] = RoleRepresentationDTO::fromArray($dummy); 57 | } 58 | } 59 | } 60 | } 61 | 62 | $application_roles = []; 63 | if ($client_id && isset($data['resource_access'][$client_id]['roles'])) { 64 | foreach ($data['resource_access'][$client_id]['roles'] as $role_name) { 65 | $dummy = ['name' => $role_name]; 66 | $application_roles[] = RoleRepresentationDTO::fromArray($dummy); 67 | } 68 | } 69 | 70 | $groups = []; 71 | foreach ($data['groups'] ?? [] as $group_name) { 72 | $dummy = ['name' => $group_name]; 73 | $groups[] = GroupRepresentationDTO::fromArray($dummy); 74 | } 75 | 76 | $scope = []; 77 | if (isset($data['scope'])) { 78 | foreach (explode(' ', $data['scope']) as $scope_name) { 79 | $dummy = ['name' => $scope_name]; 80 | $scope[] = ScopeRepresentationDTO::fromArray($dummy); 81 | } 82 | } 83 | 84 | return new self( 85 | id: $data['sub'], 86 | username: $data['preferred_username'], 87 | emailVerified: $data['email_verified'], 88 | name: $data['name'], 89 | firstName: $data['given_name'], 90 | lastName: $data['family_name'], 91 | email: $data['email'], 92 | enabled: $data['enabled'] ?? null, 93 | createdTimestamp: $data['createdTimestamp'] ?? null, 94 | updatedAt: $data['updated_at'] ?? null, 95 | attributes: $data['attributes'] ?? null, 96 | realmRoles: count($realm_roles) ? $realm_roles : null, 97 | clientRoles: count($client_roles) ? $client_roles : null, 98 | applicationRoles: count($application_roles) ? $application_roles : null, 99 | groups: count($groups) ? $groups : null, 100 | scope: count($scope) ? $scope : null, 101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 16 | $adminCliChildren = $rootNode->children()->arrayNode('admin_cli')->children(); 17 | 18 | $rootNode 19 | ->children() 20 | ->arrayNode('keycloak') 21 | ->children() 22 | ->booleanNode('verify_ssl')->isRequired()->defaultTrue()->end() 23 | ->scalarNode('base_url')->isRequired()->cannotBeEmpty()->end() 24 | ->scalarNode('realm')->isRequired()->cannotBeEmpty()->end() 25 | ->scalarNode('client_id')->isRequired()->cannotBeEmpty()->end() 26 | ->scalarNode('client_secret')->defaultNull()->end() 27 | ->scalarNode('redirect_uri')->defaultNull()->end() 28 | ->scalarNode('encryption_algorithm')->defaultNull()->end() 29 | ->scalarNode('encryption_key')->defaultNull()->end() 30 | ->scalarNode('encryption_key_path')->defaultNull()->end() 31 | ->scalarNode('encryption_key_passphrase')->defaultNull()->end() 32 | ->scalarNode('version')->defaultNull()->end() 33 | ->end() 34 | ->validate() 35 | ->ifTrue(function ($v) { 36 | return empty($v['encryption_key']) && empty($v['encryption_key_path']); 37 | }) 38 | ->thenInvalid('At least one of "encryption_key" or "encryption_key_path" must be provided.') 39 | ->end() 40 | ->end() 41 | ->arrayNode('security') 42 | ->info('Enable this if you want to use the Keycloak security layer. This will protect your application with Keycloak.') 43 | ->canBeEnabled() 44 | ->children() 45 | ->scalarNode('default_target_route_name')->defaultNull()->end() 46 | ->end() 47 | ->end() 48 | ->arrayNode('admin_cli') 49 | ->info('Enable this if you want to use the admin-cli client to authenticate with Keycloak. This is useful if you want to use the Keycloak Admin REST API.') 50 | ->canBeEnabled() 51 | ->children() 52 | ->scalarNode('realm')->isRequired()->cannotBeEmpty()->end() 53 | ->scalarNode('client_id')->isRequired()->cannotBeEmpty()->end() 54 | ->scalarNode('username')->isRequired()->cannotBeEmpty()->end() 55 | ->scalarNode('password')->isRequired()->cannotBeEmpty()->end() 56 | ->end() 57 | ->end() 58 | ->end(); 59 | 60 | return $treeBuilder; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/DependencyInjection/MainickKeycloakClientExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yaml'); 21 | 22 | $configuration = new Configuration(); 23 | $config = $this->processConfiguration($configuration, $configs); 24 | 25 | foreach ($config['keycloak'] as $key => $value) { 26 | $container->setParameter('mainick_keycloak_client.keycloak.'.$key, $value); 27 | } 28 | foreach ($config['security'] as $key => $value) { 29 | $container->setParameter('mainick_keycloak_client.security.'.$key, $value); 30 | } 31 | foreach ($config['admin_cli'] as $key => $value) { 32 | if ('enabled' === $key) { 33 | continue; 34 | } 35 | $container->setParameter('mainick_keycloak_client.admin_cli.'.$key, $value); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/EventSubscriber/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | getThrowable(); 22 | if ($exception instanceof IdentityProviderException) { 23 | $event->setResponse(new RedirectResponse($this->urlGenerator->generate('mainick_keycloak_security_auth_connect', [], UrlGeneratorInterface::ABSOLUTE_URL))); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/EventSubscriber/LogoutAuthListener.php: -------------------------------------------------------------------------------- 1 | keycloakClientLogger->info('LogoutAuthListener::__invoke'); 29 | if (null === $event->getToken() || null === $event->getToken()->getUser()) { 30 | return; 31 | } 32 | 33 | $user = $event->getToken()->getUser(); 34 | if (!$user instanceof KeycloakResourceOwner) { 35 | return; 36 | } 37 | 38 | $logoutUrl = $this->iamClient->logoutUrl([ 39 | 'state' => $user->getAccessToken()->getValues()['session_state'], 40 | 'access_token' => $user->getAccessToken(), 41 | 'redirect_uri' => $this->urlGenerator->generate($this->defaultTargetRouteName, [], UrlGeneratorInterface::ABSOLUTE_URL), 42 | ]); 43 | $this->keycloakClientLogger->info('LogoutAuthListener::__invoke', [ 44 | 'logoutUrl' => $logoutUrl, 45 | 'token' => $this->tokenStorage->getToken(), 46 | ]); 47 | 48 | $this->tokenStorage->setToken(null); 49 | $event->getRequest()->getSession()->invalidate(); 50 | 51 | $event->setResponse(new RedirectResponse($logoutUrl)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/EventSubscriber/TokenAuthListener.php: -------------------------------------------------------------------------------- 1 | ['checkValidToken', 10], 41 | ]; 42 | } 43 | 44 | public function checkValidToken(RequestEvent $requestEvent): void 45 | { 46 | if (!$requestEvent->isMainRequest()) { 47 | return; 48 | } 49 | 50 | $request = $requestEvent->getRequest(); 51 | 52 | // Check if the route belongs to the API documentation generated by nelmio/api-doc-bundle 53 | // Check if the route belongs to the Controller generated by mainick/keycloak-client-bundle 54 | if ($this->shouldSkipRouteValidation($request->attributes->get('_route'))) { 55 | return; 56 | } 57 | 58 | // Check if the method has the ExcludeTokenValidationAttribute attribute 59 | if ($this->shouldSkipControllerValidation($request->attributes->get('_controller'))) { 60 | return; 61 | } 62 | 63 | $jwtToken = $request->headers->get('X-Auth-Token'); 64 | if (!$jwtToken) { 65 | $this->setUnauthorizedResponse($requestEvent, 'Token not found'); 66 | return; 67 | } 68 | 69 | if (!$this->validateToken($jwtToken, $request)) { 70 | $this->setUnauthorizedResponse($requestEvent, 'Token not valid'); 71 | return; 72 | } 73 | } 74 | 75 | private function shouldSkipRouteValidation(?string $route): bool 76 | { 77 | if (null === $route) { 78 | return false; 79 | } 80 | 81 | return in_array($route, self::EXCLUDED_ROUTES, true) || 82 | !empty(array_filter(self::EXCLUDED_ROUTES_PREFIX, static fn (string $prefix): bool => str_starts_with($route, $prefix))); 83 | } 84 | 85 | private function shouldSkipControllerValidation(mixed $controller): bool 86 | { 87 | if (!$controller) { 88 | return false; 89 | } 90 | 91 | if ($controller instanceof \Closure) { 92 | return true; 93 | } 94 | 95 | $controllerClass = null; 96 | $controllerMethod = null; 97 | if (is_array($controller)) { 98 | $controllerClass = is_object($controller[0]) ? get_class($controller[0]) : $controller[0]; 99 | $controllerMethod = $controller[1]; 100 | } 101 | if (is_string($controller)) { 102 | // Check if "Controller::method" or "Controller:method" format 103 | $parts = preg_split('/:{1,2}/', $controller); 104 | if (count($parts) === 2) { 105 | $controllerClass = $parts[0]; 106 | $controllerMethod = $parts[1]; 107 | } 108 | 109 | if (method_exists($controller, '__invoke')) { 110 | $controllerClass = $controller; 111 | $controllerMethod = '__invoke'; 112 | } 113 | } 114 | 115 | if (!isset($controllerClass) || !isset($controllerMethod)) { 116 | return false; 117 | } 118 | 119 | try { 120 | $reflectionMethod = new \ReflectionMethod($controllerClass, $controllerMethod); 121 | return !empty($reflectionMethod->getAttributes(ExcludeTokenValidationAttribute::class)); 122 | } 123 | catch (\ReflectionException) { 124 | return false; 125 | } 126 | } 127 | 128 | private function validateToken(string $jwtToken, Request $request): bool 129 | { 130 | $token = new AccessToken(); 131 | $token->setToken($jwtToken); 132 | $token->setRefreshToken(''); 133 | $token->setExpires(time() + 3600); 134 | $userInfo = $this->iamClient->userInfo($token); 135 | if ($userInfo) { 136 | $request->attributes->set('user', $userInfo); 137 | return true; 138 | } 139 | 140 | return false; 141 | } 142 | 143 | private function setUnauthorizedResponse(RequestEvent $requestEvent, string $message): void 144 | { 145 | $this->keycloakClientLogger->error($message); 146 | $requestEvent->setResponse(new JsonResponse(['message' => 'Token not found'], Response::HTTP_UNAUTHORIZED)); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Exception/KeycloakAuthenticationException.php: -------------------------------------------------------------------------------- 1 | $options 22 | */ 23 | public function getAuthorizationUrl(array $options = []): string; 24 | 25 | /** 26 | * @param array $options 27 | */ 28 | public function logoutUrl(array $options = []): string; 29 | 30 | /** 31 | * @param array $options 32 | */ 33 | public function authorize(array $options, ?callable $redirectHandler = null): never; 34 | 35 | public function authenticate(string $username, string $password): ?AccessTokenInterface; 36 | 37 | public function getState(): string; 38 | 39 | public function authenticateCodeGrant(string $code): ?AccessTokenInterface; 40 | 41 | /** 42 | * @param array $roles 43 | */ 44 | public function hasAnyRole(AccessTokenInterface $token, array $roles): bool; 45 | 46 | /** 47 | * @param array $roles 48 | */ 49 | public function hasAllRoles(AccessTokenInterface $token, array $roles): bool; 50 | 51 | public function hasRole(AccessTokenInterface $token, string $role): bool; 52 | 53 | /** 54 | * @param array $scopes 55 | */ 56 | public function hasAnyScope(AccessTokenInterface $token, array $scopes): bool; 57 | 58 | /** 59 | * @param array $scopes 60 | */ 61 | public function hasAllScopes(AccessTokenInterface $token, array $scopes): bool; 62 | 63 | public function hasScope(AccessTokenInterface $token, string $scope): bool; 64 | 65 | /** 66 | * @param array $groups 67 | */ 68 | public function hasAnyGroup(AccessTokenInterface $token, array $groups): bool; 69 | 70 | /** 71 | * @param array $groups 72 | */ 73 | public function hasAllGroups(AccessTokenInterface $token, array $groups): bool; 74 | 75 | public function hasGroup(AccessTokenInterface $token, string $group): bool; 76 | } 77 | -------------------------------------------------------------------------------- /src/Interface/ResourceOwnerInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function decode(string $token, string $key): array; 13 | 14 | /** 15 | * @param string $realm 16 | * @param array $tokenDecoded 17 | */ 18 | public function validateToken(string $realm, array $tokenDecoded): void; 19 | } 20 | -------------------------------------------------------------------------------- /src/MainickKeycloakClientBundle.php: -------------------------------------------------------------------------------- 1 | keycloakProvider = new Keycloak([ 34 | 'authServerUrl' => $this->base_url, 35 | 'realm' => $this->admin_realm, 36 | 'clientId' => $this->admin_client_id, 37 | ]); 38 | 39 | if ('' !== $this->version) { 40 | $this->keycloakProvider->setVersion($this->version); 41 | } 42 | 43 | $httpClient = new Client([ 44 | 'verify' => $this->verify_ssl, 45 | ]); 46 | $this->keycloakProvider->setHttpClient($httpClient); 47 | } 48 | 49 | public function getKeycloakProvider(): Keycloak 50 | { 51 | return $this->keycloakProvider; 52 | } 53 | 54 | public function getBaseUrl(): string 55 | { 56 | return $this->base_url; 57 | } 58 | 59 | public function getRealm(): string 60 | { 61 | return $this->admin_realm; 62 | } 63 | 64 | public function getClientId(): string 65 | { 66 | return $this->admin_client_id; 67 | } 68 | 69 | public function getUsername(): string 70 | { 71 | return $this->admin_username; 72 | } 73 | 74 | public function getPassword(): string 75 | { 76 | return $this->admin_password; 77 | } 78 | 79 | public function getVersion(): string 80 | { 81 | return $this->version; 82 | } 83 | 84 | public function getAdminAccessToken(): ?AccessTokenInterface 85 | { 86 | return $this->adminAccessToken; 87 | } 88 | 89 | public function setAdminAccessToken(AccessTokenInterface $adminAccessToken): void 90 | { 91 | $this->adminAccessToken = $adminAccessToken; 92 | } 93 | 94 | public function realms(): RealmsService 95 | { 96 | return new RealmsService($this->keycloakClientLogger, $this); 97 | } 98 | 99 | public function clients(): ClientsService 100 | { 101 | return new ClientsService($this->keycloakClientLogger, $this); 102 | } 103 | 104 | public function users(): UsersService 105 | { 106 | return new UsersService($this->keycloakClientLogger, $this); 107 | } 108 | 109 | public function groups(): GroupsService 110 | { 111 | return new GroupsService($this->keycloakClientLogger, $this); 112 | } 113 | 114 | public function roles(): RolesService 115 | { 116 | return new RolesService($this->keycloakClientLogger, $this); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Provider/KeycloakClient.php: -------------------------------------------------------------------------------- 1 | keycloakProvider = new Keycloak([ 37 | 'authServerUrl' => $this->base_url, 38 | 'realm' => $this->realm, 39 | 'clientId' => $this->client_id, 40 | 'clientSecret' => $this->client_secret, 41 | 'redirectUri' => $this->redirect_uri, 42 | 'encryptionAlgorithm' => $this->encryption_algorithm, 43 | ]); 44 | 45 | if ('RS256' === $this->encryption_algorithm) { 46 | if ('' === $this->encryption_key && '' === $this->encryption_key_path) { 47 | throw new \RuntimeException('Either encryption_key or encryption_key_path must be provided.'); 48 | } 49 | if ('' !== $this->encryption_key) { 50 | $this->keycloakProvider->setEncryptionKey($this->encryption_key); 51 | } elseif ('' !== $this->encryption_key_path) { 52 | $this->keycloakProvider->setEncryptionKeyPath($this->encryption_key_path); 53 | } 54 | } 55 | 56 | if ('' !== $this->version) { 57 | $this->keycloakProvider->setVersion($this->version); 58 | } 59 | 60 | $httpClient = new Client([ 61 | 'verify' => $this->verify_ssl, 62 | ]); 63 | $this->keycloakProvider->setHttpClient($httpClient); 64 | } 65 | 66 | public function setHttpClient(ClientInterface $httpClient): void 67 | { 68 | $this->keycloakProvider->setHttpClient($httpClient); 69 | } 70 | 71 | public function refreshToken(AccessTokenInterface $token): ?AccessTokenInterface 72 | { 73 | try { 74 | $token = $this->keycloakProvider->getAccessToken('refresh_token', [ 75 | 'refresh_token' => $token->getRefreshToken(), 76 | ]); 77 | $accessToken = new AccessToken(); 78 | $accessToken->setToken($token->getToken()) 79 | ->setExpires($token->getExpires()) 80 | ->setRefreshToken($token->getRefreshToken()) 81 | ->setValues($token->getValues()); 82 | 83 | return $accessToken; 84 | } 85 | catch (\Exception $e) { 86 | $this->keycloakClientLogger->error('KeycloakClient::refreshToken', [ 87 | 'error' => $e->getMessage(), 88 | ]); 89 | 90 | return null; 91 | } 92 | } 93 | 94 | public function verifyToken(AccessTokenInterface $token): ?UserRepresentationDTO 95 | { 96 | try { 97 | $accessToken = new AccessTokenLib([ 98 | 'access_token' => $token->getToken(), 99 | 'refresh_token' => $token->getRefreshToken(), 100 | 'expires' => $token->getExpires(), 101 | 'values' => $token->getValues(), 102 | ]); 103 | 104 | $decoder = TokenDecoderFactory::create($this->encryption_algorithm); 105 | $tokenDecoded = $decoder->decode($accessToken->getToken(), $this->encryption_key); 106 | $decoder->validateToken($this->realm, $tokenDecoded); 107 | $this->keycloakClientLogger->info('KeycloakClient::verifyToken', [ 108 | 'tokenDecoded' => $tokenDecoded, 109 | ]); 110 | 111 | $user = new KeycloakResourceOwner($tokenDecoded, $token); 112 | $this->keycloakClientLogger->info('KeycloakClient::verifyToken', [ 113 | 'user' => $user->toArray(), 114 | ]); 115 | 116 | return UserRepresentationDTO::fromArray($user->toArray(), $this->client_id); 117 | } 118 | catch (\Exception $e) { 119 | $this->keycloakClientLogger->error('KeycloakClient::verifyToken', [ 120 | 'error' => $e->getMessage(), 121 | ]); 122 | 123 | return null; 124 | } 125 | } 126 | 127 | public function userInfo(AccessTokenInterface $token): ?UserRepresentationDTO 128 | { 129 | try { 130 | $this->verifyToken($token); 131 | $accessToken = new AccessTokenLib([ 132 | 'access_token' => $token->getToken(), 133 | 'refresh_token' => $token->getRefreshToken(), 134 | 'expires' => $token->getExpires(), 135 | 'values' => $token->getValues(), 136 | ]); 137 | $resourceOwner = $this->keycloakProvider->getResourceOwner($accessToken); 138 | $user = new KeycloakResourceOwner($resourceOwner->toArray(), $token); 139 | $this->keycloakClientLogger->info('KeycloakClient::userInfo', [ 140 | 'user' => $user->toArray(), 141 | ]); 142 | 143 | return UserRepresentationDTO::fromArray($user->toArray(), $this->client_id); 144 | } 145 | catch (\Exception $e) { 146 | $this->keycloakClientLogger->error('KeycloakClient::userInfo', [ 147 | 'error' => $e->getMessage(), 148 | ]); 149 | 150 | return null; 151 | } 152 | } 153 | 154 | public function fetchUserFromToken(AccessTokenInterface $token): ?KeycloakResourceOwner 155 | { 156 | try { 157 | $accessToken = new AccessTokenLib([ 158 | 'access_token' => $token->getToken(), 159 | 'refresh_token' => $token->getRefreshToken(), 160 | 'expires' => $token->getExpires(), 161 | 'values' => $token->getValues(), 162 | ]); 163 | $resourceOwner = $this->keycloakProvider->getResourceOwner($accessToken); 164 | $user = new KeycloakResourceOwner($resourceOwner->toArray(), $token); 165 | $this->keycloakClientLogger->info('KeycloakClient::fetchUserFromToken', [ 166 | 'user' => $user->toArray(), 167 | ]); 168 | 169 | return $user; 170 | } 171 | catch (\UnexpectedValueException $e) { 172 | $this->keycloakClientLogger->warning('KeycloakClient::fetchUserFromToken', [ 173 | 'error' => $e->getMessage(), 174 | 'message' => 'User should have been disconnected from Keycloak server', 175 | ]); 176 | 177 | throw $e; 178 | } 179 | catch (\Exception $e) { 180 | $this->keycloakClientLogger->error('KeycloakClient::fetchUserFromToken', [ 181 | 'error' => $e->getMessage(), 182 | ]); 183 | 184 | return null; 185 | } 186 | } 187 | 188 | public function getState(): string 189 | { 190 | return $this->keycloakProvider->getState(); 191 | } 192 | 193 | /** 194 | * @param array $options 195 | */ 196 | public function getAuthorizationUrl(array $options = []): string 197 | { 198 | return $this->keycloakProvider->getAuthorizationUrl($options); 199 | } 200 | 201 | /** 202 | * @param array $options 203 | */ 204 | public function logoutUrl(array $options = []): string 205 | { 206 | return $this->keycloakProvider->getLogoutUrl($options); 207 | } 208 | 209 | /** 210 | * @param array $options 211 | */ 212 | public function authorize(array $options, ?callable $redirectHandler = null): never 213 | { 214 | try { 215 | $this->keycloakProvider->authorize($options, $redirectHandler); 216 | } 217 | catch (\Exception $e) { 218 | $this->keycloakClientLogger->error('KeycloakClient::authorize', [ 219 | 'error' => $e->getMessage(), 220 | ]); 221 | } 222 | exit; 223 | } 224 | 225 | public function authenticate(string $username, string $password): ?AccessTokenInterface 226 | { 227 | try { 228 | $token = $this->keycloakProvider->getAccessToken('password', [ 229 | 'username' => $username, 230 | 'password' => $password, 231 | 'scope' => 'openid', 232 | ]); 233 | $accessToken = new AccessToken(); 234 | $accessToken->setToken($token->getToken()) 235 | ->setExpires($token->getExpires()) 236 | ->setRefreshToken($token->getRefreshToken()) 237 | ->setValues($token->getValues()); 238 | 239 | $this->keycloakClientLogger->info('KeycloakClient::authenticate', [ 240 | 'token' => $accessToken->getToken(), 241 | 'expires' => $accessToken->getExpires(), 242 | 'refresh_token' => $accessToken->getRefreshToken(), 243 | ]); 244 | 245 | return $accessToken; 246 | } 247 | catch (\Exception $e) { 248 | $this->keycloakClientLogger->error('KeycloakClient::authenticate', [ 249 | 'error' => $e->getMessage(), 250 | ]); 251 | 252 | return null; 253 | } 254 | } 255 | 256 | public function authenticateCodeGrant(string $code): ?AccessTokenInterface 257 | { 258 | try { 259 | $token = $this->keycloakProvider->getAccessToken('authorization_code', [ 260 | 'code' => $code, 261 | ]); 262 | $accessToken = new AccessToken(); 263 | $accessToken->setToken($token->getToken()) 264 | ->setExpires($token->getExpires()) 265 | ->setRefreshToken($token->getRefreshToken()) 266 | ->setValues($token->getValues()); 267 | 268 | $this->keycloakClientLogger->info('KeycloakClient::authenticateCodeGrant', [ 269 | 'token' => $accessToken->getToken(), 270 | 'expires' => $accessToken->getExpires(), 271 | 'refresh_token' => $accessToken->getRefreshToken(), 272 | ]); 273 | 274 | return $accessToken; 275 | } 276 | catch (\Exception $e) { 277 | $this->keycloakClientLogger->error('KeycloakClient::authenticateCodeGrant', [ 278 | 'error' => $e->getMessage(), 279 | ]); 280 | 281 | return null; 282 | } 283 | } 284 | 285 | /** 286 | * @param array $roles 287 | */ 288 | public function hasAnyRole(AccessTokenInterface $token, array $roles): bool 289 | { 290 | $user = $this->verifyToken($token); 291 | 292 | $userRoles = array_merge( 293 | $user->applicationRoles ?? [], 294 | $user->clientRoles ?? [], 295 | $user->realmRoles ?? [] 296 | ); 297 | 298 | $rolesList = array_map(static fn ($role) => $role->name, $userRoles); 299 | if (empty($rolesList)) { 300 | return false; 301 | } 302 | 303 | return count(array_intersect($roles, $rolesList)) > 0; 304 | } 305 | 306 | /** 307 | * @param array $roles 308 | */ 309 | public function hasAllRoles(AccessTokenInterface $token, array $roles): bool 310 | { 311 | $user = $this->verifyToken($token); 312 | 313 | $userRoles = array_merge( 314 | $user->applicationRoles ?? [], 315 | $user->clientRoles ?? [], 316 | $user->realmRoles ?? [] 317 | ); 318 | 319 | $rolesList = array_map(static fn ($role) => $role->name, $userRoles); 320 | if (empty($rolesList)) { 321 | return false; 322 | } 323 | 324 | $exists = array_intersect($roles, $rolesList); 325 | 326 | return count($exists) === count($roles); 327 | } 328 | 329 | public function hasRole(AccessTokenInterface $token, string $role): bool 330 | { 331 | $user = $this->verifyToken($token); 332 | 333 | $userRoles = array_merge( 334 | $user->applicationRoles ?? [], 335 | $user->clientRoles ?? [], 336 | $user->realmRoles ?? [] 337 | ); 338 | 339 | $rolesList = array_map(static fn ($role) => $role->name, $userRoles); 340 | if (empty($rolesList)) { 341 | return false; 342 | } 343 | 344 | return in_array($role, $rolesList, true); 345 | } 346 | 347 | /** 348 | * @param array $scopes 349 | */ 350 | public function hasAnyScope(AccessTokenInterface $token, array $scopes): bool 351 | { 352 | $user = $this->verifyToken($token); 353 | 354 | $scopesList = array_map(static fn ($scope) => $scope->name, $user->scope ?? []); 355 | if (empty($scopesList)) { 356 | return false; 357 | } 358 | 359 | return count(array_intersect($scopes, $scopesList)) > 0; 360 | } 361 | 362 | /** 363 | * @param array $scopes 364 | */ 365 | public function hasAllScopes(AccessTokenInterface $token, array $scopes): bool 366 | { 367 | $user = $this->verifyToken($token); 368 | 369 | $scopesList = array_map(static fn ($scope) => $scope->name, $user->scope ?? []); 370 | if (empty($scopesList)) { 371 | return false; 372 | } 373 | 374 | $exists = array_intersect($scopes, $scopesList); 375 | 376 | return count($exists) === count($scopes); 377 | } 378 | 379 | public function hasScope(AccessTokenInterface $token, string $scope): bool 380 | { 381 | $user = $this->verifyToken($token); 382 | 383 | $scopesList = array_map(static fn ($scope) => $scope->name, $user->scope ?? []); 384 | if (empty($scopesList)) { 385 | return false; 386 | } 387 | 388 | return in_array($scope, $scopesList, true); 389 | } 390 | 391 | /** 392 | * @param array $groups 393 | */ 394 | public function hasAnyGroup(AccessTokenInterface $token, array $groups): bool 395 | { 396 | $user = $this->verifyToken($token); 397 | 398 | $groupsList = array_map(static fn ($group) => $group->name, $user->groups ?? []); 399 | if (empty($groupsList)) { 400 | return false; 401 | } 402 | 403 | return count(array_intersect($groups, $groupsList)) > 0; 404 | } 405 | 406 | /** 407 | * @param array $groups 408 | */ 409 | public function hasAllGroups(AccessTokenInterface $token, array $groups): bool 410 | { 411 | $user = $this->verifyToken($token); 412 | 413 | $groupsList = array_map(static fn ($group) => $group->name, $user->groups ?? []); 414 | if (empty($groupsList)) { 415 | return false; 416 | } 417 | 418 | $exists = array_intersect($groups, $groupsList); 419 | 420 | return count($exists) === count($groups); 421 | } 422 | 423 | public function hasGroup(AccessTokenInterface $token, string $group): bool 424 | { 425 | $user = $this->verifyToken($token); 426 | 427 | $groupsList = array_map(static fn ($group) => $group->name, $user->groups ?? []); 428 | if (empty($groupsList)) { 429 | return false; 430 | } 431 | 432 | return in_array($group, $groupsList, true); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/Representation/ClientRepresentation.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ClientCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return ClientRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/ClientScopeCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ClientScopeCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return ClientScopeRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/Collection.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | abstract class Collection implements \Countable, \IteratorAggregate, \JsonSerializable 15 | { 16 | /** 17 | * @var array 18 | */ 19 | protected array $items = []; 20 | 21 | /** 22 | * @param iterable $items 23 | */ 24 | public function __construct(iterable $items = []) 25 | { 26 | foreach ($items as $item) { 27 | $this->add($item); 28 | } 29 | } 30 | 31 | public function count(): int 32 | { 33 | return count($this->items); 34 | } 35 | 36 | /** 37 | * @return \ArrayIterator 38 | */ 39 | public function getIterator(): \ArrayIterator 40 | { 41 | return new \ArrayIterator($this->items); 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | public function jsonSerialize(): array 48 | { 49 | return $this->items; 50 | } 51 | 52 | /** 53 | * @param T $representation 54 | */ 55 | public function add(Representation $representation): void 56 | { 57 | $expectedClass = static::getRepresentationClass(); 58 | if (!$representation instanceof $expectedClass) { 59 | throw new \InvalidArgumentException(sprintf( 60 | '%s expects items to be %s representation, %s given', 61 | (new \ReflectionClass(static::class))->getShortName(), 62 | (new \ReflectionClass($expectedClass))->getShortName(), 63 | (new \ReflectionClass($representation))->getShortName() 64 | )); 65 | } 66 | 67 | $this->items[] = $representation; 68 | } 69 | 70 | /** 71 | * @return T|null 72 | */ 73 | public function first(): ?Representation 74 | { 75 | return $this->items[0] ?? null; 76 | } 77 | 78 | /** 79 | * @return array 80 | */ 81 | public function all(): array 82 | { 83 | return $this->items; 84 | } 85 | 86 | /** 87 | * @return class-string 88 | */ 89 | abstract public static function getRepresentationClass(): string; 90 | } 91 | -------------------------------------------------------------------------------- /src/Representation/Collection/CredentialCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class CredentialCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return CredentialRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/GroupCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class GroupCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return GroupRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/ProtocolMapperCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ProtocolMapperCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return ProtocolMapperRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/RealmCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class RealmCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return RealmRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/RoleCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class RoleCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return RoleRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UPAttributeCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UPAttributeCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return UPAttribute::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UPGroupCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UPGroupCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return UPGroup::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UserCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return UserRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UserConsentCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserConsentCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return UserConsentRepresentation::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UserProfileAttributeGroupMetadataCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserProfileAttributeGroupMetadataCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return UserProfileAttributeGroupMetadata::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UserProfileAttributeMetadataCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserProfileAttributeMetadataCollection extends Collection 13 | { 14 | /** 15 | * @inheritDoc 16 | */ 17 | public static function getRepresentationClass(): string 18 | { 19 | return UserProfileAttributeMetadata::class; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Representation/Collection/UserSessionCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserSessionCollection extends Collection 13 | { 14 | public static function getRepresentationClass(): string 15 | { 16 | return UserSessionRepresentation::class; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Representation/Composites.php: -------------------------------------------------------------------------------- 1 | $value) { 23 | $representation = $representation->withProperty($property, $value); 24 | } 25 | 26 | return $representation; 27 | } 28 | 29 | /** 30 | * @param string $json 31 | * @return static 32 | * @throws PropertyDoesNotExistException 33 | */ 34 | public static function fromJson(string $json): static 35 | { 36 | return static::from((new JsonEncoder())->decode($json, JsonEncoder::FORMAT)); 37 | } 38 | 39 | final public function jsonSerialize(): array 40 | { 41 | $serializable = []; 42 | $reflectedClass = (new \ReflectionClass($this)); 43 | $properties = $reflectedClass->getProperties(\ReflectionProperty::IS_PUBLIC); 44 | foreach ($properties as $property) { 45 | $serializable[$property->getName()] = ($property->getValue($this) instanceof \JsonSerializable) 46 | ? $property->getValue($this)->jsonSerialize() 47 | : $property->getValue($this); 48 | } 49 | 50 | return $serializable; 51 | } 52 | 53 | /** 54 | * @throws PropertyDoesNotExistException 55 | */ 56 | private function withProperty(string $property, mixed $value): static 57 | { 58 | if (!property_exists(static::class, $property)) { 59 | throw new PropertyDoesNotExistException(sprintf('Property "%s" does not exist in %s.', $property, static::class)); 60 | } 61 | 62 | $representation = clone $this; 63 | $representation->$property = $value; 64 | 65 | return $representation; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Representation/RoleRepresentation.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Map extends Type implements \Countable, \IteratorAggregate 15 | { 16 | /** 17 | * @param array $data 18 | */ 19 | public function __construct( 20 | private array $data = [] 21 | ) { 22 | } 23 | 24 | /** 25 | * @return \ArrayIterator 26 | */ 27 | public function getIterator(): \ArrayIterator 28 | { 29 | return new \ArrayIterator($this->data); 30 | } 31 | 32 | public function count(): int 33 | { 34 | return count($this->data); 35 | } 36 | 37 | public function jsonSerialize(): object 38 | { 39 | return (object) $this->data; 40 | } 41 | 42 | public function contains(string $key): bool 43 | { 44 | return array_key_exists($key, $this->data); 45 | } 46 | 47 | /** 48 | * @return T 49 | */ 50 | public function get(string $key): mixed 51 | { 52 | if (!$this->contains($key)) { 53 | throw new \InvalidArgumentException(sprintf('Key "%s" does not exist.', $key)); 54 | } 55 | 56 | return $this->data[$key]; 57 | } 58 | 59 | public function with(string $key, mixed $value): self 60 | { 61 | $clone = clone $this; 62 | $clone->data[$key] = $value; 63 | 64 | return $clone; 65 | } 66 | 67 | public function without(string $key): self 68 | { 69 | $clone = clone $this; 70 | unset($clone->data[$key]); 71 | 72 | return $clone; 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | public function getMap(): array 79 | { 80 | return $this->data; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Representation/Type/Type.php: -------------------------------------------------------------------------------- 1 | attributes->get('_route'); 35 | } 36 | 37 | public function authenticate(Request $request): Passport 38 | { 39 | $queryState = $request->query->get(KeycloakAuthorizationCodeEnum::STATE_KEY); 40 | $sessionState = $request->getSession()->get(KeycloakAuthorizationCodeEnum::STATE_SESSION_KEY); 41 | if (null === $queryState || $queryState !== $sessionState) { 42 | throw new AuthenticationException(sprintf('query state (%s) is not the same as session state (%s)', $queryState ?? 'NULL', $sessionState ?? 'NULL')); 43 | } 44 | 45 | $queryCode = $request->query->get(KeycloakAuthorizationCodeEnum::CODE_KEY); 46 | if (null === $queryCode) { 47 | throw new AuthenticationException('Authentication failed! Did you authorize our app?'); 48 | } 49 | 50 | try { 51 | $accessToken = $this->iamClient->authenticateCodeGrant($queryCode); 52 | } 53 | catch (IdentityProviderException $e) { 54 | throw new AuthenticationException(sprintf('Error authenticating code grant (%s)', $e->getMessage()), previous: $e); 55 | } 56 | catch (\Exception $e) { 57 | throw new AuthenticationException(sprintf('Bad status code returned by openID server (%s)', $e->getStatusCode()), previous: $e); 58 | } 59 | 60 | if (!$accessToken || !$accessToken->getToken()) { 61 | $this->keycloakClientLogger->error('KeycloakAuthenticator::authenticate', [ 62 | 'error' => 'No access token provided', 63 | ]); 64 | throw new CustomUserMessageAuthenticationException('No access token provided'); 65 | } 66 | 67 | if (!$accessToken->getRefreshToken()) { 68 | $this->keycloakClientLogger->error('Authenticator::authenticate', [ 69 | 'error' => 'Refresh token not found', 70 | ]); 71 | throw new CustomUserMessageAuthenticationException('Refresh token not found'); 72 | } 73 | 74 | return new SelfValidatingPassport(new UserBadge($accessToken->getToken(), fn () => $this->userProvider->loadUserByIdentifier($accessToken))); 75 | } 76 | 77 | public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response 78 | { 79 | return null; 80 | } 81 | 82 | public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response 83 | { 84 | $request->getSession()->getBag('flashes')->add( 85 | 'error', 86 | 'An authentication error occured', 87 | ); 88 | 89 | // $message = strtr($exception->getMessageKey(), $exception->getMessageData()); 90 | return new Response('Authentication failed', Response::HTTP_FORBIDDEN); 91 | } 92 | 93 | public function isInteractive(): bool 94 | { 95 | return true; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Security/EntryPoint/KeycloakAuthenticationEntryPoint.php: -------------------------------------------------------------------------------- 1 | isXmlHttpRequest()) { 29 | return new JsonResponse( 30 | [ 31 | 'code' => Response::HTTP_UNAUTHORIZED, 32 | 'message' => 'Authentication Required', 33 | 'login_url' => $this->urlGenerator->generate('mainick_keycloak_security_auth_connect'), 34 | ], 35 | Response::HTTP_UNAUTHORIZED 36 | ); 37 | } 38 | 39 | if ($request->hasSession()) { 40 | $request->getSession()->set(KeycloakAuthorizationCodeEnum::LOGIN_REFERRER, $request->getUri()); 41 | 42 | $request->getSession()->getBag('flashes')->add( 43 | 'info', 44 | 'Please log in to access this page', 45 | ); 46 | } 47 | 48 | $this->keycloakClientLogger?->info('KeycloakAuthenticationEntryPoint::start', [ 49 | 'path' => $request->getPathInfo(), 50 | 'error' => $authException?->getMessage(), 51 | 'loginReferrer' => $request->getUri(), 52 | ]); 53 | 54 | return new RedirectResponse( 55 | $this->urlGenerator->generate('mainick_keycloak_security_auth_connect'), 56 | Response::HTTP_TEMPORARY_REDIRECT 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Security/User/KeycloakUserProvider.php: -------------------------------------------------------------------------------- 1 | getAccessToken(); 32 | if (!$accessToken) { 33 | $this->keycloakClientLogger->error('KeycloakUserProvider::refreshUser', [ 34 | 'message' => 'User does not have an access token.', 35 | 'user_id' => $user->getUserIdentifier(), 36 | ]); 37 | throw new AuthenticationException('No valid access token available. Please login again.'); 38 | } 39 | 40 | try { 41 | if ($accessToken->hasExpired()) { 42 | $accessToken = $this->iamClient->refreshToken($accessToken); 43 | if (!$accessToken) { 44 | throw new AuthenticationException('Failed to refresh user session. Please login again.'); 45 | } 46 | } 47 | 48 | return $this->loadUserByIdentifier($accessToken); 49 | } 50 | catch (\Exception $e) { 51 | $this->keycloakClientLogger->error('KeycloakUserProvider::refreshUser', [ 52 | 'error' => $e->getMessage(), 53 | 'message' => 'Failed to refresh user access token', 54 | 'user_id' => $user->getUserIdentifier(), 55 | ]); 56 | 57 | throw new AuthenticationException('Failed to refresh user session. Please login again.'); 58 | } 59 | } 60 | 61 | public function supportsClass(string $class): bool 62 | { 63 | return KeycloakResourceOwner::class === $class; 64 | } 65 | 66 | public function loadUserByIdentifier($identifier): UserInterface 67 | { 68 | if (!$identifier instanceof AccessTokenInterface) { 69 | throw new \LogicException('Could not load a KeycloakUser without an AccessToken.'); 70 | } 71 | 72 | try { 73 | $resourceOwner = $this->iamClient->fetchUserFromToken($identifier); 74 | if (!$resourceOwner) { 75 | $this->keycloakClientLogger->info('KeycloakUserProvider::loadUserByIdentifier', [ 76 | 'message' => 'User not found', 77 | 'token' => $identifier->getToken(), 78 | ]); 79 | throw new UserNotFoundException('User not found or invalid token.'); 80 | } 81 | 82 | $this->keycloakClientLogger->info('KeycloakUserProvider::loadUserByIdentifier', [ 83 | 'resourceOwner' => $resourceOwner->toArray(), 84 | ]); 85 | 86 | return $resourceOwner; 87 | } 88 | catch (\UnexpectedValueException $e) { 89 | $this->keycloakClientLogger->warning('KeycloakUserProvider::loadUserByIdentifier', [ 90 | 'error' => $e->getMessage(), 91 | 'message' => 'User should have been disconnected from Keycloak server', 92 | 'token' => $identifier->getToken(), 93 | ]); 94 | 95 | throw new UserNotFoundException('Failed to load user from token.'); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Serializer/AttributeNormalizer.php: -------------------------------------------------------------------------------- 1 | , array> $filteredProperties */ 15 | private array $filteredProperties = []; 16 | 17 | public function __construct( 18 | private readonly NormalizerInterface $normalizer, 19 | private readonly ?string $keycloakVersion = null, 20 | ) { 21 | } 22 | 23 | /** 24 | * @inheritDoc 25 | * @param array $context 26 | */ 27 | public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null 28 | { 29 | $properties = $this->normalizer->normalize($data, $format, $context); 30 | if (!$this->keycloakVersion) { 31 | return $properties; 32 | } 33 | 34 | foreach ($this->getFilteredProperties($data) as $property => $versions) { 35 | if (array_key_exists('since', $versions) && version_compare($this->keycloakVersion, $versions['since']) < 0) { 36 | unset($properties[$property]); 37 | } 38 | 39 | if (array_key_exists('until', $versions) && version_compare($this->keycloakVersion, $versions['until']) > 0) { 40 | unset($properties[$property]); 41 | } 42 | } 43 | 44 | return $properties; 45 | } 46 | 47 | /** 48 | * @inheritDoc 49 | * @param array $context 50 | */ 51 | public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool 52 | { 53 | return $data instanceof Representation; 54 | } 55 | 56 | /** 57 | * @inheritDoc 58 | */ 59 | public function getSupportedTypes(?string $format): array 60 | { 61 | return [ 62 | Representation::class => true, 63 | ]; 64 | } 65 | 66 | private function getFilteredProperties(Representation $representation): array 67 | { 68 | if (array_key_exists($representation::class, $this->filteredProperties)) { 69 | return $this->filteredProperties[$representation::class]; 70 | } 71 | 72 | $filteredProperties = []; 73 | $properties = (new \ReflectionClass($representation))->getProperties(); 74 | foreach ($properties as $property) { 75 | $sinceAttribute = $property->getAttributes(Since::class); 76 | foreach ($sinceAttribute as $since) { 77 | $filteredProperties[$property->getName()]['since'] = $since->getArguments()['version']; 78 | } 79 | 80 | $untilAttribute = $property->getAttributes(Until::class); 81 | foreach ($untilAttribute as $until) { 82 | $filteredProperties[$property->getName()]['until'] = $until->getArguments()['version']; 83 | } 84 | } 85 | 86 | $this->filteredProperties[$representation::class] = $filteredProperties; 87 | 88 | return $filteredProperties; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Serializer/CollectionDenormalizer.php: -------------------------------------------------------------------------------- 1 | add($this->denormalizer->denormalize($representation, $collection::getRepresentationClass(), $format, $context)); 23 | } 24 | 25 | return $collection; 26 | } 27 | 28 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 29 | { 30 | return is_subclass_of($type, Collection::class); 31 | } 32 | 33 | public function getSupportedTypes(?string $format): array 34 | { 35 | return [ 36 | Collection::class => true 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Serializer/MapDenormalizer.php: -------------------------------------------------------------------------------- 1 | $context 16 | */ 17 | public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed 18 | { 19 | if ($data instanceof Map) { 20 | return $data; 21 | } 22 | 23 | if (!is_array($data) || empty($data)) { 24 | return new Map(); 25 | } 26 | 27 | return new Map($data); 28 | } 29 | 30 | /** 31 | * @inheritDoc 32 | * @param array $context 33 | */ 34 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 35 | { 36 | return $type === Map::class; 37 | } 38 | 39 | /** 40 | * @inheritDoc 41 | */ 42 | public function getSupportedTypes(?string $format): array 43 | { 44 | return [ 45 | Map::class => true 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Serializer/MapNormalizer.php: -------------------------------------------------------------------------------- 1 | $context 16 | */ 17 | public function normalize(mixed $data, ?string $format = null, array $context = []): \ArrayObject 18 | { 19 | if (!$data instanceof Map) { 20 | throw new \InvalidArgumentException('Data must be an instance of Map.'); 21 | } 22 | 23 | return new \ArrayObject($data->jsonSerialize()); 24 | } 25 | 26 | /** 27 | * @inheritDoc 28 | * @param array $context 29 | */ 30 | public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool 31 | { 32 | return $data instanceof Map; 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | public function getSupportedTypes(?string $format): array 39 | { 40 | return [ 41 | Map::class => true 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Serializer/RepresentationDenormalizer.php: -------------------------------------------------------------------------------- 1 | getConstructor(); 39 | if (null !== $constructor) { 40 | foreach ($constructor->getParameters() as $param) { 41 | $paramName = $param->getName(); 42 | if (array_key_exists($paramName, $data)) { 43 | $paramValue = $data[$paramName]; 44 | $paramType = $param->getType(); 45 | 46 | if (null !== $paramType && !$paramType->isBuiltin() && is_array($paramValue)) { 47 | $paramTypeName = $paramType->getName(); 48 | 49 | // This is the recursive part 50 | if ( 51 | class_exists($paramTypeName) && 52 | (is_subclass_of($paramTypeName, Collection::class) || is_subclass_of($paramTypeName, Representation::class)) 53 | ) { 54 | $paramValue = $this->denormalizer->denormalize($paramValue, $paramTypeName, $format, $context); 55 | } 56 | } 57 | 58 | $constructorParams[$paramName] = $paramValue; 59 | } 60 | else { 61 | $constructorParams[$paramName] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; 62 | } 63 | } 64 | 65 | $representation = $reflectionClass->newInstanceArgs($constructorParams); 66 | } 67 | else { 68 | $representation = $type::from($data); 69 | } 70 | 71 | return $representation; 72 | } 73 | 74 | /** 75 | * @inheritDoc 76 | */ 77 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 78 | { 79 | return is_array($data) && class_exists($type) && is_subclass_of($type, Representation::class); 80 | } 81 | 82 | /** 83 | * @inheritDoc 84 | */ 85 | public function getSupportedTypes(?string $format): array 86 | { 87 | return [ 88 | Representation::class => true 89 | ]; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Serializer/Serializer.php: -------------------------------------------------------------------------------- 1 | PropertyNormalizer::NORMALIZE_PUBLIC 30 | ] 31 | ); 32 | 33 | $this->serializer = new SymfonySerializer( 34 | [ 35 | new BackedEnumNormalizer(), 36 | new ArrayDenormalizer(), 37 | new CollectionDenormalizer($propertyNormalizer), 38 | new MapNormalizer(), 39 | new MapDenormalizer(), 40 | new AttributeNormalizer($propertyNormalizer, $this->keycloakVersion), 41 | $propertyNormalizer, 42 | ], 43 | [ 44 | new JsonEncoder(), 45 | ], 46 | [ 47 | 'json_encode_options' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, 48 | ] 49 | ); 50 | } 51 | 52 | public function serialize(mixed $data): ?string 53 | { 54 | return null === $data ? null : $this->serializer->serialize($data, JsonEncoder::FORMAT); 55 | } 56 | 57 | public function deserialize(mixed $data, string $type): mixed 58 | { 59 | return $this->serializer->deserialize($data, $type, JsonEncoder::FORMAT); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Service/ClientsService.php: -------------------------------------------------------------------------------- 1 | |null 20 | */ 21 | public function all(string $realm, ?Criteria $criteria = null): ?ClientCollection 22 | { 23 | return $this->executeQuery('admin/realms/'.$realm.'/clients', ClientCollection::class, $criteria); 24 | } 25 | 26 | public function get(string $realm, string $clientUuid): ?ClientRepresentation 27 | { 28 | return $this->executeQuery('admin/realms/'.$realm.'/clients/'.$clientUuid, ClientRepresentation::class); 29 | } 30 | 31 | public function create(string $realm, ClientRepresentation $client): bool 32 | { 33 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/'.$realm.'/clients', $client); 34 | } 35 | 36 | public function update(string $realm, string $clientUuid, ClientRepresentation $client): bool 37 | { 38 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm.'/clients/'.$clientUuid, $client); 39 | } 40 | 41 | public function delete(string $realm, string $clientUuid): bool 42 | { 43 | return $this->executeCommand(HttpMethodEnum::DELETE, 'admin/realms/'.$realm.'/clients/'.$clientUuid); 44 | } 45 | 46 | public function getClientSecret(string $realm, string $clientUuid): ?CredentialRepresentation 47 | { 48 | return $this->executeQuery('admin/realms/'.$realm.'/clients/'.$clientUuid.'/client-secret', CredentialRepresentation::class); 49 | } 50 | 51 | public function getUserSessions(string $realm, string $clientUuid): ?UserSessionCollection 52 | { 53 | return $this->executeQuery('admin/realms/'.$realm.'/clients/'.$clientUuid.'/user-sessions', UserSessionCollection::class); 54 | } 55 | 56 | /** 57 | * @return RoleCollection|null 58 | */ 59 | public function roles(string $realm, string $clientUuid): ?RoleCollection 60 | { 61 | return $this->executeQuery('admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles', RoleCollection::class); 62 | } 63 | 64 | public function role(string $realm, string $clientUuid, string $roleName): ?RoleRepresentation 65 | { 66 | return $this->executeQuery('admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles/'.$roleName, RoleRepresentation::class); 67 | } 68 | 69 | public function createRole(string $realm, string $clientUuid, RoleRepresentation $role): bool 70 | { 71 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles', $role); 72 | } 73 | 74 | public function updateRole(string $realm, string $clientUuid, string $roleName, RoleRepresentation $role): bool 75 | { 76 | return $this->executeCommand( 77 | HttpMethodEnum::PUT, 78 | 'admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles/'.$roleName, 79 | $role 80 | ); 81 | } 82 | 83 | public function deleteRole(string $realm, string $clientUuid, string $roleName): bool 84 | { 85 | return $this->executeCommand( 86 | HttpMethodEnum::DELETE, 87 | 'admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles/'.$roleName 88 | ); 89 | } 90 | 91 | /** 92 | * @return GroupCollection|null 93 | */ 94 | public function getRoleGroups( 95 | string $realm, 96 | string $clientUuid, 97 | string $roleName, 98 | ?Criteria $criteria = null 99 | ): ?GroupCollection 100 | { 101 | return $this->executeQuery( 102 | 'admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles/'.$roleName.'/groups', 103 | GroupCollection::class, 104 | $criteria 105 | ); 106 | } 107 | 108 | /** 109 | * @return UserCollection|null 110 | */ 111 | public function getRoleUsers( 112 | string $realm, 113 | string $clientUuid, 114 | string $roleName, 115 | ?Criteria $criteria = null 116 | ): ?UserCollection 117 | { 118 | return $this->executeQuery( 119 | 'admin/realms/'.$realm.'/clients/'.$clientUuid.'/roles/'.$roleName.'/users', 120 | UserCollection::class, 121 | $criteria 122 | ); 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Service/Criteria.php: -------------------------------------------------------------------------------- 1 | $criteria 11 | */ 12 | public function __construct( 13 | private array $criteria = [] 14 | ) { 15 | } 16 | 17 | public function jsonSerialize(): array 18 | { 19 | return array_filter( 20 | array_map( 21 | static function ($value) { 22 | if (is_bool($value)) { 23 | return $value ? 'true' : 'false'; 24 | } 25 | 26 | if ($value instanceof \DateTimeInterface) { 27 | return $value->format('Y-m-d'); 28 | } 29 | 30 | if ($value instanceof \Stringable) { 31 | return $value->__toString(); 32 | } 33 | 34 | return $value; 35 | }, 36 | $this->criteria 37 | ), 38 | static fn($value) => null !== $value 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Service/GroupsService.php: -------------------------------------------------------------------------------- 1 | |null 17 | */ 18 | public function all(string $realm, ?Criteria $criteria = null): ?GroupCollection 19 | { 20 | return $this->executeQuery('admin/realms/'.$realm.'/groups', GroupCollection::class, $criteria); 21 | } 22 | 23 | public function count(string $realm, ?Criteria $criteria = null): int 24 | { 25 | $count = $this->executeQuery('admin/realms/'.$realm.'/groups/count', 'array', $criteria); 26 | if (null === $count) { 27 | return 0; 28 | } 29 | 30 | return (int) $count; 31 | } 32 | 33 | /** 34 | * @return GroupCollection|null 35 | */ 36 | public function children(string $realm, string $groupId, ?Criteria $criteria = null): ?GroupCollection 37 | { 38 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId.'/children', GroupCollection::class, $criteria); 39 | } 40 | 41 | public function get(string $realm, string $groupId): ?GroupRepresentation 42 | { 43 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId, GroupRepresentation::class); 44 | } 45 | 46 | public function create(string $realm, GroupRepresentation $group): bool 47 | { 48 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/'.$realm.'/groups', $group); 49 | } 50 | 51 | public function createChild(string $realm, string $parentGroupId, GroupRepresentation $group): bool 52 | { 53 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/'.$realm.'/groups/'.$parentGroupId.'/children', $group); 54 | } 55 | 56 | public function update(string $realm, string $groupId, GroupRepresentation $group): bool 57 | { 58 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm.'/groups/'.$groupId, $group); 59 | } 60 | 61 | public function delete(string $realm, string $groupId): bool 62 | { 63 | return $this->executeCommand(HttpMethodEnum::DELETE, 'admin/realms/'.$realm.'/groups/'.$groupId); 64 | } 65 | 66 | /** 67 | * @return UserCollection|null 68 | */ 69 | public function users(string $realm, string $groupId): ?UserCollection 70 | { 71 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId.'/members', UserCollection::class); 72 | } 73 | 74 | /** 75 | * @return RoleCollection|null 76 | */ 77 | public function realmRoles(string $realm, string $groupId): ?RoleCollection 78 | { 79 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/realm', RoleCollection::class); 80 | } 81 | 82 | /** 83 | * @return RoleCollection|null 84 | */ 85 | public function availableRealmRoles(string $realm, string $groupId): ?RoleCollection 86 | { 87 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/realm/available', RoleCollection::class); 88 | } 89 | 90 | public function addRealmRole(string $realm, string $groupId, RoleRepresentation $role): bool 91 | { 92 | $roles = new RoleCollection(); 93 | $roles->add($role); 94 | return $this->executeCommand( 95 | HttpMethodEnum::POST, 96 | 'admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/realm', 97 | $roles 98 | ); 99 | } 100 | 101 | public function removeRealmRole(string $realm, string $groupId, RoleRepresentation $role): bool 102 | { 103 | $roles = new RoleCollection(); 104 | $roles->add($role); 105 | return $this->executeCommand( 106 | HttpMethodEnum::DELETE, 107 | 'admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/realm', 108 | $roles 109 | ); 110 | } 111 | 112 | /** 113 | * @return RoleCollection|null 114 | */ 115 | public function clientRoles(string $realm, string $clientUuid, string $groupId): ?RoleCollection 116 | { 117 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/clients/'.$clientUuid, RoleCollection::class); 118 | } 119 | 120 | /** 121 | * @return RoleCollection|null 122 | */ 123 | public function availableClientRoles(string $realm, string $clientUuid, string $groupId): ?RoleCollection 124 | { 125 | return $this->executeQuery('admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/clients/'.$clientUuid.'/available', RoleCollection::class); 126 | } 127 | 128 | public function addClientRole(string $realm, string $clientUuid, string $groupId, RoleRepresentation $role): bool 129 | { 130 | $roles = new RoleCollection(); 131 | $roles->add($role); 132 | return $this->executeCommand( 133 | HttpMethodEnum::POST, 134 | 'admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/clients/'.$clientUuid, 135 | $roles 136 | ); 137 | } 138 | 139 | public function removeClientRole(string $realm, string $clientUuid, string $groupId, RoleRepresentation $role): bool 140 | { 141 | $roles = new RoleCollection(); 142 | $roles->add($role); 143 | return $this->executeCommand( 144 | HttpMethodEnum::DELETE, 145 | 'admin/realms/'.$realm.'/groups/'.$groupId.'/role-mappings/clients/'.$clientUuid, 146 | $roles 147 | ); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Service/HttpMethodEnum.php: -------------------------------------------------------------------------------- 1 | |null 20 | */ 21 | public function all(?Criteria $criteria = null): ?RealmCollection 22 | { 23 | return $this->executeQuery('admin/realms', RealmCollection::class, $criteria); 24 | } 25 | 26 | public function get(string $realm): ?RealmRepresentation 27 | { 28 | return $this->executeQuery('admin/realms/'.$realm, RealmRepresentation::class); 29 | } 30 | 31 | public function create(RealmRepresentation $realm): bool 32 | { 33 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/', $realm); 34 | } 35 | 36 | public function update(string $realm, RealmRepresentation $realmUpdate): bool 37 | { 38 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm, $realmUpdate); 39 | } 40 | 41 | public function delete(string $realm): bool 42 | { 43 | return $this->executeCommand(HttpMethodEnum::DELETE, 'admin/realms/'.$realm); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Service/RolesService.php: -------------------------------------------------------------------------------- 1 | |null 16 | */ 17 | public function all(string $realm, ?Criteria $criteria = null): ?RoleCollection 18 | { 19 | return $this->executeQuery('admin/realms/'.$realm.'/roles', RoleCollection::class, $criteria); 20 | } 21 | 22 | public function get(string $realm, string $roleName): ?RoleRepresentation 23 | { 24 | return $this->executeQuery('admin/realms/'.$realm.'/roles/'.$roleName, RoleRepresentation::class); 25 | } 26 | 27 | public function create(string $realm, RoleRepresentation $role): bool 28 | { 29 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/'.$realm.'/roles', $role); 30 | } 31 | 32 | public function update(string $realm, string $roleName, RoleRepresentation $role): bool 33 | { 34 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm.'/roles/'.$roleName, $role); 35 | } 36 | 37 | public function delete(string $realm, string $roleName): bool 38 | { 39 | return $this->executeCommand(HttpMethodEnum::DELETE, 'admin/realms/'.$realm.'/roles/'.$roleName); 40 | } 41 | 42 | /** 43 | * @return GroupCollection|null 44 | */ 45 | public function groups(string $realm, string $roleName, ?Criteria $criteria = null): ?GroupCollection 46 | { 47 | return $this->executeQuery('admin/realms/'.$realm.'/roles/'.$roleName.'/groups', GroupCollection::class, $criteria); 48 | } 49 | 50 | /** 51 | * @return UserCollection|null 52 | */ 53 | public function users(string $realm, string $roleName, ?Criteria $criteria = null): ?UserCollection 54 | { 55 | return $this->executeQuery('admin/realms/'.$realm.'/roles/'.$roleName.'/users', UserCollection::class, $criteria); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/Service/Service.php: -------------------------------------------------------------------------------- 1 | httpClient = $this->keycloakAdminClient->getKeycloakProvider()->getHttpClient(); 29 | 30 | $this->serializer = new Serializer($this->keycloakAdminClient->getVersion()); 31 | } 32 | 33 | protected function executeQuery(string $path, string $returnType, ?Criteria $criteria = null): mixed 34 | { 35 | if (!$this->isAuthorized()) { 36 | $this->inizializeAdminAccessToken(); 37 | } 38 | 39 | $response = $this->httpClient->request( 40 | HttpMethodEnum::GET->value, 41 | $path . $this->getQueryParams($criteria), 42 | $this->defaultOptions() 43 | ); 44 | 45 | if ($this->isSuccessful($response->getStatusCode())) { 46 | $content = $response->getBody()->getContents(); 47 | 48 | $this->logger->info('KeycloakAdminClient::Service::executeQuery', [ 49 | 'return_type' => $returnType, 50 | 'status_code' => $response->getStatusCode(), 51 | 'response' => $content, 52 | ]); 53 | 54 | if (empty($content)) { 55 | throw new \UnexpectedValueException('Empty response'); 56 | } 57 | 58 | if ($returnType === 'array') { 59 | return (new JsonDecode([JsonDecode::ASSOCIATIVE => true]))->decode($content, JsonEncoder::FORMAT); 60 | } 61 | 62 | return $this->serializer->deserialize($content, $returnType); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | protected function executeCommand( 69 | HttpMethodEnum $method, 70 | string $path, 71 | Representation|Collection|array|null $payload = null 72 | ): bool 73 | { 74 | if (!$this->isAuthorized()) { 75 | $this->inizializeAdminAccessToken(); 76 | } 77 | 78 | $options = $this->defaultOptions(); 79 | if (null !== $payload) { 80 | $options['json'] = $payload instanceof \JsonSerializable ? $payload->jsonSerialize() : $payload; 81 | } 82 | 83 | $response = $this->httpClient->request( 84 | $method->value, 85 | $path, 86 | $options 87 | ); 88 | 89 | if ($this->isSuccessful($response->getStatusCode())) { 90 | $content = $response->getBody()->getContents(); 91 | 92 | $this->logger->info('KeycloakAdminClient::Service::executeCommand', [ 93 | 'status_code' => $response->getStatusCode(), 94 | 'response' => $content, 95 | ]); 96 | 97 | return true; 98 | } 99 | 100 | return false; 101 | } 102 | 103 | private function defaultOptions(): array 104 | { 105 | return [ 106 | 'base_uri' => $this->keycloakAdminClient->getBaseUrl(), 107 | 'headers' => [ 108 | 'Accept' => 'application/json', 109 | 'Content-Type' => 'application/json', 110 | 'Authorization' => 'Bearer '.$this->keycloakAdminClient->getAdminAccessToken()?->getToken(), 111 | ], 112 | ]; 113 | } 114 | 115 | private function getQueryParams(?Criteria $criteria): string 116 | { 117 | if (null === $criteria) { 118 | return ''; 119 | } 120 | 121 | return '?' . http_build_query($criteria->jsonSerialize()); 122 | } 123 | 124 | private function isSuccessful($statusCode): bool 125 | { 126 | return ($statusCode >= Response::HTTP_OK && $statusCode < Response::HTTP_MULTIPLE_CHOICES) || $statusCode === Response::HTTP_NOT_MODIFIED; 127 | } 128 | 129 | private function isAuthorized(): bool 130 | { 131 | return null !== $this->keycloakAdminClient->getAdminAccessToken() && false === $this->keycloakAdminClient->getAdminAccessToken()->hasExpired(); 132 | } 133 | 134 | private function inizializeAdminAccessToken(): void 135 | { 136 | try { 137 | if (null === $this->keycloakAdminClient->getAdminAccessToken()) { 138 | throw new KeycloakAuthenticationException('No refresh token available'); 139 | } 140 | 141 | $token = $this->keycloakAdminClient->getKeycloakProvider()->getAccessToken('refresh_token', [ 142 | 'refresh_token' => $this->keycloakAdminClient->getAdminAccessToken()->getRefreshToken(), 143 | ]); 144 | } 145 | catch (\Exception $e) { 146 | try { 147 | $token = $this->keycloakAdminClient->getKeycloakProvider()->getAccessToken('password', [ 148 | 'username' => $this->keycloakAdminClient->getUsername(), 149 | 'password' => $this->keycloakAdminClient->getPassword(), 150 | ]); 151 | } 152 | catch (\Exception $e) { 153 | $this->logger->error('KeycloakAdminClient::getAdminAccessToken', [ 154 | 'error' => 'Authentication failed to Keycloak Admin API - '.$e->getMessage().' - '.$e->getTraceAsString(), 155 | ]); 156 | 157 | throw new KeycloakAuthenticationException('Authentication failed to Keycloak Admin API'); 158 | } 159 | } 160 | 161 | $accessToken = new AccessToken(); 162 | $accessToken 163 | ->setToken($token->getToken()) 164 | ->setExpires($token->getExpires()) 165 | ->setRefreshToken($token->getRefreshToken()) 166 | ->setValues($token->getValues()); 167 | 168 | $this->logger->info('KeycloakAdminClient::getAdminAccessToken', [ 169 | 'token' => $accessToken->getToken(), 170 | 'expires' => $accessToken->getExpires(), 171 | 'refresh_token' => $accessToken->getRefreshToken(), 172 | ]); 173 | 174 | $this->keycloakAdminClient->setAdminAccessToken($accessToken); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Service/UsersService.php: -------------------------------------------------------------------------------- 1 | |null 21 | */ 22 | public function all(string $realm, ?Criteria $criteria = null): ?UserCollection 23 | { 24 | return $this->executeQuery('admin/realms/'.$realm.'/users', UserCollection::class, $criteria); 25 | } 26 | 27 | public function get(string $realm, string $userId): ?UserRepresentation 28 | { 29 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId, UserRepresentation::class); 30 | } 31 | 32 | public function count(string $realm, ?Criteria $criteria = null): int 33 | { 34 | $count = $this->executeQuery('admin/realms/'.$realm.'/users/count', 'array', $criteria); 35 | if (null === $count) { 36 | return 0; 37 | } 38 | 39 | return (int) $count; 40 | } 41 | 42 | public function create(string $realm, UserRepresentation $user): bool 43 | { 44 | return $this->executeCommand(HttpMethodEnum::POST, 'admin/realms/'.$realm.'/users', $user); 45 | } 46 | 47 | public function update(string $realm, string $userId, UserRepresentation $user): bool 48 | { 49 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm.'/users/'.$userId, $user); 50 | } 51 | 52 | public function delete(string $realm, string $userId): bool 53 | { 54 | return $this->executeCommand(HttpMethodEnum::DELETE, 'admin/realms/'.$realm.'/users/'.$userId); 55 | } 56 | 57 | /** 58 | * @return UserSessionCollection|null 59 | */ 60 | public function sessions(string $realm, string $userId): ?UserSessionCollection 61 | { 62 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/sessions', UserSessionCollection::class); 63 | } 64 | 65 | /** 66 | * @return UserSessionCollection|null 67 | */ 68 | public function offlineSessions(string $realm, string $userId, string $clientId): ?UserSessionCollection 69 | { 70 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/offline-sessions/'.$clientId, UserSessionCollection::class); 71 | } 72 | 73 | /** 74 | * @return GroupCollection|null 75 | */ 76 | public function groups(string $realm, string $userId): ?GroupCollection 77 | { 78 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/groups', GroupCollection::class); 79 | } 80 | 81 | public function groupsCount(string $realm, string $userId): int 82 | { 83 | $count = $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/groups/count', 'array'); 84 | if (null === $count) { 85 | return 0; 86 | } 87 | 88 | return (int) $count; 89 | } 90 | 91 | public function joinGroup(string $realm, string $userId, string $groupId): bool 92 | { 93 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm.'/users/'.$userId.'/groups/'.$groupId); 94 | } 95 | 96 | public function leaveGroup(string $realm, string $userId, string $groupId): bool 97 | { 98 | return $this->executeCommand(HttpMethodEnum::DELETE, 'admin/realms/'.$realm.'/users/'.$userId.'/groups/'.$groupId); 99 | } 100 | 101 | /** 102 | * @return RoleCollection|null 103 | */ 104 | public function realmRoles(string $realm, string $userId): ?RoleCollection 105 | { 106 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/realm', RoleCollection::class); 107 | } 108 | 109 | /** 110 | * @return RoleCollection|null 111 | */ 112 | public function availableRealmRoles(string $realm, string $userId): ?RoleCollection 113 | { 114 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/realm/available', RoleCollection::class); 115 | } 116 | 117 | public function addRealmRole(string $realm, string $userId, RoleRepresentation $role): bool 118 | { 119 | $roles = new RoleCollection(); 120 | $roles->add($role); 121 | return $this->executeCommand( 122 | HttpMethodEnum::POST, 123 | 'admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/realm', 124 | $roles 125 | ); 126 | } 127 | 128 | public function removeRealmRole(string $realm, string $userId, RoleRepresentation $role): bool 129 | { 130 | $roles = new RoleCollection(); 131 | $roles->add($role); 132 | return $this->executeCommand( 133 | HttpMethodEnum::DELETE, 134 | 'admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/realm', 135 | $roles 136 | ); 137 | } 138 | 139 | /** 140 | * @return RoleCollection|null 141 | */ 142 | public function clientRoles(string $realm, string $clientUuid, string $userId): ?RoleCollection 143 | { 144 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/clients/'.$clientUuid, RoleCollection::class); 145 | } 146 | 147 | /** 148 | * @return RoleCollection|null 149 | */ 150 | public function availableClientRoles(string $realm, string $clientUuid, string $userId): ?RoleCollection 151 | { 152 | return $this->executeQuery('admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/clients/'.$clientUuid.'/available', RoleCollection::class); 153 | } 154 | 155 | public function addClientRole(string $realm, string $clientUuid, string $userId, RoleRepresentation $role): bool 156 | { 157 | $roles = new RoleCollection(); 158 | $roles->add($role); 159 | return $this->executeCommand( 160 | HttpMethodEnum::POST, 161 | 'admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/clients/'.$clientUuid, 162 | $roles 163 | ); 164 | } 165 | 166 | public function removeClientRole(string $realm, string $clientUuid, string $userId, RoleRepresentation $role): bool 167 | { 168 | $roles = new RoleCollection(); 169 | $roles->add($role); 170 | return $this->executeCommand( 171 | HttpMethodEnum::DELETE, 172 | 'admin/realms/'.$realm.'/users/'.$userId.'/role-mappings/clients/'.$clientUuid, 173 | $roles 174 | ); 175 | } 176 | 177 | public function getProfileConfig(string $realm): ?UPConfig 178 | { 179 | return $this->executeQuery('admin/realms/'.$realm.'/users/profile', UPConfig::class); 180 | } 181 | 182 | public function getProfileMetadata(string $realm): ?UserProfileMetadata 183 | { 184 | return $this->executeQuery('admin/realms/'.$realm.'/users/profile/metadata', UserProfileMetadata::class); 185 | } 186 | 187 | public function resetPassword(string $realm, string $userId): bool 188 | { 189 | return $this->executeCommand(HttpMethodEnum::PUT, 'admin/realms/'.$realm.'/users/'.$userId.'/reset-password'); 190 | } 191 | 192 | public function sendVerifyEmail(string $realm, string $userId, array $parameters): bool 193 | { 194 | return $this->executeCommand( 195 | HttpMethodEnum::PUT, 196 | 'admin/realms/'.$realm.'/users/'.$userId.'/send-verify-email', 197 | $parameters 198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Token/AccessToken.php: -------------------------------------------------------------------------------- 1 | accessToken; 23 | } 24 | 25 | public function setToken(string $token): AccessTokenInterface 26 | { 27 | $this->accessToken = $token; 28 | 29 | return $this; 30 | } 31 | 32 | public function getRefreshToken(): ?string 33 | { 34 | return $this->refreshToken; 35 | } 36 | 37 | public function setRefreshToken(string $refreshToken): AccessTokenInterface 38 | { 39 | $this->refreshToken = $refreshToken; 40 | 41 | return $this; 42 | } 43 | 44 | public function getExpires(): ?int 45 | { 46 | return $this->expires; 47 | } 48 | 49 | public function setExpires(int $expires): AccessTokenInterface 50 | { 51 | $this->expires = $expires; 52 | 53 | return $this; 54 | } 55 | 56 | public function hasExpired(): bool 57 | { 58 | $expires = $this->getExpires(); 59 | if (null === $expires) { 60 | throw new \RuntimeException('"expires" is not set on the token'); 61 | } 62 | 63 | return $expires < time(); 64 | } 65 | 66 | public function getValues(): array 67 | { 68 | return $this->values; 69 | } 70 | 71 | public function setValues(array $values): AccessTokenInterface 72 | { 73 | $this->values = $values; 74 | 75 | return $this; 76 | } 77 | 78 | public function __toString(): string 79 | { 80 | return (string) $this->getToken(); 81 | } 82 | 83 | public function jsonSerialize(): array 84 | { 85 | $parameters = $this->values; 86 | if ($this->accessToken) { 87 | $parameters['access_token'] = $this->accessToken; 88 | } 89 | if ($this->refreshToken) { 90 | $parameters['refresh_token'] = $this->refreshToken; 91 | } 92 | if ($this->expires) { 93 | $parameters['expires'] = $this->expires; 94 | } 95 | 96 | return $parameters; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Token/HS256TokenDecoder.php: -------------------------------------------------------------------------------- 1 | response = $response; 26 | $this->accessToken = $accessToken; 27 | } 28 | 29 | public function getAccessToken(): ?AccessTokenInterface 30 | { 31 | return $this->accessToken; 32 | } 33 | 34 | /** 35 | * Get resource owner id. 36 | */ 37 | public function getId(): string 38 | { 39 | return $this->response['sub']; 40 | } 41 | 42 | /** 43 | * Get resource owner email. 44 | */ 45 | public function getEmail(): ?string 46 | { 47 | return $this->response['email'] ?? null; 48 | } 49 | 50 | /** 51 | * Get resource owner name. 52 | */ 53 | public function getName(): ?string 54 | { 55 | return $this->response['name'] ?? null; 56 | } 57 | 58 | /** 59 | * Get resource owner username. 60 | */ 61 | public function getUsername(): ?string 62 | { 63 | return $this->response['preferred_username'] ?? null; 64 | } 65 | 66 | /** 67 | * Get resource owner first name. 68 | */ 69 | public function getFirstName(): ?string 70 | { 71 | return $this->response['given_name'] ?? null; 72 | } 73 | 74 | /** 75 | * Get resource owner last name. 76 | */ 77 | public function getLastName(): ?string 78 | { 79 | return $this->response['family_name'] ?? null; 80 | } 81 | 82 | /** 83 | * Get realm roles. 84 | * 85 | * @return array 86 | */ 87 | private function getRealmRoles(): array 88 | { 89 | return $this->response['realm_access']['roles'] ?? []; 90 | } 91 | 92 | /** 93 | * Get client roles. 94 | * 95 | * @param string|null $client_id Optional client ID to filter roles 96 | * @return array 97 | */ 98 | private function getClientRoles(?string $client_id = null): array 99 | { 100 | $resource_access = $this->response['resource_access'] ?? []; 101 | 102 | // If client_id is provided, return only roles for that client 103 | if ($client_id !== null) { 104 | return $resource_access[$client_id]['roles'] ?? []; 105 | } 106 | 107 | // Otherwise, collect all roles from all clients 108 | return array_reduce( 109 | $resource_access, 110 | static fn(array $carry, array $client): array => [ 111 | ...$carry, 112 | ...($client['roles'] ?? []) 113 | ], 114 | [] 115 | ); 116 | } 117 | 118 | /** 119 | * Get realm and resource owner roles. 120 | * 121 | * @return array 122 | */ 123 | public function getRoles(?string $client_id = null): array 124 | { 125 | return [...$this->getRealmRoles(), ...$this->getClientRoles($client_id)]; 126 | } 127 | 128 | /** 129 | * Get resource owner groups. 130 | * 131 | * @return array 132 | */ 133 | public function getGroups(): array 134 | { 135 | return $this->response['groups'] ?? []; 136 | } 137 | 138 | /** 139 | * Get resource owner scopes. 140 | * 141 | * @return array 142 | */ 143 | public function getScope(): array 144 | { 145 | return explode(' ', $this->response['scope'] ?? ''); 146 | } 147 | 148 | /** 149 | * Return all of the owner details available as an array. 150 | * 151 | * @return array 152 | */ 153 | public function toArray(): array 154 | { 155 | return $this->response; 156 | } 157 | 158 | public function eraseCredentials(): void 159 | { 160 | } 161 | 162 | public function getUserIdentifier(): string 163 | { 164 | return $this->getUsername(); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Token/RS256TokenDecoder.php: -------------------------------------------------------------------------------- 1 | new RS256TokenDecoder(), 20 | self::ALGORITHM_HS256 => new HS256TokenDecoder(), 21 | default => throw new \RuntimeException('Invalid algorithm'), 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/EventSubscriber/TokenAuthListenerTest.php: -------------------------------------------------------------------------------- 1 | 'text/plain']); 30 | } 31 | } 32 | 33 | class TokenAuthListenerTest extends TestCase 34 | { 35 | use QueryBuilderTrait; 36 | 37 | public const ENCRYPTION_KEY = <<keycloakClient = new KeycloakClient( 104 | $this->createMock(LoggerInterface::class), 105 | true, 106 | 'http://mock.url/auth', 107 | 'mock_realm', 108 | 'mock_client_id', 109 | 'mock_secret', 110 | 'none', 111 | ); 112 | 113 | $jwt_tmp = sprintf($this->jwtTemplate, time() + 3600, time(), time()); 114 | $this->access_token = JWT::encode(json_decode($jwt_tmp, true), self::ENCRYPTION_KEY, self::ENCRYPTION_ALGORITHM); 115 | } 116 | 117 | protected function tearDown(): void 118 | { 119 | m::close(); 120 | parent::tearDown(); 121 | } 122 | 123 | public function testCheckValidTokenOnRequest(): void 124 | { 125 | // given 126 | // mock access token 127 | $getAccessTokenStream = $this->createMock(StreamInterface::class); 128 | $getAccessTokenStream 129 | ->method('__toString') 130 | ->willReturn('{"access_token":"'.$this->access_token.'","expires_in":3600,"refresh_token":"mock_refresh_token","scope":"email","token_type":"bearer"}'); 131 | $getAccessTokenResponse = m::mock(ResponseInterface::class); 132 | $getAccessTokenResponse 133 | ->allows('getBody') 134 | ->andReturns($getAccessTokenStream); 135 | $getAccessTokenResponse 136 | ->allows('getHeader') 137 | ->andReturns(['content-type' => 'application/json']); 138 | 139 | // mock resource owner 140 | $jwt_tmp = sprintf($this->jwtTemplate, time() + 3600, time(), time()); 141 | $getResourceOwnerStream = $this->createMock(StreamInterface::class); 142 | $getResourceOwnerStream 143 | ->method('__toString') 144 | ->willReturn($jwt_tmp); 145 | $getResourceOwnerResponse = m::mock(ResponseInterface::class); 146 | $getResourceOwnerResponse 147 | ->allows('getBody') 148 | ->andReturns($getResourceOwnerStream); 149 | $getResourceOwnerResponse 150 | ->allows('getHeader') 151 | ->andReturns(['content-type' => 'application/json']); 152 | 153 | // mock http client 154 | $client = m::mock(ClientInterface::class); 155 | $client 156 | ->allows('send') 157 | ->andReturns($getAccessTokenResponse, $getResourceOwnerResponse); 158 | $this->keycloakClient->setHttpClient($client); 159 | 160 | // when 161 | $token = $this->keycloakClient->authenticate('mock_user', 'mock_password'); 162 | 163 | // mock event request 164 | $logger = $this->createMock(LoggerInterface::class); 165 | $tokenAuthListener = new TokenAuthListener($logger, $this->keycloakClient); 166 | $request = new Request(); 167 | $request->headers->set('X-Auth-Token', $token->getToken()); 168 | $eventRequest = new RequestEvent( 169 | $this->createMock(HttpKernelInterface::class), 170 | $request, 171 | HttpKernelInterface::MAIN_REQUEST 172 | ); 173 | 174 | // call checkValidToken 175 | $tokenAuthListener->checkValidToken($eventRequest); 176 | 177 | // then 178 | $user = $request->attributes->get('user'); 179 | $this->assertEquals('test-user', $user->username); 180 | } 181 | 182 | public function testCheckValidTokenExcludesRouteWithAttribute(): void 183 | { 184 | // given 185 | $logger = $this->createMock(LoggerInterface::class); 186 | $iamClient = $this->createMock(IamClientInterface::class); 187 | $tokenAuthListener = new TokenAuthListener($logger, $iamClient); 188 | 189 | // when 190 | // Create a mock controller method with ExcludeTokenValidationAttribute 191 | $controllerMethodWithAttribute = 'Mainick\KeycloakClientBundle\Tests\EventSubscriber\MyController::excludedRouteAction'; 192 | 193 | // Mock the request for a route with ExcludeTokenValidationAttribute 194 | $request = new Request(); 195 | $request->attributes->set('_controller', $controllerMethodWithAttribute); 196 | $request->headers->set('X-Auth-Token', $this->access_token); 197 | 198 | // Mock the Event 199 | $eventRequest = new RequestEvent( 200 | $this->createMock(HttpKernelInterface::class), 201 | $request, 202 | HttpKernelInterface::MAIN_REQUEST 203 | ); 204 | 205 | // call checkValidToken 206 | $tokenAuthListener->checkValidToken($eventRequest); 207 | 208 | // then 209 | // Verify that the token validation was skipped for the route with ExcludeTokenValidationAttribute 210 | $this->assertNull($eventRequest->getResponse()); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/Security/KeycloakAuthenticatorTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('The Symfony Security component is not installed.'); 98 | } 99 | 100 | $jwt_tmp = sprintf($this->jwtTemplate, time() + 3600, time(), time()); 101 | $this->access_token = JWT::encode(json_decode($jwt_tmp, true), self::ENCRYPTION_KEY, self::ENCRYPTION_ALGORITHM); 102 | 103 | $this->iamClient = m::mock(KeycloakClient::class); 104 | $accessToken = m::mock(AccessTokenInterface::class); 105 | $accessToken 106 | ->allows('getToken') 107 | ->andReturns($this->access_token); 108 | $accessToken 109 | ->allows('getRefreshToken') 110 | ->andReturns('mock_refresh_token'); 111 | $this->iamClient 112 | ->allows('authenticateCodeGrant') 113 | ->with('authorization_code') 114 | ->andReturns($accessToken); 115 | 116 | $this->userProvider = m::mock(KeycloakUserProvider::class); 117 | $this->resourceOwner = m::mock(KeycloakResourceOwner::class); 118 | $this->userProvider 119 | ->allows('loadUserByIdentifier') 120 | ->with($accessToken) 121 | ->andReturns($this->resourceOwner); 122 | 123 | $this->authenticator = new KeycloakAuthenticator( 124 | $this->createMock(LoggerInterface::class), 125 | $this->iamClient, 126 | $this->userProvider 127 | ); 128 | } 129 | 130 | protected function tearDown(): void 131 | { 132 | m::close(); 133 | parent::tearDown(); 134 | } 135 | 136 | public function testAuthenticateSuccessfulAuthentication(): void 137 | { 138 | // given 139 | $session = m::mock(SessionInterface::class); 140 | $session 141 | ->allows('get') 142 | ->with(KeycloakAuthorizationCodeEnum::STATE_SESSION_KEY) 143 | ->andReturns('mock_state'); 144 | 145 | $request = new Request(); 146 | $request->query->add([ 147 | KeycloakAuthorizationCodeEnum::STATE_KEY => 'mock_state', 148 | KeycloakAuthorizationCodeEnum::CODE_KEY => 'authorization_code', 149 | ]); 150 | $request->setSession($session); 151 | 152 | // when 153 | $passport = $this->authenticator->authenticate($request); 154 | 155 | // then 156 | $this->assertInstanceOf(SelfValidatingPassport::class, $passport); 157 | $userBadge = $passport->getBadge(UserBadge::class); 158 | $this->assertNotNull($userBadge); 159 | $this->assertEquals($this->resourceOwner, $userBadge->getUser()); 160 | $this->assertEquals($this->access_token, $userBadge->getUserIdentifier()); 161 | } 162 | 163 | public function testAuthenticateInvalidState(): void 164 | { 165 | // given 166 | $session = m::mock(SessionInterface::class); 167 | $session 168 | ->allows('get') 169 | ->with(KeycloakAuthorizationCodeEnum::STATE_SESSION_KEY) 170 | ->andReturns('some_state'); 171 | 172 | $request = new Request(); 173 | $request->query->add([ 174 | KeycloakAuthorizationCodeEnum::STATE_KEY => 'invalid_state', 175 | KeycloakAuthorizationCodeEnum::CODE_KEY => 'authorization_code', 176 | ]); 177 | $request->setSession($session); 178 | 179 | // when 180 | $this->expectException(AuthenticationException::class); 181 | $this->expectExceptionMessage('query state (invalid_state) is not the same as session state (some_state)'); 182 | $this->authenticator->authenticate($request); 183 | } 184 | 185 | public function testAuthenticateMissingCode(): void 186 | { 187 | // given 188 | $session = m::mock(SessionInterface::class); 189 | $session 190 | ->allows('get') 191 | ->with(KeycloakAuthorizationCodeEnum::STATE_SESSION_KEY) 192 | ->andReturns('mock_state'); 193 | 194 | $request = new Request(); 195 | $request->query->add([ 196 | KeycloakAuthorizationCodeEnum::STATE_KEY => 'mock_state', 197 | ]); 198 | $request->setSession($session); 199 | 200 | // when 201 | $this->expectException(AuthenticationException::class); 202 | $this->expectExceptionMessage('Authentication failed! Did you authorize our app?'); 203 | $this->authenticator->authenticate($request); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /tests/Serializer/CollectionDenormalizerTest.php: -------------------------------------------------------------------------------- 1 | createMock(DenormalizerInterface::class); 25 | $denormalizer = new CollectionDenormalizer($innerDenormalizer); 26 | 27 | $realmData = [ 28 | [ 29 | 'id' => '1', 30 | 'realm' => 'master', 31 | 'displayName' => 'Master Realm', 32 | 'enabled' => true 33 | ], 34 | [ 35 | 'id' => '2', 36 | 'realm' => 'test', 37 | 'displayName' => 'Test Realm', 38 | 'enabled' => false 39 | ] 40 | ]; 41 | 42 | $realm1 = new RealmRepresentation( 43 | id: '1', 44 | realm: 'master', 45 | displayName: 'Master Realm', 46 | enabled: true 47 | ); 48 | 49 | $realm2 = new RealmRepresentation( 50 | id: '2', 51 | realm: 'test', 52 | displayName: 'Test Realm', 53 | enabled: false 54 | ); 55 | 56 | $innerDenormalizer->expects($this->exactly(2)) 57 | ->method('denormalize') 58 | ->willReturnCallback(function ($data, $type, $format, $context) use ($realm1, $realm2) { 59 | if ($data['id'] === '1') { 60 | return $realm1; 61 | } 62 | return $realm2; 63 | }); 64 | 65 | // when 66 | $result = $denormalizer->denormalize($realmData, RealmCollection::class, JsonEncoder::FORMAT); 67 | 68 | // then 69 | $this->assertInstanceOf(RealmCollection::class, $result); 70 | $this->assertCount(2, $result); 71 | 72 | $items = $result->all(); 73 | $this->assertSame($realm1, $items[0]); 74 | $this->assertSame($realm2, $items[1]); 75 | } 76 | 77 | public function testDenormalizeRealmCollectionWithRealDenormalizer(): void 78 | { 79 | // Configurazione di un denormalizzatore reale 80 | $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); 81 | $metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory); 82 | $propertyNormalizer = new PropertyNormalizer( 83 | classMetadataFactory: $classMetadataFactory, 84 | nameConverter: $metadataAwareNameConverter, 85 | defaultContext: [ 86 | PropertyNormalizer::NORMALIZE_VISIBILITY => PropertyNormalizer::NORMALIZE_PROTECTED, 87 | ] 88 | ); 89 | 90 | // Utilizziamo RepresentationDenormalizer per gestire correttamente i costruttori delle rappresentazioni 91 | $representationDenormalizer = new RepresentationDenormalizer($propertyNormalizer); 92 | 93 | // Istanza del denormalizzatore da testare 94 | $denormalizer = new CollectionDenormalizer($representationDenormalizer); 95 | 96 | // Dati di test 97 | $realmData = [ 98 | [ 99 | 'id' => '1', 100 | 'realm' => 'master', 101 | 'displayName' => 'Master Realm', 102 | 'enabled' => true 103 | ], 104 | [ 105 | 'id' => '2', 106 | 'realm' => 'test', 107 | 'displayName' => 'Test Realm', 108 | 'enabled' => false 109 | ] 110 | ]; 111 | 112 | // Esecuzione 113 | $result = $denormalizer->denormalize($realmData, RealmCollection::class, JsonEncoder::FORMAT); 114 | 115 | // Verifiche 116 | $this->assertInstanceOf(RealmCollection::class, $result); 117 | $this->assertCount(2, $result); 118 | 119 | $items = $result->all(); 120 | 121 | // Verifica delle proprietà degli oggetti denormalizzati 122 | $this->assertInstanceOf(RealmRepresentation::class, $items[0]); 123 | $this->assertEquals('1', $items[0]->id); 124 | $this->assertEquals('master', $items[0]->realm); 125 | $this->assertEquals('Master Realm', $items[0]->displayName); 126 | $this->assertTrue($items[0]->enabled); 127 | 128 | $this->assertInstanceOf(RealmRepresentation::class, $items[1]); 129 | $this->assertEquals('2', $items[1]->id); 130 | $this->assertEquals('test', $items[1]->realm); 131 | $this->assertEquals('Test Realm', $items[1]->displayName); 132 | $this->assertFalse($items[1]->enabled); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/Service/RealmsServiceTest.php: -------------------------------------------------------------------------------- 1 | httpClient = m::mock(ClientInterface::class); 36 | $this->keycloakAdminClient = m::mock(KeycloakAdminClient::class); 37 | $this->logger = m::mock(LoggerInterface::class); 38 | $this->serializer = m::mock(Serializer::class); 39 | 40 | $keycloakProvider = m::mock(Keycloak::class); 41 | $keycloakProvider->shouldReceive('getHttpClient')->andReturn($this->httpClient); 42 | 43 | $this->keycloakAdminClient->shouldReceive('getKeycloakProvider')->andReturn($keycloakProvider); 44 | $this->keycloakAdminClient->shouldReceive('getBaseUrl')->andReturn('http://mock.url/auth'); 45 | $this->keycloakAdminClient->shouldReceive('getVersion')->andReturn('17.0.1'); 46 | 47 | $this->adminAccessToken = new AccessToken(); 48 | $this->adminAccessToken 49 | ->setToken('mock_token') 50 | ->setExpires(time() + 3600) 51 | ->setRefreshToken('mock_refresh_token') 52 | ->setValues(['scope' => 'email']); 53 | 54 | $this->keycloakAdminClient->shouldReceive('getAdminAccessToken')->andReturn($this->adminAccessToken); 55 | 56 | $this->realmsService = new RealmsService( 57 | $this->logger, 58 | $this->keycloakAdminClient 59 | ); 60 | 61 | $reflection = new \ReflectionClass($this->realmsService); 62 | $serializerProperty = $reflection->getProperty('serializer'); 63 | $serializerProperty->setAccessible(true); 64 | $serializerProperty->setValue($this->realmsService, $this->serializer); 65 | } 66 | 67 | protected function tearDown(): void 68 | { 69 | m::close(); 70 | parent::tearDown(); 71 | } 72 | 73 | public function testAll(): void 74 | { 75 | // given 76 | $responseBody = '[{"id":"master","realm":"master"},{"id":"test","realm":"test"}]'; 77 | $stream = $this->createMock(StreamInterface::class); 78 | $stream->method('getContents')->willReturn($responseBody); 79 | 80 | $response = m::mock(ResponseInterface::class); 81 | $response->shouldReceive('getStatusCode')->andReturn(200); 82 | $response->shouldReceive('getBody')->andReturn($stream); 83 | 84 | $this->httpClient 85 | ->shouldReceive('request') 86 | ->with('GET', 'admin/realms', m::on(function($options) { 87 | return isset($options['headers']['Authorization']) && 88 | $options['headers']['Authorization'] === 'Bearer mock_token'; 89 | })) 90 | ->andReturn($response); 91 | 92 | $realmCollection = new RealmCollection(); 93 | $realm1 = new RealmRepresentation(); 94 | $realm1->id = 'master'; 95 | $realm1->realm = 'master'; 96 | $realm2 = new RealmRepresentation(); 97 | $realm2->id = 'test'; 98 | $realm2->realm = 'test'; 99 | $realmCollection->add($realm1); 100 | $realmCollection->add($realm2); 101 | 102 | $this->serializer 103 | ->shouldReceive('deserialize') 104 | ->with($responseBody, RealmCollection::class) 105 | ->andReturn($realmCollection); 106 | 107 | $this->logger->shouldReceive('info')->once(); 108 | 109 | // when 110 | $result = $this->realmsService->all(); 111 | 112 | // then 113 | $this->assertInstanceOf(RealmCollection::class, $result); 114 | $this->assertSame($realmCollection, $result); 115 | $this->assertEquals(2, $result->count()); 116 | $this->assertSame('master', $result->jsonSerialize()[0]->id); 117 | $this->assertSame('test', $result->jsonSerialize()[1]->id); 118 | } 119 | 120 | public function testAllWithCriteria(): void 121 | { 122 | // given 123 | $criteria = new Criteria(['briefRepresentation' => 'true']); 124 | 125 | $responseBody = '[{"id":"master","realm":"master"},{"id":"test","realm":"test"}]'; 126 | $stream = $this->createMock(StreamInterface::class); 127 | $stream->method('getContents')->willReturn($responseBody); 128 | 129 | $response = m::mock(ResponseInterface::class); 130 | $response->shouldReceive('getStatusCode')->andReturn(200); 131 | $response->shouldReceive('getBody')->andReturn($stream); 132 | 133 | $this->httpClient 134 | ->shouldReceive('request') 135 | ->with('GET', 'admin/realms?briefRepresentation=true', m::type('array')) 136 | ->andReturn($response); 137 | 138 | $realmCollection = new RealmCollection(); 139 | $realm1 = new RealmRepresentation(); 140 | $realm1->id = 'master'; 141 | $realm1->realm = 'master'; 142 | $realm2 = new RealmRepresentation(); 143 | $realm2->id = 'test'; 144 | $realm2->realm = 'test'; 145 | $realmCollection->add($realm1); 146 | $realmCollection->add($realm2); 147 | 148 | $this->serializer 149 | ->shouldReceive('deserialize') 150 | ->with($responseBody, RealmCollection::class) 151 | ->andReturn($realmCollection); 152 | 153 | $this->logger->shouldReceive('info')->once(); 154 | 155 | // when 156 | $result = $this->realmsService->all($criteria); 157 | 158 | // then 159 | $this->assertInstanceOf(RealmCollection::class, $result); 160 | $this->assertSame($realmCollection, $result); 161 | $this->assertEquals(2, $result->count()); 162 | $this->assertSame('master', $result->jsonSerialize()[0]->id); 163 | $this->assertSame('test', $result->jsonSerialize()[1]->id); 164 | } 165 | 166 | public function testGet(): void 167 | { 168 | // given 169 | $responseBody = '{"id":"test","realm":"test"}'; 170 | $stream = $this->createMock(StreamInterface::class); 171 | $stream->method('getContents')->willReturn($responseBody); 172 | 173 | $response = m::mock(ResponseInterface::class); 174 | $response->shouldReceive('getStatusCode')->andReturn(200); 175 | $response->shouldReceive('getBody')->andReturn($stream); 176 | 177 | $this->httpClient 178 | ->shouldReceive('request') 179 | ->with('GET', 'admin/realms/test', m::type('array')) 180 | ->andReturn($response); 181 | 182 | $realm = new RealmRepresentation(); 183 | $realm->realm = 'test'; 184 | 185 | $this->serializer 186 | ->shouldReceive('deserialize') 187 | ->with($responseBody, RealmRepresentation::class) 188 | ->andReturn($realm); 189 | 190 | $this->logger->shouldReceive('info')->once(); 191 | 192 | // when 193 | $result = $this->realmsService->get('test'); 194 | 195 | // then 196 | $this->assertInstanceOf(RealmRepresentation::class, $result); 197 | $this->assertSame($realm, $result); 198 | } 199 | 200 | public function testCreate(): void 201 | { 202 | // given 203 | $realm = new RealmRepresentation(); 204 | $realm->realm = 'new-realm'; 205 | 206 | $responseBody = ''; 207 | $stream = $this->createMock(StreamInterface::class); 208 | $stream->method('getContents')->willReturn($responseBody); 209 | 210 | $response = m::mock(ResponseInterface::class); 211 | $response->shouldReceive('getStatusCode')->andReturn(201); 212 | $response->shouldReceive('getBody')->andReturn($stream); 213 | 214 | $this->httpClient 215 | ->shouldReceive('request') 216 | ->with('POST', 'admin/realms/', m::on(function($options) { 217 | return isset($options['json']); 218 | })) 219 | ->andReturn($response); 220 | 221 | $this->logger->shouldReceive('info')->once(); 222 | 223 | // when 224 | $result = $this->realmsService->create($realm); 225 | 226 | // then 227 | $this->assertTrue($result); 228 | } 229 | 230 | public function testUpdate(): void 231 | { 232 | // given 233 | $realm = new RealmRepresentation(); 234 | $realm->realm = 'test'; 235 | $realm->displayName = 'Updated Test Realm'; 236 | 237 | $responseBody = ''; 238 | $stream = $this->createMock(StreamInterface::class); 239 | $stream->method('getContents')->willReturn($responseBody); 240 | 241 | $response = m::mock(ResponseInterface::class); 242 | $response->shouldReceive('getStatusCode')->andReturn(204); 243 | $response->shouldReceive('getBody')->andReturn($stream); 244 | 245 | $this->httpClient 246 | ->shouldReceive('request') 247 | ->with('PUT', 'admin/realms/test', m::on(function($options) { 248 | return isset($options['json']); 249 | })) 250 | ->andReturn($response); 251 | 252 | $this->logger->shouldReceive('info')->once(); 253 | 254 | // when 255 | $result = $this->realmsService->update('test', $realm); 256 | 257 | // then 258 | $this->assertTrue($result); 259 | } 260 | 261 | public function testDelete(): void 262 | { 263 | // given 264 | $responseBody = ''; 265 | $stream = $this->createMock(StreamInterface::class); 266 | $stream->method('getContents')->willReturn($responseBody); 267 | 268 | $response = m::mock(ResponseInterface::class); 269 | $response->shouldReceive('getStatusCode')->andReturn(204); 270 | $response->shouldReceive('getBody')->andReturn($stream); 271 | 272 | $this->httpClient 273 | ->shouldReceive('request') 274 | ->with('DELETE', 'admin/realms/test', m::type('array')) 275 | ->andReturn($response); 276 | 277 | $this->logger->shouldReceive('info')->once(); 278 | 279 | // when 280 | $result = $this->realmsService->delete('test'); 281 | 282 | // then 283 | $this->assertTrue($result); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |