Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: Support OAuth2 token introspection #157 #158

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this empty line.

private static final String TOKEN_INTROSPECTION_PATH = "/introspect";

@Nonnull private final Protocol protocol;
private final int port;
@Nonnull private final String hostname;
Expand Down Expand Up @@ -132,4 +135,9 @@ public String getHostname() {
public String getRealm() {
return realm;
}

@Nonnull
public URI getTokenIntrospectionEndPoint() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move it next to the token endpoint method? Also, please rename EndPoint to Endpoint.

return getOpenIdPath(OPEN_ID_TOKEN_PATH + TOKEN_INTROSPECTION_PATH);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<RoutingContext> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add at least one happy test case to verify that passing in a valid token will return its claims plus the "active" field set to true. Also, please add some tests for the error cases (expired token, missing token, premature token, malformed token).


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());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to keep the logging limited to the bare minimum. We already log every response in the CommonHandler, I see no need to log this here.


String body = routingContext.body().asString();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RFC states that token is a form parameter, so you should use routingContext.request().getFormAttribute("token") to get the value.


if (!body.startsWith(TOKEN_PREFIX)) {
routingContext
.response()
.putHeader("content-type", "application/json")
.setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While netty is a transient dependency of vert.x at the moment, I'd rather not rely on that. I also don't see the point of adding an explicit dependency to a 0.5 MB library just for some constants. So please just explicitly use the number 400 here.

.end("Invalid request body");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a) I don't see the need to have a body in the response, so you could just use routingContext.fail(400).

b) You should add a return statement, because if the body is malformed, running the code down below is useless, and will fail anyway because you will end up calling routingContext.end() twice.

}

String token = body.replaceFirst("^" + TOKEN_PREFIX, "");

LOG.debug("Received a request to introspect token : {}", token);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we don't need to log the token here. The tool runs locally in a testing scope, so the user should know which token was passed in.


Map<String, Object> claims;
try {
claims = tokenHelper.parseToken(token);
} catch (Exception e) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd actually differentiate here. If the exception is a ClaimJwtException, we still have access to the claims of the token, even if the token itself is expired or otherwise invalid. In this case, I'd still add all claims to the response, only with "active": "false".

// If the token is invalid, initialize an empty claims map
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a good point to actually add logging.

claims = new HashMap<>();
}

// To support various use cases, we are returning the same claims as the input token
Map<String, Object> responseClaims = new HashMap<>(claims);

if (responseClaims.get(Claims.EXPIRATION) != null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is unnecessary, as the JWT library will already throw an exception if the token is expired. Instead, you should add the active claim as true within the try block above, and as false in the catch block.

&& 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unnecessary (see comment above).

long currentTimeInSec = Instant.now().getEpochSecond();
long expiryTimeInSec = Long.parseLong(expiryTime);
return currentTimeInSec < expiryTimeInSec;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);

Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public class ConfigurationResponse {
public List<String> subject_types_supported;
public List<String> id_token_signing_alg_values_supported;
public String end_session_endpoint;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove empty line.

public String introspection_endpoint;
}