Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Keycloak
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 20
1122
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 decryptResponse
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getBaseAuthorizationUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseAccessTokenUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResourceOwnerDetailsUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLogoutUrl
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseLogoutUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseUrlWithRealm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultScopes
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getScopeSeparator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkResponse
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 createResourceOwner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResourceOwner
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 setEncryptionAlgorithm
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setEncryptionKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setEncryptionKeyPath
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setVersion
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 usesEncryption
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 parseResponse
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 validateGteVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Oauth2Provider\Keycloak;
4
5use App\Oauth2Provider\Keycloak\Exception\EncryptionConfigurationException;
6use Firebase\JWT\JWT;
7use Firebase\JWT\Key;
8use League\OAuth2\Client\Provider\AbstractProvider;
9use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
10use League\OAuth2\Client\Token\AccessToken;
11use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
12use Psr\Http\Message\ResponseInterface;
13use Symfony\Component\HttpFoundation\Exception\JsonException;
14
15class Keycloak extends AbstractProvider
16{
17    use BearerAuthorizationTrait;
18
19    /**
20     * Keycloak URL, eg. http://localhost:8080/auth.
21     */
22    public ?string $authServerUrl = null;
23
24    /**
25     * Realm name, eg. demo.
26     */
27    public ?string $realm = null;
28
29    /**
30     * Encryption algorithm.
31     *
32     * You must specify supported algorithms for your application. See
33     * https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40
34     * for a list of spec-compliant algorithms.
35     */
36    public ?string $encryptionAlgorithm = null;
37
38    /**
39     * Encryption key.
40     */
41    public ?string $encryptionKey = null;
42
43    /**
44     * Keycloak version.
45     */
46    public ?string $version = null;
47
48    /**
49     * Constructs an OAuth 2.0 service provider.
50     *
51     * @param array $options       An array of options to set on this provider.
52     *                             Options include `clientId`, `clientSecret`, `redirectUri`, and `state`.
53     *                             Individual providers may introduce more options, as needed.
54     * @param array $collaborators An array of collaborators that may be used to
55     *                             override this provider's default behavior. Collaborators include
56     *                             `grantFactory`, `requestFactory`, `httpClient`, and `randomFactory`.
57     *                             Individual providers may introduce more collaborators, as needed.
58     */
59    public function __construct(array $options = [], array $collaborators = [])
60    {
61        $this->authServerUrl = $options['auth_server_url'];
62
63        if (isset($options['realm'])) {
64            $this->realm = $options['realm'];
65        }
66
67        if (isset($options['encryptionKeyPath'])) {
68            $this->setEncryptionKeyPath($options['encryptionKeyPath']);
69            unset($options['encryptionKeyPath']);
70        }
71
72        if (isset($options['version'])) {
73            $this->setVersion($options['version']);
74        }
75
76        parent::__construct($options, $collaborators);
77    }
78
79    /**
80     * Attempts to decrypt the given response.
81     *
82     * @throws JsonException
83     * @throws EncryptionConfigurationException
84     * @throws \JsonException
85     */
86    public function decryptResponse(array|string|null $response): array|string|null
87    {
88        if (!is_string($response)) {
89            return $response;
90        }
91
92        if ($this->usesEncryption()) {
93            return json_decode(json_encode(JWT::decode(
94                $response,
95                new Key($this->encryptionKey, $this->encryptionAlgorithm)
96            ), JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
97        }
98
99        throw EncryptionConfigurationException::undeterminedEncryption();
100    }
101
102    /**
103     * Get authorization url to begin OAuth flow.
104     */
105    public function getBaseAuthorizationUrl(): string
106    {
107        return $this->getBaseUrlWithRealm() . '/protocol/openid-connect/auth';
108    }
109
110    /**
111     * Get access token url to retrieve token.
112     */
113    public function getBaseAccessTokenUrl(array $params): string
114    {
115        return $this->getBaseUrlWithRealm() . '/protocol/openid-connect/token';
116    }
117
118    /**
119     * Get provider url to fetch user details.
120     */
121    public function getResourceOwnerDetailsUrl(AccessToken $token): string
122    {
123        return $this->getBaseUrlWithRealm() . '/protocol/openid-connect/userinfo';
124    }
125
126    /**
127     * Builds the logout URL.
128     *
129     * @return string Authorization URL
130     */
131    public function getLogoutUrl(array $options = []): string
132    {
133        $base  = $this->getBaseLogoutUrl();
134        $query = $this->buildQueryString($options);
135
136        return $this->appendQuery($base, $query);
137    }
138
139    /**
140     * Get logout url to logout of session token.
141     */
142    private function getBaseLogoutUrl(): string
143    {
144        return $this->getBaseUrlWithRealm() . '/protocol/openid-connect/logout';
145    }
146
147    /**
148     * Creates base url from provider configuration.
149     */
150    protected function getBaseUrlWithRealm(): string
151    {
152        return $this->authServerUrl . '/realms/' . $this->realm;
153    }
154
155    /**
156     * Get the default scopes used by this provider.
157     *
158     * This should not be a complete list of all scopes, but the minimum
159     * required for the provider user interface!
160     *
161     * @return string[]
162     */
163    protected function getDefaultScopes(): array
164    {
165        $scopes = [
166            'profile',
167            'email',
168        ];
169        if ($this->validateGteVersion('20.0.0')) {
170            $scopes[] = 'openid';
171        }
172
173        return $scopes;
174    }
175
176    /**
177     * Returns the string that should be used to separate scopes when building
178     * the URL for requesting an access token.
179     *
180     * @return string Scope separator, defaults to ','
181     */
182    protected function getScopeSeparator(): string
183    {
184        return ' ';
185    }
186
187    /**
188     * Check a provider response for errors.
189     *
190     * @param array|string $data Parsed response data
191     *
192     * @throws IdentityProviderException
193     */
194    protected function checkResponse(ResponseInterface $response, $data): void
195    {
196        if (!empty($data['error'])) {
197            $error = $data['error'];
198            if (isset($data['error_description'])) {
199                $error .= ': ' . $data['error_description'];
200            }
201
202            throw new IdentityProviderException($error, 0, $data);
203        }
204    }
205
206    /**
207     * Generate a user object from a successful user details request.
208     */
209    protected function createResourceOwner(array $response, AccessToken $token): KeycloakResourceOwner
210    {
211        return new KeycloakResourceOwner($response);
212    }
213
214    /**
215     * Requests and returns the resource owner of given access token.
216     *
217     * @throws JsonException
218     * @throws EncryptionConfigurationException
219     * @throws \JsonException
220     */
221    public function getResourceOwner(AccessToken $token): KeycloakResourceOwner
222    {
223        $response = $this->fetchResourceOwnerDetails($token);
224
225        // We are always getting an array. We have to check if it is
226        // the array we created
227        if (array_key_exists('jwt', $response)) {
228            $response = $response['jwt'];
229        }
230
231        $response = $this->decryptResponse($response);
232
233        return $this->createResourceOwner($response, $token);
234    }
235
236    /**
237     * Updates expected encryption algorithm of Keycloak instance.
238     */
239    public function setEncryptionAlgorithm(string $encryptionAlgorithm): static
240    {
241        $this->encryptionAlgorithm = $encryptionAlgorithm;
242
243        return $this;
244    }
245
246    /**
247     * Updates expected encryption key of Keycloak instance.
248     */
249    public function setEncryptionKey(string $encryptionKey): static
250    {
251        $this->encryptionKey = $encryptionKey;
252
253        return $this;
254    }
255
256    /**
257     * Updates expected encryption key of Keycloak instance to content of given
258     * file path.
259     */
260    public function setEncryptionKeyPath(string $encryptionKeyPath): static
261    {
262        try {
263            $this->encryptionKey = file_get_contents($encryptionKeyPath);
264        } catch (\Exception) {
265            // Not sure how to handle this yet.
266        }
267
268        return $this;
269    }
270
271    /**
272     * Updates the keycloak version.
273     */
274    public function setVersion(string $version): static
275    {
276        $this->version = $version;
277
278        return $this;
279    }
280
281    /**
282     * Checks if provider is configured to use encryption.
283     */
284    public function usesEncryption(): bool
285    {
286        return $this->encryptionAlgorithm && $this->encryptionKey;
287    }
288
289    /**
290     * Parses the response according to its content-type header.
291     *
292     * @throws \UnexpectedValueException
293     */
294    protected function parseResponse(ResponseInterface $response): array
295    {
296        // We have a problem with keycloak when the userinfo responses
297        // with a jwt token
298        // Because it just return a jwt as string with the header
299        // application/jwt
300        // This can't be parsed to a array
301        // Dont know why this function only allow an array as return value...
302        $content = (string) $response->getBody();
303        $type    = $this->getContentType($response);
304
305        if (str_contains($type, 'jwt')) {
306            // Here we make the temporary array
307            return ['jwt' => $content];
308        }
309
310        return parent::parseResponse($response);
311    }
312
313    /**
314     * Validate if version is greater or equal.
315     */
316    private function validateGteVersion(string $version): bool
317    {
318        return isset($this->version) && version_compare($this->version, $version, '>=');
319    }
320}