1 | <?php |
||
41 | use KernelDictionary; |
||
42 | |||
43 | /** |
||
44 | * @var ResponseTypeContext |
||
45 | */ |
||
46 | private $responseTypeContext; |
||
47 | |||
48 | /** |
||
49 | * @var MinkContext |
||
50 | */ |
||
51 | private $minkContext; |
||
52 | |||
53 | /** |
||
54 | * @BeforeScenario |
||
55 | * |
||
56 | * @param BeforeScenarioScope $scope |
||
57 | */ |
||
58 | public function gatherContexts(BeforeScenarioScope $scope) |
||
59 | { |
||
60 | $environment = $scope->getEnvironment(); |
||
61 | |||
62 | $this->minkContext = $environment->getContext(MinkContext::class); |
||
63 | $this->responseTypeContext = $environment->getContext(ResponseTypeContext::class); |
||
64 | } |
||
65 | |||
66 | /** |
||
67 | * @When a client send a Userinfo request without access token |
||
68 | */ |
||
69 | public function aClientSendAUserinfoRequestWithoutAccessToken() |
||
70 | { |
||
71 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
72 | 'GET', |
||
73 | 'https://oauth2.test/userinfo', |
||
74 | [], |
||
75 | [], |
||
76 | [] |
||
77 | ); |
||
78 | } |
||
79 | |||
80 | /** |
||
81 | * @When a client sends a valid Userinfo request |
||
82 | */ |
||
83 | public function aClientSendsAValidUserinfoRequest() |
||
84 | { |
||
85 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
86 | 'GET', |
||
87 | 'https://oauth2.test/userinfo', |
||
88 | [], |
||
89 | [], |
||
90 | [ |
||
91 | 'HTTP_Authorization' => 'Bearer VALID_ACCESS_TOKEN_FOR_USERINFO', |
||
92 | ] |
||
93 | ); |
||
94 | } |
||
95 | |||
96 | /** |
||
97 | * @Then the response contains an Id Token with the following claims for the client :clientId |
||
98 | */ |
||
99 | public function theResponseContainsAnIdTokenWithTheFollowingClaimsForTheClient($clientId, PyStringNode $expectedClaims) |
||
100 | { |
||
101 | $client = $this->getContainer()->get(ClientRepository::class)->find(ClientId::create($clientId)); |
||
102 | Assertion::isInstanceOf($client, Client::class); |
||
103 | $claims = json_decode($expectedClaims->getRaw(), true); |
||
104 | $response = $this->minkContext->getSession()->getPage()->getContent(); |
||
105 | $loader = new Loader(); |
||
106 | $jwt = $loader->load($response); |
||
107 | Assertion::isInstanceOf($jwt, JWSInterface::class); |
||
108 | Assertion::true(empty(array_diff($claims, $jwt->getClaims()))); |
||
109 | } |
||
110 | |||
111 | /** |
||
112 | * @When a client sends a Userinfo request but the access token has no openid scope |
||
113 | */ |
||
114 | public function aClientSendsAUserinfoRequestButTheAccessTokenHasNoOpenidScope() |
||
115 | { |
||
116 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
117 | 'GET', |
||
118 | 'https://oauth2.test/userinfo', |
||
119 | [], |
||
120 | [], |
||
121 | [ |
||
122 | 'HTTP_Authorization' => 'Bearer INVALID_ACCESS_TOKEN_FOR_USERINFO', |
||
123 | ] |
||
124 | ); |
||
125 | } |
||
126 | |||
127 | /** |
||
128 | * @When a client sends a Userinfo request but the access token has not been issued through the authorization endpoint |
||
129 | */ |
||
130 | public function aClientSendsAUserinfoRequestButTheAccessTokenHasNotBeenIssuedThroughTheAuthorizationEndpoint() |
||
131 | { |
||
132 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
133 | 'GET', |
||
134 | 'https://oauth2.test/userinfo', |
||
135 | [], |
||
136 | [], |
||
137 | [ |
||
138 | 'HTTP_Authorization' => 'Bearer ACCESS_TOKEN_ISSUED_THROUGH_TOKEN_ENDPOINT', |
||
139 | ] |
||
140 | ); |
||
141 | } |
||
142 | |||
143 | /** |
||
144 | * @When A client sends a request to get the keys used by this authorization server |
||
145 | */ |
||
146 | public function aClientSendsARequestToGetTheKeysUsedByThisAuthorizationServer() |
||
147 | { |
||
148 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
149 | 'GET', |
||
150 | 'https://oauth2.test/keys/public.jwkset', |
||
151 | [], |
||
152 | [], |
||
153 | [] |
||
154 | ); |
||
155 | } |
||
156 | |||
157 | /** |
||
158 | * @When A client sends a request to get the Session Management iFrame |
||
159 | */ |
||
160 | public function aClientSendsARequestToGetTheSessionManagementIframe() |
||
161 | { |
||
162 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
163 | 'GET', |
||
164 | 'https://oauth2.test/session/manager/iframe', |
||
165 | [], |
||
166 | [], |
||
167 | [] |
||
168 | ); |
||
169 | } |
||
170 | |||
171 | /** |
||
172 | * @Given a client send a request to the metadata endpoint |
||
173 | */ |
||
174 | public function aClientSendARequestToTheMetadataEndpoint() |
||
175 | { |
||
176 | throw new PendingException(); |
||
177 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
178 | 'GET', |
||
179 | 'https://oauth2.test/.well-known/openid-configuration', |
||
180 | [], |
||
181 | [], |
||
182 | [] |
||
183 | ); |
||
184 | } |
||
185 | |||
186 | /** |
||
187 | * @Given A client sends an authorization request with max_age parameter but the user has to authenticate again |
||
188 | */ |
||
189 | public function aClientSendsAnAuthorizationRequestWithMaxAgeParameterButTheUserHasToAuthenticateAgain() |
||
190 | { |
||
191 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
192 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
193 | 'GET', |
||
194 | 'https://oauth2.test/authorize', |
||
195 | [ |
||
196 | 'client_id' => 'client1', |
||
197 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
198 | 'response_type' => 'code', |
||
199 | 'state' => '0123456789', |
||
200 | 'max_age' => '60', |
||
201 | ], |
||
202 | [], |
||
203 | [] |
||
204 | ); |
||
205 | } |
||
206 | |||
207 | /** |
||
208 | * @Given A client sends an authorization request with max_age parameter |
||
209 | */ |
||
210 | public function aClientSendsAnAuthorizationRequestWithMaxAgeParameter() |
||
211 | { |
||
212 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
213 | 'GET', |
||
214 | 'https://oauth2.test/authorize', |
||
215 | [ |
||
216 | 'client_id' => 'client1', |
||
217 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
218 | 'response_type' => 'code', |
||
219 | 'state' => '0123456789', |
||
220 | 'max_age' => '3600', |
||
221 | ], |
||
222 | [], |
||
223 | [] |
||
224 | ); |
||
225 | } |
||
226 | |||
227 | /** |
||
228 | * @Given A client sends an authorization request with "prompt=none" parameter but the user has to authenticate again |
||
229 | */ |
||
230 | public function aClientSendsAnAuthorizationRequestWithPromptNoneParameterButTheUserHasToAuthenticateAgain() |
||
231 | { |
||
232 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
233 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
234 | 'GET', |
||
235 | 'https://oauth2.test/authorize', |
||
236 | [ |
||
237 | 'client_id' => 'client1', |
||
238 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
239 | 'response_type' => 'code', |
||
240 | 'state' => '0123456789', |
||
241 | 'prompt' => 'none', |
||
242 | ], |
||
243 | [], |
||
244 | [] |
||
245 | ); |
||
246 | } |
||
247 | |||
248 | /** |
||
249 | * @Given A client sends an authorization request with "prompt=none consent" parameter |
||
250 | */ |
||
251 | public function aClientSendsAnAuthorizationRequestWithPromptNoneConsentParameter() |
||
252 | { |
||
253 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
254 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
255 | 'GET', |
||
256 | 'https://oauth2.test/authorize', |
||
257 | [ |
||
258 | 'client_id' => 'client1', |
||
259 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
260 | 'response_type' => 'code', |
||
261 | 'state' => '0123456789', |
||
262 | 'prompt' => 'none consent', |
||
263 | ], |
||
264 | [], |
||
265 | [] |
||
266 | ); |
||
267 | } |
||
268 | |||
269 | /** |
||
270 | * @Given A client sends an authorization request with "prompt=login" parameter |
||
271 | */ |
||
272 | public function aClientSendsAnAuthorizationRequestWithPromptLoginParameter() |
||
273 | { |
||
274 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
275 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
276 | 'GET', |
||
277 | 'https://oauth2.test/authorize', |
||
278 | [ |
||
279 | 'client_id' => 'client1', |
||
280 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
281 | 'response_type' => 'code', |
||
282 | 'state' => '0123456789', |
||
283 | 'prompt' => 'login', |
||
284 | ], |
||
285 | [], |
||
286 | [] |
||
287 | ); |
||
288 | } |
||
289 | |||
290 | /** |
||
291 | * @Given A client sends an authorization request that was already accepted and saved by the resource owner |
||
292 | */ |
||
293 | public function aClientSendsAnAuthorizationRequestThatWasAlreadyAcceptedAndSavedByTheResourceOwner() |
||
294 | { |
||
295 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
296 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
297 | 'GET', |
||
298 | 'https://oauth2.test/authorize', |
||
299 | [ |
||
300 | 'client_id' => 'client1', |
||
301 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
302 | 'response_type' => 'code', |
||
303 | 'state' => '0123456789', |
||
304 | 'scope' => 'openid profile phone address email', |
||
305 | ], |
||
306 | [], |
||
307 | [] |
||
308 | ); |
||
309 | } |
||
310 | |||
311 | /** |
||
312 | * @Given A client sends an authorization request that was already accepted and saved by the resource owner with "prompt=consent" |
||
313 | */ |
||
314 | public function aClientSendsAnAuthorizationRequestThatWasAlreadyAcceptedAndSavedByTheResourceOwnerWithPromptConsent() |
||
315 | { |
||
316 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
317 | 'GET', |
||
318 | 'https://oauth2.test/authorize', |
||
319 | [ |
||
320 | 'client_id' => 'client1', |
||
321 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
322 | 'response_type' => 'code', |
||
323 | 'state' => '0123456789', |
||
324 | 'prompt' => 'consent', |
||
325 | 'scope' => 'openid profile phone address email', |
||
326 | ], |
||
327 | [], |
||
328 | [] |
||
329 | ); |
||
330 | } |
||
331 | |||
332 | /** |
||
333 | * @Given A client sends an authorization request with ui_locales parameter and at least one locale is supported |
||
334 | */ |
||
335 | public function aClientSendsAnAuthorizationRequestWithUiLocalesParameterAndAtLeastOneLocaleIsSupported() |
||
336 | { |
||
337 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
338 | 'GET', |
||
339 | 'https://oauth2.test/authorize', |
||
340 | [ |
||
341 | 'client_id' => 'client1', |
||
342 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
343 | 'response_type' => 'code', |
||
344 | 'state' => '0123456789', |
||
345 | 'ui_locales' => 'fr en', |
||
346 | ], |
||
347 | [], |
||
348 | [] |
||
349 | ); |
||
350 | } |
||
351 | |||
352 | /** |
||
353 | * @Then the consent screen should be translated |
||
354 | */ |
||
355 | public function theConsentScreenShouldBeTranslated() |
||
356 | { |
||
357 | $content = $this->minkContext->getSession()->getPage()->getContent(); |
||
358 | |||
359 | Assertion::contains($content, 'a besoin de votre autorisation pour accéder à vos resources.'); |
||
360 | } |
||
361 | |||
362 | /** |
||
363 | * @Given A client sends an authorization request with ui_locales parameter and none of them is supported |
||
364 | */ |
||
365 | public function aClientSendsAnAuthorizationRequestWithUiLocalesParameterAndNoneOfThemIsSupported() |
||
366 | { |
||
367 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
368 | 'GET', |
||
369 | 'https://oauth2.test/authorize', |
||
370 | [ |
||
371 | 'client_id' => 'client1', |
||
372 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
373 | 'response_type' => 'code', |
||
374 | 'state' => '0123456789', |
||
375 | 'ui_locales' => 'ru de', |
||
376 | ], |
||
377 | [], |
||
378 | [] |
||
379 | ); |
||
380 | } |
||
381 | |||
382 | /** |
||
383 | * @Then the consent screen should not be translated |
||
384 | */ |
||
385 | public function theConsentScreenShouldNotBeTranslated() |
||
386 | { |
||
387 | $content = $this->minkContext->getSession()->getPage()->getContent(); |
||
388 | |||
389 | Assertion::contains($content, 'needs your authorization to get access on your resources.'); |
||
390 | } |
||
391 | |||
392 | /** |
||
393 | * @When A client sends a valid authorization request with a valid id_token_hint parameter |
||
394 | */ |
||
395 | public function aClientSendsAValidAuthorizationRequestWithAValidIdTokenHintParameter() |
||
396 | { |
||
397 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
398 | 'GET', |
||
399 | 'https://oauth2.test/authorize', |
||
400 | [ |
||
401 | 'client_id' => 'client1', |
||
402 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
403 | 'response_type' => 'code', |
||
404 | 'state' => '0123456789', |
||
405 | 'id_token_hint' => $this->generateValidIdToken(), |
||
406 | ], |
||
407 | [], |
||
408 | [] |
||
409 | ); |
||
410 | } |
||
411 | |||
412 | /** |
||
413 | * @When A client sends a valid authorization request with a valid id_token_hint parameter but the current user does not correspond |
||
414 | */ |
||
415 | public function aClientSendsAValidAuthorizationRequestWithAValidIdTokenHintParameterButTheCurrentUserDoesNotCorrespond() |
||
416 | { |
||
417 | $isFollowingRedirects = $this->minkContext->getSession()->getDriver()->getClient()->isFollowingRedirects(); |
||
418 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
419 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
420 | 'GET', |
||
421 | 'https://oauth2.test/authorize', |
||
422 | [ |
||
423 | 'client_id' => 'client1', |
||
424 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
425 | 'response_type' => 'code', |
||
426 | 'state' => '0123456789', |
||
427 | 'id_token_hint' => $this->generateValidIdToken(), |
||
428 | ], |
||
429 | [], |
||
430 | [] |
||
431 | ); |
||
432 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects($isFollowingRedirects); |
||
433 | } |
||
434 | |||
435 | /** |
||
436 | * @When A client sends a valid authorization request with an invalid id_token_hint parameter |
||
437 | */ |
||
438 | public function aClientSendsAValidAuthorizationRequestWithAnInvalidIdTokenHintParameter() |
||
439 | { |
||
440 | $isFollowingRedirects = $this->minkContext->getSession()->getDriver()->getClient()->isFollowingRedirects(); |
||
441 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
442 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
443 | 'GET', |
||
444 | 'https://oauth2.test/authorize', |
||
445 | [ |
||
446 | 'client_id' => 'client1', |
||
447 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
448 | 'response_type' => 'code', |
||
449 | 'state' => '0123456789', |
||
450 | 'id_token_hint' => 'BAD_VALUE !!!!!!!!!!!!!!!!!!!!!!!!!!', |
||
451 | ], |
||
452 | [], |
||
453 | [] |
||
454 | ); |
||
455 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects($isFollowingRedirects); |
||
456 | } |
||
457 | |||
458 | /** |
||
459 | * @When A client sends a valid authorization request with a valid id_token_hint parameter but signed with an unsupported algorithm |
||
460 | */ |
||
461 | public function aClientSendsAValidAuthorizationRequestWithAValidIdTokenHintParameterButSignedWithAnUnsupportedAlgorithm() |
||
462 | { |
||
463 | $isFollowingRedirects = $this->minkContext->getSession()->getDriver()->getClient()->isFollowingRedirects(); |
||
464 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects(false); |
||
465 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
466 | 'GET', |
||
467 | 'https://oauth2.test/authorize', |
||
468 | [ |
||
469 | 'client_id' => 'client1', |
||
470 | 'redirect_uri' => 'https://example.com/redirection/callback', |
||
471 | 'response_type' => 'code', |
||
472 | 'state' => '0123456789', |
||
473 | 'id_token_hint' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIiwia2lkIjoiS1JTV3dLcENFODFrb0hTa0ZwbFhBOTFnOTFkM253YTQ1MVdGWnd0enlGSGxDMHl4cjluaU13Ymd1NmFBY21yMkFkZ01GcU1Sd2phWlFXLXdYMURTTEEifQ.eyJuYW1lIjoiSm9obiBEb2UiLCJnaXZlbl9uYW1lIjoiSm9obiIsIm1pZGRsZV9uYW1lIjoiSmFjayIsImZhbWlseV9uYW1lIjoiRG9lIiwibmlja25hbWUiOiJMaXR0bGUgSm9obiIsInByZWZlcnJlZF91c2VybmFtZSI6ImotZCIsInByb2ZpbGUiOiJodHRwczpcL1wvcHJvZmlsZS5kb2UuZnJcL2pvaG5cLyIsInBpY3R1cmUiOiJodHRwczpcL1wvd3d3Lmdvb2dsZS5jb20iLCJ3ZWJzaXRlIjoiaHR0cHM6XC9cL2pvaG4uZG9lLmNvbSIsImdlbmRlciI6Ik0iLCJiaXJ0aGRhdGUiOiIxOTUwLTAxLTAxIiwiem9uZWluZm8iOiJFdXJvcGVcL1BhcmlzIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoxNDg1NDMxMjMyLCJlbWFpbCI6InJvb3RAbG9jYWxob3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicGhvbmVfbnVtYmVyIjoiKzAxMjM0NTY3ODkiLCJwaG9uZV9udW1iZXJfdmVyaWZpZWQiOnRydWUsInN1YiI6IlVncU80U0xjTnVwWUJYekdKNXVuQjR0SWY1UTlabzVHYXU1cDJ2QjJGbGZyQTZ2MU1YS09Ib2JvOS12STU1Q2kiLCJpYXQiOjE0ODk2NjU4MjAsIm5iZiI6MTQ4OTY2NTgyMCwiZXhwIjoxNDg5NjY5NDIwLCJqdGkiOiJBNllYZDM5MkdKSGRTZTl5dHhaNGc4ZUpORjg1c0pRdS13IiwiaXNzIjoiaHR0cHM6XC9cL3d3dy5teS1zZXJ2aWNlLmNvbSJ9.', |
||
474 | ], |
||
475 | [], |
||
476 | [] |
||
477 | ); |
||
478 | $this->minkContext->getSession()->getDriver()->getClient()->followRedirects($isFollowingRedirects); |
||
479 | } |
||
480 | |||
481 | /** |
||
482 | * @When a client that set userinfo algorithm parameters sends a valid Userinfo request |
||
483 | */ |
||
484 | public function aClientThatSetUserinfoAlgorithmParametersSendsAValidUserinfoRequest() |
||
485 | { |
||
486 | $this->minkContext->getSession()->getDriver()->getClient()->request( |
||
487 | 'GET', |
||
488 | 'https://oauth2.test/userinfo', |
||
489 | [], |
||
490 | [], |
||
491 | [ |
||
492 | 'HTTP_Authorization' => 'Bearer VALID_ACCESS_TOKEN_FOR_SIGNED_USERINFO', |
||
493 | ] |
||
494 | ); |
||
495 | } |
||
496 | |||
497 | /** |
||
498 | * @return string |
||
499 | */ |
||
500 | private function generateValidIdToken(): string |
||
501 | { |
||
502 | $headers = [ |
||
503 | 'typ' => 'JWT', |
||
504 | 'alg' => 'RS256', |
||
505 | 'kid' => 'KRSWwKpCE81koHSkFplXA91g91d3nwa451WFZwtzyFHlC0yxr9niMwbgu6aAcmr2AdgMFqMRwjaZQW-wX1DSLA', |
||
506 | ]; |
||
507 | |||
508 | $payload = [ |
||
509 | 'name' => 'John Doe', |
||
510 | 'given_name' => 'John', |
||
511 | 'middle_name' => 'Jack', |
||
512 | 'family_name' => 'Doe', |
||
513 | 'nickname' => 'Little John', |
||
514 | 'preferred_username' => 'j-d', |
||
515 | 'profile' => 'https://profile.doe.fr/john/', |
||
516 | 'picture' => 'https://www.google.com', |
||
517 | 'website' => 'https://john.doe.com', |
||
518 | 'gender' => 'M', |
||
519 | 'birthdate' => '1950-01-01', |
||
520 | 'zoneinfo' => 'Europe/Paris', |
||
521 | 'locale' => 'en', |
||
522 | 'updated_at' => time() - 10, |
||
523 | 'email' => '[email protected]', |
||
524 | 'email_verified' => false, |
||
525 | 'phone_number' => '+0123456789', |
||
526 | 'phone_number_verified' => true, |
||
527 | 'sub' => 'UgqO4SLcNupYBXzGJ5unB4tIf5Q9Zo5Gau5p2vB2FlfrA6v1MXKOHobo9-vI55Ci', |
||
528 | 'iat' => time(), |
||
529 | 'nbf' => time(), |
||
530 | 'exp' => time() + 1800, |
||
531 | 'jti' => 'A6YXd392GJHdSe9ytxZ4g8eJNF85sJQu-w', |
||
532 | 'iss' => 'https://www.my-service.com', |
||
533 | ]; |
||
534 | |||
535 | $key = $this->getContainer()->get('oauth2_server.grant.id_token.key_set')->selectKey('sig', 'RS256'); |
||
536 | Assertion::notNull($key); |
||
537 | |||
538 | return $this->getContainer()->get(JWTCreatorInterface::class)->sign($payload, $headers, $key); |
||
539 | } |
||
540 | } |
||
541 |