keycloak-memoizeit

Changes

Details

diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index 88d4f70..19f57c1 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -33,7 +33,13 @@ import java.util.Set;
 public class Profile {
 
     public enum Feature {
-        AUTHORIZATION, IMPERSONATION, SCRIPTS, DOCKER, ACCOUNT2, TOKEN_EXCHANGE
+        ACCOUNT2,
+        AUTHORIZATION,
+        DOCKER,
+        IMPERSONATION,
+        OPENSHIFT_INTEGRATION,
+        SCRIPTS,
+        TOKEN_EXCHANGE
     }
 
     private enum ProductValue {
diff --git a/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java b/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java
index 72a0d4c..1b90176 100755
--- a/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java
+++ b/server-spi/src/main/java/org/keycloak/models/KeycloakSession.java
@@ -76,6 +76,8 @@ public interface KeycloakSession {
     Class<? extends Provider> getProviderClass(String providerClassName);
 
     Object getAttribute(String attribute);
+    <T> T getAttribute(String attribute, Class<T> clazz);
+
     Object removeAttribute(String attribute);
     void setAttribute(String name, Object value);
 
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java
index 78f58da..6e051df 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java
@@ -89,6 +89,10 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator 
         }
 
         if (client_id == null) {
+            client_id = context.getSession().getAttribute("client_id", String.class);
+        }
+
+        if (client_id == null) {
             Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter");
             context.challenge(challengeResponse);
             return;
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
index f6b7b91..324f806 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
@@ -67,6 +67,10 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
             }
 
             if (client_id == null) {
+                client_id = context.getSession().getAttribute("client_id", String.class);
+            }
+
+            if (client_id == null) {
                 Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter");
                 context.challenge(challengeResponse);
                 return;
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java
new file mode 100644
index 0000000..6ce631f
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProvider.java
@@ -0,0 +1,14 @@
+package org.keycloak.protocol.oidc.ext;
+
+import org.keycloak.events.EventBuilder;
+import org.keycloak.provider.Provider;
+
+public interface OIDCExtProvider extends Provider {
+
+    void setEvent(EventBuilder event);
+
+    @Override
+    default void close() {
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java
new file mode 100644
index 0000000..951a335
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtProviderFactory.java
@@ -0,0 +1,29 @@
+package org.keycloak.protocol.oidc.ext;
+
+import org.keycloak.Config;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderFactory;
+
+public interface OIDCExtProviderFactory extends ProviderFactory<OIDCExtProvider> {
+
+    @Override
+    default void init(Config.Scope config) {
+
+    }
+
+    @Override
+    default void postInit(KeycloakSessionFactory factory) {
+
+    }
+
+    @Override
+    default void close() {
+
+    }
+
+    @Override
+    default int order() {
+        return 0;
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java
new file mode 100644
index 0000000..29821e5
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/ext/OIDCExtSPI.java
@@ -0,0 +1,29 @@
+package org.keycloak.protocol.oidc.ext;
+
+import org.keycloak.provider.Provider;
+import org.keycloak.provider.ProviderFactory;
+import org.keycloak.provider.Spi;
+
+public class OIDCExtSPI implements Spi {
+
+    @Override
+    public boolean isInternal() {
+        return true;
+    }
+
+    @Override
+    public String getName() {
+        return "openid-connect-ext";
+    }
+
+    @Override
+    public Class<? extends Provider> getProviderClass() {
+        return OIDCExtProvider.class;
+    }
+
+    @Override
+    public Class<? extends ProviderFactory> getProviderFactoryClass() {
+        return OIDCExtProviderFactory.class;
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java
index 8e2784f..3fe7268 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java
@@ -35,6 +35,7 @@ import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
 import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
 import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
 import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
+import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
 import org.keycloak.services.managers.AuthenticationManager;
 import org.keycloak.services.messages.Messages;
 import org.keycloak.services.resources.Cors;
@@ -42,8 +43,10 @@ import org.keycloak.services.resources.RealmsResource;
 import org.keycloak.services.util.CacheControlUtil;
 
 import javax.ws.rs.GET;
+import javax.ws.rs.NotFoundException;
 import javax.ws.rs.OPTIONS;
 import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
@@ -258,4 +261,14 @@ public class OIDCLoginProtocolService {
         }
     }
 
+    @Path("ext/{extension}")
+    public Object resolveExtension(@PathParam("extension") String extension) {
+        OIDCExtProvider provider = session.getProvider(OIDCExtProvider.class, extension);
+        if (provider != null) {
+            provider.setEvent(event);
+            return provider;
+        }
+        throw new NotFoundException();
+    }
+
 }
diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java
new file mode 100644
index 0000000..100207b
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.protocol.openshift;
+
+import org.keycloak.RSATokenVerifier;
+import org.keycloak.common.VerificationException;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.protocol.oidc.TokenManager;
+import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
+import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.services.ErrorResponseException;
+import org.keycloak.services.Urls;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.security.PublicKey;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class OpenShiftTokenReviewEndpoint implements OIDCExtProvider {
+
+    private KeycloakSession session;
+    private TokenManager tokenManager;
+    private EventBuilder event;
+
+    public OpenShiftTokenReviewEndpoint(KeycloakSession session) {
+        this.session = session;
+        this.tokenManager = new TokenManager();
+    }
+
+    @Override
+    public void setEvent(EventBuilder event) {
+        this.event = event;
+    }
+
+    @Path("/")
+    @POST
+    @Consumes(MediaType.APPLICATION_JSON)
+    @Produces(MediaType.APPLICATION_JSON)
+    public Response tokenReview(OpenShiftTokenReviewRequestRepresentation reviewRequest) throws Exception {
+        return tokenReview(null, reviewRequest);
+    }
+
+    @Path("/{client_id}")
+    @POST
+    @Consumes(MediaType.APPLICATION_JSON)
+    @Produces(MediaType.APPLICATION_JSON)
+    public Response tokenReview(@PathParam("client_id") String clientId, OpenShiftTokenReviewRequestRepresentation reviewRequest) throws Exception {
+        event.event(EventType.INTROSPECT_TOKEN);
+
+        if (clientId != null) {
+            session.setAttribute("client_id", clientId);
+        }
+
+        checkSsl();
+        checkRealm();
+        authorizeClient();
+
+        RealmModel realm = session.getContext().getRealm();
+
+        AccessToken token = null;
+        try {
+            RSATokenVerifier verifier = RSATokenVerifier.create(reviewRequest.getSpec().getToken())
+                    .realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
+
+            PublicKey publicKey = session.keys().getRsaPublicKey(realm, verifier.getHeader().getKeyId());
+            if (publicKey == null) {
+                error(401, Errors.INVALID_TOKEN, "Invalid public key");
+            } else {
+                verifier.publicKey(publicKey);
+                verifier.verify();
+                token = verifier.getToken();
+            }
+        } catch (VerificationException e) {
+            error(401, Errors.INVALID_TOKEN, "Token verification failure");
+        }
+
+        if (!tokenManager.isTokenValid(session, realm, token)) {
+            error(401, Errors.INVALID_TOKEN, "Token verification failure");
+        }
+
+        OpenShiftTokenReviewResponseRepresentation response = new OpenShiftTokenReviewResponseRepresentation();
+        response.getStatus().setAuthenticated(true);
+        response.getStatus().setUser(new OpenShiftTokenReviewResponseRepresentation.User());
+
+        OpenShiftTokenReviewResponseRepresentation.User userRep = response.getStatus().getUser();
+        userRep.setUid(token.getSubject());
+        userRep.setUsername(token.getPreferredUsername());
+
+        if (token.getScope() != null && !token.getScope().isEmpty()) {
+            OpenShiftTokenReviewResponseRepresentation.Extra extra = new OpenShiftTokenReviewResponseRepresentation.Extra();
+            extra.setScopes(token.getScope().split(" "));
+            userRep.setExtra(extra);
+        }
+
+        if (token.getOtherClaims() != null && token.getOtherClaims().get("groups") != null) {
+            List<String> groups = (List<String>) token.getOtherClaims().get("groups");
+            userRep.setGroups(groups);
+        }
+
+        event.success();
+        return Response.ok(response, MediaType.APPLICATION_JSON).build();
+    }
+
+    private void checkSsl() {
+        if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && session.getContext().getRealm().getSslRequired().isRequired(session.getContext().getConnection())) {
+            error(401, Errors.SSL_REQUIRED, null);
+        }
+    }
+
+    private void checkRealm() {
+        if (!session.getContext().getRealm().isEnabled()) {
+            error(401, Errors.REALM_DISABLED,null);
+        }
+    }
+
+    private void authorizeClient() {
+        try {
+            ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient();
+            event.client(client);
+
+            if (client == null || client.isPublicClient()) {
+                error(401, Errors.INVALID_CLIENT, "Public client is not permitted to invoke token review endpoint");
+            }
+
+        } catch (ErrorResponseException ere) {
+            error(401, Errors.INVALID_CLIENT_CREDENTIALS, ere.getErrorDescription());
+        } catch (Exception e) {
+            error(401, Errors.INVALID_CLIENT_CREDENTIALS, null);
+        }
+    }
+
+    private void error(int statusCode, String error, String description) {
+        OpenShiftTokenReviewResponseRepresentation rep = new OpenShiftTokenReviewResponseRepresentation();
+        rep.getStatus().setAuthenticated(false);
+
+        Response response = Response.status(statusCode).entity(rep).type(MediaType.APPLICATION_JSON_TYPE).build();
+
+        event.error(error);
+        event.detail(Details.REASON, description);
+
+        throw new ErrorResponseException(response);
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java
new file mode 100644
index 0000000..a58b8ce
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpointFactory.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.protocol.openshift;
+
+import org.keycloak.common.Profile;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
+import org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory;
+import org.keycloak.provider.EnvironmentDependentProviderFactory;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class OpenShiftTokenReviewEndpointFactory implements OIDCExtProviderFactory, EnvironmentDependentProviderFactory {
+
+    @Override
+    public OIDCExtProvider create(KeycloakSession session) {
+        return new OpenShiftTokenReviewEndpoint(session);
+    }
+
+    @Override
+    public String getId() {
+        return "openshift-token-review";
+    }
+
+    @Override
+    public boolean isSupported() {
+        return Profile.isFeatureEnabled(Profile.Feature.OPENSHIFT_INTEGRATION);
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java
new file mode 100755
index 0000000..cb62e07
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewRequestRepresentation.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.protocol.openshift;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.io.Serializable;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OpenShiftTokenReviewRequestRepresentation implements Serializable {
+
+    @JsonProperty("apiVersion")
+    private String apiVersion = "authentication.k8s.io/v1beta1";
+
+    @JsonProperty("kind")
+    private String kind = "TokenReview";
+
+    @JsonProperty("spec")
+    private Spec spec;
+
+    public String getApiVersion() {
+        return apiVersion;
+    }
+
+    public void setApiVersion(String apiVersion) {
+        this.apiVersion = apiVersion;
+    }
+
+    public String getKind() {
+        return kind;
+    }
+
+    public void setKind(String kind) {
+        this.kind = kind;
+    }
+
+    public Spec getSpec() {
+        return spec;
+    }
+
+    public void setSpec(Spec spec) {
+        this.spec = spec;
+    }
+
+    public static class Spec implements Serializable {
+
+        @JsonProperty("token")
+        private String token;
+
+        public String getToken() {
+            return token;
+        }
+
+        public void setToken(String token) {
+            this.token = token;
+        }
+
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java
new file mode 100755
index 0000000..47918d2
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewResponseRepresentation.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.protocol.openshift;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.io.Serializable;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class OpenShiftTokenReviewResponseRepresentation implements Serializable {
+
+    @JsonProperty("apiVersion")
+    private String apiVersion = "authentication.k8s.io/v1beta1";
+
+    @JsonProperty("kind")
+    private String kind = "TokenReview";
+
+    @JsonProperty("status")
+    private Status status = new Status();
+
+    public Status getStatus() {
+        return status;
+    }
+
+    public void setStatus(Status status) {
+        this.status = status;
+    }
+
+    public String getApiVersion() {
+        return apiVersion;
+    }
+
+    public void setApiVersion(String apiVersion) {
+        this.apiVersion = apiVersion;
+    }
+
+    public String getKind() {
+        return kind;
+    }
+
+    public void setKind(String kind) {
+        this.kind = kind;
+    }
+
+    public static class Status implements Serializable {
+
+        @JsonProperty("authenticated")
+        private boolean authenticated;
+
+        @JsonProperty("user")
+        protected User user;
+
+        public boolean isAuthenticated() {
+            return authenticated;
+        }
+
+        public void setAuthenticated(boolean authenticated) {
+            this.authenticated = authenticated;
+        }
+
+        public User getUser() {
+            return user;
+        }
+
+        public void setUser(User user) {
+            this.user = user;
+        }
+
+    }
+
+    public static class User implements Serializable {
+
+        @JsonProperty("username")
+        protected String username;
+
+        @JsonProperty("uid")
+        protected String uid;
+
+        @JsonProperty("groups")
+        protected List<String> groups = new LinkedList<>();
+
+        @JsonProperty("extra")
+        protected Extra extra;
+
+        public String getUsername() {
+            return username;
+        }
+
+        public void setUsername(String username) {
+            this.username = username;
+        }
+
+        public String getUid() {
+            return uid;
+        }
+
+        public void setUid(String uid) {
+            this.uid = uid;
+        }
+
+        public List<String> getGroups() {
+            return groups;
+        }
+
+        public void setGroups(List<String> groups) {
+            this.groups = groups;
+        }
+
+        public Extra getExtra() {
+            return extra;
+        }
+
+        public void setExtra(Extra extra) {
+            this.extra = extra;
+        }
+
+    }
+
+    public static class Extra implements Serializable {
+
+        @JsonProperty("scopes.authorization.openshift.io")
+        private String[] scopes;
+
+        public String[] getScopes() {
+            return scopes;
+        }
+
+        public void setScopes(String[] scopes) {
+            this.scopes = scopes;
+        }
+    }
+
+}
diff --git a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java
index 6cabf76..09c2378 100644
--- a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java
+++ b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java
@@ -106,6 +106,12 @@ public class DefaultKeycloakSession implements KeycloakSession {
     }
 
     @Override
+    public <T> T getAttribute(String attribute, Class<T> clazz) {
+        Object value = getAttribute(attribute);
+        return value != null && clazz.isInstance(value) ? (T) value : null;
+    }
+
+    @Override
     public Object removeAttribute(String attribute) {
         return attributes.remove(attribute);
     }
diff --git a/services/src/main/java/org/keycloak/services/ErrorResponseException.java b/services/src/main/java/org/keycloak/services/ErrorResponseException.java
index 07c3bf5..e94bbaf 100644
--- a/services/src/main/java/org/keycloak/services/ErrorResponseException.java
+++ b/services/src/main/java/org/keycloak/services/ErrorResponseException.java
@@ -28,20 +28,37 @@ import javax.ws.rs.core.Response;
  */
 public class ErrorResponseException extends WebApplicationException {
 
+    private final Response response;
     private final String error;
     private final String errorDescription;
     private final Response.Status status;
 
     public ErrorResponseException(String error, String errorDescription, Response.Status status) {
+        this.response = null;
         this.error = error;
         this.errorDescription = errorDescription;
         this.status = status;
     }
 
+    public ErrorResponseException(Response response) {
+        this.response = response;
+        this.error = null;
+        this.errorDescription = null;
+        this.status = null;
+    }
+
+    public String getErrorDescription() {
+        return errorDescription;
+    }
+
     @Override
     public Response getResponse() {
-        OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription);
-        return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build();
+        if (response != null) {
+            return response;
+        } else {
+            OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription);
+            return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build();
+        }
     }
 
 }
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory
new file mode 100644
index 0000000..3c884ef
--- /dev/null
+++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory
@@ -0,0 +1 @@
+org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory
\ No newline at end of file
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi
index 7027379..20e9747 100755
--- a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi
+++ b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi
@@ -21,3 +21,4 @@ org.keycloak.services.clientregistration.ClientRegistrationSpi
 org.keycloak.services.clientregistration.policy.ClientRegistrationPolicySpi
 org.keycloak.authentication.actiontoken.ActionTokenHandlerSpi
 org.keycloak.services.x509.X509ClientCertificateLookupSpi
+org.keycloak.protocol.oidc.ext.OIDCExtSPI
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java
new file mode 100755
index 0000000..d4a6c6b
--- /dev/null
+++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/DummyClientAuthenticator.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.testsuite.forms;
+
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.ClientAuthenticationFlowContext;
+import org.keycloak.authentication.FlowStatus;
+import org.keycloak.authentication.authenticators.client.AbstractClientAuthenticator;
+import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.ClientModel;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class DummyClientAuthenticator extends AbstractClientAuthenticator {
+
+    public static final String PROVIDER_ID = "testsuite-client-dummy";
+
+    public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+            AuthenticationExecutionModel.Requirement.ALTERNATIVE
+    };
+
+    @Override
+    public void authenticateClient(ClientAuthenticationFlowContext context) {
+        ClientIdAndSecretAuthenticator authenticator = new ClientIdAndSecretAuthenticator();
+        authenticator.authenticateClient(context);
+        if (context.getStatus().equals(FlowStatus.SUCCESS)) {
+            return;
+        }
+
+        String clientId = context.getUriInfo().getQueryParameters().getFirst("client_id");
+
+        if (clientId == null) {
+            clientId = context.getSession().getAttribute("client_id", String.class);
+        }
+
+        ClientModel client = context.getRealm().getClientByClientId(clientId);
+        if (client == null) {
+            context.failure(AuthenticationFlowError.CLIENT_NOT_FOUND, null);
+            return;
+        }
+
+        context.getEvent().client(client);
+        context.setClient(client);
+        context.success();
+    }
+
+    @Override
+    public String getDisplayType() {
+        return "Testsuite ClientId Dummy";
+    }
+
+    @Override
+    public boolean isConfigurable() {
+        return false;
+    }
+
+    @Override
+    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+        return REQUIREMENT_CHOICES;
+    }
+
+    @Override
+    public String getHelpText() {
+        return "Dummy client authenticator, which authenticates the client with clientId only";
+    }
+
+    @Override
+    public List<ProviderConfigProperty> getConfigProperties() {
+        return new LinkedList<>();
+    }
+
+    @Override
+    public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
+        return Collections.emptyList();
+    }
+
+    @Override
+    public Map<String, Object> getAdapterConfiguration(ClientModel client) {
+        return Collections.emptyMap();
+    }
+
+    @Override
+    public String getId() {
+        return PROVIDER_ID;
+    }
+
+    @Override
+    public Set<String> getProtocolAuthenticatorMethods(String loginProtocol) {
+        return Collections.emptySet();
+    }
+}
diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java
index b5efe23..5088e7a 100755
--- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java
+++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/forms/PassThroughClientAuthenticator.java
@@ -41,7 +41,9 @@ public class PassThroughClientAuthenticator extends AbstractClientAuthenticator 
     public static String clientId = "test-app";
 
     public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
-            AuthenticationExecutionModel.Requirement.REQUIRED
+            AuthenticationExecutionModel.Requirement.REQUIRED,
+            AuthenticationExecutionModel.Requirement.ALTERNATIVE,
+            AuthenticationExecutionModel.Requirement.DISABLED
     };
 
     private static final List<ProviderConfigProperty> clientConfigProperties = new ArrayList<ProviderConfigProperty>();
diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
index 05ea07c..67616c4 100644
--- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
+++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
@@ -15,4 +15,5 @@
 # limitations under the License.
 #
 
-org.keycloak.testsuite.forms.PassThroughClientAuthenticator
\ No newline at end of file
+org.keycloak.testsuite.forms.PassThroughClientAuthenticator
+org.keycloak.testsuite.forms.DummyClientAuthenticator
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java
index a257096..9b75790 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java
@@ -148,6 +148,8 @@ public class OAuthClient {
     private String codeChallengeMethod;
     private String origin;
 
+    private boolean openid = true;
+
     private Supplier<CloseableHttpClient> httpClient = OAuthClient::newCloseableHttpClient;
 
     public class LogoutUrlBuilder {
@@ -212,6 +214,7 @@ public class OAuthClient {
         codeChallenge = null;
         codeChallengeMethod = null;
         origin = null;
+        openid = true;
     }
 
     public void setDriver(WebDriver driver) {
@@ -773,8 +776,10 @@ public class OAuthClient {
             b.queryParam(OIDCLoginProtocol.NONCE_PARAM, nonce);
         }
 
-        String scopeParam = TokenUtil.attachOIDCScope(scope);
-        b.queryParam(OAuth2Constants.SCOPE, scopeParam);
+        String scopeParam = openid ? TokenUtil.attachOIDCScope(scope) : scope;
+        if (scopeParam != null && !scopeParam.isEmpty()) {
+            b.queryParam(OAuth2Constants.SCOPE, scopeParam);
+        }
 
         if (maxAge != null) {
             b.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
@@ -883,6 +888,11 @@ public class OAuthClient {
         return this;
     }
 
+    public OAuthClient openid(boolean openid) {
+        this.openid = openid;
+        return this;
+    }
+
     public OAuthClient uiLocales(String uiLocales){
         this.uiLocales = uiLocales;
         return this;
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java
index 0aa53e0..794199b 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java
@@ -81,12 +81,13 @@ public class ProvidersTest extends AbstractAuthenticationTest {
                 "'client_secret' sent either in request parameters or in 'Authorization: Basic' header");
         addProviderInfo(expected, "testsuite-client-passthrough", "Testsuite Dummy Client Validation", "Testsuite dummy authenticator, " +
                 "which automatically authenticates hardcoded client (like 'test-app' )");
+        addProviderInfo(expected, "testsuite-client-dummy", "Testsuite ClientId Dummy",
+                "Dummy client authenticator, which authenticates the client with clientId only");
         addProviderInfo(expected, "client-x509", "X509 Certificate",
                 "Validates client based on a X509 Certificate");
         addProviderInfo(expected, "client-secret-jwt", "Signed Jwt with Client Secret",
                 "Validates client based on signed JWT issued by client and signed with the Client Secret");
 
-
         compareProviders(expected, result);
     }
 
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java
new file mode 100644
index 0000000..48006ca
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/openshift/OpenShiftTokenReviewEndpointTest.java
@@ -0,0 +1,354 @@
+package org.keycloak.testsuite.openshift;
+
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.broker.provider.util.SimpleHttp;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.events.Details;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper;
+import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
+import org.keycloak.protocol.openshift.OpenShiftTokenReviewRequestRepresentation;
+import org.keycloak.protocol.openshift.OpenShiftTokenReviewResponseRepresentation;
+import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.ClientScopeRepresentation;
+import org.keycloak.representations.idm.EventRepresentation;
+import org.keycloak.representations.idm.ProtocolMapperRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
+import org.keycloak.testsuite.util.OAuthClient;
+import org.keycloak.testsuite.util.UserBuilder;
+
+import javax.ws.rs.core.Response;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder;
+import static org.junit.Assert.*;
+
+public class OpenShiftTokenReviewEndpointTest extends AbstractTestRealmKeycloakTest {
+
+    private static boolean flowConfigured;
+
+    @Rule
+    public AssertEvents events = new AssertEvents(this);
+
+    @Override
+    public void configureTestRealm(RealmRepresentation testRealm) {
+        ClientRepresentation client = testRealm.getClients().stream().filter(r -> r.getClientId().equals("test-app")).findFirst().get();
+
+        List<ProtocolMapperRepresentation> mappers = new LinkedList<>();
+        ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation();
+        mapper.setName("groups");
+        mapper.setProtocolMapper(GroupMembershipMapper.PROVIDER_ID);
+        mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
+        Map<String, String> config = new HashMap<>();
+        config.put("full.path", "false");
+        config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, "groups");
+        config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
+        config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
+        mapper.setConfig(config);
+        mappers.add(mapper);
+
+        client.setProtocolMappers(mappers);
+        client.setPublicClient(false);
+        client.setClientAuthenticatorType("testsuite-client-dummy");
+
+        testRealm.getUsers().add(UserBuilder.create().username("groups-user").password("password").addGroups("/topGroup", "/topGroup/level2group").build());
+    }
+
+    @Before
+    public void enablePassthroughAuthenticator() {
+        if (!flowConfigured) {
+            HashMap<String, String> data = new HashMap<>();
+            data.put("newName", "testsuite-client-dummy");
+            Response response = testRealm().flows().copy("clients", data);
+            assertEquals(201, response.getStatus());
+            response.close();
+
+            data = new HashMap<>();
+            data.put("provider", "testsuite-client-dummy");
+            data.put("requirement", "ALTERNATIVE");
+
+            testRealm().flows().addExecution("testsuite-client-dummy", data);
+
+            RealmRepresentation realmRep = testRealm().toRepresentation();
+            realmRep.setClientAuthenticationFlow("testsuite-client-dummy");
+            testRealm().update(realmRep);
+
+            List<AuthenticationExecutionInfoRepresentation> executions = testRealm().flows().getExecutions("testsuite-client-dummy");
+            for (AuthenticationExecutionInfoRepresentation e : executions) {
+                if (e.getProviderId().equals("testsuite-client-dummy")) {
+                    e.setRequirement("ALTERNATIVE");
+                    testRealm().flows().updateExecutions("testsuite-client-dummy", e);
+                }
+            }
+            flowConfigured = true;
+        }
+    }
+
+    @Test
+    public void basicTest() {
+        Review r = new Review().invoke()
+                .assertSuccess();
+
+        String userId = testRealm().users().search(r.username).get(0).getId();
+
+        OpenShiftTokenReviewResponseRepresentation.User user = r.response.getStatus().getUser();
+
+        assertEquals(userId, user.getUid());
+        assertEquals("test-user@localhost", user.getUsername());
+        assertNotNull(user.getExtra());
+
+        r.assertScope("openid", "email", "profile");
+    }
+
+    @Test
+    public void groups() {
+        new Review().username("groups-user")
+                .invoke()
+                .assertSuccess().assertGroups("topGroup", "level2group");
+    }
+
+    @Test
+    public void customScopes() {
+        ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
+        clientScope.setProtocol("openid-connect");
+        clientScope.setId("user:info");
+        clientScope.setName("user:info");
+
+        testRealm().clientScopes().create(clientScope);
+
+        ClientRepresentation clientRep = testRealm().clients().findByClientId("test-app").get(0);
+
+        testRealm().clients().get(clientRep.getId()).addOptionalClientScope("user:info");
+
+        try {
+            oauth.scope("user:info");
+            new Review()
+                    .invoke()
+                    .assertSuccess().assertScope("openid", "user:info", "profile", "email");
+        } finally {
+            testRealm().clients().get(clientRep.getId()).removeOptionalClientScope("user:info");
+        }
+    }
+
+    @Test
+    public void emptyScope() {
+        ClientRepresentation clientRep = testRealm().clients().findByClientId("test-app").get(0);
+
+        List<String> scopes = new LinkedList<>();
+        for (ClientScopeRepresentation s : testRealm().clients().get(clientRep.getId()).getDefaultClientScopes()) {
+            scopes.add(s.getId());
+        }
+
+        for (String s : scopes) {
+            testRealm().clients().get(clientRep.getId()).removeDefaultClientScope(s);
+        }
+
+        oauth.openid(false);
+        try {
+            new Review()
+                    .invoke()
+                    .assertSuccess().assertEmptyScope();
+        } finally {
+            oauth.openid(true);
+
+            for (String s : scopes) {
+                testRealm().clients().get(clientRep.getId()).addDefaultClientScope(s);
+            }
+        }
+    }
+
+    @Test
+    public void expiredToken() {
+        try {
+            new Review()
+                    .runAfterTokenRequest(i -> setTimeOffset(testRealm().toRepresentation().getAccessTokenLifespan() + 10))
+                    .invoke()
+                    .assertError(401, "Token verification failure");
+        } finally {
+            resetTimeOffset();
+        }
+    }
+
+    @Test
+    public void invalidPublicKey() {
+        new Review()
+                .runAfterTokenRequest(i -> {
+                    String header = i.token.split("\\.")[0];
+                    String s = new String(Base64Url.decode(header));
+                    s = s.replace(",\"kid\" : \"", ",\"kid\" : \"x");
+                    String newHeader = Base64Url.encode(s.getBytes());
+                    i.token = i.token.replaceFirst(header, newHeader);
+                })
+                .invoke()
+                .assertError(401, "Invalid public key");
+    }
+
+    @Test
+    public void noUserSession() {
+        new Review()
+                .runAfterTokenRequest(i -> {
+                    String userId = testRealm().users().search(i.username).get(0).getId();
+                    testRealm().users().get(userId).logout();
+                })
+                .invoke()
+                .assertError(401, "Token verification failure");
+    }
+
+    @Test
+    public void invalidTokenSignature() {
+        new Review()
+                .runAfterTokenRequest(i -> i.token += "x")
+                .invoke()
+                .assertError(401, "Token verification failure");
+    }
+
+    @Test
+    public void realmDisabled() {
+        RealmRepresentation r = testRealm().toRepresentation();
+        try {
+            new Review().runAfterTokenRequest(i -> {
+                r.setEnabled(false);
+                testRealm().update(r);
+            }).invoke().assertError(401, null);
+
+
+        } finally {
+            r.setEnabled(true);
+            testRealm().update(r);
+        }
+    }
+
+    @Test
+    public void publicClientNotPermitted() {
+        ClientRepresentation clientRep = testRealm().clients().findByClientId("test-app").get(0);
+        clientRep.setPublicClient(true);
+        testRealm().clients().get(clientRep.getId()).update(clientRep);
+        try {
+            new Review().invoke().assertError(401, "Public client is not permitted to invoke token review endpoint");
+        } finally {
+            clientRep.setPublicClient(false);
+            testRealm().clients().get(clientRep.getId()).update(clientRep);
+        }
+    }
+
+    private class Review {
+
+        private String realm = "test";
+        private String clientId = "test-app";
+        private String username = "test-user@localhost";
+        private String password = "password";
+        private InvokeRunnable runAfterTokenRequest;
+
+        private String token;
+        private int responseStatus;
+        private OpenShiftTokenReviewResponseRepresentation response;
+
+        public Review username(String username) {
+            this.username = username;
+            return this;
+        }
+
+        public Review runAfterTokenRequest(InvokeRunnable runnable) {
+            this.runAfterTokenRequest = runnable;
+            return this;
+        }
+
+        public Review invoke() {
+            try {
+                String userId = testRealm().users().search(username).get(0).getId();
+                oauth.doLogin(username, password);
+                EventRepresentation loginEvent = events.expectLogin().user(userId).assertEvent();
+
+                String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+                OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password");
+
+                events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), loginEvent.getSessionId()).detail("client_auth_method", "testsuite-client-dummy").user(userId).assertEvent();
+
+                token = accessTokenResponse.getAccessToken();
+
+                if (runAfterTokenRequest != null) {
+                    runAfterTokenRequest.run(this);
+                }
+
+                CloseableHttpClient client = HttpClientBuilder.create().build();
+
+                String url = AuthServerTestEnricher.getAuthServerContextRoot() + "/auth/realms/" + realm +"/protocol/openid-connect/ext/openshift-token-review/" + clientId;
+
+                OpenShiftTokenReviewRequestRepresentation request = new OpenShiftTokenReviewRequestRepresentation();
+                OpenShiftTokenReviewRequestRepresentation.Spec spec = new OpenShiftTokenReviewRequestRepresentation.Spec();
+                spec.setToken(token);
+                request.setSpec(spec);
+
+                SimpleHttp.Response r = SimpleHttp.doPost(url, client).json(request).asResponse();
+
+                responseStatus = r.getStatus();
+                response = r.asJson(OpenShiftTokenReviewResponseRepresentation.class);
+
+                assertEquals("authentication.k8s.io/v1beta1", response.getApiVersion());
+                assertEquals("TokenReview", response.getKind());
+
+                client.close();
+
+                return this;
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        public Review assertSuccess() {
+            assertEquals(200, responseStatus);
+            assertTrue(response.getStatus().isAuthenticated());
+            assertNotNull(response.getStatus().getUser());
+            return this;
+        }
+
+        private Review assertError(int expectedStatus, String expectedReason) {
+            assertEquals(expectedStatus, responseStatus);
+            assertFalse(response.getStatus().isAuthenticated());
+            assertNull(response.getStatus().getUser());
+
+            if (expectedReason != null) {
+                EventRepresentation poll = events.poll();
+                assertEquals(expectedReason, poll.getDetails().get(Details.REASON));
+            }
+
+            return this;
+        }
+
+        private void assertScope(String... expectedScope) {
+            List<String> actualScopes = Arrays.asList(response.getStatus().getUser().getExtra().getScopes());
+            assertEquals(expectedScope.length, actualScopes.size());
+            assertThat(actualScopes, containsInAnyOrder(expectedScope));
+        }
+
+        private void assertEmptyScope() {
+            assertNull(response.getStatus().getUser().getExtra());
+        }
+
+        private void assertGroups(String... expectedGroups) {
+            List<String> actualGroups = new LinkedList<>(response.getStatus().getUser().getGroups());
+            assertEquals(expectedGroups.length, actualGroups.size());
+            assertThat(actualGroups, containsInAnyOrder(expectedGroups));
+        }
+
+    }
+
+    private interface InvokeRunnable {
+        void run(Review i);
+    }
+
+}