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