* Copyright (C) 2024 MDW * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /** * \file htdocs/core/lib/openid_connect.lib.php * \ingroup openid_connect * \brief Library of functions for OpenID Connect authentication */ require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/security2.lib.php'; /** * Generate a self-verifiable state token for the OIDC authorization request. * * Uses HMAC with the instance unique ID as secret so the state can be verified * without depending on the original session. This is necessary because browsers * with SameSite=Lax cookies do not send the session cookie on cross-site redirects * from the OIDC provider, causing a new session to be created on the callback. * * @return string State token (format: nonce.signature) */ function openid_connect_get_state() { global $dolibarr_main_instance_unique_id; $nonce = bin2hex(random_bytes(16)); $signature = hash_hmac('sha256', $nonce, $dolibarr_main_instance_unique_id); return $nonce.'.'.$signature; } /** * Verify an OIDC state token. * * Checks that the state was generated by this Dolibarr instance by recomputing * the HMAC signature from the nonce and comparing it to the provided signature. * * @param string $state The state token to verify (format: nonce.signature) * @return bool True if valid, false otherwise */ function openid_connect_verify_state($state) { global $dolibarr_main_instance_unique_id; if (empty($state)) { return false; } $parts = explode('.', $state, 2); if (count($parts) !== 2) { return false; } $nonce = $parts[0]; $signature = $parts[1]; $expected = hash_hmac('sha256', $nonce, $dolibarr_main_instance_unique_id); return hash_equals($expected, $signature); } /** * Return the OIDC callback redirect URL * * @return string Redirect URL */ function openid_connect_get_redirect_url() { return DOL_MAIN_URL_ROOT . '/core/modules/openid_connect/callback.php'; } /** * Return the OIDC authorization URL * * @return string Authorization URL */ function openid_connect_get_url() { // Note: For the scope, we must use rawurlencode instead of urlencode. $url = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_AUTHORIZE_URL').'?client_id='.urlencode(getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLIENT_ID')).'&redirect_uri='.urlencode(openid_connect_get_redirect_url()).'&scope='.rawurlencode(getDolGlobalString('MAIN_AUTHENTICATION_OIDC_SCOPES')).'&response_type=code&state='.urlencode(openid_connect_get_state()); return $url; } /** * Create a Dolibarr user from OIDC userinfo claims. * * The login is sanitized to remove characters not allowed by Dolibarr (e.g. @ from emails). * If the OIDC userinfo contains a preferred_username claim without bad characters, it is used instead. * * @param DoliDB $db Database handler * @param stdClass $userinfo Decoded OIDC userinfo JSON (claims from json_decode) * @param string $login Login value extracted from the configured claim * @param int $entity Entity (multicompany) ID * @return string|int Sanitized login string on success, negative int on failure * @see check_user_password_openid_connect() */ function openid_connect_create_user($db, $userinfo, $login, $entity) { // Read claim mapping configuration $claim_email = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLAIM_EMAIL', 'email'); $claim_firstname = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLAIM_FIRSTNAME', 'given_name'); $claim_lastname = getDolGlobalString('MAIN_AUTHENTICATION_OIDC_CLAIM_LASTNAME', 'family_name'); // Sanitize login: Dolibarr rejects certain characters (default: ,@<>"') $badChars = getDolGlobalString('MAIN_LOGIN_BADCHARUNAUTHORIZED', ',@<>"\''); $sanitized_login = $login; if (preg_match('/['.preg_quote($badChars, '/').']/', $login)) { // First try preferred_username from OIDC (common standard claim) if (property_exists($userinfo, 'preferred_username') && !empty($userinfo->preferred_username)) { $preferred = $userinfo->preferred_username; if (!preg_match('/['.preg_quote($badChars, '/').']/', $preferred)) { $sanitized_login = $preferred; } } // If still invalid (no preferred_username or it also has bad chars), extract local part of email if (preg_match('/['.preg_quote($badChars, '/').']/', $sanitized_login) && strpos($sanitized_login, '@') !== false) { $sanitized_login = substr($sanitized_login, 0, strpos($sanitized_login, '@')); } // Final fallback: replace any remaining bad characters $sanitized_login = preg_replace('/['.preg_quote($badChars, '/').']/', '.', $sanitized_login); } $newuser = new User($db); $newuser->login = $sanitized_login; $newuser->entity = $entity; $newuser->statut = 1; // Active $newuser->status = 1; // Active (alias) if (property_exists($userinfo, $claim_email)) { $newuser->email = $userinfo->$claim_email; } if (property_exists($userinfo, $claim_firstname)) { $newuser->firstname = $userinfo->$claim_firstname; } if (property_exists($userinfo, $claim_lastname)) { $newuser->lastname = $userinfo->$claim_lastname; } // If no lastname from claims, use login as fallback (lastname is required) if (empty($newuser->lastname)) { $newuser->lastname = $login; } // Set a random password (user authenticates via OIDC, not locally) $newuser->pass = getRandomPassword(true); // Find the admin user to set as creator (configurable, default: user ID 1) $creator_id = getDolGlobalInt('MAIN_AUTHENTICATION_OIDC_DEFAULT_CREATOR', 1); if ($creator_id <= 0) { $creator_id = 1; } $adminuser = new User($db); $result_fetch = $adminuser->fetch($creator_id); if ($result_fetch <= 0 || empty($adminuser->admin) || $adminuser->statut != 1) { dol_syslog("openid_connect_create_user::Error: configured creator user ID=".$creator_id." is not a valid active admin", LOG_ERR); return -2; } $db->begin(); $result_create = $newuser->create($adminuser); if ($result_create < 0) { $db->rollback(); dol_syslog("openid_connect_create_user::Error creating user: ".$newuser->error, LOG_ERR); return $result_create; } // Add to default group if configured $default_group = getDolGlobalInt('MAIN_AUTHENTICATION_OIDC_DEFAULT_GROUP'); if ($default_group > 0) { $res_group = $newuser->SetInGroup($default_group, $entity); if ($res_group < 0) { dol_syslog("openid_connect_create_user::Warning: Error adding user to group ".$default_group.": ".$newuser->error, LOG_WARNING); // Don't fail user creation if group assignment fails } } $db->commit(); dol_syslog("openid_connect_create_user::User created id=".$result_create." login=".$sanitized_login); return $sanitized_login; }