oidc-functions.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. <?php
  2. trait OIDCFunctions
  3. {
  4. /**
  5. * Provider registry with provider-specific configurations
  6. */
  7. private $oidcProviders = [
  8. 'authentik' => [
  9. 'configPrefix' => 'oidcAuthentik',
  10. 'defaultGroupClaim' => 'groups',
  11. 'supportsEndSession' => true,
  12. ],
  13. 'keycloak' => [
  14. 'configPrefix' => 'oidcKeycloak',
  15. 'defaultGroupClaim' => 'groups',
  16. 'supportsEndSession' => true,
  17. ],
  18. 'pocketid' => [
  19. 'configPrefix' => 'oidcPocketid',
  20. 'defaultGroupClaim' => 'groups',
  21. 'supportsEndSession' => false,
  22. ],
  23. 'zitadel' => [
  24. 'configPrefix' => 'oidcZitadel',
  25. 'defaultGroupClaim' => 'urn:zitadel:iam:org:project:roles',
  26. 'supportsEndSession' => true,
  27. 'rolesAsObject' => true,
  28. ],
  29. ];
  30. /**
  31. * Get list of enabled OIDC providers
  32. */
  33. public function getEnabledOIDCProviders()
  34. {
  35. $enabled = [];
  36. if (!$this->config['oidcEnabled']) {
  37. return $enabled;
  38. }
  39. foreach ($this->oidcProviders as $provider => $config) {
  40. $enabledKey = $config['configPrefix'] . 'Enabled';
  41. if ($this->config[$enabledKey] ?? false) {
  42. $enabled[$provider] = $config;
  43. }
  44. }
  45. return $enabled;
  46. }
  47. /**
  48. * Get provider configuration
  49. */
  50. public function getOIDCProviderConfig($provider)
  51. {
  52. if (!isset($this->oidcProviders[$provider])) {
  53. return null;
  54. }
  55. $config = $this->oidcProviders[$provider];
  56. $prefix = $config['configPrefix'];
  57. return [
  58. 'provider' => $provider,
  59. 'name' => $this->config[$prefix . 'Name'] ?? ucfirst($provider),
  60. 'clientId' => $this->config[$prefix . 'ClientId'] ?? '',
  61. 'clientSecret' => ($this->config[$prefix . 'ClientSecret'] ?? ''),
  62. 'discoveryUrl' => $this->config[$prefix . 'DiscoveryUrl'] ?? '',
  63. 'scopes' => $this->config[$prefix . 'Scopes'] ?? 'openid,profile,email',
  64. 'groupClaim' => $this->config[$prefix . 'GroupClaim'] ?: ($config['defaultGroupClaim'] ?? 'groups'),
  65. 'supportsEndSession' => $config['supportsEndSession'] ?? false,
  66. 'rolesAsObject' => $config['rolesAsObject'] ?? false,
  67. ];
  68. }
  69. /**
  70. * Fetch and cache OIDC discovery document
  71. */
  72. public function getOIDCDiscovery($provider)
  73. {
  74. $config = $this->getOIDCProviderConfig($provider);
  75. if (!$config || empty($config['discoveryUrl'])) {
  76. $this->setLoggerChannel('OIDC')->warning('Discovery URL not configured for provider: ' . $provider);
  77. return null;
  78. }
  79. $cacheKey = 'oidc_discovery_' . $provider;
  80. $cached = $_SESSION[$cacheKey] ?? null;
  81. if ($cached && (time() - ($cached['fetched_at'] ?? 0)) < 3600) {
  82. return $cached['data'];
  83. }
  84. try {
  85. $options = ($this->localURL($config['discoveryUrl'])) ? array('verify' => false) : array('verify' => $this->getCert());
  86. $response = Requests::get($config['discoveryUrl'], [], $options);
  87. if ($response->success) {
  88. $discovery = json_decode($response->body, true);
  89. $_SESSION[$cacheKey] = [
  90. 'data' => $discovery,
  91. 'fetched_at' => time(),
  92. ];
  93. return $discovery;
  94. }
  95. $this->setLoggerChannel('OIDC')->warning('Failed to fetch discovery document: ' . $response->status_code);
  96. return null;
  97. } catch (Requests_Exception $e) {
  98. $this->setLoggerChannel('OIDC')->error($e);
  99. return null;
  100. }
  101. }
  102. /**
  103. * Generate PKCE code verifier and challenge
  104. */
  105. public function generatePKCE()
  106. {
  107. $codeVerifier = bin2hex(random_bytes(32));
  108. $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
  109. return [
  110. 'code_verifier' => $codeVerifier,
  111. 'code_challenge' => $codeChallenge,
  112. 'code_challenge_method' => 'S256',
  113. ];
  114. }
  115. /**
  116. * Generate state parameter for CSRF protection
  117. */
  118. public function generateOIDCState($provider)
  119. {
  120. $state = bin2hex(random_bytes(16));
  121. $_SESSION['oidc_state'] = $state;
  122. $_SESSION['oidc_state_provider'] = $provider;
  123. $_SESSION['oidc_state_time'] = time();
  124. return $state;
  125. }
  126. /**
  127. * Validate state parameter
  128. */
  129. public function validateOIDCState($state, $provider)
  130. {
  131. $storedState = $_SESSION['oidc_state'] ?? null;
  132. $storedProvider = $_SESSION['oidc_state_provider'] ?? null;
  133. $stateTime = $_SESSION['oidc_state_time'] ?? 0;
  134. // State expires after 10 minutes
  135. if (time() - $stateTime > 600) {
  136. $this->setLoggerChannel('OIDC')->warning('OIDC state expired');
  137. return false;
  138. }
  139. if (!hash_equals($storedState ?? '', $state) || $storedProvider !== $provider) {
  140. $this->setLoggerChannel('OIDC')->warning('OIDC state mismatch');
  141. return false;
  142. }
  143. // Clear used state
  144. unset($_SESSION['oidc_state'], $_SESSION['oidc_state_provider'], $_SESSION['oidc_state_time']);
  145. return true;
  146. }
  147. /**
  148. * Generate authorization URL for OIDC provider
  149. */
  150. public function getOIDCAuthorizationUrl($provider)
  151. {
  152. $config = $this->getOIDCProviderConfig($provider);
  153. if (!$config) {
  154. return null;
  155. }
  156. $discovery = $this->getOIDCDiscovery($provider);
  157. if (!$discovery || empty($discovery['authorization_endpoint'])) {
  158. $this->setLoggerChannel('OIDC')->warning('Authorization endpoint not found in discovery');
  159. return null;
  160. }
  161. $pkce = $this->generatePKCE();
  162. $_SESSION['oidc_code_verifier'] = $pkce['code_verifier'];
  163. $state = $this->generateOIDCState($provider);
  164. $nonce = bin2hex(random_bytes(16));
  165. $_SESSION['oidc_nonce'] = $nonce;
  166. $scopes = str_replace(',', ' ', $config['scopes']);
  167. $params = [
  168. 'response_type' => 'code',
  169. 'client_id' => $config['clientId'],
  170. 'redirect_uri' => $this->getServerPath() . 'api/v2/oidc/' . $provider . '/callback',
  171. 'scope' => $scopes,
  172. 'state' => $state,
  173. 'nonce' => $nonce,
  174. 'code_challenge' => $pkce['code_challenge'],
  175. 'code_challenge_method' => $pkce['code_challenge_method'],
  176. ];
  177. return $discovery['authorization_endpoint'] . '?' . http_build_query($params);
  178. }
  179. /**
  180. * Exchange authorization code for tokens
  181. */
  182. public function exchangeOIDCCode($provider, $code)
  183. {
  184. $config = $this->getOIDCProviderConfig($provider);
  185. if (!$config) {
  186. return null;
  187. }
  188. $discovery = $this->getOIDCDiscovery($provider);
  189. if (!$discovery || empty($discovery['token_endpoint'])) {
  190. $this->setLoggerChannel('OIDC')->warning('Token endpoint not found in discovery');
  191. return null;
  192. }
  193. $codeVerifier = $_SESSION['oidc_code_verifier'] ?? '';
  194. unset($_SESSION['oidc_code_verifier']);
  195. $data = [
  196. 'grant_type' => 'authorization_code',
  197. 'code' => $code,
  198. 'redirect_uri' => $this->getServerPath() . 'api/v2/oidc/' . $provider . '/callback',
  199. 'client_id' => $config['clientId'],
  200. 'client_secret' => $config['clientSecret'],
  201. 'code_verifier' => $codeVerifier,
  202. ];
  203. try {
  204. $options = ($this->localURL($discovery['token_endpoint'])) ? array('verify' => false) : array('verify' => $this->getCert());
  205. $response = Requests::post($discovery['token_endpoint'], [
  206. 'Content-Type' => 'application/x-www-form-urlencoded',
  207. ], http_build_query($data), $options);
  208. if ($response->success) {
  209. $tokens = json_decode($response->body, true);
  210. $this->setLoggerChannel('OIDC')->debug('Token exchange successful for provider: ' . $provider);
  211. return $tokens;
  212. }
  213. $this->setLoggerChannel('OIDC')->warning('Token exchange failed: ' . $response->body);
  214. return null;
  215. } catch (Requests_Exception $e) {
  216. $this->setLoggerChannel('OIDC')->error($e);
  217. return null;
  218. }
  219. }
  220. /**
  221. * Get user info from OIDC provider
  222. */
  223. public function getOIDCUserInfo($provider, $accessToken)
  224. {
  225. $discovery = $this->getOIDCDiscovery($provider);
  226. if (!$discovery || empty($discovery['userinfo_endpoint'])) {
  227. $this->setLoggerChannel('OIDC')->warning('Userinfo endpoint not found, using ID token claims');
  228. return null;
  229. }
  230. try {
  231. $options = ($this->localURL($discovery['userinfo_endpoint'])) ? array('verify' => false) : array('verify' => $this->getCert());
  232. $response = Requests::get($discovery['userinfo_endpoint'], [
  233. 'Authorization' => 'Bearer ' . $accessToken,
  234. ], $options);
  235. if ($response->success) {
  236. return json_decode($response->body, true);
  237. }
  238. $this->setLoggerChannel('OIDC')->warning('Userinfo request failed: ' . $response->status_code);
  239. return null;
  240. } catch (Requests_Exception $e) {
  241. $this->setLoggerChannel('OIDC')->error($e);
  242. return null;
  243. }
  244. }
  245. /**
  246. * Decode JWT ID token (without verification - tokens already verified by provider)
  247. */
  248. public function decodeOIDCIdToken($idToken)
  249. {
  250. $parts = explode('.', $idToken);
  251. if (count($parts) !== 3) {
  252. return null;
  253. }
  254. $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
  255. return $payload;
  256. }
  257. /**
  258. * Extract groups from OIDC claims
  259. */
  260. public function extractOIDCGroups($provider, $claims)
  261. {
  262. $config = $this->getOIDCProviderConfig($provider);
  263. $groupClaim = $config['groupClaim'] ?? $this->config['oidcGroupClaimName'];
  264. $groups = $claims[$groupClaim] ?? [];
  265. // Handle Zitadel's role format (object keys)
  266. if ($provider === 'zitadel' && ($config['rolesAsObject'] ?? false)) {
  267. if (is_array($groups) && !empty($groups) && !isset($groups[0])) {
  268. $groups = array_keys($groups);
  269. }
  270. }
  271. // Ensure array format
  272. if (is_string($groups)) {
  273. $groups = [$groups];
  274. }
  275. return $groups;
  276. }
  277. /**
  278. * Map OIDC groups to Organizr group ID
  279. */
  280. public function mapOIDCGroupToOrganizr($oidcGroups)
  281. {
  282. $mappings = json_decode($this->config['oidcGroupMappings'] ?? '{}', true) ?: [];
  283. $mode = $this->config['oidcGroupMappingMode'] ?? 'first';
  284. $matchedGroups = [];
  285. foreach ($oidcGroups as $oidcGroup) {
  286. // Direct match
  287. if (isset($mappings[$oidcGroup])) {
  288. $matchedGroups[] = (int)$mappings[$oidcGroup];
  289. continue;
  290. }
  291. // Try matching without leading slash (Keycloak nested groups)
  292. $stripped = ltrim($oidcGroup, '/');
  293. if (isset($mappings[$stripped])) {
  294. $matchedGroups[] = (int)$mappings[$stripped];
  295. continue;
  296. }
  297. // Try matching last segment only
  298. $segments = explode('/', $stripped);
  299. $lastSegment = end($segments);
  300. if (isset($mappings[$lastSegment])) {
  301. $matchedGroups[] = (int)$mappings[$lastSegment];
  302. }
  303. }
  304. if (empty($matchedGroups)) {
  305. // Fallback to default group
  306. $defaultGroupId = $this->config['oidcDefaultGroupId'];
  307. if ($defaultGroupId !== null && $defaultGroupId !== '') {
  308. return (int)$defaultGroupId;
  309. }
  310. // Use system default
  311. return $this->getDefaultGroupId();
  312. }
  313. switch ($mode) {
  314. case 'highest_privilege':
  315. // Lower group_id = higher privilege in Organizr (0 = admin)
  316. return min($matchedGroups);
  317. case 'lowest_privilege':
  318. return max($matchedGroups);
  319. case 'first':
  320. default:
  321. return $matchedGroups[0];
  322. }
  323. }
  324. /**
  325. * Get default group ID from system settings
  326. */
  327. private function getDefaultGroupId()
  328. {
  329. // Default to group ID 3 (User) if not configured
  330. return 3;
  331. }
  332. /**
  333. * Create or link user from OIDC claims
  334. */
  335. public function createOrLinkOIDCUser($provider, $userInfo, $oidcGroups)
  336. {
  337. $email = $userInfo['email'] ?? '';
  338. $username = $userInfo['preferred_username'] ?? $userInfo['name'] ?? $userInfo['sub'] ?? '';
  339. $image = $userInfo['picture'] ?? '';
  340. if (empty($username)) {
  341. $this->setLoggerChannel('OIDC')->warning('No username available from OIDC claims');
  342. return null;
  343. }
  344. $groupId = $this->mapOIDCGroupToOrganizr($oidcGroups);
  345. $group = $this->getGroupById($groupId);
  346. $groupName = $group['group'] ?? 'User';
  347. // Check if user exists by username
  348. $existingUser = $this->getUserByUsername($username);
  349. if ($existingUser) {
  350. // Update auth_service and optionally group
  351. $updates = ['auth_service' => 'oidc::' . $provider];
  352. if ($this->config['oidcUpdateGroupsOnLogin']) {
  353. $updates['group_id'] = $groupId;
  354. $updates['group'] = $groupName;
  355. }
  356. $this->updateUserById($existingUser['id'], $updates);
  357. $this->setLoggerChannel('OIDC')->info('Linked existing user: ' . $username);
  358. return $existingUser;
  359. }
  360. // Check if user exists by email and linking is enabled
  361. if (!empty($email) && $this->config['oidcLinkExistingUsers']) {
  362. $existingUser = $this->getUserByEmail($email);
  363. if ($existingUser) {
  364. $updates = ['auth_service' => 'oidc::' . $provider];
  365. if ($this->config['oidcUpdateGroupsOnLogin']) {
  366. $updates['group_id'] = $groupId;
  367. $updates['group'] = $groupName;
  368. }
  369. $this->updateUserById($existingUser['id'], $updates);
  370. $this->setLoggerChannel('OIDC')->info('Linked user by email: ' . $email);
  371. return $existingUser;
  372. }
  373. }
  374. // Create new user if auto-create is enabled
  375. if (!$this->config['oidcAutoCreateUsers']) {
  376. $this->setLoggerChannel('OIDC')->warning('User not found and auto-create disabled: ' . $username);
  377. return null;
  378. }
  379. // Generate random password (user won't use it)
  380. $password = bin2hex(random_bytes(16));
  381. $userInfo = [
  382. 'username' => $username,
  383. 'password' => password_hash($password, PASSWORD_BCRYPT),
  384. 'email' => $email,
  385. 'group' => $groupName,
  386. 'group_id' => $groupId,
  387. 'image' => $image ?: $this->gravatar($email),
  388. 'register_date' => $this->currentTime,
  389. 'auth_service' => 'oidc::' . $provider,
  390. ];
  391. try {
  392. $this->db->query('INSERT INTO [users]', $userInfo);
  393. $this->setLoggerChannel('OIDC')->info('Created new OIDC user: ' . $username);
  394. return $this->getUserByUsername($username);
  395. } catch (Exception $e) {
  396. $this->setLoggerChannel('OIDC')->error($e);
  397. return null;
  398. }
  399. }
  400. /**
  401. * Process OIDC callback
  402. */
  403. public function processOIDCCallback($provider, $code, $state)
  404. {
  405. // Validate state
  406. if (!$this->validateOIDCState($state, $provider)) {
  407. $this->setAPIResponse('error', 'Invalid or expired state parameter', 400);
  408. return false;
  409. }
  410. // Exchange code for tokens
  411. $tokens = $this->exchangeOIDCCode($provider, $code);
  412. if (!$tokens || empty($tokens['access_token'])) {
  413. $this->setAPIResponse('error', 'Failed to exchange authorization code', 500);
  414. return false;
  415. }
  416. // Get user info from userinfo endpoint or ID token
  417. $userInfo = $this->getOIDCUserInfo($provider, $tokens['access_token']);
  418. if (!$userInfo && !empty($tokens['id_token'])) {
  419. $userInfo = $this->decodeOIDCIdToken($tokens['id_token']);
  420. }
  421. if (!$userInfo) {
  422. $this->setAPIResponse('error', 'Failed to get user information', 500);
  423. return false;
  424. }
  425. // Validate nonce if present
  426. $storedNonce = $_SESSION['oidc_nonce'] ?? null;
  427. unset($_SESSION['oidc_nonce']);
  428. if ($storedNonce && isset($userInfo['nonce']) && !hash_equals($storedNonce, $userInfo['nonce'])) {
  429. $this->setLoggerChannel('OIDC')->warning('Nonce mismatch');
  430. $this->setAPIResponse('error', 'Invalid nonce', 400);
  431. return false;
  432. }
  433. // Extract groups
  434. $groups = $this->extractOIDCGroups($provider, $userInfo);
  435. // Create or link user
  436. $user = $this->createOrLinkOIDCUser($provider, $userInfo, $groups);
  437. if (!$user) {
  438. $this->setAPIResponse('error', 'Failed to create or link user', 500);
  439. return false;
  440. }
  441. // Create Organizr token
  442. $this->createToken($user['username'], $user['email'], $this->config['rememberMeDays']);
  443. $this->setLoggerChannel('OIDC')->info('OIDC login successful: ' . $user['username']);
  444. return $user;
  445. }
  446. /**
  447. * Initiate OIDC authorization flow
  448. */
  449. public function initiateOIDCFlow($provider)
  450. {
  451. if (!$this->config['oidcEnabled']) {
  452. $this->setAPIResponse('error', 'OIDC is not enabled', 403);
  453. return false;
  454. }
  455. $enabledProviders = $this->getEnabledOIDCProviders();
  456. if (!isset($enabledProviders[$provider])) {
  457. $this->setAPIResponse('error', 'Provider not enabled: ' . $provider, 404);
  458. return false;
  459. }
  460. $authUrl = $this->getOIDCAuthorizationUrl($provider);
  461. if (!$authUrl) {
  462. $this->setAPIResponse('error', 'Failed to generate authorization URL', 500);
  463. return false;
  464. }
  465. header('Location: ' . $authUrl);
  466. exit;
  467. }
  468. /**
  469. * Test OIDC provider connection
  470. */
  471. public function testOIDCConnection($provider)
  472. {
  473. $config = $this->getOIDCProviderConfig($provider);
  474. if (!$config) {
  475. $this->setAPIResponse('error', 'Provider not found', 404);
  476. return false;
  477. }
  478. if (empty($config['discoveryUrl'])) {
  479. $this->setAPIResponse('error', 'Discovery URL not configured', 422);
  480. return false;
  481. }
  482. if (empty($config['clientId'])) {
  483. $this->setAPIResponse('error', 'Client ID not configured', 422);
  484. return false;
  485. }
  486. $discovery = $this->getOIDCDiscovery($provider);
  487. if (!$discovery) {
  488. $this->setAPIResponse('error', 'Failed to fetch discovery document', 500);
  489. return false;
  490. }
  491. $required = ['authorization_endpoint', 'token_endpoint', 'issuer'];
  492. foreach ($required as $field) {
  493. if (empty($discovery[$field])) {
  494. $this->setAPIResponse('error', 'Discovery document missing: ' . $field, 500);
  495. return false;
  496. }
  497. }
  498. $this->setAPIResponse('success', 'OIDC connection successful - Issuer: ' . $discovery['issuer'], 200);
  499. return true;
  500. }
  501. /**
  502. * Output callback success page - redirects to Organizr root
  503. */
  504. public function outputOIDCCallbackSuccess($username)
  505. {
  506. $this->setLoggerChannel('OIDC')->info('Redirecting user after successful login: ' . $username);
  507. header('Location: ' . $this->getServerPath());
  508. exit;
  509. }
  510. /**
  511. * Output callback error page
  512. */
  513. public function outputOIDCCallbackError($error)
  514. {
  515. echo '<!DOCTYPE html>
  516. <html lang="en">
  517. <head>
  518. <link rel="stylesheet" href="' . $this->getServerPath() . '/css/mvp.css">
  519. <meta charset="utf-8">
  520. <meta name="description" content="OIDC Login Error">
  521. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  522. <title>OIDC Login Error</title>
  523. </head>
  524. <body>
  525. <main>
  526. <section>
  527. <aside>
  528. <h3>Login Failed</h3>
  529. <p>' . htmlspecialchars($error) . '</p>
  530. <p><a href="javascript:window.close()">Close this window</a></p>
  531. </aside>
  532. </section>
  533. </main>
  534. </body>
  535. </html>';
  536. exit;
  537. }
  538. /**
  539. * Get OIDC logout URL
  540. */
  541. public function getOIDCLogoutUrl($provider)
  542. {
  543. $config = $this->getOIDCProviderConfig($provider);
  544. if (!$config || !$config['supportsEndSession']) {
  545. return null;
  546. }
  547. $discovery = $this->getOIDCDiscovery($provider);
  548. if (!$discovery || empty($discovery['end_session_endpoint'])) {
  549. return null;
  550. }
  551. $params = [
  552. 'client_id' => $config['clientId'],
  553. 'post_logout_redirect_uri' => $this->getServerPath(),
  554. ];
  555. return $discovery['end_session_endpoint'] . '?' . http_build_query($params);
  556. }
  557. /**
  558. * Check if auto-redirect to OIDC is enabled
  559. */
  560. public function shouldAutoRedirectToOIDC()
  561. {
  562. if (!$this->config['oidcEnabled'] || !$this->config['oidcAutoRedirect']) {
  563. return false;
  564. }
  565. $provider = $this->config['oidcAutoRedirectProvider'] ?? '';
  566. if (empty($provider)) {
  567. return false;
  568. }
  569. $enabledProviders = $this->getEnabledOIDCProviders();
  570. return isset($enabledProviders[$provider]);
  571. }
  572. /**
  573. * Get auto-redirect provider
  574. */
  575. public function getAutoRedirectProvider()
  576. {
  577. return $this->config['oidcAutoRedirectProvider'] ?? '';
  578. }
  579. }