diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java index aecc9fc..e21d9a0 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java @@ -16,6 +16,9 @@ public class UrlConfiguration { private static final String OPEN_ID_JWKS_PATH = "certs"; private static final String OPEN_ID_AUTHORIZATION_PATH = "auth"; private static final String OPEN_ID_END_SESSION_PATH = "logout"; + + private static final String TOKEN_INTROSPECTION_PATH = "/introspect"; + @Nonnull private final Protocol protocol; private final int port; @Nonnull private final String hostname; @@ -132,4 +135,9 @@ public String getHostname() { public String getRealm() { return realm; } + + @Nonnull + public URI getTokenIntrospectionEndPoint() { + return getOpenIdPath(OPEN_ID_TOKEN_PATH + TOKEN_INTROSPECTION_PATH); + } } 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 79729ac..6807009 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 @@ -14,6 +14,7 @@ import com.tngtech.keycloakmock.impl.handler.OutOfBandLoginRoute; import com.tngtech.keycloakmock.impl.handler.RequestUrlConfigurationHandler; import com.tngtech.keycloakmock.impl.handler.ResourceFileHandler; +import com.tngtech.keycloakmock.impl.handler.TokenIntrospectionRoute; import com.tngtech.keycloakmock.impl.handler.TokenRoute; import com.tngtech.keycloakmock.impl.handler.WellKnownRoute; import dagger.Lazy; @@ -148,7 +149,8 @@ Router provideRouter( @Nonnull LogoutRoute logoutRoute, @Nonnull DelegationRoute delegationRoute, @Nonnull OutOfBandLoginRoute outOfBandLoginRoute, - @Nonnull @Named("keycloakJs") ResourceFileHandler keycloakJsRoute) { + @Nonnull @Named("keycloakJs") ResourceFileHandler keycloakJsRoute, + @Nonnull TokenIntrospectionRoute tokenIntrospectionRoute) { UrlConfiguration routing = defaultConfiguration.forRequestContext(null, ":realm"); Router router = Router.router(vertx); router @@ -180,6 +182,10 @@ Router provideRouter( router.get(routing.getOpenIdPath("delegated").getPath()).handler(delegationRoute); router.get(routing.getOutOfBandLoginLoginEndpoint().getPath()).handler(outOfBandLoginRoute); router.route("/auth/js/keycloak.js").handler(keycloakJsRoute); + router + .post(routing.getTokenIntrospectionEndPoint().getPath()) + .handler(BodyHandler.create()) + .handler(tokenIntrospectionRoute); return router; } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/TokenIntrospectionRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/TokenIntrospectionRoute.java new file mode 100644 index 0000000..faf343e --- /dev/null +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/TokenIntrospectionRoute.java @@ -0,0 +1,83 @@ +package com.tngtech.keycloakmock.impl.handler; + +import com.tngtech.keycloakmock.impl.helper.TokenHelper; +import io.jsonwebtoken.Claims; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.Handler; +import io.vertx.core.json.Json; +import io.vertx.ext.web.RoutingContext; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nonnull; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class TokenIntrospectionRoute implements Handler { + + private static final Logger LOG = LoggerFactory.getLogger(TokenIntrospectionRoute.class); + + private static final String TOKEN_PREFIX = "token="; + private static final String ACTIVE_CLAIM = "active"; + + @Nonnull private final TokenHelper tokenHelper; + + @Inject + public TokenIntrospectionRoute(@Nonnull TokenHelper tokenHelper) { + this.tokenHelper = tokenHelper; + } + + @Override + public void handle(RoutingContext routingContext) { + LOG.info( + "Inside TokenIntrospectionRoute. Request body is : {}", routingContext.body().asString()); + + String body = routingContext.body().asString(); + + if (!body.startsWith(TOKEN_PREFIX)) { + routingContext + .response() + .putHeader("content-type", "application/json") + .setStatusCode(HttpResponseStatus.BAD_REQUEST.code()) + .end("Invalid request body"); + } + + String token = body.replaceFirst("^" + TOKEN_PREFIX, ""); + + LOG.debug("Received a request to introspect token : {}", token); + + Map claims; + try { + claims = tokenHelper.parseToken(token); + } catch (Exception e) { + // If the token is invalid, initialize an empty claims map + claims = new HashMap<>(); + } + + // To support various use cases, we are returning the same claims as the input token + Map responseClaims = new HashMap<>(claims); + + if (responseClaims.get(Claims.EXPIRATION) != null + && isExpiryTimeInFuture(responseClaims.get(Claims.EXPIRATION).toString())) { + LOG.debug("Introspected token is valid"); + responseClaims.put(ACTIVE_CLAIM, true); + } else { + LOG.debug("Introspected token is invalid"); + responseClaims.put(ACTIVE_CLAIM, false); + } + + routingContext + .response() + .putHeader("content-type", "application/json") + .end(Json.encode(responseClaims)); + } + + private boolean isExpiryTimeInFuture(String expiryTime) { + long currentTimeInSec = Instant.now().getEpochSecond(); + long expiryTimeInSec = Long.parseLong(expiryTime); + return currentTimeInSec < expiryTimeInSec; + } +} diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/WellKnownRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/WellKnownRoute.java index b349a67..f8f763a 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/WellKnownRoute.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/WellKnownRoute.java @@ -44,7 +44,8 @@ private JsonObject getConfiguration(@Nonnull UrlConfiguration requestConfigurati .put("subject_types_supported", new JsonArray(Collections.singletonList("public"))) .put( "id_token_signing_alg_values_supported", - new JsonArray(Collections.singletonList("RS256"))); + new JsonArray(Collections.singletonList("RS256"))) + .put("introspection_endpoint", requestConfiguration.getTokenIntrospectionEndPoint()); return result; } } diff --git a/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java b/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java index cc78641..e1ef148 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java @@ -156,6 +156,9 @@ void urls_are_correct() { .hasToString("http://localhost:8000/auth/realms/master/protocol/openid-connect/certs"); assertThat(urlConfiguration.getTokenEndpoint()) .hasToString("http://localhost:8000/auth/realms/master/protocol/openid-connect/token"); + assertThat(urlConfiguration.getTokenIntrospectionEndPoint()) + .hasToString( + "http://localhost:8000/auth/realms/master/protocol/openid-connect/token/introspect"); } @Test @@ -174,6 +177,9 @@ void urls_are_correct_with_no_context_path() { .hasToString("http://localhost:8000/realms/master/protocol/openid-connect/certs"); assertThat(urlConfiguration.getTokenEndpoint()) .hasToString("http://localhost:8000/realms/master/protocol/openid-connect/token"); + assertThat(urlConfiguration.getTokenIntrospectionEndPoint()) + .hasToString( + "http://localhost:8000/realms/master/protocol/openid-connect/token/introspect"); } @Test @@ -198,6 +204,10 @@ void urls_are_correct_with_custom_context_path() { assertThat(urlConfiguration.getTokenEndpoint()) .hasToString( "http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/token"); + + assertThat(urlConfiguration.getTokenIntrospectionEndPoint()) + .hasToString( + "http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/token/introspect"); } @Test diff --git a/mock/src/test/java/com/tngtech/keycloakmock/impl/handler/WellKnownRouteTest.java b/mock/src/test/java/com/tngtech/keycloakmock/impl/handler/WellKnownRouteTest.java index ce887df..29a9fce 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/impl/handler/WellKnownRouteTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/impl/handler/WellKnownRouteTest.java @@ -24,6 +24,7 @@ class WellKnownRouteTest extends HandlerTestBase { private static final String END_SESSION_ENDPOINT = "endSessionEndpoint"; private static final String JWKS_URI = "jwksUri"; private static final String TOKEN_ENDPOINT = "tokenEndpoint"; + private static final String TOKEN_INTROSPECTION_ENDPOINT = "tokenIntrospectionEndpoint"; @Mock private UrlConfiguration urlConfiguration; @@ -37,6 +38,9 @@ void well_known_configuration_is_complete() throws URISyntaxException { doReturn(new URI(END_SESSION_ENDPOINT)).when(urlConfiguration).getEndSessionEndpoint(); doReturn(new URI(JWKS_URI)).when(urlConfiguration).getJwksUri(); doReturn(new URI(TOKEN_ENDPOINT)).when(urlConfiguration).getTokenEndpoint(); + doReturn(new URI(TOKEN_INTROSPECTION_ENDPOINT)) + .when(urlConfiguration) + .getTokenIntrospectionEndPoint(); wellKnownRoute.handle(routingContext); @@ -57,6 +61,7 @@ private ConfigurationResponse getExpectedResponse() { Arrays.asList("code", "code id_token", "id_token", "token id_token"); response.subject_types_supported = Collections.singletonList("public"); response.id_token_signing_alg_values_supported = Collections.singletonList("RS256"); + response.introspection_endpoint = TOKEN_INTROSPECTION_ENDPOINT; return response; } } diff --git a/mock/src/test/java/com/tngtech/keycloakmock/test/ConfigurationResponse.java b/mock/src/test/java/com/tngtech/keycloakmock/test/ConfigurationResponse.java index daed2b6..a9a12d9 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/test/ConfigurationResponse.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/test/ConfigurationResponse.java @@ -11,4 +11,6 @@ public class ConfigurationResponse { public List subject_types_supported; public List id_token_signing_alg_values_supported; public String end_session_endpoint; + + public String introspection_endpoint; }