From eb137cd53116acc4ebfcf0054b7b0edfab814d32 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 | 21 ++++++++---- .../james/jwt/OidcJwtTokenVerifier.java | 5 ++- .../james/jwt/OidcJwtTokenVerifierTest.java | 33 +++++++++++++++++++ 3 files changed, 51 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 c5bc4ff9b82..66daee29a2c 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 @@ -25,6 +25,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; @@ -67,16 +68,22 @@ public Optional verifyAndExtractClaim(String token, String claimName, Cla .findFirst(); } + public Optional verify(String token) { + 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.parseClaimsJws(token); - T claim = jws - .getBody() - .get(claimName, returnType); - if (claim == null) { - throw new MalformedJwtException("'" + claimName + "' field in token is mandatory"); - } - return Optional.of(claim); + return Optional.of(jws.getBody()); } catch (JwtException e) { 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 6f0be04e560..b1ca932fe80 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 @@ -82,7 +82,10 @@ Optional verifySignatureAndExtractClaim(String jwtToken) { .map(kidValue -> JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL(), kidValue)) .orElse(JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL())); return new JwtTokenVerifier(jwksPublicKeyProvider) - .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))); } private Optional getClaimWithoutSignatureVerification(String token, String claimName) { 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 0aee8af43cb..1e1e09a33bc 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 @@ -68,6 +68,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 5b4464bf9959938af1f6da3237a67551f7ad979b 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 | 20 +++++++++---------- .../james/jwt/OidcJwtTokenVerifier.java | 19 +++++++++++------- 2 files changed, 21 insertions(+), 18 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 66daee29a2c..f7a1909599f 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 @@ -25,7 +25,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; @@ -33,7 +32,6 @@ import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; public class JwtTokenVerifier { @@ -63,9 +61,15 @@ public Optional verifyAndExtractLogin(String token) { } public Optional verifyAndExtractClaim(String token, String claimName, Class returnType) { - 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) { @@ -74,12 +78,6 @@ public Optional verify(String token) { .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.parseClaimsJws(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 b1ca932fe80..dea93ea239e 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 @@ -77,15 +77,20 @@ private Optional validTokenWithIntrospection(String token) { @VisibleForTesting Optional verifySignatureAndExtractClaim(String jwtToken) { - Optional unverifiedClaim = getClaimWithoutSignatureVerification(jwtToken, "kid"); - PublicKeyProvider jwksPublicKeyProvider = unverifiedClaim + try { + Optional unverifiedClaim = getClaimWithoutSignatureVerification(jwtToken, "kid"); + PublicKeyProvider jwksPublicKeyProvider = unverifiedClaim .map(kidValue -> JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL(), kidValue)) .orElse(JwksPublicKeyProvider.of(oidcSASLConfiguration.getJwksURL())); - return new JwtTokenVerifier(jwksPublicKeyProvider) - .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))); + return new JwtTokenVerifier(jwksPublicKeyProvider) + .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(); + } } private Optional getClaimWithoutSignatureVerification(String token, String claimName) { From 05925afb00b7ee64a9c3ed680f1a8ffb41852cde 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 dea93ea239e..e3b6e5d2222 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; @@ -117,27 +116,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 c8535f74d3306ee93280041593fe1f89da146680 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 --- .../modules/ROOT/pages/configure/imap.adoc | 18 ++++++++++++++++++ .../modules/ROOT/pages/configure/smtp.adoc | 13 ++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc index 6b8f6d68a16..bb1da25807d 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc @@ -65,6 +65,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. diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc index e5d9abb9550..0b648b054af 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc @@ -109,20 +109,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 6054f79d7c4ca65b346470fa3a48e5afe5ebcdd2 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/ROOT/pages/configure/imap.adoc | 2 -- .../docs/modules/ROOT/pages/configure/smtp.adoc | 2 -- .../java/org/apache/james/jwt/OidcSASLConfiguration.java | 9 +-------- server/protocols/protocols-imap4/pom.xml | 2 +- server/protocols/protocols-lmtp/pom.xml | 2 +- server/protocols/protocols-smtp/pom.xml | 2 +- 6 files changed, 4 insertions(+), 15 deletions(-) diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc index bb1da25807d..d98fc3648cc 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc @@ -71,9 +71,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/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc index 0b648b054af..ed3afb6e200 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/smtp.adoc @@ -115,9 +115,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 4a0db876cd2..8f17de4bbbe 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 @@ -136,14 +136,7 @@ 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 - -Xms512m -Xmx1024m -Djames.sasl.oidc.force.introspect=false -Djames.sasl.oidc.validate.aud=false + -Xms1024m -Xmx2048m -Djames.sasl.oidc.validate.aud=false true 1200 diff --git a/server/protocols/protocols-lmtp/pom.xml b/server/protocols/protocols-lmtp/pom.xml index 0368fa63024..e1ffab357e1 100644 --- a/server/protocols/protocols-lmtp/pom.xml +++ b/server/protocols/protocols-lmtp/pom.xml @@ -190,7 +190,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 1200 diff --git a/server/protocols/protocols-smtp/pom.xml b/server/protocols/protocols-smtp/pom.xml index 2c6f13f3e71..8721d252a9e 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 1200