From d4e4d23904af8dbd9a945910843d43002d8c7001 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 16 Jan 2026 17:52:36 +0100 Subject: [PATCH 1/5] [ENHANCEMENT] Validate aud without introspect --- .../apache/james/jwt/JwtTokenVerifier.java | 27 +++++++++++---- .../james/jwt/OidcJwtTokenVerifier.java | 5 ++- .../james/jwt/OidcJwtTokenVerifierTest.java | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java index ae197d8e68b..f5b79927ecc 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java @@ -29,6 +29,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; import io.jsonwebtoken.Claims; @@ -132,16 +133,28 @@ public Optional verifyAndExtractClaim(String token, String claimName, Cla } } + public Optional verify(String token) { + try { + // if the token contains a kid, verify only with the corresponding key (or fail) + return retrieveClaims(token, kidJwtParser); + } catch (NullPointerException npe) { // our own key locator throws NPE when there is no kid + // if token does not specify kid, fallback to trying all keys + return jwtParsers.stream() + .flatMap(parser -> retrieveClaims(token, parser).stream()) + .findFirst(); + } + } + private Optional verifyAndExtractClaim(String token, String claimName, Class returnType, JwtParser parser) { + return retrieveClaims(token, parser) + .map(Throwing.function(claims -> Optional.ofNullable(claims.get(claimName, returnType)) + .orElseThrow(() -> new MalformedJwtException("'" + claimName + "' field in token is mandatory")))); + } + + private Optional retrieveClaims(String token, JwtParser parser) { try { Jws jws = parser.parseSignedClaims(token); - T claim = jws - .getPayload() - .get(claimName, returnType); - if (claim == null) { - throw new MalformedJwtException("'" + claimName + "' field in token is mandatory"); - } - return Optional.of(claim); + return Optional.of(jws.getPayload()); } catch (JwtException e) { // also if kid was given but our locator didn't find the corresponding key LOGGER.info("Failed Jwt verification", e); return Optional.empty(); diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java index 7f87132bb7c..d158fd22994 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java @@ -73,7 +73,10 @@ private Optional validTokenWithIntrospection(String token) { @VisibleForTesting Optional verifySignatureAndExtractClaim(String jwtToken) { return new JwtTokenVerifier(JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL())) - .verifyAndExtractClaim(jwtToken, oidcSASLConfiguration.getClaim(), String.class); + .verify(jwtToken) + .filter(claims -> oidcSASLConfiguration.getAud().map(expectedAud -> claims.getAudience().contains(expectedAud)) + .orElse(true)) // true if no aud is configured + .flatMap(claims -> Optional.ofNullable(claims.get(oidcSASLConfiguration.getClaim(), String.class))); } @VisibleForTesting diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java index 3755d2fb2d4..5480dd53b1e 100644 --- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java +++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java @@ -78,6 +78,39 @@ public void afterEach() { } } + @Test + void verifyAndClaimShouldAcceptValidAud() throws Exception { + Optional emailAddress = new OidcJwtTokenVerifier( + OidcSASLConfiguration.builder() + .jwksURL(getJwksURL()) + .scope("email") + .oidcConfigurationURL(new URL("https://whatever.nte")) + .claim("email_address") + .aud("account") + .build()) + .verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(emailAddress.isPresent()).isTrue(); + softly.assertThat(emailAddress.get()).isEqualTo("user@domain.org"); + }); + } + + @Test + void verifyAndClaimShouldRejectInvalidAud() throws Exception { + Optional emailAddress = new OidcJwtTokenVerifier( + OidcSASLConfiguration.builder() + .jwksURL(getJwksURL()) + .scope("email") + .oidcConfigurationURL(new URL("https://whatever.nte")) + .claim("email_address") + .aud("other") + .build()) + .verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN); + + assertThat(emailAddress).isEmpty(); + } + @Test void verifyAndClaimShouldReturnClaimValueWhenValidTokenHasKid() { Optional emailAddress = new OidcJwtTokenVerifier(configForClaim("email_address")).verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN); From 3e3313c8a9e64e2fdff1f56f8b16e603588b0229 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 16 Jan 2026 21:30:34 +0100 Subject: [PATCH 2/5] [ENHANCEMENT] Validate aud without introspect --- .../apache/james/jwt/JwtTokenVerifier.java | 26 +++++++------------ .../james/jwt/OidcJwtTokenVerifier.java | 16 ++++++++---- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java index f5b79927ecc..7fb8350c151 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java @@ -29,7 +29,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; import io.jsonwebtoken.Claims; @@ -38,7 +37,6 @@ import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Locator; -import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.io.CompressionAlgorithm; public class JwtTokenVerifier { @@ -122,15 +120,15 @@ public Optional verifyAndExtractLogin(String token) { } public Optional verifyAndExtractClaim(String token, String claimName, Class returnType) { - try { - // if the token contains a kid, verify only with the corresponding key (or fail) - return verifyAndExtractClaim(token, claimName, returnType, kidJwtParser); - } catch (NullPointerException npe) { // our own key locator throws NPE when there is no kid - // if token does not specify kid, fallback to trying all keys - return jwtParsers.stream() - .flatMap(parser -> verifyAndExtractClaim(token, claimName, returnType, parser).stream()) - .findFirst(); - } + return verify(token) + .flatMap(claims -> { + try { + return Optional.ofNullable(claims.get(claimName, returnType)); + } catch (JwtException e) { + LOGGER.info("Failed Jwt verification", e); + return Optional.empty(); + } + }); } public Optional verify(String token) { @@ -145,12 +143,6 @@ public Optional verify(String token) { } } - private Optional verifyAndExtractClaim(String token, String claimName, Class returnType, JwtParser parser) { - return retrieveClaims(token, parser) - .map(Throwing.function(claims -> Optional.ofNullable(claims.get(claimName, returnType)) - .orElseThrow(() -> new MalformedJwtException("'" + claimName + "' field in token is mandatory")))); - } - private Optional retrieveClaims(String token, JwtParser parser) { try { Jws jws = parser.parseSignedClaims(token); diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java index d158fd22994..eb3cb458055 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java @@ -32,6 +32,7 @@ import com.google.common.annotations.VisibleForTesting; +import io.jsonwebtoken.JwtException; import reactor.core.publisher.Mono; public class OidcJwtTokenVerifier { @@ -72,11 +73,16 @@ private Optional validTokenWithIntrospection(String token) { @VisibleForTesting Optional verifySignatureAndExtractClaim(String jwtToken) { - return new JwtTokenVerifier(JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL())) - .verify(jwtToken) - .filter(claims -> oidcSASLConfiguration.getAud().map(expectedAud -> claims.getAudience().contains(expectedAud)) - .orElse(true)) // true if no aud is configured - .flatMap(claims -> Optional.ofNullable(claims.get(oidcSASLConfiguration.getClaim(), String.class))); + try { + return new JwtTokenVerifier(JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL())) + .verify(jwtToken) + .filter(claims -> oidcSASLConfiguration.getAud().map(expectedAud -> claims.getAudience().contains(expectedAud)) + .orElse(true)) // true if no aud is configured + .flatMap(claims -> Optional.ofNullable(claims.get(oidcSASLConfiguration.getClaim(), String.class))); + } catch (JwtException e) { + LOGGER.info("Failed Jwt verification", e); + return Optional.empty(); + } } @VisibleForTesting From c6b08fc228eae39282d0cabe8ac68a7c70d08a52 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Mon, 19 Jan 2026 16:49:49 +0100 Subject: [PATCH 3/5] [ENHANCEMENT] OIDC SASL only validate aud upon token verification --- .../apache/james/jwt/OidcJwtTokenVerifier.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java index eb3cb458055..78dd2444305 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java @@ -21,7 +21,6 @@ import java.net.URL; import java.util.Optional; -import java.util.function.Predicate; import org.apache.james.core.Username; import org.apache.james.jwt.introspection.IntrospectionEndpoint; @@ -91,27 +90,12 @@ Publisher verifyWithIntrospection(String jwtToken, IntrospectionEndpoint .flatMap(optional -> optional.map(Mono::just).orElseGet(Mono::empty)) .flatMap(claimResult -> Mono.from(CHECK_TOKEN_CLIENT.introspect(introspectionEndpoint, jwtToken)) .filter(TokenIntrospectionResponse::active) - .filter(validateAud()) .filter(tokenIntrospectionResponse -> tokenIntrospectionResponse.claimByPropertyName(oidcSASLConfiguration.getClaim()) .map(claim -> claim.equals(claimResult)) .orElse(false)) .map(activeResponse -> claimResult)); } - private Predicate validateAud() { - return oidcSASLConfiguration.getAud() - .map(this::validateAud) - .orElse(any -> true); - } - - private Predicate validateAud(String expectedAud) { - return token -> { - boolean result = token.aud().map(expectedAud::equals).orElse(false); - LOGGER.warn("Wrong aud. Expected {} got {}", expectedAud, token.aud()); - return result; - }; - } - @VisibleForTesting Publisher verifyWithUserinfo(String jwtToken, URL userinfoEndpoint) { return Mono.fromCallable(() -> verifySignatureAndExtractClaim(jwtToken)) From f051faa80a595a4a7c1512276f5370fba063efaf Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 16 Jan 2026 18:00:14 +0100 Subject: [PATCH 4/5] [ENHANCEMENT] Improve SASL OpenId doc --- .../servers/partials/configure/imap.adoc | 20 ++++++++++++++++++- .../servers/partials/configure/smtp.adoc | 13 +++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/modules/servers/partials/configure/imap.adoc b/docs/modules/servers/partials/configure/imap.adoc index af6911fe434..d09341310e0 100644 --- a/docs/modules/servers/partials/configure/imap.adoc +++ b/docs/modules/servers/partials/configure/imap.adoc @@ -62,6 +62,24 @@ Whether to enable Authentication PLAIN if the connection is not encrypted via SS | auth.oidc.scope | An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate IMAP server using a OIDC provider. +| auth.oidc.aud +| An OAuth audience to access the service (RF: RFC7628). Only configure this when you want to authenticate IMAP server using a OIDC provider. +Compulsory but can be relaxed with `-Djames.sasl.oidc.validate.aud=false` + +| auth.oidc.introspection.url +| Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662). +Required to harden access token validation, but can be relaxed with `-Djames.sasl.oidc.force.introspect=false` +Note that James always verifies the signature of the token even whether this configuration is provided or not. +This endpoint is expected to return `aud`. + +| auth.oidc.introspection.auth +| Optional. Provide Authorization in header request when introspecting token. +Eg: `Basic xyz` + +| auth.oidc.userinfo.url +| Optional. An Userinfo URL will be called to retrieve additional user information +(RF: OpenId.Core https://openid.net/specs/openid-connect-core-1_0.html). + | timeout | Default to 30 minutes. After this time, inactive channels that have not performed read, write, or both operation for a while will be closed. Negative value disable this behaviour. @@ -265,4 +283,4 @@ Example: 2min 500 MiB -.... \ No newline at end of file +.... diff --git a/docs/modules/servers/partials/configure/smtp.adoc b/docs/modules/servers/partials/configure/smtp.adoc index 1a68a0094fa..a98af568f82 100644 --- a/docs/modules/servers/partials/configure/smtp.adoc +++ b/docs/modules/servers/partials/configure/smtp.adoc @@ -111,20 +111,23 @@ can be used to enforce strong authentication mechanisms. | auth.oidc.scope | An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate SMTP server using a OIDC provider. +| auth.oidc.aud +| An OAuth audience to access the service (RF: RFC7628). Only configure this when you want to authenticate IMAP server using a OIDC provider. +Compulsory but can be relaxed with `-Djames.sasl.oidc.validate.aud=false` + | auth.oidc.introspection.url | Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662). -Only configure this when you want to validate the revocation token by the OIDC provider. +Required to harden access token validation, but can be relaxed with `-Djames.sasl.oidc.force.introspect=false` Note that James always verifies the signature of the token even whether this configuration is provided or not. +This endpoint is expected to return `aud`. | auth.oidc.introspection.auth | Optional. Provide Authorization in header request when introspecting token. Eg: `Basic xyz` | auth.oidc.userinfo.url -| Optional. An Userinfo URL will be called to validate the token (RF: OpenId.Core https://openid.net/specs/openid-connect-core-1_0.html). -Only configure this when you want to validate the revocation token by the OIDC provider. -Note that James always verifies the signature of the token even whether this configuration is provided or not. -James will ignore check token by userInfo if the `auth.oidc.introspection.url` is already configured +| Optional. An Userinfo URL will be called to retrieve additional user information +(RF: OpenId.Core https://openid.net/specs/openid-connect-core-1_0.html). | authorizedAddresses | Authorize specific addresses/networks. From eb3d0cb7e3a39330d4e933413d9792c83ec3abde Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Mon, 19 Jan 2026 16:53:50 +0100 Subject: [PATCH 5/5] [ENHANCEMENT] Relax Introspect requirement --- docs/modules/servers/partials/configure/imap.adoc | 2 -- docs/modules/servers/partials/configure/smtp.adoc | 2 -- .../java/org/apache/james/jwt/OidcSASLConfiguration.java | 8 -------- server/protocols/protocols-imap4/pom.xml | 2 +- server/protocols/protocols-lmtp/pom.xml | 2 +- server/protocols/protocols-smtp/pom.xml | 2 +- 6 files changed, 3 insertions(+), 15 deletions(-) diff --git a/docs/modules/servers/partials/configure/imap.adoc b/docs/modules/servers/partials/configure/imap.adoc index d09341310e0..c42b9432b3d 100644 --- a/docs/modules/servers/partials/configure/imap.adoc +++ b/docs/modules/servers/partials/configure/imap.adoc @@ -68,9 +68,7 @@ Compulsory but can be relaxed with `-Djames.sasl.oidc.validate.aud=false` | auth.oidc.introspection.url | Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662). -Required to harden access token validation, but can be relaxed with `-Djames.sasl.oidc.force.introspect=false` Note that James always verifies the signature of the token even whether this configuration is provided or not. -This endpoint is expected to return `aud`. | auth.oidc.introspection.auth | Optional. Provide Authorization in header request when introspecting token. diff --git a/docs/modules/servers/partials/configure/smtp.adoc b/docs/modules/servers/partials/configure/smtp.adoc index a98af568f82..788f205b7bf 100644 --- a/docs/modules/servers/partials/configure/smtp.adoc +++ b/docs/modules/servers/partials/configure/smtp.adoc @@ -117,9 +117,7 @@ Compulsory but can be relaxed with `-Djames.sasl.oidc.validate.aud=false` | auth.oidc.introspection.url | Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662). -Required to harden access token validation, but can be relaxed with `-Djames.sasl.oidc.force.introspect=false` Note that James always verifies the signature of the token even whether this configuration is provided or not. -This endpoint is expected to return `aud`. | auth.oidc.introspection.auth | Optional. Provide Authorization in header request when introspecting token. diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java index cb59ef1811b..2084fe62feb 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java @@ -139,14 +139,6 @@ public static OidcSASLConfiguration parse(HierarchicalConfiguration -Djava.library.path= -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms1024m -Xmx2048m -Djames.sasl.oidc.force.introspect=false -Djames.sasl.oidc.validate.aud=false + -Xms1024m -Xmx2048m -Djames.sasl.oidc.validate.aud=false true 1800 diff --git a/server/protocols/protocols-lmtp/pom.xml b/server/protocols/protocols-lmtp/pom.xml index f9a0dfca741..ff8fd671b17 100644 --- a/server/protocols/protocols-lmtp/pom.xml +++ b/server/protocols/protocols-lmtp/pom.xml @@ -195,7 +195,7 @@ -Djava.library.path= -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms512m -Xmx1024m -Djames.sasl.oidc.force.introspect=false -Djames.sasl.oidc.validate.aud=false + -Xms512m -Xmx1024m -Djames.sasl.oidc.validate.aud=false true 1800 diff --git a/server/protocols/protocols-smtp/pom.xml b/server/protocols/protocols-smtp/pom.xml index 5cf71317544..9507cd95ec0 100644 --- a/server/protocols/protocols-smtp/pom.xml +++ b/server/protocols/protocols-smtp/pom.xml @@ -226,7 +226,7 @@ -Djava.library.path= -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms512m -Xmx1024m -Djames.sasl.oidc.force.introspect=false -Djames.sasl.oidc.validate.aud=false + -Xms512m -Xmx1024m -Djames.sasl.oidc.validate.aud=false true 1800