Skip to content

Commit

Permalink
add documentation endpoint
Browse files Browse the repository at this point in the history
and UI improvements for login page

Signed-off-by: Kai Helbig <[email protected]>
  • Loading branch information
ostrya committed Jan 18, 2025
1 parent 3c93e22 commit 2da7340
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 54 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -125,40 +133,68 @@ 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
.route()
.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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<RoutingContext> {
private static final Logger LOG = LoggerFactory.getLogger(DocumentationRoute.class);

@Nonnull private final Lazy<Router> lazyRouter;
@Nonnull private final TemplateEngine engine;

@Inject
public DocumentationRoute(@Nonnull Lazy<Router> lazyRouter, @Nonnull TemplateEngine engine) {
this.lazyRouter = lazyRouter;
this.engine = engine;
}

@Override
public void handle(RoutingContext routingContext) {
List<Route> 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);
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
27 changes: 27 additions & 0 deletions mock/src/main/resources/documentation.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Documentation</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<h1>Keycloak Mock API</h1>
<p>These are the endpoints that are currently supported by Keycloak Mock.</p>
<table>
<tr>
<th>Methods</th>
<th>Path</th>
<th>Description</th>
</tr>
<#list descriptions as description>
<tr>
<td>${description.methods()?join(", ")}</td>
<td>${description.getPath()}</td>
<td>${description.getName()}</td>
</tr>
</#list>
</table>
</body>
</html>
8 changes: 4 additions & 4 deletions mock/src/main/resources/loginPage.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login</title>
<link rel="stylesheet" href="/style.css">
<style>body { text-align: center; }</style>
</head>
<body>
<h1>Keycloak Mock</h1>
Expand All @@ -12,14 +14,12 @@
<form action="${authentication_uri}" id="authenticate" method="post">
<p>
<label for="username">User</label>
<br>
<input type="text" name="username" id="username">
<input type="text" name="username" id="username" placeholder="[email protected]" required>
</p>

<p>
<label for="password">Roles</label>
<br>
<input type="text" name="password" id="password">
<input type="text" name="password" id="password" placeholder="role1,role2,...">
</p>

<button type="submit">Login</button>
Expand Down
72 changes: 72 additions & 0 deletions mock/src/main/resources/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 2da7340

Please sign in to comment.