Merge pull request #1999 from pedroigor/KEYCLOAK-2266

[KEYCLOAK-2266] - OAuth2 Token Introspection.
This commit is contained in:
Stian Thorgersen 2016-01-12 15:37:41 +01:00
commit ee3a880a55
14 changed files with 642 additions and 20 deletions

View file

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

View file

@ -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;
}
}

View file

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

View file

@ -79,6 +79,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>

View file

@ -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),

View file

@ -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;

View file

@ -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());

View file

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

View file

@ -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()) {

View file

@ -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);
}
}

View file

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

View file

@ -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;
}

View file

@ -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) {

View file

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