Merge pull request #1999 from pedroigor/KEYCLOAK-2266
[KEYCLOAK-2266] - OAuth2 Token Introspection.
This commit is contained in:
commit
ee3a880a55
14 changed files with 642 additions and 20 deletions
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue