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(); + } +}