diff --git a/Changelog_iseta.md b/Changelog_iseta.md new file mode 100644 index 0000000000000..73ad19fbe6254 --- /dev/null +++ b/Changelog_iseta.md @@ -0,0 +1,36 @@ +# Changelog iseta — patchs spécifiques cœur Dolibarr + +Modifications spécifiques iseta appliquées au cœur Dolibarr sur la branche `22.0_iseta` +(hors modules `htdocs/custom/`). Chaque changement est marqué en code par `BACKPORT PR #37329` +(ou une référence équivalente) pour être retrouvé lors des montées de version. + +## 2026-07-16 — Backport OAuth Microsoft Exchange Online (PR Dolibarr #37329) + +Backport de la fonctionnalité « Microsoft Exchange Online [SMTP/IMAP] OAuth2 » depuis la +branche 23.0 vers la 22.0. + +- **Source** : https://github.com/Dolibarr/dolibarr/pull/37329 (mergée en 23.0) +- **Périmètre** : nouveau provider OAuth `MICROSOFT3` (scopes `outlook.office.com` : + `SMTP.Send` + `IMAP.AccessAsUser.All`, `offline_access`), pour l'envoi/lecture de mail + via OAuth2 sur Exchange Online. + +### Fichiers ajoutés (copie conforme de la 23.0) +- `htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php` +- `htdocs/core/modules/oauth/microsoft3_oauthcallback.php` + +### Fichiers modifiés (hunks OAuth uniquement, jamais de copie complète) +- `htdocs/core/lib/oauth.lib.php` — enregistrement du provider `MICROSOFT3` + (`getAllOauth2Array` + `getSupportedOauth2Array`) +- `htdocs/admin/oauth.php` — affichage du champ Tenant pour `MICROSOFT3` +- `htdocs/core/modules/oauth/microsoft_oauthcallback.php` — gestion du paramètre `state` +- `htdocs/core/class/CMailFile.class.php` — `setAuthMode('XOAUTH2')` en tête du bloc XOAUTH2 + + `dol_syslog` quand l'objet token est invalide +- `htdocs/langs/en_US/oauth.lang`, `htdocs/langs/fr_FR/oauth.lang` — clé `OAuthErrorStateDiffers` + +### Volontairement EXCLUS de la PR (bugfixes sans rapport avec OAuth) +- `htdocs/compta/facture/card.php` (guard `fetch_thirdparty`) +- `htdocs/website/class/websitepage.class.php` (`$this->errors[]`) +- suppressions cosmétiques de lignes vides dans `CMailFile.class.php` + +### Intégration +- Branche : `NEW/oauth/PR37329/MicrosoftExchangeOAuth` → PR vers `22.0_iseta` (remote `atm`). diff --git a/htdocs/admin/oauth.php b/htdocs/admin/oauth.php index dea7e1e8d5360..eae48e0368676 100644 --- a/htdocs/admin/oauth.php +++ b/htdocs/admin/oauth.php @@ -442,7 +442,8 @@ print ''; // Tenant - if ($keybeforeprovider == 'MICROSOFT' || $keybeforeprovider == 'MICROSOFT2') { + // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth (MICROSOFT3 tenant field) + if ($keybeforeprovider == 'MICROSOFT' || $keybeforeprovider == 'MICROSOFT2' || $keybeforeprovider == 'MICROSOFT3') { print ''; print ''; print ''; diff --git a/htdocs/core/class/CMailFile.class.php b/htdocs/core/class/CMailFile.class.php index d8900befe76d6..59ba582238872 100644 --- a/htdocs/core/class/CMailFile.class.php +++ b/htdocs/core/class/CMailFile.class.php @@ -1300,6 +1300,8 @@ public function sendfile() $this->transport->setPassword(getDolGlobalString($keyforsmtppw)); } if (getDolGlobalString($keyforsmtpauthtype) === "XOAUTH2") { + // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth + $this->transport->setAuthMode('XOAUTH2'); require_once DOL_DOCUMENT_ROOT.'/core/lib/oauth.lib.php'; $supportedoauth2array = getSupportedOauth2Array(); @@ -1364,6 +1366,8 @@ public function sendfile() $this->transport->setPassword($tokenobj->getAccessToken()); } else { $this->errors[] = "Token not found"; + // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth + dol_syslog("CMailFile::sendfile: OAuth2 token object is not valid", LOG_ERR); } } catch (Exception $e) { // Return an error if token not found diff --git a/htdocs/core/lib/oauth.lib.php b/htdocs/core/lib/oauth.lib.php index b5ff496e774ad..fd4080f9853af 100644 --- a/htdocs/core/lib/oauth.lib.php +++ b/htdocs/core/lib/oauth.lib.php @@ -156,6 +156,13 @@ function getAllOauth2Array() 'OAUTH_MICROSOFT2_ID', 'OAUTH_MICROSOFT2_SECRET', ), + // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth + array( + 'OAUTH_MICROSOFT3_NAME', + 'OAUTH_MICROSOFT3_ID', + 'OAUTH_MICROSOFT3_SECRET', + 'OAUTH_MICROSOFT3_DESC', + ), array( 'OAUTH_NEST_NAME', 'OAUTH_NEST_ID', @@ -341,6 +348,20 @@ function getSupportedOauth2Array() 'availablescopes' => 'openid,offline_access,profile,email,User.Read,https://outlook.office.com/.default', 'returnurl' => '/core/modules/oauth/microsoft2_oauthcallback.php' ); + // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth + $supportedoauth2array['OAUTH_MICROSOFT3_NAME'] = array( + 'callbackfile' => 'microsoft3', + 'picto' => 'microsoft', + 'urlforapp' => 'OAUTH_MICROSOFT3_DESC', + 'name' => 'Microsoft Exchange Online [SMTP/IMAP]', + 'urlforcredentials' => 'https://portal.azure.com/', + // CRITICAL: Use ONLY outlook.office.com scopes here, do NOT mix with Graph scopes (openid/profile/email). + // Mixing two resource namespaces in one token request causes AADSTS28000 error. + // offline_access is a neutral scope (no resource prefix) and is allowed alongside any resource. + // Azure permissions required: Microsoft Graph > Delegated > SMTP.Send and IMAP.AccessAsUser.All + 'availablescopes' => 'offline_access,https://outlook.office.com/SMTP.Send,https://outlook.office.com/IMAP.AccessAsUser.All', + 'returnurl' => '/core/modules/oauth/microsoft3_oauthcallback.php' + ); // Add a generic Oauth token handler. Tested with Mastodon. $supportedoauth2array['OAUTH_GENERIC_NAME'] = array( diff --git a/htdocs/core/modules/oauth/microsoft3_oauthcallback.php b/htdocs/core/modules/oauth/microsoft3_oauthcallback.php new file mode 100644 index 0000000000000..f24c1882eea2c --- /dev/null +++ b/htdocs/core/modules/oauth/microsoft3_oauthcallback.php @@ -0,0 +1,210 @@ + + * Copyright (C) 2015-2024 Frédéric France + * Copyright (C) 2024 MDW + * Copyright (C) 2026 Vidal Nicolas + * + * 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 . + */ + +// BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth callback (new file copied from core 23.0) + +/** + * \file htdocs/core/modules/oauth/microsoft3_oauthcallback.php + * \ingroup oauth + * \brief Page to get oauth callback for Microsoft Exchange Online (SMTP/IMAP) + * + * Uses Exchange Online OAuth2 scopes for SMTP/IMAP: + * - offline_access + * - https://outlook.office.com/SMTP.Send + * - https://outlook.office.com/IMAP.AccessAsUser.All + */ + +// Load Dolibarr environment +require '../../../main.inc.php'; +require_once DOL_DOCUMENT_ROOT.'/includes/OAuth/bootstrap.php'; +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + * + * @var string $dolibarr_main_url_root + */ + +use OAuth\Common\Storage\DoliStorage; +use OAuth\Common\Consumer\Credentials; + +// Define $urlwithroot +$urlwithouturlroot = preg_replace('/'.preg_quote(DOL_URL_ROOT, '/').'$/i', '', trim($dolibarr_main_url_root)); +$urlwithroot = $urlwithouturlroot.DOL_URL_ROOT; // This is to use external domain name found into config file + + +$action = GETPOST('action', 'aZ09'); +$backtourl = GETPOST('backtourl', 'alpha'); +$keyforprovider = GETPOST('keyforprovider', 'aZ09'); +if (empty($keyforprovider) && !empty($_SESSION["oauthkeyforproviderbeforeoauthjump"]) && (GETPOST('code') || $action == 'delete')) { + $keyforprovider = $_SESSION["oauthkeyforproviderbeforeoauthjump"]; +} +$genericstring = 'MICROSOFT3'; + + +/** + * Create a new instance of the URI class with the current URI, stripping the query string + */ +$uriFactory = new \OAuth\Common\Http\Uri\UriFactory(); + + +$currentUri = $uriFactory->createFromAbsolute($urlwithroot.'/core/modules/oauth/microsoft3_oauthcallback.php'); + + +/** + * Load the credential for the service + */ + +/** @var \OAuth\ServiceFactory $serviceFactory An OAuth service factory. */ +$serviceFactory = new \OAuth\ServiceFactory(); +$httpClient = new \OAuth\Common\Http\Client\CurlClient(); + +$serviceFactory->setHttpClient($httpClient); + +// Setup the credentials for the requests +$keyforparamid = 'OAUTH_'.$genericstring.($keyforprovider ? '-'.$keyforprovider : '').'_ID'; +$keyforparamsecret = 'OAUTH_'.$genericstring.($keyforprovider ? '-'.$keyforprovider : '').'_SECRET'; +$keyforparamtenant = 'OAUTH_'.$genericstring.($keyforprovider ? '-'.$keyforprovider : '').'_TENANT'; + +// Dolibarr storage +$storage = new DoliStorage($db, $conf, $keyforprovider, getDolGlobalString($keyforparamtenant)); + +$credentials = new Credentials( + getDolGlobalString($keyforparamid), + getDolGlobalString($keyforparamsecret), + $currentUri->getAbsoluteUri() +); + +$state = GETPOST('state'); + +$requestedpermissionsarray = array(); +if ($state) { + $requestedpermissionsarray = explode(',', $state); // Example: 'user'. 'state' parameter is standard to retrieve some parameters back +} +if ($action != 'delete' && empty($requestedpermissionsarray)) { + print 'Error, parameter state is not defined'; + exit; +} + +try { + $nameofservice = ucfirst(strtolower($genericstring)); + $apiService = $serviceFactory->createService($nameofservice, $credentials, $storage, $requestedpermissionsarray); + '@phan-var-force OAuth\OAuth2\Service\AbstractService|OAuth\OAuth1\Service\AbstractService $apiService'; // createService is only ServiceInterface +} catch (Exception $e) { + print $e->getMessage(); + exit; +} + +if (empty($apiService)) { + print 'Error, failed to create serviceFactory'; + exit; +} + +$langs->load("oauth"); + +if (!getDolGlobalString($keyforparamid)) { + accessforbidden('Setup of service is not complete. Customer ID is missing'); +} +if (!getDolGlobalString($keyforparamsecret)) { + accessforbidden('Setup of service is not complete. Secret key is missing'); +} +if (!getDolGlobalString($keyforparamtenant)) { + accessforbidden('Setup of service is not complete. Tenant/Annuary ID key is missing'); +} + +/* + * Actions + */ + +if ($action == 'delete' && (!empty($user->admin) || $user->id == GETPOSTINT('userid'))) { + $storage->userid = GETPOSTINT('userid'); + $storage->clearToken($genericstring); + + setEventMessages($langs->trans('TokenDeleted'), null, 'mesgs'); + + if (empty($backtourl)) { + $backtourl = DOL_URL_ROOT.'/'; + } + + header('Location: '.$backtourl); + exit(); +} + + +if (GETPOST('code') || GETPOST('error')) { // We are coming from oauth provider page + // We should have + + dol_syslog(basename(__FILE__)." We are coming from the oauth provider page code=".dol_trunc(GETPOST('code'), 5)." error=".GETPOST('error')); + + // We must validate that the $state is the same than the one into $_SESSION['oauthstateanticsrf'], return error if not. + if (isset($_SESSION['oauthstateanticsrf']) && $state != $_SESSION['oauthstateanticsrf']) { + print $langs->trans("OAuthErrorStateDiffers", dol_escape_htmltag($state)); + unset($_SESSION['oauthstateanticsrf']); + exit; + } + + // This was a callback request from service, get the token + try { + if (GETPOST('error')) { + setEventMessages(GETPOST('error').' '.GETPOST('error_description'), null, 'errors'); + } else { + $token = $apiService->requestAccessToken(GETPOST('code')); + setEventMessages($langs->trans('NewTokenStored'), null, 'mesgs'); // Stored into object managed by class DoliStorage so into table oauth_token + } + + $backtourl = $_SESSION["backtourlsavedbeforeoauthjump"]; + unset($_SESSION["backtourlsavedbeforeoauthjump"]); + + header('Location: '.$backtourl); + exit(); + } catch (Exception $e) { + print $e->getMessage(); + } +} else { + // If we enter this page without 'code' parameter, we arrive here. This is the case when we want to get the redirect + // to the OAuth provider login page. + $_SESSION["backtourlsavedbeforeoauthjump"] = $backtourl; + $_SESSION["oauthkeyforproviderbeforeoauthjump"] = $keyforprovider; + $_SESSION['oauthstateanticsrf'] = $state; + + // This may create record into oauth_state before the header redirect. + // Creation of record with state in this tables depend on the Provider used (see its constructor). + $params = array('prompt' => 'consent'); + if ($state) { + $params['state'] = $state; + } + $url = $apiService->getAuthorizationUri($params); + + // Show url to get authorization + dol_syslog("Redirect to url=".$url); + + // we go on oauth provider authorization page + header('Location: '.$url); + exit(); +} + +/* + * View + */ + +// No view at all, just actions + +$db->close(); diff --git a/htdocs/core/modules/oauth/microsoft_oauthcallback.php b/htdocs/core/modules/oauth/microsoft_oauthcallback.php index 3cc0c5da50c4f..5dfdc95ee67d3 100644 --- a/htdocs/core/modules/oauth/microsoft_oauthcallback.php +++ b/htdocs/core/modules/oauth/microsoft_oauthcallback.php @@ -208,8 +208,13 @@ // This may create record into oauth_state before the header redirect. // Creation of record with state in this tables depend on the Provider used (see its constructor). + // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth (state param handling) + $params = array(); if ($state) { - $url = $apiService->getAuthorizationUri(array('state' => $state)); + $params['state'] = $state; + } + if (!empty($params)) { + $url = $apiService->getAuthorizationUri($params); } else { $url = $apiService->getAuthorizationUri(); // Parameter state will be randomly generated } diff --git a/htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php b/htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php new file mode 100644 index 0000000000000..099c71c2f9e17 --- /dev/null +++ b/htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php @@ -0,0 +1,111 @@ + 22.0_iseta): Microsoft Exchange Online OAuth service (new file copied from core 23.0) + +namespace OAuth\OAuth2\Service; + +use OAuth\OAuth2\Token\StdOAuth2Token; +use OAuth\Common\Http\Exception\TokenResponseException; +use OAuth\Common\Http\Uri\Uri; +use OAuth\Common\Consumer\CredentialsInterface; +use OAuth\Common\Http\Client\ClientInterface; +use OAuth\Common\Storage\TokenStorageInterface; +use OAuth\Common\Http\Uri\UriInterface; + +/** + * Microsoft Exchange Online OAuth2 service (SMTP/IMAP) + * + * Uses Exchange Online OAuth2 scopes for legacy protocols (SMTP/IMAP): + * - offline_access (required for refresh token) + * - https://outlook.office.com/SMTP.Send + * - https://outlook.office.com/IMAP.AccessAsUser.All + */ +class Microsoft3 extends AbstractService +{ + // offline_access is resource-neutral, allowed with any resource scope + const SCOPE_OFFLINE_ACCESS = 'offline_access'; + + // Exchange Online scopes for SMTP/IMAP XOAUTH2 protocol authentication. + // MUST NOT be mixed with Microsoft Graph scopes (openid/profile/email/User.Read) + // in the same token request — doing so causes error AADSTS28000. + // Azure app registration requires: Microsoft Graph > Delegated > SMTP.Send and IMAP.AccessAsUser.All + // See: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth + const SCOPE_SMTP_SEND = 'https://outlook.office.com/SMTP.Send'; + const SCOPE_IMAP_ACCESSASUSERALL = 'https://outlook.office.com/IMAP.AccessAsUser.All'; + + protected $storage; + + public function __construct( + CredentialsInterface $credentials, + ClientInterface $httpClient, + TokenStorageInterface $storage, + $scopes = array(), + UriInterface $baseApiUri = null + ) { + parent::__construct($credentials, $httpClient, $storage, $scopes, $baseApiUri); + + $this->storage = $storage; + + if (null === $baseApiUri) { + // baseApiUri is not used for SMTP/IMAP auth, but keep a sensible default for potential API calls. + $this->baseApiUri = new Uri('https://graph.microsoft.com/v1.0/'); + } + } + + /** + * {@inheritdoc} + */ + public function getAuthorizationEndpoint() + { + $tenant = $this->storage->getTenant(); + + return new Uri('https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/authorize'); + } + + /** + * {@inheritdoc} + */ + public function getAccessTokenEndpoint() + { + $tenant = $this->storage->getTenant(); + + return new Uri('https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/token'); + } + + /** + * {@inheritdoc} + */ + public function getAuthorizationMethod() + { + return static::AUTHORIZATION_METHOD_QUERY_STRING; + } + + /** + * {@inheritdoc} + */ + protected function parseAccessTokenResponse($responseBody) + { + $data = json_decode($responseBody, true); + + if (null === $data || !is_array($data)) { + throw new TokenResponseException('Unable to parse response.'); + } elseif (isset($data['error'])) { + throw new TokenResponseException('Error in retrieving token: "' . $data['error'] . '"'); + } + + $token = new StdOAuth2Token(); + $token->setAccessToken($data['access_token']); + $token->setLifetime($data['expires_in']); + + if (isset($data['refresh_token'])) { + $token->setRefreshToken($data['refresh_token']); + unset($data['refresh_token']); + } + + unset($data['access_token']); + unset($data['expires_in']); + + $token->setExtraParams($data); + + return $token; + } +} diff --git a/htdocs/langs/en_US/oauth.lang b/htdocs/langs/en_US/oauth.lang index e976e4675ca60..8365ff571da2b 100644 --- a/htdocs/langs/en_US/oauth.lang +++ b/htdocs/langs/en_US/oauth.lang @@ -50,3 +50,4 @@ RefreshToken=Refresh token RefreshTokenHelp=Use the Refresh Token to get a new Access Token OldTokenWasExpiredItHasBeenRefresh=Old token was expired, it has been refreshed OldTokenWasNotExpiredButItHasBeenRefresh=Old token was not expired but it has been refreshed +OAuthErrorStateDiffers=Value for state=%s differs from value in session. Code is refused. diff --git a/htdocs/langs/fr_FR/oauth.lang b/htdocs/langs/fr_FR/oauth.lang index 15ae26c8025e3..0e38d622453e1 100644 --- a/htdocs/langs/fr_FR/oauth.lang +++ b/htdocs/langs/fr_FR/oauth.lang @@ -50,3 +50,4 @@ RefreshToken=Rafraîchir Jeton RefreshTokenHelp=Utilisez le jeton d'actualisation pour obtenir un nouveau jeton d'accès OldTokenWasExpiredItHasBeenRefresh=L'ancien jeton avait expiré, il a été actualisé OldTokenWasNotExpiredButItHasBeenRefresh=L'ancien jeton n'avait pas expiré mais il a été actualisé +OAuthErrorStateDiffers=La valeur de state=%s diffère de la valeur en session. Le code est refusé.