This is an automated email from the ASF dual-hosted git repository.

mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 110ae1e985 Implemented: Add OAuth2/XOAUTH2 authentication for outgoing 
SMTP (OFBIZ-13474)
110ae1e985 is described below

commit 110ae1e985f7d6f2749c7d15d6b318dd31902882
Author: Mridul Pathak <[email protected]>
AuthorDate: Fri Aug 7 11:14:51 2026 +0530

    Implemented: Add OAuth2/XOAUTH2 authentication for outgoing SMTP 
(OFBIZ-13474)
    
    OFBiz's outgoing SMTP only supported basic-auth username/password via 
mail.smtp.auth.user/password in general.properties. Gmail and Office365 are 
moving away from plain basic auth toward OAuth2, so this adds XOAUTH2 as a 
second, configurable authentication mechanism alongside the existing one.
    
    A new MailSmtpConfig entity holds all mail.smtp.* settings (connection, 
basic-auth, and OAuth2 client id/secret/refresh token/endpoint/scope), 
encrypted at rest. Every field falls back per-field to the legacy 
general.properties/SystemProperty mail.smtp.* keys when unset, so existing 
installs are unaffected unless a row is created — no forced migration.
    
    XOAUTH2 access tokens are obtained via a standard OAuth2 refresh_token 
grant against a configurable token endpoint (provider-agnostic, no 
Gmail/Office365-specific code), cached with a per-entry TTL, and refreshed 
under a per-config lock. A rotated refresh token is persisted in its own 
suspended transaction, independent of the caller's, so a later, unrelated send 
failure can't roll back and discard a token the provider has already rotated. 
authMechanism (NONE/BASIC/XOAUTH2) is authorit [...]
    
    Live-verified end-to-end against a real Gmail account: XOAUTH2 send and 
delivery, invalid_grant failure handling, refresh-after-expiry caching, and 
both zero-row and partial-row config fallback.
---
 framework/common/config/CommonUiLabels.xml         |   3 +
 framework/common/entitydef/entitymodel.xml         |  26 +++
 .../apache/ofbiz/common/email/EmailServices.java   |  47 +++--
 .../ofbiz/common/email/MailSmtpConfigUtil.java     | 141 ++++++++++++++
 .../common/email/SmtpOAuth2TokenProvider.java      | 208 +++++++++++++++++++++
 5 files changed, 410 insertions(+), 15 deletions(-)

diff --git a/framework/common/config/CommonUiLabels.xml 
b/framework/common/config/CommonUiLabels.xml
index 90601c7f7f..da56b33e87 100644
--- a/framework/common/config/CommonUiLabels.xml
+++ b/framework/common/config/CommonUiLabels.xml
@@ -3575,6 +3575,9 @@
         <value xml:lang="zh">当 sendType 不是 mail.smtp.host 时,必须有参数 sendVia 
</value>
         <value xml:lang="zh-TW">當 sendType 不是 mail.smtp.host 時,必須有參數 sendVia 
</value>
     </property>
+    <property key="CommonEmailSendOAuth2Error">
+        <value xml:lang="en">[OAUTH2] Error obtaining SMTP OAuth2 access token 
for ${sendVia}: ${errorString}</value>
+    </property>
     <property key="CommonEmailSendRenderingScreenEmailError">
         <value xml:lang="ar">خطأ في تقديم شاشة البريد الإلكتروني: 
${errorString}</value>
         <value xml:lang="cs">Nepodařilo se připravit obrazovku pro email: 
${errorString}</value>
diff --git a/framework/common/entitydef/entitymodel.xml 
b/framework/common/entitydef/entitymodel.xml
index 47385cc61a..484029341e 100644
--- a/framework/common/entitydef/entitymodel.xml
+++ b/framework/common/entitydef/entitymodel.xml
@@ -911,4 +911,30 @@ under the License.
         <field name="description" type="description"></field>
         <prim-key field="telecomGatewayConfigId"/>
     </entity>
+
+    <!-- ============================ -->
+    <!-- org.apache.ofbiz.common.email -->
+    <!-- ============================ -->
+    <entity entity-name="MailSmtpConfig"
+            package-name="org.apache.ofbiz.common.email"
+            title="Mail SMTP Configuration">
+        <field name="mailSmtpConfigId" type="id"></field>
+        <field name="description" type="description"></field>
+        <field name="relayHost" type="value"><description>SMTP relay host, 
e.g. smtp.gmail.com</description></field>
+        <field name="port" type="value"><description>SMTP port, e.g. 
587</description></field>
+        <field name="starttlsEnable" type="indicator"><description>Y enables 
STARTTLS</description></field>
+        <field name="socketFactoryPort" type="value"></field>
+        <field name="socketFactoryClass" type="value"></field>
+        <field name="socketFactoryFallback" type="value"></field>
+        <field name="sendPartial" type="indicator"><description>Y allows 
partial delivery on multi-recipient failures</description></field>
+        <field name="authMechanism" type="value"><description>NONE, BASIC, or 
XOAUTH2</description></field>
+        <field name="authUser" type="value"></field>
+        <field name="authPassword" type="value" encrypt="true"></field>
+        <field name="oauth2ClientId" type="value"></field>
+        <field name="oauth2ClientSecret" type="value" encrypt="true"></field>
+        <field name="oauth2RefreshToken" type="very-long" 
encrypt="true"><description>May be rewritten if the provider rotates 
it</description></field>
+        <field name="oauth2TokenEndpoint" type="value"><description>Must be 
https://</description></field>
+        <field name="oauth2Scope" type="value"></field>
+        <prim-key field="mailSmtpConfigId"/>
+    </entity>
 </entitymodel>
diff --git 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
index d28859843d..940d133141 100644
--- 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
+++ 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
@@ -177,44 +177,49 @@ public class EmailServices {
         Boolean isStartTLSEnabled = (Boolean) context.get("startTLSEnabled");
 
         boolean useSmtpAuth = false;
+        MailSmtpConfigUtil.ResolvedConfig mailSmtpConfig = 
MailSmtpConfigUtil.resolve(delegator);
 
         // define some default
         if (sendType == null || "mail.smtp.host".equals(sendType)) {
             sendType = "mail.smtp.host";
             if (UtilValidate.isEmpty(sendVia)) {
-                sendVia = EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.relay.host", "localhost", delegator);
+                sendVia = mailSmtpConfig.relayHost;
             }
             if (UtilValidate.isEmpty(authUser)) {
-                authUser = EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.auth.user", delegator);
+                authUser = mailSmtpConfig.authUser;
             }
             if (UtilValidate.isEmpty(authPass)) {
-                authPass = EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.auth.password", delegator);
-            }
-            if (UtilValidate.isNotEmpty(authUser)) {
-                useSmtpAuth = true;
+                authPass = mailSmtpConfig.authPassword;
             }
+            useSmtpAuth = !"NONE".equals(mailSmtpConfig.authMechanism) && 
UtilValidate.isNotEmpty(authUser);
             if (UtilValidate.isEmpty(port)) {
-                port = EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.port", delegator);
+                port = mailSmtpConfig.port;
             }
             if (UtilValidate.isEmpty(socketFactoryPort)) {
-                socketFactoryPort = 
EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.socketFactory.port", delegator);
+                socketFactoryPort = mailSmtpConfig.socketFactoryPort;
             }
             if (UtilValidate.isEmpty(socketFactoryClass)) {
-                socketFactoryClass = 
EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.socketFactory.class", delegator);
+                socketFactoryClass = mailSmtpConfig.socketFactoryClass;
             }
             if (UtilValidate.isEmpty(socketFactoryFallback)) {
-                socketFactoryFallback = 
EntityUtilProperties.getPropertyValue("general", 
"mail.smtp.socketFactory.fallback", "false", delegator);
+                socketFactoryFallback = mailSmtpConfig.socketFactoryFallback;
             }
             if (sendPartial == null) {
-                sendPartial = 
EntityUtilProperties.propertyValueEqualsIgnoreCase("general", 
"mail.smtp.sendpartial", "true", delegator);
+                sendPartial = mailSmtpConfig.sendPartial;
             }
             if (isStartTLSEnabled == null) {
-                isStartTLSEnabled = 
EntityUtilProperties.propertyValueEqualsIgnoreCase("general", 
"mail.smtp.starttls.enable", "true", delegator);
+                isStartTLSEnabled = mailSmtpConfig.starttlsEnable;
             }
         } else if (sendVia == null) {
             return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, 
"CommonEmailSendMissingParameterSendVia", locale));
         }
 
+        // Only the (cheap, no-I/O) XOAUTH2-vs-BASIC decision is made here. 
The actual access-token
+        // fetch (a live network round-trip) is deferred until right before 
the SMTP connect attempt,
+        // below, so it never runs when mail sending is disabled 
(mail.notifications.enabled=N, the
+        // default) or on any other early-return path.
+        boolean useXOAuth2 = useSmtpAuth && 
"XOAUTH2".equals(mailSmtpConfig.authMechanism);
+
         if (contentType == null) {
             contentType = "text/html";
         }
@@ -244,6 +249,9 @@ public class EmailServices {
             if (useSmtpAuth) {
                 props.put("mail.smtp.auth", "true");
             }
+            if (useXOAuth2) {
+                props.put("mail.smtp.auth.mechanisms", "XOAUTH2");
+            }
             if (sendPartial != null) {
                 props.put("mail.smtp.sendpartial", sendPartial ? "true" : 
"false");
             }
@@ -315,7 +323,6 @@ public class EmailServices {
         } catch (MessagingException e) {
             Debug.logError(e, "MessagingException when creating message to [" 
+ sendTo + "] from [" + sendFrom + "] cc [" + sendCc + "] bcc ["
                     + sendBcc + "] subject [" + subject + "]", MODULE);
-            Debug.logError("Email message that could not be created to [" + 
sendTo + "] had context: " + context, MODULE);
             return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, 
"CommonEmailSendMessagingException", UtilMisc.toMap("sendTo",
                     sendTo, "sendFrom", sendFrom, "sendCc", sendCc, "sendBcc", 
sendBcc, "subject", subject), locale));
         }
@@ -333,13 +340,24 @@ public class EmailServices {
             return results;
         }
 
+        String effectiveAuthPass = authPass;
+        if (useXOAuth2) {
+            try {
+                effectiveAuthPass = 
SmtpOAuth2TokenProvider.getAccessToken(delegator, mailSmtpConfig);
+            } catch (GeneralException e) {
+                Debug.logError(e, "Failed to obtain SMTP OAuth2 access token 
for [" + sendVia + "]", MODULE);
+                return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, 
"CommonEmailSendOAuth2Error", UtilMisc.toMap("sendVia",
+                        sendVia, "errorString", e.getMessage()), locale));
+            }
+        }
+
         Transport trans = null;
         try {
             trans = session.getTransport("smtp");
             if (!useSmtpAuth) {
                 trans.connect();
             } else {
-                trans.connect(sendVia, authUser, authPass);
+                trans.connect(sendVia, authUser, effectiveAuthPass);
             }
             trans.sendMessage(mail, mail.getAllRecipients());
             results.put("messageWrapper", new MimeMessageWrapper(session, 
mail));
@@ -378,7 +396,6 @@ public class EmailServices {
             // message code prefix may be used by calling services to 
determine the cause of the failure
             Debug.logError(e, "[CON] Connection error when sending message to 
[" + sendTo + "] from [" + sendFrom + "] cc [" + sendCc
                     + "] bcc [" + sendBcc + "] subject [" + subject + "]", 
MODULE);
-            Debug.logError("Email message that could not be sent to [" + 
sendTo + "] had context: " + context, MODULE);
             return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, 
"CommonEmailSendConnectionError", UtilMisc.toMap("sendTo",
                     sendTo, "sendFrom", sendFrom, "sendCc", sendCc, "sendBcc", 
sendBcc, "subject", subject), locale));
         }
diff --git 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/MailSmtpConfigUtil.java
 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/MailSmtpConfigUtil.java
new file mode 100644
index 0000000000..9e77718251
--- /dev/null
+++ 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/MailSmtpConfigUtil.java
@@ -0,0 +1,141 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.common.email;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityQuery;
+import org.apache.ofbiz.entity.util.EntityUtilProperties;
+
+/**
+ * Resolves {@code MailSmtpConfig} entity fields, falling back per-field to 
legacy
+ * {@code mail.smtp.*} general.properties/SystemProperty keys when unset. 
Shared by
+ * {@link EmailServices} and {@link SmtpOAuth2TokenProvider}.
+ */
+public final class MailSmtpConfigUtil {
+
+    private static final String MODULE = MailSmtpConfigUtil.class.getName();
+
+    private MailSmtpConfigUtil() { }
+
+    /** Resolved SMTP configuration, after entity + legacy-property fallback 
merge. */
+    public static final class ResolvedConfig {
+        //ALLOW PUBLIC FIELDS
+        public final String mailSmtpConfigId;
+        public final String relayHost;
+        public final String port;
+        public final boolean starttlsEnable;
+        public final String socketFactoryPort;
+        public final String socketFactoryClass;
+        public final String socketFactoryFallback;
+        public final boolean sendPartial;
+        public final String authMechanism;
+        public final String authUser;
+        public final String authPassword;
+        public final String oauth2ClientId;
+        public final String oauth2ClientSecret;
+        public final String oauth2RefreshToken;
+        public final String oauth2TokenEndpoint;
+        public final String oauth2Scope;
+        //FORBID PUBLIC FIELDS
+
+        ResolvedConfig(String mailSmtpConfigId, String relayHost, String port, 
boolean starttlsEnable,
+                String socketFactoryPort, String socketFactoryClass, String 
socketFactoryFallback,
+                boolean sendPartial, String authMechanism, String authUser, 
String authPassword,
+                String oauth2ClientId, String oauth2ClientSecret, String 
oauth2RefreshToken,
+                String oauth2TokenEndpoint, String oauth2Scope) {
+            this.mailSmtpConfigId = mailSmtpConfigId;
+            this.relayHost = relayHost;
+            this.port = port;
+            this.starttlsEnable = starttlsEnable;
+            this.socketFactoryPort = socketFactoryPort;
+            this.socketFactoryClass = socketFactoryClass;
+            this.socketFactoryFallback = socketFactoryFallback;
+            this.sendPartial = sendPartial;
+            this.authMechanism = authMechanism;
+            this.authUser = authUser;
+            this.authPassword = authPassword;
+            this.oauth2ClientId = oauth2ClientId;
+            this.oauth2ClientSecret = oauth2ClientSecret;
+            this.oauth2RefreshToken = oauth2RefreshToken;
+            this.oauth2TokenEndpoint = oauth2TokenEndpoint;
+            this.oauth2Scope = oauth2Scope;
+        }
+    }
+
+    /** Looks up the (at most one, for now) MailSmtpConfig row and resolves it 
per {@link ResolvedConfig}. */
+    public static ResolvedConfig resolve(Delegator delegator) {
+        GenericValue configRow;
+        try {
+            configRow = 
EntityQuery.use(delegator).from("MailSmtpConfig").cache(true).orderBy("mailSmtpConfigId").queryFirst();
+        } catch (GenericEntityException e) {
+            Debug.logWarning(e, "Error loading MailSmtpConfig entity; falling 
back to legacy properties", MODULE);
+            configRow = null;
+        }
+        String mailSmtpConfigId = configRow != null ? 
configRow.getString("mailSmtpConfigId") : null;
+
+        String relayHost = valueOrFallback(configRow, "relayHost", delegator, 
"mail.smtp.relay.host", "localhost");
+        String port = valueOrFallback(configRow, "port", delegator, 
"mail.smtp.port", "");
+        boolean starttlsEnable = booleanOrFallback(configRow, 
"starttlsEnable", delegator, "mail.smtp.starttls.enable");
+        String socketFactoryPort = valueOrFallback(configRow, 
"socketFactoryPort", delegator, "mail.smtp.socketFactory.port", "");
+        String socketFactoryClass = valueOrFallback(configRow, 
"socketFactoryClass", delegator, "mail.smtp.socketFactory.class", "");
+        String socketFactoryFallback = valueOrFallback(configRow, 
"socketFactoryFallback", delegator,
+                "mail.smtp.socketFactory.fallback", "false");
+        boolean sendPartial = booleanOrFallback(configRow, "sendPartial", 
delegator, "mail.smtp.sendpartial");
+        String authUser = valueOrFallback(configRow, "authUser", delegator, 
"mail.smtp.auth.user", "");
+        String authPassword = valueOrFallback(configRow, "authPassword", 
delegator, "mail.smtp.auth.password", "");
+
+        String authMechanism = configRow != null ? 
configRow.getString("authMechanism") : null;
+        if (UtilValidate.isEmpty(authMechanism)) {
+            authMechanism = UtilValidate.isNotEmpty(authUser) ? "BASIC" : 
"NONE";
+        }
+
+        return new ResolvedConfig(mailSmtpConfigId, relayHost, port, 
starttlsEnable, socketFactoryPort,
+                socketFactoryClass, socketFactoryFallback, sendPartial, 
authMechanism, authUser, authPassword,
+                entityValue(configRow, "oauth2ClientId"), 
entityValue(configRow, "oauth2ClientSecret"),
+                entityValue(configRow, "oauth2RefreshToken"), 
entityValue(configRow, "oauth2TokenEndpoint"),
+                entityValue(configRow, "oauth2Scope"));
+    }
+
+    /** OAuth2 fields have no legacy fallback; an unset entity field just 
resolves to "". */
+    private static String entityValue(GenericValue configRow, String 
fieldName) {
+        String value = configRow != null ? configRow.getString(fieldName) : 
null;
+        return value != null ? value : "";
+    }
+
+    private static String valueOrFallback(GenericValue configRow, String 
fieldName, Delegator delegator,
+            String legacyKey, String legacyDefault) {
+        String value = configRow != null ? configRow.getString(fieldName) : 
null;
+        if (UtilValidate.isNotEmpty(value)) {
+            return value;
+        }
+        return EntityUtilProperties.getPropertyValue("general", legacyKey, 
legacyDefault, delegator);
+    }
+
+    private static boolean booleanOrFallback(GenericValue configRow, String 
fieldName, Delegator delegator, String legacyKey) {
+        String value = configRow != null ? configRow.getString(fieldName) : 
null;
+        if (UtilValidate.isNotEmpty(value)) {
+            return "Y".equalsIgnoreCase(value);
+        }
+        return EntityUtilProperties.propertyValueEqualsIgnoreCase("general", 
legacyKey, "true", delegator);
+    }
+}
diff --git 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/SmtpOAuth2TokenProvider.java
 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/SmtpOAuth2TokenProvider.java
new file mode 100644
index 0000000000..a25a0f2cba
--- /dev/null
+++ 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/SmtpOAuth2TokenProvider.java
@@ -0,0 +1,208 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.common.email;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.transaction.Transaction;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.HttpClient;
+import org.apache.ofbiz.base.util.HttpClientException;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.cache.UtilCache;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.transaction.GenericTransactionException;
+import org.apache.ofbiz.entity.transaction.TransactionUtil;
+import org.apache.ofbiz.entity.util.EntityQuery;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Obtains and caches SMTP XOAUTH2 access tokens via the standard OAuth2
+ * refresh_token grant against a configurable, provider-agnostic token 
endpoint.
+ */
+public final class SmtpOAuth2TokenProvider {
+
+    private static final String MODULE = 
SmtpOAuth2TokenProvider.class.getName();
+    private static final long EXPIRY_SAFETY_MARGIN_MILLIS = 60_000L;
+
+    private static final UtilCache<String, String> TOKEN_CACHE = 
UtilCache.createUtilCache("smtp.oauth2.accessToken");
+    private static final Map<String, Object> REFRESH_LOCKS = new 
ConcurrentHashMap<>();
+
+    private SmtpOAuth2TokenProvider() { }
+
+    static final class TokenResponse {
+        //ALLOW PUBLIC FIELDS
+        final String accessToken;
+        final long expiresInSeconds;
+        final String rotatedRefreshToken;
+        //FORBID PUBLIC FIELDS
+
+        TokenResponse(String accessToken, long expiresInSeconds, String 
rotatedRefreshToken) {
+            this.accessToken = accessToken;
+            this.expiresInSeconds = expiresInSeconds;
+            this.rotatedRefreshToken = rotatedRefreshToken;
+        }
+    }
+
+    /** Parses the token endpoint's JSON response. */
+    static TokenResponse parseTokenResponse(String json) throws IOException {
+        ObjectMapper mapper = new ObjectMapper();
+        JsonNode node = mapper.readTree(json);
+        JsonNode accessTokenNode = node.get("access_token");
+        if (accessTokenNode == null || accessTokenNode.asText().isEmpty()) {
+            throw new IOException("OAuth2 token endpoint response missing 
access_token: " + json);
+        }
+        long expiresIn = node.path("expires_in").asLong(3600L);
+        String rotatedRefreshToken = node.hasNonNull("refresh_token") ? 
node.get("refresh_token").asText() : null;
+        return new TokenResponse(accessTokenNode.asText(), expiresIn, 
rotatedRefreshToken);
+    }
+
+    /** Explicit HTTP timeout (milliseconds) applied to the token-endpoint 
refresh call. */
+    private static final int REFRESH_HTTP_TIMEOUT_MILLIS = 30000;
+
+    /**
+     * Core refresh call; no Delegator dependency, so unit-testable against a 
local stub server.
+     * Does not enforce https:// (the public entry point below does).
+     *
+     * <p>Known limitation: {@link HttpClient} has no reliable connect or read 
timeout, so a token
+     * endpoint that accepts a connection but never responds can stall this 
call (and the per-config
+     * refresh lock in {@link #getAccessToken}) indefinitely.
+     *
+     * <p>Security note: enabling OFBiz's HTTP verbose debug logging logs 
{@code client_secret} and
+     * {@code refresh_token} in plain text via {@link HttpClient#post()}.
+     */
+    static TokenResponse refreshAccessToken(String tokenEndpoint, String 
clientId, String clientSecret,
+            String refreshToken, String scope) throws HttpClientException, 
IOException {
+        Map<String, Object> params = new HashMap<>();
+        params.put("grant_type", "refresh_token");
+        params.put("client_id", clientId);
+        params.put("client_secret", clientSecret);
+        params.put("refresh_token", refreshToken);
+        if (UtilValidate.isNotEmpty(scope)) {
+            params.put("scope", scope);
+        }
+        HttpClient httpClient = new HttpClient(tokenEndpoint, params);
+        httpClient.setTimeout(REFRESH_HTTP_TIMEOUT_MILLIS);
+        String responseBody = httpClient.post();
+        if (UtilValidate.isEmpty(responseBody)) {
+            throw new IOException("Empty response from OAuth2 token endpoint: 
" + tokenEndpoint);
+        }
+        return parseTokenResponse(responseBody);
+    }
+
+    /** Returns a cached access token when valid, otherwise refreshes it and 
persists a rotated refresh token, if issued. */
+    public static String getAccessToken(Delegator delegator, 
MailSmtpConfigUtil.ResolvedConfig config)
+            throws GeneralException {
+        if (!config.oauth2TokenEndpoint.startsWith("https://";)) {
+            throw new GeneralException("SMTP OAuth2 token endpoint must use 
https://: " + config.oauth2TokenEndpoint);
+        }
+        if (UtilValidate.isEmpty(config.authUser) || 
UtilValidate.isEmpty(config.oauth2ClientId)
+                || UtilValidate.isEmpty(config.oauth2ClientSecret) || 
UtilValidate.isEmpty(config.oauth2RefreshToken)) {
+            throw new GeneralException("SMTP OAuth2 configuration for 
mailSmtpConfigId ["
+                    + config.mailSmtpConfigId + "] is incomplete: authUser, 
oauth2ClientId, oauth2ClientSecret and "
+                    + "oauth2RefreshToken are all required when authMechanism 
is XOAUTH2");
+        }
+        String cacheKey = config.mailSmtpConfigId;
+        Object lock = REFRESH_LOCKS.computeIfAbsent(cacheKey, k -> new 
Object());
+        String accessToken;
+        String rotatedRefreshToken = null;
+        synchronized (lock) {
+            String cachedToken = TOKEN_CACHE.get(cacheKey);
+            if (cachedToken != null) {
+                return cachedToken;
+            }
+            TokenResponse response;
+            try {
+                response = refreshAccessToken(config.oauth2TokenEndpoint, 
config.oauth2ClientId,
+                        config.oauth2ClientSecret, config.oauth2RefreshToken, 
config.oauth2Scope);
+            } catch (HttpClientException | IOException e) {
+                throw new GeneralException("Failed to refresh SMTP OAuth2 
access token for mailSmtpConfigId ["
+                        + cacheKey + "]: " + e.getMessage(), e);
+            }
+            accessToken = response.accessToken;
+            long ttlMillis = (response.expiresInSeconds * 1000L) - 
EXPIRY_SAFETY_MARGIN_MILLIS;
+            if (ttlMillis > 0) {
+                // UtilCache treats a non-positive expireTimeMillis as "never 
expire" rather than
+                // "already expired" (no eviction pulse is scheduled), so an 
already-expired-or-about-
+                // to-expire token must simply not be cached at all, forcing a 
refresh on the next call.
+                TOKEN_CACHE.put(cacheKey, accessToken, ttlMillis);
+            }
+            if (UtilValidate.isNotEmpty(response.rotatedRefreshToken)
+                    && 
!response.rotatedRefreshToken.equals(config.oauth2RefreshToken)) {
+                rotatedRefreshToken = response.rotatedRefreshToken;
+            }
+        }
+        // Outside the lock: uses its own transaction, and the cache is 
already updated so no
+        // concurrent caller will trigger a redundant refresh while this DB 
write is in flight.
+        if (rotatedRefreshToken != null) {
+            persistRotatedRefreshToken(delegator, cacheKey, 
rotatedRefreshToken);
+        }
+        return accessToken;
+    }
+
+    /**
+     * Persists in its own suspended transaction, independent of the caller's 
(typically
+     * {@code sendMail}): the provider has already issued (and may have 
invalidated the prior)
+     * refresh token by now, so an unrelated later failure in the caller must 
not roll this back.
+     */
+    private static void persistRotatedRefreshToken(Delegator delegator, String 
mailSmtpConfigId, String newRefreshToken) {
+        Transaction parentTx = null;
+        boolean beganTransaction = false;
+        try {
+            if (TransactionUtil.isTransactionInPlace()) {
+                parentTx = TransactionUtil.suspend();
+            }
+            try {
+                beganTransaction = TransactionUtil.begin();
+                GenericValue configRow = 
EntityQuery.use(delegator).from("MailSmtpConfig")
+                        .where("mailSmtpConfigId", 
mailSmtpConfigId).queryOne();
+                if (configRow != null) {
+                    configRow.set("oauth2RefreshToken", newRefreshToken);
+                    configRow.store();
+                }
+                TransactionUtil.commit(beganTransaction);
+            } catch (Exception e) {
+                // Catches beyond GenericEntityException: must roll back this 
child transaction, or
+                // resuming the parent below throws IllegalStateException and 
leaves it suspended.
+                TransactionUtil.rollback(beganTransaction, "Failed to persist 
rotated SMTP OAuth2 refresh token", e);
+                Debug.logError(e, "Failed to persist rotated SMTP OAuth2 
refresh token for mailSmtpConfigId ["
+                        + mailSmtpConfigId + "]", MODULE);
+            }
+        } catch (GenericTransactionException e) {
+            Debug.logError(e, "Transaction error while persisting rotated SMTP 
OAuth2 refresh token for mailSmtpConfigId ["
+                    + mailSmtpConfigId + "]", MODULE);
+        } finally {
+            if (parentTx != null) {
+                try {
+                    TransactionUtil.resume(parentTx);
+                } catch (GenericTransactionException e) {
+                    Debug.logError(e, "Failed to resume parent transaction 
after persisting rotated SMTP OAuth2 refresh token", MODULE);
+                }
+            }
+        }
+    }
+}

Reply via email to