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..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 @@ -65,6 +65,22 @@ 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). +Note that James always verifies the signature of the token even whether this configuration is provided or not. + +| 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..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 @@ -109,9 +109,12 @@ 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. Note that James always verifies the signature of the token even whether this configuration is provided or not. | auth.oidc.introspection.auth @@ -119,10 +122,8 @@ Note that James always verifies the signature of the token even whether this con 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. 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..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 @@ -32,7 +32,6 @@ import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; public class JwtTokenVerifier { @@ -62,21 +61,27 @@ public Optional verifyAndExtractLogin(String token) { } public Optional verifyAndExtractClaim(String token, String claimName, Class returnType) { + 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) { return jwtParsers.stream() - .flatMap(parser -> verifyAndExtractClaim(token, claimName, returnType, parser).stream()) + .flatMap(parser -> retrieveClaims(token, parser).stream()) .findFirst(); } - private Optional verifyAndExtractClaim(String token, String claimName, Class returnType, JwtParser parser) { + 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..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; @@ -77,12 +76,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) - .verifyAndExtractClaim(jwtToken, 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) { @@ -109,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)) 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 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); diff --git a/server/protocols/protocols-imap4/pom.xml b/server/protocols/protocols-imap4/pom.xml index 69ea06b1877..06dfb8c69dc 100644 --- a/server/protocols/protocols-imap4/pom.xml +++ b/server/protocols/protocols-imap4/pom.xml @@ -192,7 +192,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 + -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