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);
+ }
+ }
+ }
+ }
+}