diff --git a/vertx-auth-oauth2/pom.xml b/vertx-auth-oauth2/pom.xml
index e1bef5402..a1177756a 100644
--- a/vertx-auth-oauth2/pom.xml
+++ b/vertx-auth-oauth2/pom.xml
@@ -32,6 +32,18 @@
false
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ 1.21.3
+ pom
+ import
+
+
+
+
io.vertx
@@ -50,9 +62,18 @@
org.testcontainers
testcontainers
- 1.18.0
test
+
+ org.testcontainers
+ mockserver
+ test
+
+
+ org.mock-server
+ mockserver-client-java
+ 5.15.0
+
diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OpenIDConnectAuth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OpenIDConnectAuth.java
index f50474386..3cf7b72a2 100644
--- a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OpenIDConnectAuth.java
+++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OpenIDConnectAuth.java
@@ -19,13 +19,18 @@
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpMethod;
-import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.JWTOptions;
import io.vertx.ext.auth.impl.http.SimpleHttpClient;
import io.vertx.ext.auth.oauth2.OAuth2Auth;
import io.vertx.ext.auth.oauth2.OAuth2Options;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
/**
* Simplified factory to create an {@link io.vertx.ext.auth.oauth2.OAuth2Auth} for OpenID Connect.
*
@@ -134,14 +139,39 @@ static Future discover(final Vertx vertx, final OAuth2Options config
jwtOptions.setIssuer(json.getString("issuer"));
}
-
- // reset config
- config.setSupportedGrantTypes(null);
-
if (json.containsKey("grant_types_supported")) {
// optional config
- JsonArray flows = json.getJsonArray("grant_types_supported");
- flows.forEach(el -> config.addSupportedGrantType((String) el));
+ List configuredGrantTypes = config.getSupportedGrantTypes();
+ final Set configured = configuredGrantTypes == null ? null : new HashSet<>(configuredGrantTypes);
+
+ // reset config
+ config.setSupportedGrantTypes(null);
+
+ Stream supportedGrantTypes = json.getJsonArray("grant_types_supported")
+ .stream()
+ .map(el -> (String) el);
+
+ // If the caller configured supported grant types, use the intersection with the server-supported grant types.
+ // Otherwise, use all grant types that the server supports.
+ if (configured != null) {
+ supportedGrantTypes = supportedGrantTypes.filter(configured::contains);
+ }
+
+ supportedGrantTypes
+ .forEach(config::addSupportedGrantType);
+
+ // If the supported grant types are still null here, either the server sent an empty list of supported grant
+ // types or the intersection with the configured grant types was empty. Both cases are errors.
+ if (config.getSupportedGrantTypes() == null) {
+ return Future.failedFuture(
+ "No supported grant types with this authorization provider. Supported: " +
+ json.getJsonArray("grant_types_supported").stream()
+ .map(el -> (String) el)
+ .collect(Collectors.joining(", ", "[", "]")) +
+ ". Configured: " +
+ (configuredGrantTypes == null ? "" : configuredGrantTypes.stream().collect(Collectors.joining(", ", "[", "]")))
+ );
+ }
}
try {
diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java
index 88f1c458e..bdc1fb77b 100644
--- a/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java
+++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java
@@ -3,6 +3,7 @@
import io.vertx.core.buffer.Buffer;
import io.vertx.ext.auth.JWTOptions;
import io.vertx.ext.auth.PubSecKeyOptions;
+import io.vertx.ext.auth.oauth2.OAuth2FlowType;
import io.vertx.ext.auth.oauth2.OAuth2Options;
import io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl;
import io.vertx.ext.auth.oauth2.providers.*;
@@ -10,17 +11,53 @@
import io.vertx.ext.unit.TestContext;
import io.vertx.ext.unit.junit.RunTestOnContext;
import io.vertx.ext.unit.junit.VertxUnitRunner;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.Test;
+import org.junit.*;
import org.junit.runner.RunWith;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.matchers.Times;
+import org.mockserver.model.HttpTemplate;
+import org.mockserver.model.MediaType;
+import org.testcontainers.containers.MockServerContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
@RunWith(VertxUnitRunner.class)
public class OpenIDCDiscoveryTest {
+ private static final DockerImageName MOCKSERVER_IMAGE = DockerImageName
+ .parse("mockserver/mockserver")
+ .withTag("mockserver-" + MockServerClient.class.getPackage().getImplementationVersion());
+
+ @ClassRule
+ public static final MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE);
+ public static MockServerClient mockServerClient;
@Rule
public final RunTestOnContext rule = new RunTestOnContext();
+ @BeforeClass
+ public static void setup() {
+ mockServerClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort());
+ }
+
+ @AfterClass
+ public static void teardown() {
+ // This will also stop MockServer
+ mockServerClient.stop();
+ }
+
+ @Before
+ public void resetMockServer() {
+ mockServerClient.reset();
+ }
+
@Test
public void testGoogle(TestContext should) {
final Async test = should.async();
@@ -140,4 +177,432 @@ public void testApple(TestContext should) {
.onFailure(should::fail);
}
+ @Test
+ public void testConfiguredFlowTypes(TestContext should) {
+ final Async test = should.async(3);
+
+ // Setup expectations for mockserver
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(fakeAuthServerConfigurationTemplate());
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/jwks")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(
+ response()
+ .withContentType(MediaType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8))
+ .withBody("{\"keys\": []}")
+ );
+
+ // Configured grant types should be retained, as the server doesn't send any
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.AUTH_CODE.getGrantType()))
+ .onSuccess(result -> {
+ var options = ((OAuth2AuthProviderImpl) result).getConfig();
+
+ should.assertEquals(
+ new HashSet<>(options.getSupportedGrantTypes()),
+ Set.of(OAuth2FlowType.AUTH_CODE.getGrantType())
+ );
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ // This one should work without a client ID, as it is not required when only the implicit flow is supported
+ //.setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.IMPLICIT.getGrantType()))
+ .onSuccess(result -> {
+ var options = ((OAuth2AuthProviderImpl) result).getConfig();
+
+ should.assertEquals(
+ new HashSet<>(options.getSupportedGrantTypes()),
+ Set.of(OAuth2FlowType.IMPLICIT.getGrantType())
+ );
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.AUTH_JWT.getGrantType())
+ .addSupportedGrantType(OAuth2FlowType.AUTH_CODE.getGrantType())
+ .addSupportedGrantType(OAuth2FlowType.IMPLICIT.getGrantType())
+ )
+ .onSuccess(result -> {
+ var options = ((OAuth2AuthProviderImpl) result).getConfig();
+
+ should.assertEquals(
+ new HashSet<>(options.getSupportedGrantTypes()),
+ Set.of(
+ OAuth2FlowType.IMPLICIT.getGrantType(),
+ OAuth2FlowType.AUTH_JWT.getGrantType(),
+ OAuth2FlowType.AUTH_CODE.getGrantType()
+ )
+ );
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+ }
+
+ @Test
+ public void testIntersectFlowTypes(TestContext should) {
+ final Async test = should.async(2);
+
+ // Setup expectations for mockserver
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET"),
+ Times.exactly(1)
+ )
+ .respond(fakeAuthServerConfigurationTemplate(OAuth2FlowType.AUTH_CODE, OAuth2FlowType.IMPLICIT));
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET"),
+ Times.exactly(1)
+ )
+ .respond(fakeAuthServerConfigurationTemplate(OAuth2FlowType.IMPLICIT, OAuth2FlowType.PASSWORD, OAuth2FlowType.AUTH_CODE));
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/jwks")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(
+ response()
+ .withContentType(MediaType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8))
+ .withBody("{\"keys\": []}")
+ );
+
+ // Configured grant types should be overridden, as the server sends some
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ // This one should work without a client ID, as it is not required when only the implicit flow is supported
+ //.setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.IMPLICIT.getGrantType()))
+ .onSuccess(result -> {
+ var options = ((OAuth2AuthProviderImpl) result).getConfig();
+
+ // Server sends authorization_code, implicit, so the intersection is only implicit
+ should.assertEquals(
+ new HashSet<>(options.getSupportedGrantTypes()),
+ Set.of(OAuth2FlowType.IMPLICIT.getGrantType())
+ );
+
+ test.countDown();
+
+ // Need to serialize requests this time to make the assertions reproducible
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.AUTH_CODE.getGrantType())
+ .addSupportedGrantType(OAuth2FlowType.IMPLICIT.getGrantType()))
+ .onSuccess(result2 -> {
+ var options2 = ((OAuth2AuthProviderImpl) result2).getConfig();
+
+ // Server sends authorization_code, implicit, password, so the intersection is implicit, authorization_code
+ should.assertEquals(
+ new HashSet<>(options2.getSupportedGrantTypes()),
+ Set.of(
+ OAuth2FlowType.IMPLICIT.getGrantType(),
+ OAuth2FlowType.AUTH_CODE.getGrantType()
+ )
+ );
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+ })
+ .onFailure(should::fail);
+ }
+
+ @Test
+ public void testServerSupportedFlowTypes(TestContext should) {
+ final Async test = should.async(2);
+
+ // Setup expectations for mockserver
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET"),
+ Times.exactly(1)
+ )
+ .respond(fakeAuthServerConfigurationTemplate(OAuth2FlowType.AUTH_CODE, OAuth2FlowType.IMPLICIT));
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET"),
+ Times.exactly(1)
+ )
+ .respond(fakeAuthServerConfigurationTemplate(OAuth2FlowType.IMPLICIT, OAuth2FlowType.PASSWORD, OAuth2FlowType.AUTH_CODE));
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/jwks")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(
+ response()
+ .withContentType(MediaType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8))
+ .withBody("{\"keys\": []}")
+ );
+
+ // Configured grant types should be overridden, as the server sends some
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ )
+ .onSuccess(result -> {
+ var options = ((OAuth2AuthProviderImpl) result).getConfig();
+
+ // Server sends authorization_code, implicit
+ should.assertEquals(
+ new HashSet<>(options.getSupportedGrantTypes()),
+ Set.of(OAuth2FlowType.AUTH_CODE.getGrantType(), OAuth2FlowType.IMPLICIT.getGrantType())
+ );
+
+ test.countDown();
+
+ // Need to serialize requests this time to make the assertions reproducible
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ )
+ .onSuccess(result2 -> {
+ var options2 = ((OAuth2AuthProviderImpl) result2).getConfig();
+
+ // Server sends authorization_code, implicit, password
+ should.assertEquals(
+ new HashSet<>(options2.getSupportedGrantTypes()),
+ Set.of(
+ OAuth2FlowType.IMPLICIT.getGrantType(),
+ OAuth2FlowType.PASSWORD.getGrantType(),
+ OAuth2FlowType.AUTH_CODE.getGrantType()
+ )
+ );
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+ })
+ .onFailure(should::fail);
+ }
+
+ @Test
+ public void testDefaultFlowTypes(TestContext should) {
+ final Async test = should.async(2);
+
+ // Setup expectations for mockserver
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(fakeAuthServerConfigurationTemplate());
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/jwks")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(
+ response()
+ .withContentType(MediaType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8))
+ .withBody("{\"keys\": []}")
+ );
+
+ // Configured grant types should be overridden, as the server sends some
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ )
+ .onSuccess(result -> {
+ var options = ((OAuth2AuthProviderImpl) result).getConfig();
+
+ // Server sends nothing, nothing is configured -> fall back to default
+ should.assertNull(options.getSupportedGrantTypes());
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ )
+ .onSuccess(result2 -> {
+ var options2 = ((OAuth2AuthProviderImpl) result2).getConfig();
+
+ // Server sends nothing, nothing is configured -> fall back to default
+ should.assertNull(options2.getSupportedGrantTypes());
+
+ test.countDown();
+ })
+ .onFailure(should::fail);
+ }
+
+ @Test
+ public void testNoSupportedFlowTypes(TestContext should) {
+ final Async test = should.async(2);
+
+ // Setup expectations for mockserver
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/.well-known/openid-configuration")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(fakeAuthServerConfigurationTemplate(OAuth2FlowType.AUTH_CODE, OAuth2FlowType.IMPLICIT));
+
+ mockServerClient
+ .when(
+ request()
+ .withPath("/fake-auth-server/{tenant}/jwks")
+ .withPathParameter("tenant", "[a-z][a-zA-Z0-9]*")
+ .withMethod("GET")
+ )
+ .respond(
+ response()
+ .withContentType(MediaType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8))
+ .withBody("{\"keys\": []}")
+ );
+
+ // Configured grant types should be overridden, as the server sends some
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.PASSWORD.getGrantType())
+ )
+ .onSuccess(result -> should.fail("Discovery should fail"))
+ .onFailure(err -> {
+ should.assertEquals(
+ "No supported grant types with this authorization provider. Supported: [authorization_code, implicit]. Configured: [password]",
+ err.getMessage()
+ );
+
+ test.countDown();
+
+ // Need to serialize requests this time to make the assertions reproducible
+ OpenIDConnectAuth.discover(
+ rule.vertx(),
+ new OAuth2Options()
+ .setSite(mockServer.getEndpoint() + "/fake-auth-server/{tenant}")
+ .setTenant("test")
+ .setClientId("test-client")
+ .addSupportedGrantType(OAuth2FlowType.CLIENT.getGrantType())
+ .addSupportedGrantType(OAuth2FlowType.PASSWORD.getGrantType())
+ .addSupportedGrantType(OAuth2FlowType.AUTH_JWT.getGrantType())
+ )
+ .onSuccess(result -> should.fail("Discovery should fail"))
+ .onFailure(err2 -> {
+ should.assertEquals(
+ "No supported grant types with this authorization provider. Supported: [authorization_code, implicit]. Configured: [client_credentials, password, urn:ietf:params:oauth:grant-type:jwt-bearer]",
+ err2.getMessage()
+ );
+
+ test.countDown();
+ });
+ });
+ }
+
+ private static HttpTemplate fakeAuthServerConfigurationTemplate(OAuth2FlowType... supportedGrantTypes) {
+ var base = mockServer.getEndpoint() + "/fake-auth-server/{{request.pathParameters.tenant.0}}";
+ var body = "{" +
+ "\\\"issuer\\\": \\\"" + base + "\\\"," +
+ "\\\"authorization_endpoint\\\": \\\"" + base + "/auth\\\"," +
+ "\\\"token_endpoint\\\": \\\"" + base + "/token\\\"," +
+ "\\\"end_session_endpoint\\\": \\\"" + base + "/logout\\\"," +
+ "\\\"revocation_endpoint\\\": \\\"" + base + "/revoke\\\"," +
+ "\\\"userinfo_endpoint\\\": \\\"" + base + "/userinfo\\\"," +
+ "\\\"introspection_endpoint\\\": \\\"" + base + "/introspect\\\",";
+
+ if (supportedGrantTypes.length > 0) {
+ body += "\\\"grant_types_supported\\\": " +
+ Stream.of(supportedGrantTypes)
+ .map(OAuth2FlowType::getGrantType)
+ .map(grantType -> "\\\"" + grantType + "\\\"")
+ .collect(Collectors.joining(", ", "[", "]")) +
+ ",";
+ }
+
+ body += "\\\"jwks_uri\\\": \\\"" + base + "/jwks\\\"";
+ body += "}";
+
+ var template =
+ "{\n" +
+ " \"statusCode\": 200,\n" +
+ " \"headers\": {\"Content-Type\": \"application/json; charset=utf-8\"},\n" +
+ " \"body\": \"" + body + "\"\n" +
+ "}";
+
+ return HttpTemplate.template(HttpTemplate.TemplateType.MUSTACHE, template);
+ }
}