From d631a07d3dc96557026c10aa7a73ab98c4c1ba6f Mon Sep 17 00:00:00 2001 From: Yorick van Pelt Date: Sun, 15 Feb 2026 19:37:14 +0100 Subject: [PATCH] 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 --- config.php.dist | 1 + controllers/EditUser.php | 12 ++++ controllers/OIDCLogin.php | 88 +++++++++++++++++++++++++++--- migrations/2026-02-15-oidc-sub.sql | 2 + models/Member.php | 39 ++++++++++--- models/User.php | 1 + 6 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 migrations/2026-02-15-oidc-sub.sql diff --git a/config.php.dist b/config.php.dist index 587c36d..86fb8f0 100644 --- a/config.php.dist +++ b/config.php.dist @@ -40,3 +40,4 @@ const OIDC_PROVIDER_URL = ''; // e.g. 'https://kanidm.example.com/oauth2/op const OIDC_CLIENT_ID = ''; const OIDC_CLIENT_SECRET = ''; 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' diff --git a/controllers/EditUser.php b/controllers/EditUser.php index 089dca6..b303988 100644 --- a/controllers/EditUser.php +++ b/controllers/EditUser.php @@ -109,6 +109,14 @@ class EditUser extends HTMLController 'maxlength' => 255, 'is_optional' => true, ], + 'oidc_sub' => [ + 'header' => 'OIDC', + 'type' => 'text', + 'label' => 'OIDC subject identifier', + 'size' => 50, + 'maxlength' => 255, + 'is_optional' => true, + ], 'is_admin' => [ 'header' => 'Privileges', 'type' => 'checkbox', @@ -145,6 +153,10 @@ class EditUser extends HTMLController // Quick stripping. $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! $data['is_admin'] = empty($data['is_admin']) ? 0 : 1; diff --git a/controllers/OIDCLogin.php b/controllers/OIDCLogin.php index 5216661..0f1145b 100644 --- a/controllers/OIDCLogin.php +++ b/controllers/OIDCLogin.php @@ -29,7 +29,7 @@ class OIDCLogin $oidc = new OpenIDConnectClient(OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET); $oidc->setRedirectURL(BASEURL . '/oidclogin/'); - $oidc->addScope(['openid', 'email']); + $oidc->addScope(['openid', 'email', 'profile', 'groups']); try { @@ -42,22 +42,62 @@ class OIDCLogin exit; } - $email = $oidc->requestUserInfo('email'); - if (empty($email)) + // Get the stable subject identifier from the ID token. + $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/'); 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) { - $_SESSION['login_msg'] = ['', 'No account found for this email address. Please contact an administrator.', 'danger']; - header('Location: ' . BASEURL . '/login/'); - exit; + $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'])) @@ -77,4 +117,36 @@ class OIDCLogin 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)); + } } diff --git a/migrations/2026-02-15-oidc-sub.sql b/migrations/2026-02-15-oidc-sub.sql new file mode 100644 index 0000000..695ba91 --- /dev/null +++ b/migrations/2026-02-15-oidc-sub.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN oidc_sub TEXT; +CREATE UNIQUE INDEX idx_users_oidc_sub ON users (oidc_sub); diff --git a/models/Member.php b/models/Member.php index 0417a0e..ea78108 100644 --- a/models/Member.php +++ b/models/Member.php @@ -27,6 +27,15 @@ class Member extends User ['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) { $row = Registry::get('db')->queryAssoc(' @@ -73,18 +82,27 @@ class Member extends User 'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true, 'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true, 'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true, - 'password_hash' => !empty($data['password']) ? Authentication::computeHash($data['password']) : $error |= true, 'creation_time' => time(), 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'is_admin' => empty($data['is_admin']) ? 0 : 1, '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) return false; - $db = Registry::get('db'); - $bool = $db->insert('insert', 'users', [ + $columns = [ 'first_name' => 'string-30', 'surname' => 'string-60', 'slug' => 'string-90', @@ -93,8 +111,14 @@ class Member extends User 'creation_time' => 'int', 'ip_address' => 'string-45', 'is_admin' => 'int', - 'reset_key' => 'string-16' - ], $new_user, ['id_user']); + 'reset_key' => 'string-16', + ]; + + 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) return false; @@ -113,7 +137,7 @@ class Member extends User { 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; elseif ($key === 'password') $this->password_hash = Authentication::computeHash($value); @@ -132,7 +156,8 @@ class Member extends User slug = :slug, emailaddress = :emailaddress, password_hash = :password_hash, - is_admin = :is_admin + is_admin = :is_admin, + oidc_sub = :oidc_sub WHERE id_user = :id_user', get_object_vars($this)); } diff --git a/models/User.php b/models/User.php index a26241d..8bfafbd 100644 --- a/models/User.php +++ b/models/User.php @@ -24,6 +24,7 @@ abstract class User protected $is_admin; protected $reset_key; protected $reset_blocked_until; + protected $oidc_sub; protected bool $is_logged; protected bool $is_guest;