Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 60 |
|
0.00% |
0 / 20 |
CRAP | |
0.00% |
0 / 1 |
Keycloak | |
0.00% |
0 / 60 |
|
0.00% |
0 / 20 |
1122 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
decryptResponse | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getBaseAuthorizationUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBaseAccessTokenUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getResourceOwnerDetailsUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLogoutUrl | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getBaseLogoutUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBaseUrlWithRealm | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultScopes | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getScopeSeparator | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
checkResponse | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
createResourceOwner | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getResourceOwner | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
setEncryptionAlgorithm | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setEncryptionKey | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setEncryptionKeyPath | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
setVersion | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
usesEncryption | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
parseResponse | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
validateGteVersion | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace App\Oauth2Provider\Keycloak; |
4 | |
5 | use App\Oauth2Provider\Keycloak\Exception\EncryptionConfigurationException; |
6 | use Firebase\JWT\JWT; |
7 | use Firebase\JWT\Key; |
8 | use League\OAuth2\Client\Provider\AbstractProvider; |
9 | use League\OAuth2\Client\Provider\Exception\IdentityProviderException; |
10 | use League\OAuth2\Client\Token\AccessToken; |
11 | use League\OAuth2\Client\Tool\BearerAuthorizationTrait; |
12 | use Psr\Http\Message\ResponseInterface; |
13 | use Symfony\Component\HttpFoundation\Exception\JsonException; |
14 | |
15 | class 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 | } |