oidc-functions.php 18 KB

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