diff --git a/README.md b/README.md index dc799d0..0f61a34 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,11 @@ You can even use it as a replacement in end-to-end tests, as the server is e.g. `cypress-keycloak`. Have a look at the [example-frontend-react](example-frontend-react) project on this can be set up. +## Server Method documentation + +You can get a list of all implemented endpoints of the mock at `http://localhost:8000/docs`. This is mainly meant for +checking if a specific endpoint you want to use is supported by the mock (yet). + ## License This project is licensed under the Apache 2.0 license (see [LICENSE](LICENSE)). diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java index 8007381..b85ba11 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java @@ -5,6 +5,7 @@ import com.tngtech.keycloakmock.impl.UrlConfiguration; import com.tngtech.keycloakmock.impl.handler.AuthenticationRoute; import com.tngtech.keycloakmock.impl.handler.CommonHandler; +import com.tngtech.keycloakmock.impl.handler.DocumentationRoute; import com.tngtech.keycloakmock.impl.handler.FailureHandler; import com.tngtech.keycloakmock.impl.handler.IFrameRoute; import com.tngtech.keycloakmock.impl.handler.JwksRoute; @@ -81,6 +82,13 @@ ResourceFileHandler provideKeycloakJsHandler() { return new ResourceFileHandler("/package/lib/keycloak.js"); } + @Provides + @Singleton + @Named("stylesheet") + ResourceFileHandler provideStylesheetHandler() { + return new ResourceFileHandler("/style.css"); + } + @Provides @Singleton Buffer keystoreBuffer(@Nonnull KeyStore keyStore) { @@ -125,7 +133,9 @@ Router provideRouter( @Nonnull @Named("cookie2") ResourceFileHandler thirdPartyCookies2Route, @Nonnull LogoutRoute logoutRoute, @Nonnull OutOfBandLoginRoute outOfBandLoginRoute, - @Nonnull @Named("keycloakJs") ResourceFileHandler keycloakJsRoute) { + @Nonnull @Named("keycloakJs") ResourceFileHandler keycloakJsRoute, + @Nonnull @Named("stylesheet") ResourceFileHandler stylesheetRoute, + @Nonnull DocumentationRoute documentationRoute) { UrlConfiguration routing = defaultConfiguration.forRequestContext(null, ":realm"); Router router = Router.router(vertx); router @@ -133,32 +143,58 @@ Router provideRouter( .handler(commonHandler) .failureHandler(failureHandler) .failureHandler(ErrorHandler.create(vertx)); - router.get(routing.getJwksUri().getPath()).handler(jwksRoute); - router.get(routing.getIssuerPath().resolve(".well-known/*").getPath()).handler(wellKnownRoute); - router.get(routing.getAuthorizationEndpoint().getPath()).handler(loginRoute); + router.get(routing.getJwksUri().getPath()).setName("key signing data").handler(jwksRoute); + router + .get(routing.getIssuerPath().resolve(".well-known/*").getPath()) + .setName("configuration discovery data") + .handler(wellKnownRoute); + router + .get(routing.getAuthorizationEndpoint().getPath()) + .setName("login page") + .handler(loginRoute); router .post(routing.getAuthenticationCallbackEndpoint(":sessionId").getPath()) + .setName("custom authentication endpoint used by login page") .handler(BodyHandler.create()) .handler(authenticationRoute); router .post(routing.getTokenEndpoint().getPath()) + .setName("token endpoint") .handler(BodyHandler.create()) .handler(basicAuthHandler) .handler(tokenRoute); - router.get(routing.getOpenIdPath("login-status-iframe.html*").getPath()).handler(iframeRoute); + router + .get(routing.getOpenIdPath("login-status-iframe.html*").getPath()) + .setName("Keycloak login iframe") + .handler(iframeRoute); router .get(routing.getOpenIdPath("3p-cookies/step1.html").getPath()) + .setName("keycloak third party cookies - step 1") .handler(thirdPartyCookies1Route); router .get(routing.getOpenIdPath("3p-cookies/step2.html").getPath()) + .setName("Keycloak third party cookies - step 2") .handler(thirdPartyCookies2Route); router .route(routing.getEndSessionEndpoint().getPath()) + .setName("logout endpoint") .method(HttpMethod.GET) .method(HttpMethod.POST) .handler(logoutRoute); - router.get(routing.getOutOfBandLoginLoginEndpoint().getPath()).handler(outOfBandLoginRoute); - router.route(routing.getContextPath("/js/keycloak.js").getPath()).handler(keycloakJsRoute); + router + .get(routing.getOutOfBandLoginLoginEndpoint().getPath()) + .setName("out-of-band login endpoint") + .handler(outOfBandLoginRoute); + router + .get(routing.getContextPath("/js/keycloak.js").getPath()) + .setName("provided keycloak.js") + .handler(keycloakJsRoute); + router.get("/style.css").handler(stylesheetRoute); + router + .get("/docs") + .setName("documentation endpoint") + .produces("text/html") + .handler(documentationRoute); return router; } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java new file mode 100644 index 0000000..3b8e54f --- /dev/null +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java @@ -0,0 +1,69 @@ +package com.tngtech.keycloakmock.impl.handler; + +import dagger.Lazy; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.common.template.TemplateEngine; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class DocumentationRoute implements Handler { + private static final Logger LOG = LoggerFactory.getLogger(DocumentationRoute.class); + + @Nonnull private final Lazy lazyRouter; + @Nonnull private final TemplateEngine engine; + + @Inject + public DocumentationRoute(@Nonnull Lazy lazyRouter, @Nonnull TemplateEngine engine) { + this.lazyRouter = lazyRouter; + this.engine = engine; + } + + @Override + public void handle(RoutingContext routingContext) { + List descriptions = + lazyRouter.get().getRoutes().stream() + // annoyingly, if a path is set but the name is null, the path is returned instead + .filter(r -> r.getName() != null && !Objects.equals(r.getName(), r.getPath())) + .sorted(Comparator.comparing(Route::getPath)) + .collect(Collectors.toList()); + if ("application/json".equals(routingContext.getAcceptableContentType())) { + JsonObject result = new JsonObject(); + descriptions.forEach( + r -> { + JsonObject routeDescription = new JsonObject(); + routeDescription.put( + "methods", + r.methods().stream().map(HttpMethod::name).sorted().collect(Collectors.toList())); + routeDescription.put("description", r.getName()); + result.put(r.getPath(), routeDescription); + }); + routingContext.response().putHeader("content-type", "application/json").end(result.encode()); + } else { + routingContext.put("descriptions", descriptions); + engine + .render(routingContext.data(), "documentation.ftl") + .onSuccess( + b -> + routingContext.response().putHeader(HttpHeaders.CONTENT_TYPE, "text/html").end(b)) + .onFailure( + t -> { + LOG.error("Unable to render documentation page", t); + routingContext.fail(t); + }); + } + } +} diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java index a81dafa..3dc88b9 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java @@ -60,20 +60,27 @@ public void handle(@Nonnull RoutingContext routingContext) { .map(sessionRepository::getSession); // for now, we just override the settings of the session with values of the new client - SessionRequest request = - new SessionRequest.Builder() - .setClientId(routingContext.queryParams().get(CLIENT_ID)) - .setState(routingContext.queryParams().get(STATE)) - .setRedirectUri(routingContext.queryParams().get(REDIRECT_URI)) - .setSessionId( - existingSession - .map(PersistentSession::getSessionId) - .orElseGet(() -> UUID.randomUUID().toString())) - .setResponseType(routingContext.queryParams().get(RESPONSE_TYPE)) - // optional parameter - .setNonce(routingContext.queryParams().get(NONCE)) - .setResponseMode(routingContext.queryParams().get(RESPONSE_MODE)) - .build(); + SessionRequest request; + try { + request = + new SessionRequest.Builder() + .setClientId(routingContext.queryParams().get(CLIENT_ID)) + .setRedirectUri(routingContext.queryParams().get(REDIRECT_URI)) + .setSessionId( + existingSession + .map(PersistentSession::getSessionId) + .orElseGet(() -> UUID.randomUUID().toString())) + .setResponseType(routingContext.queryParams().get(RESPONSE_TYPE)) + // optional parameter + .setState(routingContext.queryParams().get(STATE)) + .setNonce(routingContext.queryParams().get(NONCE)) + .setResponseMode(routingContext.queryParams().get(RESPONSE_MODE)) + .build(); + } catch (NullPointerException e) { + LOG.warn("Mandatory parameter missing", e); + routingContext.fail(400); + return; + } UrlConfiguration requestConfiguration = baseConfiguration.forRequestContext(routingContext); if (existingSession.isPresent()) { diff --git a/mock/src/main/resources/documentation.ftl b/mock/src/main/resources/documentation.ftl new file mode 100644 index 0000000..449b472 --- /dev/null +++ b/mock/src/main/resources/documentation.ftl @@ -0,0 +1,27 @@ + + + + + + Documentation + + + +

Keycloak Mock API

+

These are the endpoints that are currently supported by Keycloak Mock.

+ + + + + + + <#list descriptions as description> + + + + + + +
MethodsPathDescription
${description.methods()?join(", ")}${description.getPath()}${description.getName()}
+ + diff --git a/mock/src/main/resources/loginPage.ftl b/mock/src/main/resources/loginPage.ftl index b1cdbda..9635f51 100644 --- a/mock/src/main/resources/loginPage.ftl +++ b/mock/src/main/resources/loginPage.ftl @@ -4,6 +4,8 @@ Login + +

Keycloak Mock

@@ -12,14 +14,12 @@

-
- +

-
- +

diff --git a/mock/src/main/resources/style.css b/mock/src/main/resources/style.css new file mode 100644 index 0000000..ee0a905 --- /dev/null +++ b/mock/src/main/resources/style.css @@ -0,0 +1,72 @@ +body { + font-family: Arial, sans-serif; + margin: 1em; + background-color: #f0f0f0; +} + +h1 { + color: #00698f; +} + +form { + width: 20em; + margin: 1em auto; + padding: 1em; + background-color: #fff; + border: 0.1em solid #ddd; + border-radius: 1em; + box-shadow: 0 0 1em rgba(0, 0, 0, 0.1); +} + +label { + display: block; + margin-bottom: 0.5em; +} + +input[type="text"] { + width: calc(100% - 2em); + height: 2.5em; + margin-bottom: 1em; + padding: 0 0.5em; + border: 0.1em solid #ccc; + border-radius: 0.25em; +} + +button[type="submit"] { + width: 100%; + height: 2.5em; + background-color: #00698f; + color: #fff; + padding: 0.5em; + border: none; + border-radius: 0.25em; + cursor: pointer; +} + +button[type="submit"]:hover { + background-color: #004d6f; +} + +table { + border-collapse: collapse; + width: 100%; + margin-top: 1em; +} + +th, td { + border: 0.1em solid #ddd; + padding: 0.5em; + text-align: left; +} + +th { + background-color: #f2f2f2; +} + +tr:nth-child(even) { + background-color: #fff; +} + +tr:hover { + background-color: #ddd; +} diff --git a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java index 7164680..d4bcc67 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java @@ -2,7 +2,9 @@ import static com.tngtech.keycloakmock.api.ServerConfig.aServerConfig; import static com.tngtech.keycloakmock.test.KeyHelper.loadValidKey; +import static io.restassured.RestAssured.enableLoggingOfRequestAndResponseIfValidationFails; import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; import static io.restassured.config.RedirectConfig.redirectConfig; import static io.restassured.config.RestAssuredConfig.config; import static io.restassured.http.ContentType.HTML; @@ -24,7 +26,6 @@ import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; -import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.http.Cookie; import io.restassured.http.Method; @@ -63,7 +64,7 @@ static void setupJwtsParser() { @BeforeAll static void setupRestAssured() { - RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + enableLoggingOfRequestAndResponseIfValidationFails(); } @AfterEach @@ -99,7 +100,7 @@ void mock_server_can_be_started_and_stopped_twice() { private void assertServerMockRunnning(boolean running) { try { - RestAssured.when() + when() .get("http://localhost:8000/auth/realms/master/protocol/openid-connect/certs") .then() .statusCode(200) @@ -124,7 +125,7 @@ void mock_server_fails_when_port_is_claimed() { void mock_server_endpoint_is_correctly_configured(int port, boolean tls) { keycloakMock = new KeycloakMock(aServerConfig().withPort(port).withTls(tls).build()); keycloakMock.start(); - RestAssured.given() + given() .relaxedHTTPSValidation() .when() .get( @@ -213,7 +214,7 @@ void mock_server_uses_host_header_as_server_host() { keycloakMock = new KeycloakMock(); keycloakMock.start(); String issuer = - RestAssured.given() + given() .when() .header("Host", hostname) .get("http://localhost:8000/auth/realms/test/.well-known/openid-configuration") @@ -243,7 +244,7 @@ private static Stream resourcesWithContent() { void mock_server_answers_204_on_iframe_init() { keycloakMock = new KeycloakMock(); keycloakMock.start(); - RestAssured.given() + given() .when() .get( "http://localhost:8000/auth/realms/test/protocol/openid-connect/login-status-iframe.html/init") @@ -261,7 +262,7 @@ void mock_server_properly_returns_resources( keycloakMock = new KeycloakMock(); keycloakMock.start(); String body = - RestAssured.given() + given() .when() .get("http://localhost:8000/auth" + resource) .then() @@ -280,12 +281,7 @@ void mock_server_properly_returns_resources( void mock_server_returns_404_on_nonexistent_resource() { keycloakMock = new KeycloakMock(); keycloakMock.start(); - RestAssured.given() - .when() - .get("http://localhost:8000/i-do-not-exist") - .then() - .assertThat() - .statusCode(404); + given().when().get("http://localhost:8000/i-do-not-exist").then().assertThat().statusCode(404); } @Test @@ -347,7 +343,7 @@ void mock_server_login_with_authorization_code_flow_works() throws Exception { } private String openLoginPageAndGetCallbackUrl(ClientRequest request) { - return RestAssured.given() + return given() .when() .get(request.getLoginPageUrl()) .then() @@ -364,7 +360,7 @@ private String openLoginPageAndGetCallbackUrl(ClientRequest request) { private Cookie loginAndValidateAndReturnSessionCookie(ClientRequest request, String callbackUrl) throws URISyntaxException { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("username", "username") @@ -384,7 +380,7 @@ private Cookie loginAndValidateAndReturnSessionCookie(ClientRequest request, Str private String loginAndValidateAndReturnAuthCode(ClientRequest request, String callbackUrl) throws URISyntaxException { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("username", "username") @@ -402,7 +398,7 @@ private String loginAndValidateAndReturnAuthCode(ClientRequest request, String c private String validateAuthorizationAndRetrieveToken(String authorizationCode, String nonce) { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("grant_type", "authorization_code") @@ -422,7 +418,7 @@ private String validateAuthorizationAndRetrieveToken(String authorizationCode, S private void validateRefreshTokenFlow(String refreshToken, String nonce) { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("grant_type", "refresh_token") @@ -450,7 +446,7 @@ private void validateToken(String accessToken, String nonce) { private void openLoginPageAgainAndExpectToBeLoggedInAlready( ClientRequest request, Cookie keycloakSession) throws URISyntaxException { String location = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .cookie(keycloakSession) @@ -512,7 +508,7 @@ private String validateCookieAndReturnSessionId(Cookie keycloakSession) { } private void logoutAndExpectSessionCookieReset(Method method) { - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .request( @@ -532,7 +528,7 @@ private void logoutAndExpectSessionCookieReset(Method method) { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .when() .formParam("client_id", "client") .formParam("username", "username") @@ -561,7 +557,7 @@ void mock_server_login_with_resource_owner_password_credentials_flow_works() { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .auth() .preemptive() .basic("client", "does not matter") @@ -592,7 +588,7 @@ void mock_server_login_with_client_credentials_flow_works() { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .auth() .preemptive() .basic("client", "role1,role2,role3") @@ -621,7 +617,7 @@ void mock_server_login_with_client_credentials_flow_using_form_works() { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .when() .formParam("grant_type", "client_credentials") .formParam("client_id", "client") @@ -643,6 +639,29 @@ void mock_server_login_with_client_credentials_flow_using_form_works() { assertThat(tokenConfig.getAudience()).containsExactlyInAnyOrder("client", "server"); } + @Test + void documentation_works() { + keycloakMock = new KeycloakMock(); + keycloakMock.start(); + + ExtractableResponse extractableResponse = + given() + .when() + .get("http://localhost:8000/docs") + .then() + .assertThat() + .statusCode(200) + .extract(); + + assertThat(extractableResponse.body().asPrettyString()) + .contains( + " \n" + + " GET\n" + + " /docs\n" + + " documentation endpoint\n" + + " "); + } + private static class ClientRequest { private final String redirectUri; diff --git a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java index 5fc418e..0b03c33 100644 --- a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java +++ b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java @@ -107,11 +107,9 @@ public Void call() { .build()) .start(); - LOG.info( - "Server is running on {}://localhost:{}{}", - (tls ? "https" : "http"), - port, - usedContextPath); + String url = (tls ? "https" : "http") + "://localhost:" + port; + LOG.info("Server is running on {}{}", url, usedContextPath); + LOG.info("A documentation of all endpoints is available at {}/docs", url); return null; }