keycloak-uncached

Details

diff --git a/core/src/main/java/org/keycloak/representations/oidc/TokenMetadataRepresentation.java b/core/src/main/java/org/keycloak/representations/oidc/TokenMetadataRepresentation.java
new file mode 100644
index 0000000..a8f7f31
--- /dev/null
+++ b/core/src/main/java/org/keycloak/representations/oidc/TokenMetadataRepresentation.java
@@ -0,0 +1,60 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2016 Red Hat, Inc., and individual 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.representations.oidc;
+
+import org.codehaus.jackson.annotate.JsonProperty;
+import org.keycloak.representations.AccessToken;
+
+/**
+ * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
+ */
+public class TokenMetadataRepresentation extends AccessToken {
+
+    @JsonProperty("active")
+    private boolean active;
+
+    @JsonProperty("username")
+    private String userName;
+
+    @JsonProperty("client_id")
+    private String clientId;
+
+    public boolean isActive() {
+        return this.active;
+    }
+
+    public void setActive(boolean active) {
+        this.active = active;
+    }
+
+    public String getUserName() {
+        return this.userName;
+    }
+
+    public void setUserName(String userName) {
+        this.userName = userName;
+    }
+
+    public String getClientId() {
+        return this.clientId;
+    }
+
+    public void setClientId(String clientId) {
+        this.clientId = clientId;
+    }
+}
diff --git a/core/src/main/java/org/keycloak/RSATokenVerifier.java b/core/src/main/java/org/keycloak/RSATokenVerifier.java
index 19babef..ab1fa39 100755
--- a/core/src/main/java/org/keycloak/RSATokenVerifier.java
+++ b/core/src/main/java/org/keycloak/RSATokenVerifier.java
@@ -7,7 +7,6 @@ import org.keycloak.jose.jws.crypto.RSAProvider;
 import org.keycloak.representations.AccessToken;
 import org.keycloak.util.TokenUtil;
 
-import java.io.IOException;
 import java.security.PublicKey;
 
 /**
@@ -20,20 +19,8 @@ public class RSATokenVerifier {
     }
 
     public static AccessToken verifyToken(String tokenString, PublicKey realmKey, String realmUrl, boolean checkActive, boolean checkTokenType) throws VerificationException {
-        JWSInput input = null;
-        try {
-            input = new JWSInput(tokenString);
-        } catch (JWSInputException e) {
-            throw new VerificationException("Couldn't parse token", e);
-        }
-        if (!isPublicKeyValid(input, realmKey)) throw new VerificationException("Invalid token signature.");
+        AccessToken token = toAccessToken(tokenString, realmKey);
 
-        AccessToken token;
-        try {
-            token = input.readJsonContent(AccessToken.class);
-        } catch (JWSInputException e) {
-            throw new VerificationException("Couldn't parse token signature", e);
-        }
         String user = token.getSubject();
         if (user == null) {
             throw new VerificationException("Token user was null.");
@@ -59,6 +46,24 @@ public class RSATokenVerifier {
         return token;
     }
 
+    public static AccessToken toAccessToken(String tokenString, PublicKey realmKey) throws VerificationException {
+        JWSInput input;
+        try {
+            input = new JWSInput(tokenString);
+        } catch (JWSInputException e) {
+            throw new VerificationException("Couldn't parse token", e);
+        }
+        if (!isPublicKeyValid(input, realmKey)) throw new VerificationException("Invalid token signature.");
+
+        AccessToken token;
+        try {
+            token = input.readJsonContent(AccessToken.class);
+        } catch (JWSInputException e) {
+            throw new VerificationException("Couldn't parse token signature", e);
+        }
+        return token;
+    }
+
     private static boolean isPublicKeyValid(JWSInput input, PublicKey realmKey) throws VerificationException {
         try {
             return RSAProvider.verify(input, realmKey);
diff --git a/core/src/main/java/org/keycloak/util/JsonSerialization.java b/core/src/main/java/org/keycloak/util/JsonSerialization.java
index 19df33f..deb75f6 100755
--- a/core/src/main/java/org/keycloak/util/JsonSerialization.java
+++ b/core/src/main/java/org/keycloak/util/JsonSerialization.java
@@ -1,8 +1,11 @@
 package org.keycloak.util;
 
+import org.codehaus.jackson.JsonNode;
+import org.codehaus.jackson.JsonParser;
 import org.codehaus.jackson.map.ObjectMapper;
 import org.codehaus.jackson.map.SerializationConfig;
 import org.codehaus.jackson.map.annotate.JsonSerialize;
+import org.codehaus.jackson.node.ObjectNode;
 import org.codehaus.jackson.type.TypeReference;
 
 import java.io.IOException;
@@ -69,6 +72,33 @@ public class JsonSerialization {
         }
     }
 
+    /**
+     * Creates an {@link ObjectNode} based on the given {@code pojo}, copying all its properties to the resulting {@link ObjectNode}.
+     *
+     * @param pojo a pojo which properties will be populates into the resulting a {@link ObjectNode}
+     * @return a {@link ObjectNode} with all the properties from the given pojo
+     * @throws IOException if the resulting a {@link ObjectNode} can not be created
+     */
+    public static ObjectNode createObjectNode(Object pojo) throws IOException {
+        if (pojo == null) {
+            throw new IllegalArgumentException("Pojo can not be null.");
+        }
+
+        ObjectNode objectNode = createObjectNode();
+        JsonParser jsonParser = mapper.getJsonFactory().createJsonParser(writeValueAsBytes(pojo));
+        JsonNode jsonNode = jsonParser.readValueAsTree();
+
+        if (!jsonNode.isObject()) {
+            throw new RuntimeException("JsonNode [" + jsonNode + "] is not a object.");
+        }
 
+        objectNode.putAll((ObjectNode) jsonNode);
+
+        return objectNode;
+    }
+
+    public static ObjectNode createObjectNode() {
+        return mapper.createObjectNode();
+    }
 
 }
diff --git a/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml b/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml
index 5ab29ec..26dae42 100755
--- a/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml
+++ b/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml
@@ -80,6 +80,27 @@
     <section>
         <title>Version specific migration</title>
         <section>
+            <title>Migrating to 1.8.0</title>
+            <simplesect>
+                <title>OAuth2 Token Introspection</title>
+                <para>
+                    In order to add more compliance with OAuth2 specification, we added a new endpoint for token introspection.
+                    The new endpoint can reached at <literal>/realms/{realm}/protocols/openid-connect/token/introspect</literal> and it is solely
+                    based on <literal>RFC-7662.</literal>
+                </para>
+                <para>
+                    The <literal>/realms/{realm}/protocols/openid-connect/validate</literal> endpoint is now deprecated and we strongly recommend
+                    you to move to the new introspection endpoint as soon as possible. The reason for this change is that RFC-7662 provides a more
+                    standard and secure introspection endpoint.
+                </para>
+                <para>
+                    The new token introspection URL can now be obtained from OpenID Connect Provider's configuration at <literal>/realms/{realm}/.well-known/openid-configuration</literal>. There
+                    you will find a claim with name <literal>token_introspection_endpoint</literal> within the response. Only <literal>confidential clients</literal> are allowed to
+                    invoke the new endpoint, where these clients will be usually acting as a resource server and looking for token metadata in order to perform local authorization checks.
+                </para>
+            </simplesect>
+        </section>
+        <section>
             <title>Migrating to 1.7.0.CR1</title>
             <simplesect>
                 <title>Direct access grants disabled by default for clients</title>
diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java
index 599dec5..7464789 100755
--- a/events/api/src/main/java/org/keycloak/events/EventType.java
+++ b/events/api/src/main/java/org/keycloak/events/EventType.java
@@ -20,8 +20,16 @@ public enum EventType {
 
     REFRESH_TOKEN(false),
     REFRESH_TOKEN_ERROR(false),
+
+    /**
+     * @deprecated see KEYCLOAK-2266
+     */
+    @Deprecated
     VALIDATE_ACCESS_TOKEN(false),
+    @Deprecated
     VALIDATE_ACCESS_TOKEN_ERROR(false),
+    INTROSPECT_TOKEN(false),
+    INTROSPECT_TOKEN_ERROR(false),
 
     FEDERATED_IDENTITY_LINK(true),
     FEDERATED_IDENTITY_LINK_ERROR(true),
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 178624b..d937ff9 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -2,6 +2,7 @@ package org.keycloak.protocol.oidc.endpoints;
 
 import org.jboss.logging.Logger;
 import org.jboss.resteasy.spi.HttpRequest;
+import org.jboss.resteasy.spi.ResteasyProviderFactory;
 import org.keycloak.common.ClientConnection;
 import org.keycloak.OAuth2Constants;
 import org.keycloak.OAuthErrorException;
@@ -35,6 +36,7 @@ import org.keycloak.services.Urls;
 
 import javax.ws.rs.OPTIONS;
 import javax.ws.rs.POST;
+import javax.ws.rs.Path;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.HttpHeaders;
 import javax.ws.rs.core.MediaType;
@@ -115,6 +117,15 @@ public class TokenEndpoint {
         throw new RuntimeException("Unknown action " + action);
     }
 
+    @Path("introspect")
+    public Object introspect() {
+        TokenIntrospectionEndpoint tokenIntrospectionEndpoint = new TokenIntrospectionEndpoint(this.realm, this.tokenManager, this.event);
+
+        ResteasyProviderFactory.getInstance().injectProperties(tokenIntrospectionEndpoint);
+
+        return tokenIntrospectionEndpoint;
+    }
+
     @OPTIONS
     public Response preflight() {
         if (logger.isDebugEnabled()) {
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java
new file mode 100755
index 0000000..8af07ae
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java
@@ -0,0 +1,179 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2016 Red Hat, Inc., and individual 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.oidc.endpoints;
+
+import org.codehaus.jackson.node.ObjectNode;
+import org.jboss.resteasy.annotations.cache.NoCache;
+import org.jboss.resteasy.spi.HttpRequest;
+import org.keycloak.OAuthErrorException;
+import org.keycloak.RSATokenVerifier;
+import org.keycloak.common.ClientConnection;
+import org.keycloak.common.VerificationException;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.events.EventType;
+import org.keycloak.jose.jws.JWSInputException;
+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.utils.AuthorizeClientUtil;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.services.ErrorResponseException;
+import org.keycloak.util.JsonSerialization;
+
+import javax.ws.rs.POST;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.core.UriInfo;
+
+/**
+ * A token introspection endpoint based on RFC-7662.
+ *
+ * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
+ */
+public class TokenIntrospectionEndpoint {
+
+    private static final String TOKEN_TYPE_ACCESS_TOKEN = "access_token";
+    private static final String TOKEN_TYPE_REFRESH_TOKEN = "refresh_token";
+    private static final String PARAM_TOKEN_TYPE_HINT = "token_type_hint";
+    private static final String PARAM_TOKEN = "token";
+
+    @Context
+    private KeycloakSession session;
+    @Context
+    private HttpRequest request;
+
+    @Context
+    private HttpHeaders headers;
+
+    @Context
+    private UriInfo uriInfo;
+
+    @Context
+    private ClientConnection clientConnection;
+
+    private final RealmModel realm;
+    private final TokenManager tokenManager;
+    private final EventBuilder event;
+
+    public TokenIntrospectionEndpoint(RealmModel realm, TokenManager tokenManager, EventBuilder event) {
+        this.realm = realm;
+        this.tokenManager = tokenManager;
+        this.event = event;
+    }
+
+    @POST
+    @NoCache
+    public Response introspect() {
+        event.event(EventType.INTROSPECT_TOKEN);
+
+        checkSsl();
+        checkRealm();
+        authorizeClient();
+
+        MultivaluedMap<String, String> formParams = request.getDecodedFormParameters();
+        String tokenTypeHint = formParams.getFirst(PARAM_TOKEN_TYPE_HINT);
+
+        if (tokenTypeHint == null) {
+            tokenTypeHint = TOKEN_TYPE_ACCESS_TOKEN;
+        }
+
+        String token = formParams.getFirst(PARAM_TOKEN);
+
+        if (token == null) {
+            throw throwErrorResponseException(Errors.INVALID_REQUEST, "Token not provided.", Status.BAD_REQUEST);
+        }
+
+        try {
+            AccessToken toIntrospect = toAccessToken(tokenTypeHint, token);
+            ObjectNode tokenMetadata;
+
+            if (toIntrospect.isActive()) {
+                tokenMetadata = JsonSerialization.createObjectNode(toIntrospect);
+                tokenMetadata.put("client_id", toIntrospect.getIssuedFor());
+                tokenMetadata.put("username", toIntrospect.getPreferredUsername());
+            } else {
+                tokenMetadata = JsonSerialization.createObjectNode();
+            }
+
+            tokenMetadata.put("active", toIntrospect.isActive());
+
+            this.event.success();
+
+            return Response.ok(JsonSerialization.writeValueAsBytes(tokenMetadata)).build();
+        } catch (Exception e) {
+            throw throwErrorResponseException(Errors.INVALID_REQUEST, "Failed to introspect token.", Status.BAD_REQUEST);
+        }
+    }
+
+    private AccessToken toAccessToken(String tokenTypeHint, String token) throws JWSInputException, OAuthErrorException {
+        if (TOKEN_TYPE_ACCESS_TOKEN.equals(tokenTypeHint)) {
+            return toAccessToken(token);
+        } else if (TOKEN_TYPE_REFRESH_TOKEN.equals(tokenTypeHint)) {
+            return this.tokenManager.toRefreshToken(this.realm, token);
+        } else {
+            throw throwErrorResponseException(Errors.INVALID_REQUEST, "Unsupported token type [" + tokenTypeHint + "].", Status.BAD_REQUEST);
+        }
+    }
+
+    private void authorizeClient() {
+        try {
+            ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient();
+
+            this.event.client(client);
+
+            if (client == null || client.isPublicClient()) {
+                throw throwErrorResponseException(Errors.INVALID_REQUEST, "Client not allowed.", Status.FORBIDDEN);
+            }
+
+        } catch (ErrorResponseException ere) {
+            throw ere;
+        } catch (Exception e) {
+            throw throwErrorResponseException(Errors.INVALID_REQUEST, "Authentication failed.", Status.UNAUTHORIZED);
+        }
+    }
+
+    private AccessToken toAccessToken(String tokenString) {
+        try {
+            return RSATokenVerifier.toAccessToken(tokenString, realm.getPublicKey());
+        } catch (VerificationException e) {
+            throw new ErrorResponseException("invalid_request", "Invalid token.", Status.UNAUTHORIZED);
+        }
+    }
+
+    private void checkSsl() {
+        if (!uriInfo.getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) {
+            throw new ErrorResponseException("invalid_request", "HTTPS required", Status.FORBIDDEN);
+        }
+    }
+
+    private void checkRealm() {
+        if (!realm.isEnabled()) {
+            throw new ErrorResponseException("access_denied", "Realm not enabled", Status.FORBIDDEN);
+        }
+    }
+
+    private ErrorResponseException throwErrorResponseException(String error, String detail, Status status) {
+        this.event.detail("detail", detail).error(error);
+        return new ErrorResponseException(error, detail, status);
+    }
+}
\ No newline at end of file
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ValidateTokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ValidateTokenEndpoint.java
index 2a375e7..71e64f7 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ValidateTokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ValidateTokenEndpoint.java
@@ -25,8 +25,10 @@ import java.util.HashMap;
 import java.util.Map;
 
 /**
+ * @deprecated use {@link TokenIntrospectionEndpoint} instead
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
  */
+@Deprecated
 public class ValidateTokenEndpoint {
 
     private static final Logger logger = Logger.getLogger(ValidateTokenEndpoint.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 9ad9c95..456a128 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java
@@ -14,6 +14,7 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
 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.TokenIntrospectionEndpoint;
 import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
 import org.keycloak.protocol.oidc.endpoints.ValidateTokenEndpoint;
 import org.keycloak.protocol.oidc.representations.JSONWebKeySet;
@@ -86,6 +87,16 @@ public class OIDCLoginProtocolService {
         return uriBuilder.path(OIDCLoginProtocolService.class, "token");
     }
 
+    public static UriBuilder tokenIntrospectionUrl(UriBuilder baseUriBuilder) {
+        return tokenUrl(baseUriBuilder).path(TokenEndpoint.class, "introspect");
+    }
+
+    /**
+     * @deprecated use {@link OIDCLoginProtocolService#tokenIntrospectionUrl(UriBuilder)} instead
+     * @param baseUriBuilder
+     * @return
+     */
+    @Deprecated
     public static UriBuilder validateAccessTokenUrl(UriBuilder baseUriBuilder) {
         UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
         return uriBuilder.path(OIDCLoginProtocolService.class, "validateAccessToken");
@@ -180,8 +191,15 @@ public class OIDCLoginProtocolService {
         return endpoint.legacy(OAuth2Constants.AUTHORIZATION_CODE);
     }
 
+    /**
+     * @deprecated use {@link TokenIntrospectionEndpoint#introspect()} instead
+     * @param tokenString
+     * @return
+     */
     @Path("validate")
+    @Deprecated
     public Object validateAccessToken(@QueryParam("access_token") String tokenString) {
+        logger.warnv("Invoking deprecated endpoint {0}", uriInfo.getRequestUri());
         ValidateTokenEndpoint endpoint = new ValidateTokenEndpoint(tokenManager, realm, event);
         ResteasyProviderFactory.getInstance().injectProperties(endpoint);
         return endpoint;
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
index 98fb49e..8e0cdbf 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
@@ -3,6 +3,7 @@ package org.keycloak.protocol.oidc;
 import org.keycloak.OAuth2Constants;
 import org.keycloak.models.KeycloakSession;
 import org.keycloak.models.RealmModel;
+import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
 import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
 import org.keycloak.protocol.oidc.utils.OIDCResponseType;
 import org.keycloak.services.clientregistration.ClientRegistrationService;
@@ -48,6 +49,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
         config.setIssuer(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));
         config.setAuthorizationEndpoint(uriBuilder.clone().path(OIDCLoginProtocolService.class, "auth").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
         config.setTokenEndpoint(uriBuilder.clone().path(OIDCLoginProtocolService.class, "token").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
+        config.setTokenIntrospectionEndpoint(uriBuilder.clone().path(OIDCLoginProtocolService.class, "token").path(TokenEndpoint.class, "introspect").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
         config.setUserinfoEndpoint(uriBuilder.clone().path(OIDCLoginProtocolService.class, "issueUserInfo").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
         config.setLogoutEndpoint(uriBuilder.clone().path(OIDCLoginProtocolService.class, "logout").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
         config.setJwksUri(uriBuilder.clone().path(OIDCLoginProtocolService.class, "certs").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java b/services/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java
index 0226331..ff019f4 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java
@@ -23,6 +23,9 @@ public class OIDCConfigurationRepresentation {
     @JsonProperty("token_endpoint")
     private String tokenEndpoint;
 
+    @JsonProperty("token_introspection_endpoint")
+    private String tokenIntrospectionEndpoint;
+
     @JsonProperty("userinfo_endpoint")
     private String userinfoEndpoint;
 
@@ -76,6 +79,14 @@ public class OIDCConfigurationRepresentation {
         this.tokenEndpoint = tokenEndpoint;
     }
 
+    public String getTokenIntrospectionEndpoint() {
+        return this.tokenIntrospectionEndpoint;
+    }
+
+    public void setTokenIntrospectionEndpoint(String tokenIntrospectionEndpoint) {
+        this.tokenIntrospectionEndpoint = tokenIntrospectionEndpoint;
+    }
+
     public String getUserinfoEndpoint() {
         return userinfoEndpoint;
     }
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
index d1a83bb..68a4aaf 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
@@ -199,12 +199,7 @@ public class TokenManager {
 
     public RefreshToken verifyRefreshToken(RealmModel realm, String encodedRefreshToken) throws OAuthErrorException {
         try {
-            JWSInput jws = new JWSInput(encodedRefreshToken);
-            RefreshToken refreshToken = null;
-            if (!RSAProvider.verify(jws, realm.getPublicKey())) {
-                throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token");
-            }
-            refreshToken = jws.readJsonContent(RefreshToken.class);
+            RefreshToken refreshToken = toRefreshToken(realm, encodedRefreshToken);
 
             if (refreshToken.getExpiration() != 0 && refreshToken.isExpired()) {
                 throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired");
@@ -218,6 +213,17 @@ public class TokenManager {
             throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
         }
     }
+
+    public RefreshToken toRefreshToken(RealmModel realm, String encodedRefreshToken) throws JWSInputException, OAuthErrorException {
+        JWSInput jws = new JWSInput(encodedRefreshToken);
+
+        if (!RSAProvider.verify(jws, realm.getPublicKey())) {
+            throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token");
+        }
+
+        return jws.readJsonContent(RefreshToken.class);
+    }
+
     public IDToken verifyIDToken(RealmModel realm, String encodedIDToken) throws OAuthErrorException {
         try {
             JWSInput jws = new JWSInput(encodedIDToken);
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java
new file mode 100755
index 0000000..0ee1f91
--- /dev/null
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java
@@ -0,0 +1,218 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2012, Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags. See the copyright.txt file in the
+ * distribution for a full listing of individual contributors.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.keycloak.testsuite.oauth;
+
+import org.codehaus.jackson.JsonNode;
+import org.codehaus.jackson.map.ObjectMapper;
+import org.h2.value.ValueStringIgnoreCase;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.events.Event;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.Constants;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.RoleModel;
+import org.keycloak.models.UserCredentialModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.representations.oidc.TokenMetadataRepresentation;
+import org.keycloak.services.managers.ClientManager;
+import org.keycloak.services.managers.RealmManager;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.OAuthClient;
+import org.keycloak.testsuite.OAuthClient.AccessTokenResponse;
+import org.keycloak.testsuite.pages.LoginPage;
+import org.keycloak.testsuite.rule.KeycloakRule;
+import org.keycloak.testsuite.rule.WebResource;
+import org.keycloak.testsuite.rule.WebRule;
+import org.openqa.selenium.WebDriver;
+
+import java.util.HashSet;
+
+import static org.junit.Assert.*;
+
+/**
+ * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
+ */
+public class TokenIntrospectionTest {
+
+    protected static Keycloak keycloak;
+
+    @ClassRule
+    public static KeycloakRule keycloakRule = new KeycloakRule(new KeycloakRule.KeycloakSetup() {
+
+        @Override
+        public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
+            appRealm.getClientByClientId("test-app").setDirectAccessGrantsEnabled(true);
+            ClientModel confApp = KeycloakModelUtils.createClient(appRealm, "confidential-cli");
+            confApp.setSecret("secret1");
+            new ClientManager(manager).enableServiceAccount(confApp);
+            ClientModel pubApp = KeycloakModelUtils.createClient(appRealm, "public-cli");
+            pubApp.setPublicClient(true);
+            {
+                UserModel user = manager.getSession().users().addUser(appRealm, KeycloakModelUtils.generateId(), "no-permissions", false, false);
+                user.updateCredential(UserCredentialModel.password("password"));
+                user.setEnabled(true);
+                RoleModel role = appRealm.getRole("user");
+                user.grantRole(role);
+            }
+
+            keycloak = Keycloak.getInstance("http://localhost:8081/auth", "master", "admin", "admin", Constants.ADMIN_CLI_CLIENT_ID);
+        }
+
+    });
+
+    @Rule
+    public WebRule webRule = new WebRule(this);
+
+    @WebResource
+    protected WebDriver driver;
+
+    @WebResource
+    protected OAuthClient oauth;
+
+    @WebResource
+    protected LoginPage loginPage;
+
+    @Rule
+    public AssertEvents events = new AssertEvents(keycloakRule);
+
+    @Test
+    public void testConfidentialClientCredentialsBasicAuthentication() throws Exception {
+        oauth.doLogin("test-user@localhost", "password");
+        String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password");
+        String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "secret1", accessTokenResponse.getAccessToken());
+        ObjectMapper objectMapper = new ObjectMapper();
+        JsonNode jsonNode = objectMapper.readTree(tokenResponse);
+
+        assertTrue(jsonNode.get("active").asBoolean());
+        assertEquals("test-user@localhost", jsonNode.get("username").asText());
+        assertEquals("test-app", jsonNode.get("client_id").asText());
+        assertTrue(jsonNode.has("exp"));
+        assertTrue(jsonNode.has("iat"));
+        assertTrue(jsonNode.has("nbf"));
+        assertTrue(jsonNode.has("sub"));
+        assertTrue(jsonNode.has("aud"));
+        assertTrue(jsonNode.has("iss"));
+        assertTrue(jsonNode.has("jti"));
+
+        TokenMetadataRepresentation rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class);
+
+        assertTrue(rep.isActive());
+        assertEquals("test-user@localhost", rep.getUserName());
+        assertEquals("test-app", rep.getClientId());
+        assertEquals(jsonNode.get("exp").asInt(), rep.getExpiration());
+        assertEquals(jsonNode.get("iat").asInt(), rep.getIssuedAt());
+        assertEquals(jsonNode.get("nbf").asInt(), rep.getNotBefore());
+        assertEquals(jsonNode.get("sub").asText(), rep.getSubject());
+        assertEquals(jsonNode.get("aud").asText(), rep.getAudience()[0]);
+        assertEquals(jsonNode.get("iss").asText(), rep.getIssuer());
+        assertEquals(jsonNode.get("jti").asText(), rep.getId());
+
+        events.clear();
+    }
+
+    @Test
+    public void testInvalidClientCredentials() throws Exception {
+        oauth.doLogin("test-user@localhost", "password");
+        String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password");
+        String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "bad_credential", accessTokenResponse.getAccessToken());
+
+        assertEquals("{\"error_description\":\"Authentication failed.\",\"error\":\"invalid_request\"}", tokenResponse);
+
+        events.clear();
+    }
+
+    @Test
+    public void testIntrospectRefreshToken() throws Exception {
+        oauth.doLogin("test-user@localhost", "password");
+        String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+        Event loginEvent = events.expectLogin().assertEvent();
+        String sessionId = loginEvent.getSessionId();
+        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password");
+        String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "secret1", accessTokenResponse.getAccessToken());
+        ObjectMapper objectMapper = new ObjectMapper();
+        JsonNode jsonNode = objectMapper.readTree(tokenResponse);
+
+        assertTrue(jsonNode.get("active").asBoolean());
+        assertEquals(sessionId, jsonNode.get("session_state").asText());
+        assertEquals("test-app", jsonNode.get("client_id").asText());
+        assertTrue(jsonNode.has("exp"));
+        assertTrue(jsonNode.has("iat"));
+        assertTrue(jsonNode.has("nbf"));
+        assertTrue(jsonNode.has("sub"));
+        assertTrue(jsonNode.has("aud"));
+        assertTrue(jsonNode.has("iss"));
+        assertTrue(jsonNode.has("jti"));
+
+        TokenMetadataRepresentation rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class);
+
+        assertTrue(rep.isActive());
+        assertEquals("test-app", rep.getClientId());
+        assertEquals(jsonNode.get("session_state").asText(), rep.getSessionState());
+        assertEquals(jsonNode.get("exp").asInt(), rep.getExpiration());
+        assertEquals(jsonNode.get("iat").asInt(), rep.getIssuedAt());
+        assertEquals(jsonNode.get("nbf").asInt(), rep.getNotBefore());
+        assertEquals(jsonNode.get("iss").asText(), rep.getIssuer());
+        assertEquals(jsonNode.get("jti").asText(), rep.getId());
+
+        events.clear();
+    }
+
+    @Test
+    public void testPublicClientCredentialsNotAllowed() throws Exception {
+        oauth.doLogin("test-user@localhost", "password");
+
+        String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(code, "password");
+        String tokenResponse = oauth.introspectAccessTokenWithClientCredential("public-cli", "it_doesnt_matter", accessTokenResponse.getAccessToken());
+
+        assertEquals("{\"error_description\":\"Client not allowed.\",\"error\":\"invalid_request\"}", tokenResponse);
+
+        events.clear();
+    }
+
+    @Test
+    public void testInactiveAccessToken() throws Exception {
+        oauth.doLogin("test-user@localhost", "password");
+        String inactiveAccessToken = "eyJhbGciOiJSUzI1NiJ9.eyJub25jZSI6IjczMGZjNjQ1LTBlMDQtNDE3Yi04MDY0LTkyYWIyY2RjM2QwZSIsImp0aSI6ImU5ZGU1NjU2LWUzMjctNDkxNC1hNjBmLTI1MzJlYjBiNDk4OCIsImV4cCI6MTQ1MjI4MTAwMCwibmJmIjowLCJpYXQiOjE0NTIyODA3MDAsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hdXRoL3JlYWxtcy9leGFtcGxlIiwiYXVkIjoianMtY29uc29sZSIsInN1YiI6IjFkNzQ0MDY5LWYyOTgtNGU3Yy1hNzNiLTU1YzlhZjgzYTY4NyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImpzLWNvbnNvbGUiLCJzZXNzaW9uX3N0YXRlIjoiNzc2YTA0OTktODNjNC00MDhkLWE5YjctYTZiYzQ5YmQ3MThjIiwiY2xpZW50X3Nlc3Npb24iOiJjN2Y5ODczOC05MDhlLTQxOWYtYTdkNC1kODYxYjRhYTI3NjkiLCJhbGxvd2VkLW9yaWdpbnMiOltdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsidXNlciJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJ2aWV3LXByb2ZpbGUiXX19LCJuYW1lIjoiU2FtcGxlIFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ1c2VyIiwiZ2l2ZW5fbmFtZSI6IlNhbXBsZSIsImZhbWlseV9uYW1lIjoiVXNlciIsImVtYWlsIjoic2FtcGxlLXVzZXJAZXhhbXBsZSJ9.YyPV74j9CqOG2Jmq692ZZpqycjNpUgtYVRfQJccS_FU84tGVXoKKsXKYeY2UJ1Y_bPiYG1I1J6JSXC8XqgQijCG7Nh7oK0yN74JbRN58HG75fvg6K9BjR6hgJ8mHT8qPrCux2svFucIMIZ180eoBoRvRstkidOhl_mtjT_i31fU";
+        String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "secret1", inactiveAccessToken);
+        ObjectMapper objectMapper = new ObjectMapper();
+        JsonNode jsonNode = objectMapper.readTree(tokenResponse);
+
+        assertFalse(jsonNode.get("active").asBoolean());
+
+        TokenMetadataRepresentation rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class);
+
+        assertFalse(rep.isActive());
+        assertNull(rep.getUserName());
+        assertNull(rep.getClientId());
+        assertNull(rep.getSubject());
+
+        events.clear();
+    }
+}
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
index 493b74e..58bce39 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
@@ -22,6 +22,7 @@
 package org.keycloak.testsuite;
 
 import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.output.ByteArrayOutputStream;
 import org.apache.http.HttpResponse;
 import org.apache.http.NameValuePair;
 import org.apache.http.client.entity.UrlEncodedFormEntity;
@@ -160,6 +161,51 @@ public class OAuthClient {
         }
     }
 
+    public String introspectAccessTokenWithClientCredential(String clientId, String clientSecret, String tokenToIntrospect) {
+        return introspectTokenWithClientCredential(clientId, clientSecret, "access_token", tokenToIntrospect);
+    }
+
+    public String introspectRefreshTokenWithClientCredential(String clientId, String clientSecret, String tokenToIntrospect) {
+        return introspectTokenWithClientCredential(clientId, clientSecret, "refresh_token", tokenToIntrospect);
+    }
+
+    public String introspectTokenWithClientCredential(String clientId, String clientSecret, String tokenType, String tokenToIntrospect) {
+        CloseableHttpClient client = new DefaultHttpClient();
+        try {
+            HttpPost post = new HttpPost(getTokenIntrospectionUrl());
+
+            String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
+            post.setHeader("Authorization", authorization);
+
+            List<NameValuePair> parameters = new LinkedList<>();
+
+            parameters.add(new BasicNameValuePair("token", tokenToIntrospect));
+            parameters.add(new BasicNameValuePair("token_type_hint", tokenType));
+
+            UrlEncodedFormEntity formEntity;
+
+            try {
+                formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
+            } catch (UnsupportedEncodingException e) {
+                throw new RuntimeException(e);
+            }
+
+            post.setEntity(formEntity);
+
+            try {
+                ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+                client.execute(post).getEntity().writeTo(out);
+
+                return new String(out.toByteArray());
+            } catch (Exception e) {
+                throw new RuntimeException("Failed to retrieve access token", e);
+            }
+        } finally {
+            closeClient(client);
+        }
+    }
+
     public AccessTokenResponse doGrantAccessTokenRequest(String clientSecret, String username,  String password) throws Exception {
         return doGrantAccessTokenRequest(realm, username, password, null, clientId, clientSecret);
     }
@@ -408,6 +454,11 @@ public class OAuthClient {
         return b.build(realm).toString();
     }
 
+    public String getTokenIntrospectionUrl() {
+        UriBuilder b = OIDCLoginProtocolService.tokenIntrospectionUrl(UriBuilder.fromUri(baseUrl));
+        return b.build(realm).toString();
+    }
+
     public String getLogoutUrl(String redirectUri, String sessionState) {
         UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl));
         if (redirectUri != null) {