<?php
/* Copyright (C) 2017      Open-DSI             <support@open-dsi.fr>
 * Copyright (C) 2024-2026	MDW						<mdeweerd@users.noreply.github.com>
 *
 * 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 <http://www.gnu.org/licenses/>.
 */

/**
 *	\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 using Dolibarr forbidden characters for logins.
	$badChars = getDolGlobalLoginBadCharUnauthorized();
	$sanitized_login = $login;  // @phan-suppress-current-line SqlInjection

	if ($badChars !== '' && 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;  // @phan-suppress-current-line SqlInjection
			}
		}
		// 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 = (string) 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;
}
