Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,21 @@ 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
| 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;

public class JwtTokenVerifier {

Expand Down Expand Up @@ -62,21 +61,27 @@ public Optional<String> verifyAndExtractLogin(String token) {
}

public <T> Optional<T> verifyAndExtractClaim(String token, String claimName, Class<T> 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<Claims> verify(String token) {
return jwtParsers.stream()
.flatMap(parser -> verifyAndExtractClaim(token, claimName, returnType, parser).stream())
.flatMap(parser -> retrieveClaims(token, parser).stream())
.findFirst();
}

private <T> Optional<T> verifyAndExtractClaim(String token, String claimName, Class<T> returnType, JwtParser parser) {
private Optional<Claims> retrieveClaims(String token, JwtParser parser) {
try {
Jws<Claims> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,12 +76,20 @@ private Optional<Username> validTokenWithIntrospection(String token) {

@VisibleForTesting
Optional<String> verifySignatureAndExtractClaim(String jwtToken) {
Optional<String> unverifiedClaim = getClaimWithoutSignatureVerification(jwtToken, "kid");
PublicKeyProvider jwksPublicKeyProvider = unverifiedClaim
try {
Optional<String> 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 <T> Optional<T> getClaimWithoutSignatureVerification(String token, String claimName) {
Expand All @@ -109,27 +116,12 @@ Publisher<String> 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<TokenIntrospectionResponse> validateAud() {
return oidcSASLConfiguration.getAud()
.map(this::validateAud)
.orElse(any -> true);
}

private Predicate<TokenIntrospectionResponse> 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<String> verifyWithUserinfo(String jwtToken, URL userinfoEndpoint) {
return Mono.fromCallable(() -> verifySignatureAndExtractClaim(jwtToken))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,7 @@ public static OidcSASLConfiguration parse(HierarchicalConfiguration<ImmutableNod
String introspectionUrl = configuration.getString("introspection.url", null);
String userInfoUrl = configuration.getString("userinfo.url", null);
String aud = configuration.getString("aud", null);

if (introspectionUrl == null) {
if (FORCE_INTROSPECT) {
throw new IllegalArgumentException("'introspection.url' is mandatory for secure set up. Disable this check with -Djames.sasl.oidc.force.introspect=false.");
} else {
LOGGER.warn("'introspection.url' is mandatory for secure set up. This check was disabled with -Djames.sasl.oidc.force.introspect=false.");
}
}

if (aud == null) {
if (VALIDATE_AUD) {
throw new IllegalArgumentException("'aud' is mandatory for secure set up. Disable this check with -Djames.sasl.oidc.validate.aud=false.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ public void afterEach() {
}
}

@Test
void verifyAndClaimShouldAcceptValidAud() throws Exception {
Optional<String> 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<String> 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<String> emailAddress = new OidcJwtTokenVerifier(configForClaim("email_address")).verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN);
Expand Down
2 changes: 1 addition & 1 deletion server/protocols/protocols-imap4/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
</systemPropertyVariables>
<argLine>-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</argLine>
-Xms1024m -Xmx2048m -Djames.sasl.oidc.validate.aud=false</argLine>
<reuseForks>true</reuseForks>
<!-- Fail tests longer than 20 minutes, prevent form random locking tests -->
<forkedProcessTimeoutInSeconds>1200</forkedProcessTimeoutInSeconds>
Expand Down
2 changes: 1 addition & 1 deletion server/protocols/protocols-lmtp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@
</systemPropertyVariables>
<argLine>-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</argLine>
-Xms512m -Xmx1024m -Djames.sasl.oidc.validate.aud=false</argLine>
<reuseForks>true</reuseForks>
<!-- Fail tests longer than 20 minutes, prevent form random locking tests -->
<forkedProcessTimeoutInSeconds>1200</forkedProcessTimeoutInSeconds>
Expand Down
2 changes: 1 addition & 1 deletion server/protocols/protocols-smtp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@
</systemPropertyVariables>
<argLine>-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</argLine>
-Xms512m -Xmx1024m -Djames.sasl.oidc.validate.aud=false</argLine>
<reuseForks>true</reuseForks>
<!-- Fail tests longer than 20 minutes, prevent form random locking tests -->
<forkedProcessTimeoutInSeconds>1200</forkedProcessTimeoutInSeconds>
Expand Down