blob: fedf69b50b59064875a60037068d291c81cff717 [file] [log] [blame]
/****************************************************************
* Licensed to the Apache Software Foundation (ASF) under one *
* or more contributor license agreements. See the NOTICE file *
* distributed with this work for additional information *
* regarding copyright ownership. The ASF licenses this file *
* to you under the Apache License, Version 2.0 (the *
* "License"); you may not use this file except in compliance *
* with the License. You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, *
* software distributed under the License is distributed on an *
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
* KIND, either express or implied. See the License for the *
* specific language governing permissions and limitations *
* under the License. *
****************************************************************/
package org.apache.james.jwt;
import static org.apache.james.jwt.OidcTokenFixture.INTROSPECTION_RESPONSE;
import static org.apache.james.jwt.OidcTokenFixture.USERINFO_RESPONSE;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import org.apache.james.jwt.introspection.IntrospectionEndpoint;
import org.apache.james.jwt.introspection.TokenIntrospectionException;
import org.apache.james.jwt.userinfo.UserInfoCheckException;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockserver.integration.ClientAndServer;
import org.mockserver.model.HttpRequest;
import org.mockserver.model.HttpResponse;
import io.jsonwebtoken.CompressionCodecs;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver;
import reactor.core.publisher.Mono;
class OidcJwtTokenVerifierTest {
private static final String JWKS_URI_PATH = "/auth/realms/realm1/protocol/openid-connect/certs";
private static final String USERINFO_PATH = "/auth/realms/oidc/protocol/openid-connect/userinfo";
private static final String INTROSPECTION_PATH = "/auth/realms/oidc/protocol/openid-connect/token/introspect";
ClientAndServer mockServer;
@BeforeEach
public void setUp() {
mockServer = ClientAndServer.startClientAndServer(0);
mockServer
.when(HttpRequest.request().withPath(JWKS_URI_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8));
}
@AfterEach
public void afterEach() {
if (mockServer != null) {
mockServer.close();
}
}
@Test
void verifyAndClaimShouldReturnClaimValueWhenValidTokenHasKid() {
Optional<String> email_address = OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address");
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(email_address.isPresent()).isTrue();
softly.assertThat(email_address.get()).isEqualTo("user@domain.org");
});
}
@Test
void verifyAndClaimShouldReturnClaimValueWhenValidTokenHasNotKid() {
Optional<String> email_address = OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN_HAS_NOT_KID, getJwksURL(), "email_address");
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(email_address.isPresent()).isTrue();
softly.assertThat(email_address.get()).isEqualTo("user@domain.org");
});
}
@Test
void shouldRejectZippedJWTByDefault() {
String jws = Jwts.builder()
.claim("kid", "a".repeat(100))
.compressWith(CompressionCodecs.DEFLATE)
.signWith(SignatureAlgorithm.HS256, OidcTokenFixture.PRIVATE_KEY_BASE64.replace("\n", ""))
.compact();
assertThatThrownBy(() -> OidcJwtTokenVerifier.verifySignatureAndExtractClaim(jws, getJwksURL(), "kid"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Rejecting a ZIP JWT");
}
@Test
void shouldAcceptZippedJWTWhenConfigured() {
String jws = Jwts.builder()
.claim("kid", "a".repeat(100))
.compressWith(CompressionCodecs.DEFLATE)
.signWith(SignatureAlgorithm.HS256, OidcTokenFixture.PRIVATE_KEY_BASE64.replace("\n", ""))
.compact();
JwtTokenVerifier.CONFIGURED_COMPRESSION_CODEC_RESOLVER = new DefaultCompressionCodecResolver();
assertThatCode(() -> OidcJwtTokenVerifier.verifySignatureAndExtractClaim(jws, getJwksURL(), "kid"))
.doesNotThrowAnyException();
}
@Test
void verifyAndClaimShouldReturnEmptyWhenValidTokenHasNotFoundKid() {
assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN_HAS_NOT_FOUND_KID, getJwksURL(), "email_address"))
.isEmpty();
}
@Test
void verifyAndClaimShouldReturnEmptyWhenClaimNameNotFound() {
assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "not_found"))
.isEmpty();
}
@Test
void verifyAndClaimShouldReturnEmptyWhenInvalidToken() {
assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "email_address"))
.isEmpty();
}
@Test
void verifyWithUserinfoShouldFailWhenUserInfoEndpointNotReturnOKHttpStatus() {
mockServer
.when(HttpRequest.request().withPath(USERINFO_PATH))
.respond(HttpResponse.response().withStatusCode(201));
assertThatThrownBy(() -> Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address", getUserInfoEndpoint()))
.block())
.isInstanceOf(UserInfoCheckException.class)
.hasMessageContaining("Error when check token by userInfo");
}
@Test
void verifyWithUserinfoShouldFailWhenUserInfoEndpointReturnBadResponse() {
mockServer
.when(HttpRequest.request().withPath(USERINFO_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody("badResponse1", StandardCharsets.UTF_8));
assertThatThrownBy(() -> Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address", getUserInfoEndpoint()))
.block())
.isInstanceOf(UserInfoCheckException.class)
.hasMessageContaining("Error when check token by userInfo");
}
@Test
void verifyWithUserinfoShouldReturnEmptyWhenClaimValueIsEmpty() {
String userInfoResponse = "{" +
" \"sub\": \"a0d03864-12f7-4f0b-b732-699c27eff3e7\"," +
" \"email_verified\": false," +
" \"name\": \"User name 1\"," +
" \"email\": \"user1@example.com\"" +
"}";
mockServer
.when(HttpRequest.request().withPath(USERINFO_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(userInfoResponse, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "preferred_username", getUserInfoEndpoint()))
.block())
.isNull();
}
@Test
void verifyWithUserinfoShouldReturnClaimValueWhenPassCheckToken() {
mockServer
.when(HttpRequest.request().withPath(USERINFO_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(USERINFO_RESPONSE, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address", getUserInfoEndpoint()))
.block())
.isEqualTo("user@domain.org");
}
@Test
void verifyWithUserinfoShouldReturnEmptyWhenINVALIDToken() {
mockServer
.when(HttpRequest.request().withPath(USERINFO_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(USERINFO_RESPONSE, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "email_address", getUserInfoEndpoint()))
.block())
.isNull();
}
@Test
void verifyWithUserinfoShouldReturnEmptyWhenClaimValueIsNotMatch() {
String userInfoResponse = "{" +
" \"sub\": \"a0d03864-12f7-4f0b-b732-699c27eff3e7\"," +
" \"email_verified\": false," +
" \"name\": \"User name 1\"," +
" \"preferred_username\": \"different1\"," +
" \"email\": \"user1@example.com\"" +
"}";
mockServer
.when(HttpRequest.request().withPath(USERINFO_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(userInfoResponse, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithUserinfo(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "preferred_username", getUserInfoEndpoint()))
.block())
.isNull();
}
@Test
void verifyWithIntrospectionShouldFailWhenEndpointNotReturnOKHttpStatus() {
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(201));
assertThatThrownBy(() -> Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address"
, new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isInstanceOf(TokenIntrospectionException.class)
.hasMessageContaining("Error when introspecting token");
}
@Test
void verifyWithIntrospectionShouldFailWhenEndpointReturnBadResponse() {
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody("badResponse1", StandardCharsets.UTF_8));
assertThatThrownBy(() -> Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isInstanceOf(TokenIntrospectionException.class)
.hasMessageContaining("Error when introspecting token");
}
@Test
void verifyWithIntrospectionInfoShouldFailWhenActivePropertyIsAbsent() {
String introspectionResponse = "{" +
" \"exp\": 1669719841," +
" \"iat\": 1669719541," +
" \"aud\": \"account\"," +
" \"sub\": \"a0d03864-12f7-4f0b-b732-699c27eff3e7\"," +
" \"typ\": \"Bearer\"," +
" \"session_state\": \"42799d76-be33-4f24-bcec-fc0dbb5d126d\"," +
" \"preferred_username\": \"user1\"," +
" \"email\": \"user1@example.com\"," +
" \"scope\": \"profile email\"," +
" \"sid\": \"42799d76-be33-4f24-bcec-fc0dbb5d126d\"," +
" \"client_id\": \"james-thunderbird\"," +
" \"username\": \"user1\"" +
"}";
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(introspectionResponse, StandardCharsets.UTF_8));
assertThatThrownBy(() -> Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isInstanceOf(TokenIntrospectionException.class)
.hasMessageContaining("Error when introspecting token");
}
@Test
void verifyWithIntrospectionInfoShouldFailWhenActiveIsFalse() {
String introspectionResponse = "{" +
" \"exp\": 1669719841," +
" \"iat\": 1669719541," +
" \"aud\": \"account\"," +
" \"sub\": \"a0d03864-12f7-4f0b-b732-699c27eff3e7\"," +
" \"typ\": \"Bearer\"," +
" \"session_state\": \"42799d76-be33-4f24-bcec-fc0dbb5d126d\"," +
" \"preferred_username\": \"user1\"," +
" \"email\": \"user1@example.com\"," +
" \"scope\": \"profile email\"," +
" \"sid\": \"42799d76-be33-4f24-bcec-fc0dbb5d126d\"," +
" \"client_id\": \"james-thunderbird\"," +
" \"username\": \"user1\"," +
" \"active\": false," +
"}";
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(introspectionResponse, StandardCharsets.UTF_8));
assertThatThrownBy(() -> Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isInstanceOf(TokenIntrospectionException.class)
.hasMessageContaining("Error when introspecting token");
}
@Test
void verifyWithIntrospectionShouldReturnClaimValueWhenPassCheckToken() {
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(INTROSPECTION_RESPONSE, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "email_address",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isEqualTo("user@domain.org");
}
@Test
void verifyWithIntrospectionShouldReturnEmptyWhenClaimValueIsNotMatch() {
String introspectionResponse = "{" +
" \"exp\": 1669719841," +
" \"iat\": 1669719541," +
" \"aud\": \"account\"," +
" \"sub\": \"a0d03864-12f7-4f0b-b732-699c27eff3e7\"," +
" \"preferred_username\": \"difference1\"," +
" \"active\": true" +
"}";
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(introspectionResponse, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "preferred_username",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isNull();
}
@Test
void verifyWithIntrospectionShouldReturnEmptyWhenClaimValueIsAbsent() {
String introspectionResponse = "{" +
" \"exp\": 1669719841," +
" \"iat\": 1669719541," +
" \"aud\": \"account\"," +
" \"sub\": \"a0d03864-12f7-4f0b-b732-699c27eff3e7\"," +
" \"typ\": \"Bearer\"," +
" \"active\": true" +
"}";
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(introspectionResponse, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.VALID_TOKEN, getJwksURL(), "preferred_username",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isNull();
}
@Test
void verifyWithIntrospectionShouldReturnEmptyWhenINVALIDToken() {
mockServer
.when(HttpRequest.request().withPath(INTROSPECTION_PATH))
.respond(HttpResponse.response().withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(INTROSPECTION_RESPONSE, StandardCharsets.UTF_8));
assertThat(Mono.from(OidcJwtTokenVerifier.verifyWithIntrospection(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "email_address",
new IntrospectionEndpoint(getIntrospectionEndpoint(), Optional.empty())))
.block())
.isNull();
}
private URL getJwksURL() {
try {
return new URI(String.format("http://127.0.0.1:%s%s", mockServer.getLocalPort(), JWKS_URI_PATH)).toURL();
} catch (MalformedURLException | URISyntaxException e) {
throw new RuntimeException(e);
}
}
private URL getUserInfoEndpoint() {
try {
return new URI(String.format("http://127.0.0.1:%s%s", mockServer.getLocalPort(), USERINFO_PATH)).toURL();
} catch (MalformedURLException | URISyntaxException e) {
throw new RuntimeException(e);
}
}
private URL getIntrospectionEndpoint() {
try {
return new URI(String.format("http://127.0.0.1:%s%s", mockServer.getLocalPort(), INTROSPECTION_PATH)).toURL();
} catch (MalformedURLException | URISyntaxException e) {
throw new RuntimeException(e);
}
}
}