Skip to content

Commit 2841c16

Browse files
authored
Implemented: JWT validation for tokens issued by an external authentication server.
The system now supports two token validation modes: 1) External authentication server (JWK-based): if an issuer is configured in the "security.token.issuer" property, the token is verified using a JWK provider and the issuer's public key used to sign the token. 2) Local HMAC verification: If no issuer is configured, the token is verified locally using an HMAC key derived from the secret key configured in the "security.token.key" (and optionally a salt). This is the legacy mode whose behavior is not affected by this change. With the default configuration, this is the method used by OFBiz for token verification. Change access modifiers and method signatures for token validation methods to allow upcoming implementation for external JWT validation. Thanks: Anahita Goljahani for the analysis and research about OAuth 2.0/OpenID Connect providers and for the tests with Keycloak and its deployment and configuration.
1 parent ae3b657 commit 2841c16

6 files changed

Lines changed: 134 additions & 49 deletions

File tree

dependencies.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ dependencies {
7676
implementation 'oro:oro:2.0.8'
7777
implementation 'wsdl4j:wsdl4j:1.6.3'
7878
implementation 'com.auth0:java-jwt:4.4.0'
79+
implementation 'com.auth0:jwks-rsa:0.22.2'
7980
implementation 'org.jdom:jdom2:2.0.6.1'
8081
implementation 'com.google.re2j:re2j:1.7'
8182
implementation 'xerces:xercesImpl:2.12.2'

framework/security/config/security.properties

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ security.internal.sso.enabled=false
161161
# The key must be 512 bits (ie 64 chars) as we use HMAC512 to create the token, cf. OFBIZ-12724
162162
security.token.key=%D*G-JaNdRgUkXp2s5v8y/B?E(H+MbPeShVmYq3t6w9z$C&F)J@NcRfTjWnZr4u7
163163

164+
# -- Specifies the expected issuer (the "iss" claim) of JSON Web Tokens (JWTs).
165+
# If this property is set, the system assumes that tokens are issued and signed by an external
166+
# authentication server (for example, an OAuth 2.0/OpenID Connect provider).
167+
# During validation, the method retrieves the issuer's public keys (JWKs) and verifies
168+
# the token's signature, issuer, and audience.
169+
#security.token.issuer=
170+
171+
# -- Defines the expected audience (the "aud" claim) of valid JWTs.
172+
# This identifies the intended recipient of the token, typically the URL or identifier of OFBiz.
173+
# During validation, only tokens containing this audience value will be considered valid.
174+
#security.token.audience=
175+
164176
# -- List of domains or IP addresses to be checked to prevent Host Header Injection,
165177
# -- no spaces after commas,no wildcard, can be extended of course...
166178
host-headers-allowed=localhost,127.0.0.1,demo-trunk.ofbiz.apache.org,demo-stable.ofbiz.apache.org,demo-next.ofbiz.apache.org

framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/JWTManager.java

Lines changed: 117 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@
1818
*/
1919
package org.apache.ofbiz.webapp.control;
2020

21+
import java.net.MalformedURLException;
22+
import java.net.URL;
23+
import java.security.interfaces.RSAPublicKey;
2124
import java.sql.Timestamp;
2225
import java.util.Calendar;
2326
import java.util.HashMap;
2427
import java.util.Map;
28+
import java.util.concurrent.ConcurrentHashMap;
29+
import java.util.concurrent.TimeUnit;
2530

2631
import jakarta.servlet.ServletContext;
2732
import jakarta.servlet.http.HttpServletRequest;
@@ -47,6 +52,9 @@
4752
import org.apache.ofbiz.service.ServiceUtil;
4853
import org.apache.ofbiz.webapp.WebAppUtil;
4954

55+
import com.auth0.jwk.Jwk;
56+
import com.auth0.jwk.JwkProvider;
57+
import com.auth0.jwk.JwkProviderBuilder;
5058
import com.auth0.jwt.JWT;
5159
import com.auth0.jwt.JWTCreator;
5260
import com.auth0.jwt.JWTVerifier;
@@ -62,6 +70,29 @@
6270
public class JWTManager {
6371
private static final String MODULE = JWTManager.class.getName();
6472

73+
// Static map of thread-safe JwkProvider instances for each delegator.
74+
private static volatile Map<String, JwkProvider> jwkProviders = new ConcurrentHashMap<>();
75+
/**
76+
* Returns a shared, thread-safe JwkProvider instance for the delegator.
77+
*/
78+
private static JwkProvider getJwkProvider(Delegator delegator) throws IllegalStateException, MalformedURLException {
79+
JwkProvider localRef = jwkProviders.get(delegator.getDelegatorName());
80+
if (localRef == null) {
81+
synchronized (JWTManager.class) {
82+
localRef = jwkProviders.get(delegator.getDelegatorName());
83+
if (localRef == null) {
84+
String issuer = EntityUtilProperties.getPropertyValue("security", "security.token.issuer", "", delegator);
85+
String jwksUrl = issuer + "/protocol/openid-connect/certs";
86+
localRef = new JwkProviderBuilder(new URL(jwksUrl))
87+
.cached(10, 24, TimeUnit.HOURS) // cache up to 10 keys for 24h
88+
.rateLimited(10, 1, TimeUnit.MINUTES) // prevent frequent fetches
89+
.build();
90+
jwkProviders.put(delegator.getDelegatorName(), localRef);
91+
}
92+
}
93+
}
94+
return localRef;
95+
}
6596
/**
6697
* OFBiz controller preprocessor event.
6798
* The method is designed to be used in a chain of controller preprocessor event: it always returns "success"
@@ -94,7 +125,7 @@ public static String checkJWTLogin(HttpServletRequest request, HttpServletRespon
94125
return "success";
95126
}
96127

97-
Map<String, Object> claims = validateJwtToken(jwtToken, getJWTKey(delegator));
128+
Map<String, Object> claims = validateJwtToken(delegator, jwtToken);
98129
if (claims.containsKey(ModelService.ERROR_MESSAGE)) {
99130
// The JWT is wrong somehow, stop the process, details are in log
100131
return "success";
@@ -130,17 +161,7 @@ public static String checkJWTLogin(HttpServletRequest request, HttpServletRespon
130161
* @param delegator the delegator
131162
* @return the JWT secret key
132163
*/
133-
public static String getJWTKey(Delegator delegator) {
134-
return getJWTKey(delegator, null);
135-
}
136-
137-
/**
138-
* Get the JWT secret key from database or security.properties.
139-
* @param delegator the delegator
140-
* @return the JWT secret key
141-
*/
142-
143-
public static String getJWTKey(Delegator delegator, String salt) {
164+
private static String getJWTKey(Delegator delegator, String salt) {
144165
String key = UtilProperties.getPropertyValue("security", "security.token.key");
145166
if (key.length() < 64) { // The key must be 512 bits (ie 64 chars) as we use HMAC512 to create the token, cf. OFBIZ-12724
146167
throw new SecurityException("The JWT secret key is too short. It must be at least 512 bites.");
@@ -151,7 +172,7 @@ public static String getJWTKey(Delegator delegator, String salt) {
151172
return key;
152173
}
153174

154-
/**
175+
/**
155176
* Get the authentication token based for user
156177
* This takes OOTB username/password and if user is authenticated it will generate the JWT token using a secret key.
157178
* @param request the http request in which the authentication token is searched and stored
@@ -226,26 +247,75 @@ public static String getHeaderAuthBearerToken(HttpServletRequest request) {
226247
return headerAuthValue.replaceFirst(bearerPrefix, "").trim();
227248
}
228249

229-
/** Validates the provided token using the secret key.
230-
* If the token is valid it will get the conteined claims and return them.
231-
* If token validation failed it will return an error.
232-
* Public for API access from third party code.
233-
* @param jwtToken the JWT token
234-
* @param key the server side key to verify the signature
235-
* @return Map of the claims contained in the token or an error
250+
/**
251+
* Validates a JSON Web Token (JWT) and extracts its claims.
252+
*
253+
* This method supports two validation modes:
254+
* External authentication server (JWK-based): if an issuer is configured
255+
* in the "security.token.issuer" property, the token is verified using a JWK provider and
256+
* the issuer's public key used to sign the token.
257+
* Local HMAC verification: If no issuer is configured, the token is verified
258+
* locally using an HMAC key derived from the secret key configured
259+
* in the "security.token.key" (and optionally a salt).
260+
*
261+
* If the token is successfully verified, the contained claims are returned as a map.
262+
* Otherwise, an error map is returned containing the failure message.
263+
*
264+
* @param delegator the delegator used to retrieve security properties and keys from a database
265+
* @param jwtToken the JWT string to validate
266+
* @param keySalt an optional salt used when building the local HMAC key (can be null or empty)
267+
* @return a map containing:
268+
* the token claims if validation succeeds
269+
* an error entry if validation fails
236270
*/
237-
public static Map<String, Object> validateToken(String jwtToken, String key) {
238-
Map<String, Object> result = new HashMap<>();
239-
if (UtilValidate.isEmpty(jwtToken) || UtilValidate.isEmpty(key)) {
271+
public static Map<String, Object> validateToken(Delegator delegator, String jwtToken, String keySalt) {
272+
JWTVerifier verifier = null;
273+
// Retrieve configured issuer (if present, assume external JWK-based validation)
274+
String issuer = EntityUtilProperties.getPropertyValue("security", "security.token.issuer", "", delegator);
275+
if (UtilValidate.isNotEmpty(issuer)) {
276+
String audience = EntityUtilProperties.getPropertyValue("security", "security.token.audience", "", delegator);
277+
try {
278+
// Decode the token to extract the Key ID (kid)
279+
DecodedJWT decodedJWT = JWT.decode(jwtToken);
280+
String kid = decodedJWT.getKeyId();
281+
282+
// Fetch the corresponding JWK (JSON Web Key) for this Key ID
283+
JwkProvider provider = getJwkProvider(delegator);
284+
Jwk jwk = provider.get(kid);
285+
286+
// Build the RSA256 Algorithm using the JWK’s public key
287+
Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);
288+
289+
// Create a JWT verifier: include expected issuer and audience for safety
290+
verifier = JWT.require(algorithm)
291+
.withIssuer(issuer)
292+
.withAudience(audience)
293+
.build();
294+
} catch (Exception e) {
295+
String msg = "JWT token: unable to build a token verifier for tokens issued by " + issuer;
296+
Debug.logError(msg, MODULE);
297+
return ServiceUtil.returnError(msg);
298+
}
299+
} else {
300+
// Fallback: validate using local secret key
301+
String key = getJWTKey(delegator, keySalt);
302+
if (UtilValidate.isEmpty(jwtToken) || UtilValidate.isEmpty(key)) {
303+
String msg = "JWT token or key can not be empty.";
304+
Debug.logError(msg, MODULE);
305+
return ServiceUtil.returnError(msg);
306+
}
307+
verifier = JWT.require(Algorithm.HMAC512(key))
308+
.withIssuer("ApacheOFBiz")
309+
.build();
310+
}
311+
if (UtilValidate.isEmpty(verifier)) {
240312
String msg = "JWT token or key can not be empty.";
241313
Debug.logError(msg, MODULE);
242314
return ServiceUtil.returnError(msg);
243315
}
244316
try {
245-
JWTVerifier verifToken = JWT.require(Algorithm.HMAC512(key))
246-
.withIssuer("ApacheOFBiz")
247-
.build();
248-
DecodedJWT jwt = verifToken.verify(jwtToken);
317+
Map<String, Object> result = new HashMap<>();
318+
DecodedJWT jwt = verifier.verify(jwtToken);
249319
Map<String, Claim> claims = jwt.getClaims();
250320
//OK, we can trust this JWT
251321
for (Map.Entry<String, Claim> entry : claims.entrySet()) {
@@ -260,16 +330,23 @@ public static Map<String, Object> validateToken(String jwtToken, String key) {
260330
}
261331

262332
/**
263-
* Validates the provided token using a salt to recreate the key from the secret
264-
* If the token is valid it will get the contained claims and return them.
265-
* If token validation failed it will return an error.
266-
* @param delegator
267-
* @param jwtToken
268-
* @param keySalt
269-
* @return Map of the claims contained in the token or an error
333+
* Validates a JSON Web Token (JWT) and extracts its claims using the default validation process.
334+
*
335+
* This method is a convenience overload that calls validateToken(Delegator, String, String)
336+
* without providing a key salt. The validation will use either an external authentication
337+
* server (if configured) or the locally stored secret key.
338+
*
339+
* If the token is successfully verified, the contained claims are returned as a map.
340+
* If validation fails, an error map is returned containing details about the failure.
341+
*
342+
* @param delegator the delegator used to retrieve security properties and keys from the database
343+
* @param jwtToken the JWT string to validate
344+
* @return a map containing the token claims if validation succeeds,
345+
* or an error entry if validation fails
346+
* @see #validateToken(Delegator, String, String)
270347
*/
271-
public static Map<String, Object> validateToken(Delegator delegator, String jwtToken, String keySalt) {
272-
return validateToken(jwtToken, JWTManager.getJWTKey(delegator, keySalt));
348+
public static Map<String, Object> validateToken(Delegator delegator, String jwtToken) {
349+
return validateToken(delegator, jwtToken, null);
273350
}
274351

275352
/**
@@ -396,8 +473,8 @@ private static GenericValue getUserlogin(Delegator delegator, Map<String, Object
396473
* @param key the secret key to decrypt the token
397474
* @return Map of name, value pairs composing the result
398475
*/
399-
private static Map<String, Object> validateJwtToken(String jwtToken, String key) {
400-
Map<String, Object> result = validateToken(jwtToken, key);
476+
private static Map<String, Object> validateJwtToken(Delegator delegator, String jwtToken) {
477+
Map<String, Object> result = validateToken(delegator, jwtToken);
401478
if (result.containsKey(ModelService.ERROR_MESSAGE)) {
402479
// Something unexpected happened here
403480
Debug.logWarning("There was a problem with the JWT token, no single sign on user login possible.", MODULE);
@@ -411,8 +488,8 @@ public static String createRefreshToken(Delegator delegator, String userLoginId)
411488
return createJwt(delegator, UtilMisc.toMap("userLoginId", userLoginId, "type", "refresh"), refreshTokenExpireTime);
412489
}
413490

414-
public static Map<String, Object> validateRefreshToken(String refreshToken, String key) {
415-
Map<String, Object> claims = validateToken(refreshToken, key);
491+
public static Map<String, Object> validateRefreshToken(Delegator delegator, String refreshToken) {
492+
Map<String, Object> claims = validateToken(delegator, refreshToken);
416493
if (!claims.containsKey("type") || !"refresh".equals(claims.get("type"))) {
417494
return ServiceUtil.returnError("Invalid refresh token.");
418495
}

framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/TokenFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
6363
String token = JWTManager.getHeaderAuthBearerToken(httpRequest);
6464

6565
if (UtilValidate.isNotEmpty(token)) {
66-
Map<String, Object> result = JWTManager.validateToken(token, JWTManager.getJWTKey(delegator));
66+
Map<String, Object> result = JWTManager.validateToken(delegator, token);
6767
String userLoginId = (String) result.get("userLoginId");
6868
if (UtilValidate.isNotEmpty(result.get(ModelService.ERROR_MESSAGE))) {
6969
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

framework/widget/src/main/java/org/apache/ofbiz/widget/model/ModelForm.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2519,7 +2519,7 @@ public static ModelForm.UpdateArea fromJwtToken(Map<String, Object> context) {
25192519
String jwtToken = WidgetWorker.getJwtCallback(context);
25202520
if (UtilValidate.isEmpty(jwtToken)) return null;
25212521

2522-
Map<String, Object> claims = JWTManager.validateToken(jwtToken, JWTManager.getJWTKey(delegator));
2522+
Map<String, Object> claims = JWTManager.validateToken(delegator, jwtToken);
25232523
if (claims.containsKey(ModelService.ERROR_MESSAGE)) {
25242524
// Something unexpected happened here
25252525
Debug.logWarning("There was a problem with the JWT token, signature not valid.", MODULE);

framework/widget/src/test/java/org/apache/ofbiz/widget/model/ModelFormTest.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@
1818
*/
1919
package org.apache.ofbiz.widget.model;
2020

21-
import static org.mockito.ArgumentMatchers.any;
22-
import static org.mockito.Mockito.when;
23-
24-
import java.util.ArrayList;
2521
import java.util.HashMap;
2622
import java.util.List;
2723
import java.util.Map;
@@ -43,10 +39,9 @@ public class ModelFormTest {
4339

4440
@Before
4541
public void setUp() throws GenericEntityException {
46-
context = new HashMap<>();
4742
delegator = Mockito.mock(Delegator.class);
48-
when(delegator.findList(any(), any(), any(), any(), any(), Mockito.anyBoolean()))
49-
.thenReturn(new ArrayList<>());
43+
context = new HashMap<>();
44+
context.put("delegator", delegator);
5045
}
5146

5247
@Test

0 commit comments

Comments
 (0)