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>
This commit is contained in:
2026-02-15 19:37:14 +01:00
parent a361df2668
commit 555a3dbb95
7 changed files with 130 additions and 16 deletions

View File

@@ -40,3 +40,4 @@ const OIDC_PROVIDER_URL = ''; // e.g. 'https://kanidm.example.com/oauth2/op
const OIDC_CLIENT_ID = ''; const OIDC_CLIENT_ID = '';
const OIDC_CLIENT_SECRET = ''; const OIDC_CLIENT_SECRET = '';
const OIDC_PROVIDER_NAME = ''; // e.g. 'Kanidm' — used as button label const OIDC_PROVIDER_NAME = ''; // e.g. 'Kanidm' — used as button label
const OIDC_ADMIN_GROUP = ''; // OIDC group claim value that grants admin, e.g. 'pics_admins'

View File

@@ -109,6 +109,14 @@ class EditUser extends HTMLController
'maxlength' => 255, 'maxlength' => 255,
'is_optional' => true, 'is_optional' => true,
], ],
'oidc_sub' => [
'header' => 'OIDC',
'type' => 'text',
'label' => 'OIDC subject identifier',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
'is_admin' => [ 'is_admin' => [
'header' => 'Privileges', 'header' => 'Privileges',
'type' => 'checkbox', 'type' => 'checkbox',
@@ -145,6 +153,10 @@ class EditUser extends HTMLController
// Quick stripping. // Quick stripping.
$data['slug'] = strtr(strtolower($data['slug']), [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']); $data['slug'] = strtr(strtolower($data['slug']), [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']);
// Normalise empty OIDC sub to null (unique constraint).
if (empty($data['oidc_sub']))
$data['oidc_sub'] = null;
// Checkboxes, fun! // Checkboxes, fun!
$data['is_admin'] = empty($data['is_admin']) ? 0 : 1; $data['is_admin'] = empty($data['is_admin']) ? 0 : 1;

View File

@@ -29,7 +29,7 @@ class OIDCLogin
$oidc = new OpenIDConnectClient(OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET); $oidc = new OpenIDConnectClient(OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET);
$oidc->setRedirectURL(BASEURL . '/oidclogin/'); $oidc->setRedirectURL(BASEURL . '/oidclogin/');
$oidc->addScope(['openid', 'email']); $oidc->addScope(['openid', 'email', 'profile', 'groups']);
try try
{ {
@@ -42,22 +42,62 @@ class OIDCLogin
exit; exit;
} }
$email = $oidc->requestUserInfo('email'); // Get the stable subject identifier from the ID token.
if (empty($email)) $sub = $oidc->getVerifiedClaims('sub');
if (empty($sub))
{ {
$_SESSION['login_msg'] = ['', 'No email address received from OIDC provider.', 'danger']; $_SESSION['login_msg'] = ['', 'No subject identifier received from OIDC provider.', 'danger'];
header('Location: ' . BASEURL . '/login/'); header('Location: ' . BASEURL . '/login/');
exit; exit;
} }
$user = Member::fromEmailAddress($email); // 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) if ($user === null || $user === false)
{ {
$_SESSION['login_msg'] = ['', 'No account found for this email address. Please contact an administrator.', 'danger']; $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/'); header('Location: ' . BASEURL . '/login/');
exit; 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(); $_SESSION['user_id'] = $user->getUserId();
if (!empty($_SESSION['oidc_redirect_url'])) if (!empty($_SESSION['oidc_redirect_url']))
@@ -77,4 +117,36 @@ class OIDCLogin
exit; 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));
}
} }

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN oidc_sub TEXT;
CREATE UNIQUE INDEX idx_users_oidc_sub ON users (oidc_sub);

View File

@@ -27,6 +27,15 @@ class Member extends User
['email_address' => $email_address]); ['email_address' => $email_address]);
} }
public static function fromOidcSub($sub)
{
return Registry::get('db')->queryObject(static::class, '
SELECT *
FROM users
WHERE oidc_sub = :oidc_sub',
['oidc_sub' => $sub]);
}
public static function fromId($id_user) public static function fromId($id_user)
{ {
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
@@ -73,18 +82,27 @@ class Member extends User
'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true, 'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true,
'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true, 'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true,
'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true, 'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true,
'password_hash' => !empty($data['password']) ? Authentication::computeHash($data['password']) : $error |= true,
'creation_time' => time(), 'creation_time' => time(),
'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
'is_admin' => empty($data['is_admin']) ? 0 : 1, 'is_admin' => empty($data['is_admin']) ? 0 : 1,
'reset_key' => '', 'reset_key' => '',
]; ];
// Password is required unless oidc_sub is provided.
if (!empty($data['password']))
$new_user['password_hash'] = Authentication::computeHash($data['password']);
elseif (!empty($data['oidc_sub']))
$new_user['password_hash'] = '';
else
$error |= true;
if (!empty($data['oidc_sub']))
$new_user['oidc_sub'] = $data['oidc_sub'];
if ($error) if ($error)
return false; return false;
$db = Registry::get('db'); $columns = [
$bool = $db->insert('insert', 'users', [
'first_name' => 'string-30', 'first_name' => 'string-30',
'surname' => 'string-60', 'surname' => 'string-60',
'slug' => 'string-90', 'slug' => 'string-90',
@@ -93,8 +111,14 @@ class Member extends User
'creation_time' => 'int', 'creation_time' => 'int',
'ip_address' => 'string-45', 'ip_address' => 'string-45',
'is_admin' => 'int', 'is_admin' => 'int',
'reset_key' => 'string-16' 'reset_key' => 'string-16',
], $new_user, ['id_user']); ];
if (isset($new_user['oidc_sub']))
$columns['oidc_sub'] = 'string-255';
$db = Registry::get('db');
$bool = $db->insert('insert', 'users', $columns, $new_user, ['id_user']);
if (!$bool) if (!$bool)
return false; return false;
@@ -113,7 +137,7 @@ class Member extends User
{ {
foreach ($new_data as $key => $value) foreach ($new_data as $key => $value)
{ {
if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress'])) if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress', 'oidc_sub']))
$this->$key = $value; $this->$key = $value;
elseif ($key === 'password') elseif ($key === 'password')
$this->password_hash = Authentication::computeHash($value); $this->password_hash = Authentication::computeHash($value);
@@ -132,7 +156,8 @@ class Member extends User
slug = :slug, slug = :slug,
emailaddress = :emailaddress, emailaddress = :emailaddress,
password_hash = :password_hash, password_hash = :password_hash,
is_admin = :is_admin is_admin = :is_admin,
oidc_sub = :oidc_sub
WHERE id_user = :id_user', WHERE id_user = :id_user',
get_object_vars($this)); get_object_vars($this));
} }

View File

@@ -24,6 +24,7 @@ abstract class User
protected $is_admin; protected $is_admin;
protected $reset_key; protected $reset_key;
protected $reset_blocked_until; protected $reset_blocked_until;
protected $oidc_sub;
protected bool $is_logged; protected bool $is_logged;
protected bool $is_guest; protected bool $is_guest;

View File

@@ -19,7 +19,8 @@ CREATE TABLE IF NOT EXISTS users (
ip_address TEXT, ip_address TEXT,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
reset_key TEXT, reset_key TEXT,
reset_blocked_until INTEGER reset_blocked_until INTEGER,
oidc_sub TEXT UNIQUE
); );
CREATE TABLE IF NOT EXISTS assets ( CREATE TABLE IF NOT EXISTS assets (