isLoggedIn()) { header('Location: ' . BASEURL . '/'); exit; } // Store redirect URL in session before OIDC flow. if (isset($_GET['redirect'])) $_SESSION['oidc_redirect_url'] = base64_decode($_GET['redirect']); $oidc = new OpenIDConnectClient(OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET); $oidc->setRedirectURL(BASEURL . '/oidclogin/'); $oidc->addScope(['openid', 'email', 'profile', 'groups']); try { $oidc->authenticate(); } catch (\Exception $e) { $_SESSION['login_msg'] = ['', 'OIDC authentication failed: ' . $e->getMessage(), 'danger']; header('Location: ' . BASEURL . '/login/'); exit; } // Get the stable subject identifier from the ID token. $sub = $oidc->getVerifiedClaims('sub'); if (empty($sub)) { $_SESSION['login_msg'] = ['', 'No subject identifier received from OIDC provider.', 'danger']; header('Location: ' . BASEURL . '/login/'); exit; } // Step 1: Look up user by oidc_sub. $user = Member::fromOidcSub($sub); // Step 2: One-time email migration — link existing account by email. if ($user === null || $user === false) { $email = $oidc->requestUserInfo('email'); if (!empty($email)) { $user = Member::fromEmailAddress($email); if ($user !== null && $user !== false) $user->update(['oidc_sub' => $sub]); } } // Step 3: Auto-enroll — create a new user from OIDC claims. if ($user === null || $user === false) { $first_name = $oidc->requestUserInfo('given_name') ?: ''; $last_name = $oidc->requestUserInfo('family_name') ?: ''; $email = $oidc->requestUserInfo('email') ?: $sub . '@oidc.placeholder'; $slug = $this->generateSlug($first_name, $last_name); $user = Member::createNew([ 'first_name' => $first_name ?: 'OIDC', 'surname' => $last_name ?: 'User', 'slug' => $slug, 'emailaddress' => $email, 'oidc_sub' => $sub, ]); if ($user === false) { $_SESSION['login_msg'] = ['', 'Failed to create account. Please contact an administrator.', 'danger']; header('Location: ' . BASEURL . '/login/'); exit; } } // Step 4: Sync admin status from groups claim. $groups = $oidc->requestUserInfo('groups'); $should_be_admin = is_array($groups) && in_array(OIDC_ADMIN_GROUP, $groups); if ($should_be_admin !== $user->isAdmin()) $user->update(['is_admin' => $should_be_admin ? 1 : 0]); // Set session and redirect. $_SESSION['user_id'] = $user->getUserId(); if (!empty($_SESSION['oidc_redirect_url'])) { $redirect = $_SESSION['oidc_redirect_url']; unset($_SESSION['oidc_redirect_url']); header('Location: ' . $redirect); } elseif (!empty($_SESSION['login_url'])) { $redirect = $_SESSION['login_url']; unset($_SESSION['login_url']); header('Location: ' . $redirect); } else header('Location: ' . BASEURL . '/'); exit; } private function generateSlug($first_name, $last_name) { $base = trim($first_name . ' ' . $last_name); if (empty($base)) $base = 'user'; $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $base)); $slug = trim($slug, '-'); if (empty($slug)) $slug = 'user'; // Check if slug is available. $candidate = $slug; for ($i = 0; $i < 10; $i++) { try { Member::fromSlug($candidate); // Slug taken — append random suffix. $candidate = $slug . '-' . bin2hex(random_bytes(3)); } catch (NotFoundException $e) { // Slug is available. return $candidate; } } // Fallback: use sub-based slug. return 'user-' . bin2hex(random_bytes(4)); } }