├── .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 |