Files
pics/controllers/OIDCLogin.php
Yorick van Pelt d631a07d3d Match OIDC users by sub claim, auto-enroll, sync admin from groups
Switch from email-based OIDC matching to the stable `sub` claim.
Existing users are migrated by email on first login, new users are
auto-enrolled from OIDC claims, and admin status is synced from the
IdP's groups claim. Also expose oidc_sub on the admin edit-user page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:04:22 +01:00

153 lines
4.0 KiB
PHP

<?php
/*****************************************************************************
* OIDCLogin.php
* Handles OIDC authentication via Kanidm (or other OIDC providers).
*****************************************************************************/
use Jumbojett\OpenIDConnectClient;
class OIDCLogin
{
public function __construct()
{
if (empty(OIDC_PROVIDER_URL))
{
header('Location: ' . BASEURL . '/login/');
exit;
}
// Already logged in? Redirect home.
if (Registry::get('user')->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));
}
}