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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 4121a741397a CAMEL-25022: camel-oauth - honor the JWT nbf claim when 
authenticating bearer tokens on the servlet backend
4121a741397a is described below

commit 4121a741397a5538ac37c3430d53e15421513355
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 28 19:02:00 2026 +0200

    CAMEL-25022: camel-oauth - honor the JWT nbf claim when authenticating 
bearer tokens on the servlet backend
    
    When OAuthBearerTokenProcessor authenticated an access token through the
    Jakarta servlet backend (ServletOAuth), UserProfile validated the
    signature, issuer and exp, but only copied the nbf (not before) claim into
    the profile attributes without evaluating it. RFC 7519 section 4.1.5 says
    a JWT must not be accepted before its nbf time. The Vert.x backend and the
    OAuthTokenValidationFactory SPI already evaluate nbf.
    
    UserProfile.verifyToken() now rejects a token whose nbf is later than the
    current time plus the JWTOptions leeway (default 0, as on the other
    backends). Tokens without nbf are unaffected. The upgrade guide notes that
    the servlet backend does not read the leeway from configuration and shows
    how to raise it on the OAuth instance for identity providers whose clock
    runs ahead.
    
    Closes #26893
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../java/org/apache/camel/oauth/UserProfile.java   |  7 +++
 .../org/apache/camel/oauth/UserProfileTest.java    | 53 +++++++++++++++--
 .../ServletOAuthTokenCredentialsTest.java}         | 68 +++++++++++-----------
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 23 ++++++++
 4 files changed, 112 insertions(+), 39 deletions(-)

diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
index 71394c10ef47..43ca1400d2d3 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
@@ -227,6 +227,13 @@ public class UserProfile {
             if (issuer != null && 
!issuer.equals(tokenJwt.get("iss").getAsString())) {
                 throw new OAuthException("Invalid JWT issuer");
             }
+            // A token must not be accepted before its not-before time (RFC 
7519, section 4.1.5)
+            if (tokenJwt.has("nbf")) {
+                long now = System.currentTimeMillis() / 1000L;
+                if (tokenJwt.get("nbf").getAsLong() > now + 
jwtOptions.getLeeway()) {
+                    throw new OAuthException("Token is not yet valid (nbf)");
+                }
+            }
         } catch (OAuthException ex) {
             throw ex;
         } catch (Exception ex) {
diff --git 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
index a246c259880b..ebb5a2d688c8 100644
--- 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
+++ 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
@@ -30,6 +30,7 @@ import com.nimbusds.jwt.SignedJWT;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -79,15 +80,59 @@ class UserProfileTest {
         assertNotNull(profile);
     }
 
+    @Test
+    void accessTokenRejectedBeforeNotBefore() throws Exception {
+        JsonObject json = new JsonObject();
+        json.addProperty("access_token", signedToken(new 
Date(System.currentTimeMillis() + 3_600_000L)));
+
+        // The signature verifies, but the token must not be accepted for 
another hour.
+        OAuthException ex = assertThrows(OAuthException.class, () -> 
UserProfile.fromJson(verifyingConfig(), json));
+        assertEquals("Token is not yet valid (nbf)", ex.getMessage());
+    }
+
+    @Test
+    void accessTokenAcceptedOnceNotBeforeHasPassed() throws Exception {
+        JsonObject json = new JsonObject();
+        json.addProperty("access_token", signedToken(new 
Date(System.currentTimeMillis() - 60_000L)));
+
+        assertNotNull(UserProfile.fromJson(verifyingConfig(), json));
+    }
+
+    @Test
+    void accessTokenNotBeforeAllowsConfiguredLeeway() throws Exception {
+        OAuthConfig config = verifyingConfig();
+        config.getJWTOptions().setLeeway(60);
+
+        JsonObject withinLeeway = new JsonObject();
+        withinLeeway.addProperty("access_token", signedToken(new 
Date(System.currentTimeMillis() + 30_000L)));
+        assertNotNull(UserProfile.fromJson(config, withinLeeway));
+
+        JsonObject beyondLeeway = new JsonObject();
+        beyondLeeway.addProperty("access_token", signedToken(new 
Date(System.currentTimeMillis() + 3_600_000L)));
+        assertThrows(OAuthException.class, () -> UserProfile.fromJson(config, 
beyondLeeway));
+    }
+
+    private OAuthConfig verifyingConfig() {
+        OAuthConfig config = new OAuthConfig().setClientId("my-client");
+        config.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+        return config;
+    }
+
     private String signedToken() throws Exception {
-        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+        return signedToken(null);
+    }
+
+    private String signedToken(Date notBefore) throws Exception {
+        JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder()
                 .subject("user1")
                 .issuer("https://idp.example.com";)
                 .audience("my-client")
                 .expirationTime(new Date(System.currentTimeMillis() + 
300_000L))
-                .issueTime(new Date())
-                .build();
-        SignedJWT jwt = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims);
+                .issueTime(new Date());
+        if (notBefore != null) {
+            claims.notBeforeTime(notBefore);
+        }
+        SignedJWT jwt = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims.build());
         jwt.sign(new RSASSASigner(rsaKey));
         return jwt.serialize();
     }
diff --git 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
similarity index 52%
copy from 
components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
copy to 
components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
index a246c259880b..e88017e1f53b 100644
--- 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
+++ 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
@@ -14,11 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.camel.oauth;
+package org.apache.camel.oauth.jakarta;
 
 import java.util.Date;
 
-import com.google.gson.JsonObject;
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.JWSHeader;
 import com.nimbusds.jose.crypto.RSASSASigner;
@@ -27,65 +26,64 @@ import com.nimbusds.jose.jwk.RSAKey;
 import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
+import org.apache.camel.oauth.OAuthConfig;
+import org.apache.camel.oauth.OAuthException;
+import org.apache.camel.oauth.TokenCredentials;
+import org.apache.camel.oauth.UserProfile;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
-class UserProfileTest {
+/**
+ * Authenticates bearer tokens the way {@code OAuthBearerTokenProcessor} does 
on the servlet backend. The tokens verify
+ * against a local JWK set and are not expired, so no identity provider is 
contacted.
+ */
+class ServletOAuthTokenCredentialsTest {
 
     private static final String KID = "test-key-1";
+    private static final String ISSUER = "https://idp.example.com";;
 
     private RSAKey rsaKey;
+    private ServletOAuth oauth;
 
     @BeforeEach
     void setUp() throws Exception {
         rsaKey = new RSAKeyGenerator(2048).keyID(KID).generate();
+        OAuthConfig oauthConfig = new OAuthConfig().setClientId("my-client");
+        oauthConfig.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+        oauthConfig.getJWTOptions().setIssuer(ISSUER);
+        oauth = new ServletOAuth() {
+            {
+                config = oauthConfig;
+            }
+        };
     }
 
     @Test
-    void accessTokenRejectedWhenJwkSetEmpty() throws Exception {
-        JsonObject json = new JsonObject();
-        json.addProperty("access_token", signedToken());
-
-        // A present-but-empty JWK set means the signature cannot be verified; 
the token must not be trusted.
-        OAuthConfig config = new OAuthConfig().setClientId("my-client");
-        config.setJWKSet(new JWKSet());
-
-        assertThrows(OAuthException.class, () -> UserProfile.fromJson(config, 
json));
-    }
-
-    @Test
-    void accessTokenRejectedWhenJwkSetMissing() throws Exception {
-        JsonObject json = new JsonObject();
-        json.addProperty("access_token", signedToken());
+    void bearerTokenRejectedBeforeNotBefore() throws Exception {
+        String token = signedToken(new Date(System.currentTimeMillis() + 
3_600_000L));
 
-        // No JWK set configured at all: still must not trust an unverified 
token.
-        OAuthConfig config = new OAuthConfig().setClientId("my-client");
-
-        assertThrows(OAuthException.class, () -> UserProfile.fromJson(config, 
json));
+        OAuthException ex = assertThrows(OAuthException.class, () -> 
oauth.authenticate(new TokenCredentials(token)));
+        assertEquals("Token is not yet valid (nbf)", 
ex.getCause().getMessage());
     }
 
     @Test
-    void accessTokenAcceptedWhenSignatureVerifies() throws Exception {
-        JsonObject json = new JsonObject();
-        json.addProperty("access_token", signedToken());
-
-        OAuthConfig config = new OAuthConfig().setClientId("my-client");
-        config.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+    void bearerTokenAcceptedOnceNotBeforeHasPassed() throws Exception {
+        String token = signedToken(new Date(System.currentTimeMillis() - 
60_000L));
 
-        UserProfile profile = UserProfile.fromJson(config, json);
-        assertNotNull(profile);
+        UserProfile profile = oauth.authenticate(new TokenCredentials(token));
+        assertEquals("user1", profile.subject());
     }
 
-    private String signedToken() throws Exception {
+    private String signedToken(Date notBefore) throws Exception {
         JWTClaimsSet claims = new JWTClaimsSet.Builder()
                 .subject("user1")
-                .issuer("https://idp.example.com";)
-                .audience("my-client")
-                .expirationTime(new Date(System.currentTimeMillis() + 
300_000L))
+                .issuer(ISSUER)
+                .expirationTime(new Date(System.currentTimeMillis() + 
7_200_000L))
                 .issueTime(new Date())
+                .notBeforeTime(notBefore)
                 .build();
         SignedJWT jwt = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims);
         jwt.sign(new RSASSASigner(rsaKey));
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 432875ded31b..13a5ab6b8749 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -2057,6 +2057,29 @@ identity provider.
 Note that `nonce` and PKCE (`code_challenge`) are still not sent, and the 
session cookie is still
 `SameSite=None; Secure`.
 
+=== camel-oauth
+
+The Jakarta servlet backend (`ServletOAuth`) now evaluates the `nbf` (not 
before) claim of the tokens it
+validates, and rejects a token before that time as RFC 7519 section 4.1.5 
requires. This covers the bearer
+token that `OAuthBearerTokenProcessor` authenticates as well as the tokens 
received from the identity
+provider. Previously only `exp` was evaluated, so a token was accepted before 
its `nbf` time.
+
+The comparison allows for the leeway, in seconds, configured on the 
`JWTOptions` of the OAuth
+configuration, which defaults to `0`. That matches the Vert.x backend, which 
already evaluated `nbf`, and
+the default of the `clock-skew-seconds` property of incoming token validation. 
Tokens without an `nbf`
+claim are not affected.
+
+The servlet backend does not read this leeway from configuration, and 
`clock-skew-seconds` does not apply
+to it. If the identity provider's clock runs ahead of the Camel host and it 
issues tokens whose `nbf`
+equals `iat`, raise the leeway on the `OAuth` instance:
+
+[source,java]
+----
+OAuthFactory factory = OAuthFactory.lookupFactory(camelContext);
+OAuth oauth = factory.findOAuth().orElseGet(factory::createOAuth);
+oauth.getOAuthConfig().getJWTOptions().setLeeway(30);
+----
+
 === camel-platform-http-vertx
 
 The CORS handler used to send `Access-Control-Allow-Credentials: true` on 
every response to a request

Reply via email to