diff --git a/core/src/main/java/org/keycloak/RSATokenVerifier.java b/core/src/main/java/org/keycloak/RSATokenVerifier.java
index 19babeff64..ab1fa393c7 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/representations/oidc/TokenMetadataRepresentation.java b/core/src/main/java/org/keycloak/representations/oidc/TokenMetadataRepresentation.java
new file mode 100644
index 0000000000..a8f7f316ef
--- /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 Pedro Igor
+ */
+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/util/JsonSerialization.java b/core/src/main/java/org/keycloak/util/JsonSerialization.java
index 19df33f0c4..deb75f6368 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 5ab29ecbc6..26dae4299e 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
@@ -79,6 +79,27 @@
Version specific migration
+
+ Migrating to 1.8.0
+
+ OAuth2 Token Introspection
+
+ In order to add more compliance with OAuth2 specification, we added a new endpoint for token introspection.
+ The new endpoint can reached at /realms/{realm}/protocols/openid-connect/token/introspect and it is solely
+ based on RFC-7662.
+
+
+ The /realms/{realm}/protocols/openid-connect/validate 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.
+
+
+ The new token introspection URL can now be obtained from OpenID Connect Provider's configuration at /realms/{realm}/.well-known/openid-configuration. There
+ you will find a claim with name token_introspection_endpoint within the response. Only confidential clients 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.
+
+
+
Migrating to 1.7.0.CR1
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 599dec5bf3..74647892fb 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/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java
index 9ad9c9558f..456a1287c8 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 98fb49e08d..8e0cdbf0e2 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/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
index d1a83bb127..68a4aaf63f 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/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 178624b63d..d937ff985e 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 0000000000..8af07ae9f5
--- /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 Pedro Igor
+ */
+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 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 2a375e77ca..71e64f7311 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 Stian Thorgersen
*/
+@Deprecated
public class ValidateTokenEndpoint {
private static final Logger logger = Logger.getLogger(ValidateTokenEndpoint.class);
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 02263317ca..ff019f490c 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/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java
index 493b74e822..58bce399df 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 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) {
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 0000000000..0ee1f916cc
--- /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 Pedro Igor
+ */
+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();
+ }
+}