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); + } }