From c8516c2349d5d4c1a9a6fa3374a4ef1387089f98 Mon Sep 17 00:00:00 2001 From: Bill Burke Date: Fri, 6 Oct 2017 16:44:26 -0400 Subject: [PATCH 1/2] support social external exchange --- .../java/org/keycloak/OAuth2Constants.java | 1 - .../provider/ExchangeExternalToken.java | 1 + .../oidc/AbstractOAuth2IdentityProvider.java | 115 +++++++++++++++- .../oidc/KeycloakOIDCIdentityProvider.java | 22 ++++ .../broker/oidc/OIDCIdentityProvider.java | 124 ++++++++++++------ .../oidc/endpoints/TokenEndpoint.java | 49 +++++-- .../bitbucket/BitbucketIdentityProvider.java | 57 ++++++++ .../facebook/FacebookIdentityProvider.java | 95 +++++++++----- .../social/github/GitHubIdentityProvider.java | 42 ++++-- .../social/gitlab/GitLabIdentityProvider.java | 41 +++++- .../social/google/GoogleIdentityProvider.java | 46 ++----- .../linkedin/LinkedInIdentityProvider.java | 43 ++++-- .../microsoft/MicrosoftIdentityProvider.java | 74 ++++++++--- .../OpenshiftV3IdentityProvider.java | 24 ++++ .../social/paypal/PayPalIdentityProvider.java | 38 ++++-- .../StackoverflowIdentityProvider.java | 54 +++++--- ...bstractBrokerLinkAndTokenExchangeTest.java | 33 +++-- 17 files changed, 655 insertions(+), 204 deletions(-) diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 098fdcd8f8..409843936f 100644 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -105,7 +105,6 @@ public interface OAuth2Constants { String ACCESS_TOKEN_TYPE="urn:ietf:params:oauth:token-type:access_token"; String REFRESH_TOKEN_TYPE="urn:ietf:params:oauth:token-type:refresh_token"; String JWT_TOKEN_TYPE="urn:ietf:params:oauth:token-type:jwt"; - String JWT_ACCESS_TOKEN_TYPE="urn:ietf:params:oauth:token-type:jwt:access_token"; String ID_TOKEN_TYPE="urn:ietf:params:oauth:token-type:id_token"; diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java index a448d3d412..2b3ef3b117 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java @@ -29,6 +29,7 @@ import javax.ws.rs.core.MultivaluedMap; * @version $Revision: 1 $ */ public interface ExchangeExternalToken { + boolean isIssuer(String issuer, MultivaluedMap params); BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap params); void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap params); diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java index 918a38586b..c6f4b0501c 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -20,9 +20,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.jboss.logging.Logger; import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.provider.AbstractIdentityProvider; import org.keycloak.broker.provider.AuthenticationRequest; import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.ExchangeExternalToken; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken; import org.keycloak.broker.provider.util.SimpleHttp; @@ -41,6 +43,7 @@ import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.services.ErrorPage; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; @@ -62,11 +65,12 @@ import java.util.regex.Pattern; /** * @author Pedro Igor */ -public abstract class AbstractOAuth2IdentityProvider extends AbstractIdentityProvider implements ExchangeTokenToIdentityProviderToken { +public abstract class AbstractOAuth2IdentityProvider extends AbstractIdentityProvider implements ExchangeTokenToIdentityProviderToken, ExchangeExternalToken { protected static final Logger logger = Logger.getLogger(AbstractOAuth2IdentityProvider.class); public static final String OAUTH2_GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; public static final String OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"; + public static final String FEDERATED_ACCESS_TOKEN = "FEDERATED_ACCESS_TOKEN"; public static final String FEDERATED_REFRESH_TOKEN = "FEDERATED_REFRESH_TOKEN"; public static final String FEDERATED_TOKEN_EXPIRATION = "FEDERATED_TOKEN_EXPIRATION"; @@ -412,4 +416,113 @@ public abstract class AbstractOAuth2IdentityProvider params) { + if (!supportsExternalExchange()) return false; + String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER); + if (requestedIssuer == null) requestedIssuer = issuer; + return requestedIssuer.equals(getConfig().getAlias()); + } + + + final public BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap params) { + if (!supportsExternalExchange()) return null; + BrokeredIdentityContext context = exchangeExternalImpl(event, params); + if (context != null) { + context.setIdp(this); + context.setIdpConfig(getConfig()); + } + return context; + } + + protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + return exchangeExternalUserInfoValidationOnly(event, params); + + } + + protected BrokeredIdentityContext exchangeExternalUserInfoValidationOnly(EventBuilder event, MultivaluedMap params) { + String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); + if (subjectToken == null) { + event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token not set", Response.Status.BAD_REQUEST); + } + String subjectTokenType = params.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); + if (subjectTokenType == null) { + subjectTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE; + } + if (!OAuth2Constants.ACCESS_TOKEN_TYPE.equals(subjectTokenType)) { + event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN_TYPE + " invalid"); + event.error(Errors.INVALID_TOKEN_TYPE); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token type", Response.Status.BAD_REQUEST); + } + return validateExternalTokenThroughUserInfo(event, subjectToken, subjectTokenType); + } + + @Override + public void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap params) { + if (context.getContextData().containsKey(OIDCIdentityProvider.VALIDATED_ID_TOKEN)) + userSession.setNote(FEDERATED_ACCESS_TOKEN, params.getFirst(OAuth2Constants.SUBJECT_TOKEN)); + if (context.getContextData().containsKey(OIDCIdentityProvider.VALIDATED_ID_TOKEN)) + userSession.setNote(OIDCIdentityProvider.FEDERATED_ID_TOKEN, params.getFirst(OAuth2Constants.SUBJECT_TOKEN)); + userSession.setNote(OIDCIdentityProvider.EXCHANGE_PROVIDER, getConfig().getAlias()); + + } + + } diff --git a/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java index 1f2871b9aa..4e3d160ca5 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java @@ -17,9 +17,13 @@ package org.keycloak.broker.oidc; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.constants.AdapterConstants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; @@ -30,11 +34,13 @@ import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.adapters.action.AdminAction; import org.keycloak.representations.adapters.action.LogoutAction; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.util.JsonSerialization; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import java.io.IOException; import java.security.PublicKey; @@ -134,5 +140,21 @@ public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider { } + @Override + protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); + if (subjectToken == null) { + event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token not set", Response.Status.BAD_REQUEST); + } + String subjectTokenType = params.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); + if (subjectTokenType == null) { + subjectTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE; + } + return validateJwt(event, subjectToken, subjectTokenType); + } + + } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 6482544a43..7fceae8640 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -379,11 +379,12 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider params) { + @Override + public boolean isIssuer(String issuer, MultivaluedMap params) { + if (!supportsExternalExchange()) return false; String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER); - if (requestedIssuer == null) return true; + if (requestedIssuer == null) requestedIssuer = issuer; if (requestedIssuer.equals(getConfig().getAlias())) return true; String[] issuers = getConfig().getIssuer().split(","); @@ -534,38 +537,65 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider params) { - if (!isIssuer(params)) { - return null; - } - String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); - if (subjectToken == null) { - event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset"); + protected String getProfileEndpointForValidation(EventBuilder event) { + String userInfoUrl = getUserInfoUrl(); + if (getConfig().isDisableUserInfoService() || userInfoUrl == null || userInfoUrl.isEmpty()) { + event.detail(Details.REASON, "user info service disabled"); event.error(Errors.INVALID_TOKEN); - throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token not set", Response.Status.BAD_REQUEST); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + } - String subjectTokenType = params.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); - if (subjectTokenType == null) { - event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN_TYPE + " param unset"); - event.error(Errors.INVALID_TOKEN_TYPE); - throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token type unset", Response.Status.BAD_REQUEST); + return userInfoUrl; + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode userInfo) { + String id = getJsonProperty(userInfo, "sub"); + if (id == null) { + event.detail(Details.REASON, "sub claim is null from user info json"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); } - boolean jwtAccessTokenType = subjectTokenType.equals(OAuth2Constants.JWT_ACCESS_TOKEN_TYPE); - boolean idTokenType = subjectTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE); - if (!jwtAccessTokenType && !idTokenType) { - event.error(Errors.INVALID_TOKEN_TYPE); - throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token type", Response.Status.BAD_REQUEST); + BrokeredIdentityContext identity = new BrokeredIdentityContext(id); + + String name = getJsonProperty(userInfo, "name"); + String preferredUsername = getUsernameFromUserInfo(userInfo); + String email = getJsonProperty(userInfo, "email"); + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias()); + + identity.setId(id); + identity.setName(name); + identity.setEmail(email); + + identity.setBrokerUserId(getConfig().getAlias() + "." + id); + + if (preferredUsername == null) { + preferredUsername = email; } - - if (getConfig().isValidateSignature() == false) { - event.detail(Details.REASON, "validate signature unset"); - event.error(Errors.INVALID_CONFIG); - throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST); + if (preferredUsername == null) { + preferredUsername = id; } + + identity.setUsername(preferredUsername); + return identity; + } + + protected String getUsernameFromUserInfo(JsonNode userInfo) { + return getJsonProperty(userInfo, "preferred_username"); + } + + final protected BrokeredIdentityContext validateJwt(EventBuilder event, String subjectToken, String subjectTokenType) { + if (!getConfig().isValidateSignature()) { + return validateExternalTokenThroughUserInfo(event, subjectToken, subjectTokenType); + } + event.detail("validation_method", "signature"); if (getConfig().isUseJwksUrl()) { - logger.debug("using jwks url to validate token exchange"); if (getConfig().getJwksUrl() == null) { event.detail(Details.REASON, "jwks url unset"); event.error(Errors.INVALID_CONFIG); @@ -589,6 +619,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider params) { - if (context.getContextData().containsKey(VALIDATED_ID_TOKEN)) - userSession.setNote(FEDERATED_ACCESS_TOKEN, params.getFirst(OAuth2Constants.SUBJECT_TOKEN)); - if (context.getContextData().containsKey(VALIDATED_ID_TOKEN)) - userSession.setNote(FEDERATED_ID_TOKEN, params.getFirst(OAuth2Constants.SUBJECT_TOKEN)); - userSession.setNote(EXCHANGE_PROVIDER, getConfig().getAlias()); - + protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + if (!supportsExternalExchange()) return null; + String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); + if (subjectToken == null) { + event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token not set", Response.Status.BAD_REQUEST); + } + String subjectTokenType = params.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); + if (subjectTokenType == null) { + subjectTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE; + } + if (OAuth2Constants.JWT_TOKEN_TYPE.equals(subjectTokenType) || OAuth2Constants.ID_TOKEN_TYPE.equals(subjectTokenType)) { + return validateJwt(event, subjectToken, subjectTokenType); + } else if (OAuth2Constants.ACCESS_TOKEN_TYPE.equals(subjectTokenType)) { + return validateExternalTokenThroughUserInfo(event, subjectToken, subjectTokenType); + } else { + event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN_TYPE + " invalid"); + event.error(Errors.INVALID_TOKEN_TYPE); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token type", Response.Status.BAD_REQUEST); + } } } 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 42b42d95f4..9d1359c7c0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -40,6 +40,8 @@ import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; @@ -58,6 +60,7 @@ import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ServicesLogger; @@ -590,15 +593,29 @@ public class TokenEndpoint { String subjectToken = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN); if (subjectToken != null) { - String subjectIssuer = formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER); + String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); String realmIssuerUrl = Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()); + String subjectIssuer = formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER); + + if (subjectIssuer == null && OAuth2Constants.JWT_TOKEN_TYPE.equals(subjectTokenType)) { + try { + JWSInput jws = new JWSInput(subjectToken); + JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class); + subjectIssuer = jwt.getIssuer(); + } catch (JWSInputException e) { + event.detail(Details.REASON, "unable to parse jwt subject_token"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token type, must be access token", Response.Status.BAD_REQUEST); + + } + } + if (subjectIssuer != null && !realmIssuerUrl.equals(subjectIssuer)) { event.detail(OAuth2Constants.SUBJECT_ISSUER, subjectIssuer); - return exchangeExternalToken(); + return exchangeExternalToken(subjectIssuer); } - String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) { event.detail(Details.REASON, "subject_token supports access tokens only"); event.error(Errors.INVALID_TOKEN); @@ -764,32 +781,44 @@ public class TokenEndpoint { return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(uriInfo, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); } - public Response exchangeExternalToken() { - BrokeredIdentityContext context = null; + public Response exchangeExternalToken(String issuer) { + ExchangeExternalToken externalIdp = null; + IdentityProviderModel externalIdpModel = null; for (IdentityProviderModel idpModel : realm.getIdentityProviders()) { IdentityProviderFactory factory = IdentityBrokerService.getIdentityProviderFactory(session, idpModel); IdentityProvider idp = factory.create(session, idpModel); if (idp instanceof ExchangeExternalToken) { - context = ((ExchangeExternalToken)idp).exchangeExternal(event, formParams); - break; + ExchangeExternalToken external = (ExchangeExternalToken) idp; + if (idpModel.getAlias().equals(issuer) || externalIdp.isIssuer(issuer, formParams)) { + externalIdp = external; + externalIdpModel = idpModel; + break; + } } } - if (context == null) { + + + if (externalIdp == null) { event.error(Errors.INVALID_ISSUER); throw new ErrorResponseException(Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); } - if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, context.getIdpConfig())) { + if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, externalIdpModel)) { event.detail(Details.REASON, "client not allowed to exchange subject_issuer"); event.error(Errors.NOT_ALLOWED); throw new ErrorResponseException(OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); } + BrokeredIdentityContext context = externalIdp.exchangeExternal(event, formParams); + if (context == null) { + event.error(Errors.INVALID_ISSUER); + throw new ErrorResponseException(Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); + } UserModel user = importUserFromExternalIdentity(context); String sessionId = KeycloakModelUtils.generateId(); UserSessionModel userSession = session.sessions().createUserSession(sessionId, realm, user, user.getUsername(), clientConnection.getRemoteAddr(), "external-exchange", false, null, null); - ((ExchangeExternalToken)context.getIdp()).exchangeExternalComplete(userSession, context, formParams); + externalIdp.exchangeExternalComplete(userSession, context, formParams); return exchangeClientToClient(user, userSession); diff --git a/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java b/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java index bb7aa64102..4b894fa43f 100755 --- a/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java @@ -18,6 +18,7 @@ package org.keycloak.social.bitbucket; import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; @@ -25,7 +26,13 @@ import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.services.ErrorResponseException; + +import javax.ws.rs.core.Response; /** * @author Stian Thorgersen @@ -50,6 +57,56 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im } } + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return USER_URL; + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + String type = getJsonProperty(profile, "type"); + if (type == null) { + event.detail(Details.REASON, "no type data in user info response"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + + } + if (type.equals("error")) { + JsonNode errorNode = profile.get("error"); + if (errorNode != null) { + String errorMsg = getJsonProperty(errorNode, "message"); + event.detail(Details.REASON, "user info call failure: " + errorMsg); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + } else { + event.detail(Details.REASON, "user info call failure"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + } + } + if (!type.equals("user")) { + event.detail(Details.REASON, "no user info in response"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + + } + BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "account_id")); + + String username = getJsonProperty(profile, "username"); + user.setUsername(username); + user.setIdpConfig(getConfig()); + user.setIdp(this); + + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + + return user; + } + @Override protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { try { diff --git a/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java b/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java index 54be72c4ee..57c0d03900 100755 --- a/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java @@ -18,6 +18,7 @@ package org.keycloak.social.facebook; import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; @@ -25,7 +26,14 @@ import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.services.ErrorResponseException; + +import javax.ws.rs.core.Response; +import java.io.IOException; /** * @author Stian Thorgersen @@ -48,47 +56,62 @@ public class FacebookIdentityProvider extends AbstractOAuth2IdentityProvider imp try { JsonNode profile = SimpleHttp.doGet(PROFILE_URL, session).header("Authorization", "Bearer " + accessToken).asJson(); - String id = getJsonProperty(profile, "id"); - - BrokeredIdentityContext user = new BrokeredIdentityContext(id); - - String email = getJsonProperty(profile, "email"); - - user.setEmail(email); - - String username = getJsonProperty(profile, "username"); - - if (username == null) { - if (email != null) { - username = email; - } else { - username = id; - } - } - - user.setUsername(username); - - String firstName = getJsonProperty(profile, "first_name"); - String lastName = getJsonProperty(profile, "last_name"); - - if (lastName == null) { - lastName = ""; - } else { - lastName = " " + lastName; - } - - user.setName(firstName + lastName); - user.setIdpConfig(getConfig()); - user.setIdp(this); - - AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); - - return user; + return extractIdentityFromProfile(null, profile); } catch (Exception e) { throw new IdentityBrokerException("Could not obtain user profile from facebook.", e); } } + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return PROFILE_URL; + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + String id = getJsonProperty(profile, "id"); + + BrokeredIdentityContext user = new BrokeredIdentityContext(id); + + String email = getJsonProperty(profile, "email"); + + user.setEmail(email); + + String username = getJsonProperty(profile, "username"); + + if (username == null) { + if (email != null) { + username = email; + } else { + username = id; + } + } + + user.setUsername(username); + + String firstName = getJsonProperty(profile, "first_name"); + String lastName = getJsonProperty(profile, "last_name"); + + if (lastName == null) { + lastName = ""; + } else { + lastName = " " + lastName; + } + + user.setName(firstName + lastName); + user.setIdpConfig(getConfig()); + user.setIdp(this); + + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + + return user; + } + @Override protected String getDefaultScopes() { return DEFAULT_SCOPE; diff --git a/services/src/main/java/org/keycloak/social/github/GitHubIdentityProvider.java b/services/src/main/java/org/keycloak/social/github/GitHubIdentityProvider.java index 4120e43292..9b04b76aca 100755 --- a/services/src/main/java/org/keycloak/social/github/GitHubIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/github/GitHubIdentityProvider.java @@ -25,6 +25,7 @@ import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; /** @@ -44,23 +45,40 @@ public class GitHubIdentityProvider extends AbstractOAuth2IdentityProvider imple config.setUserInfoUrl(PROFILE_URL); } + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return PROFILE_URL; + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id")); + + String username = getJsonProperty(profile, "login"); + user.setUsername(username); + user.setName(getJsonProperty(profile, "name")); + user.setEmail(getJsonProperty(profile, "email")); + user.setIdpConfig(getConfig()); + user.setIdp(this); + + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + + return user; + + } + + @Override protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { try { JsonNode profile = SimpleHttp.doGet(PROFILE_URL, session).header("Authorization", "Bearer " + accessToken).asJson(); - BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id")); - - String username = getJsonProperty(profile, "login"); - user.setUsername(username); - user.setName(getJsonProperty(profile, "name")); - user.setEmail(getJsonProperty(profile, "email")); - user.setIdpConfig(getConfig()); - user.setIdp(this); - - AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); - - return user; + return extractIdentityFromProfile(null, profile); } catch (Exception e) { throw new IdentityBrokerException("Could not obtain user profile from github.", e); } diff --git a/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java b/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java index f700d459b2..adf2e05399 100755 --- a/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java @@ -18,19 +18,25 @@ package org.keycloak.social.gitlab; import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.ErrorResponseException; import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; import java.io.IOException; /** @@ -56,6 +62,37 @@ public class GitLabIdentityProvider extends OIDCIdentityProvider implements Soc } } + protected String getUsernameFromUserInfo(JsonNode userInfo) { + return getJsonProperty(userInfo, "username"); + } + + protected String getusernameClaimNameForIdToken() { + return IDToken.NICKNAME; + } + + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return getUserInfoUrl(); + } + + @Override + public boolean isIssuer(String issuer, MultivaluedMap params) { + String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER); + if (requestedIssuer == null) requestedIssuer = issuer; + return requestedIssuer.equals(getConfig().getAlias()); + } + + + @Override + protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + return exchangeExternalUserInfoValidationOnly(event, params); + } + protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException { String id = idToken.getSubject(); BrokeredIdentityContext identity = new BrokeredIdentityContext(id); @@ -100,10 +137,6 @@ public class GitLabIdentityProvider extends OIDCIdentityProvider implements Soc return identity; } - @Override - public BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap params) { - return null; - } diff --git a/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java b/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java index afd04301f6..f3e4990228 100755 --- a/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import org.jboss.resteasy.spi.ResteasyProviderFactory; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; +import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; @@ -79,42 +80,23 @@ public class GoogleIdentityProvider extends OIDCIdentityProvider implements Soci return uri; } - protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException { - String id = idToken.getSubject(); - BrokeredIdentityContext identity = new BrokeredIdentityContext(id); - String name = (String) idToken.getOtherClaims().get(IDToken.NAME); - String preferredUsername = (String) idToken.getOtherClaims().get(getUsernameClaimName()); - String email = (String) idToken.getOtherClaims().get(IDToken.EMAIL); - - identity.getContextData().put(VALIDATED_ID_TOKEN, idToken); - - identity.setId(id); - identity.setName(name); - identity.setEmail(email); - - identity.setBrokerUserId(getConfig().getAlias() + "." + id); - - if (preferredUsername == null) { - preferredUsername = email; - } - - if (preferredUsername == null) { - preferredUsername = id; - } - - identity.setUsername(preferredUsername); - if (tokenResponse != null && tokenResponse.getSessionState() != null) { - identity.setBrokerSessionId(getConfig().getAlias() + "." + tokenResponse.getSessionState()); - } - if (tokenResponse != null) identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse); - if (tokenResponse != null) processAccessTokenResponse(identity, tokenResponse); - return identity; + @Override + protected boolean supportsExternalExchange() { + return true; } @Override - public BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap params) { - return null; + public boolean isIssuer(String issuer, MultivaluedMap params) { + String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER); + if (requestedIssuer == null) requestedIssuer = issuer; + return requestedIssuer.equals(getConfig().getAlias()); + } + + + @Override + protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + return exchangeExternalUserInfoValidationOnly(event, params); } diff --git a/services/src/main/java/org/keycloak/social/linkedin/LinkedInIdentityProvider.java b/services/src/main/java/org/keycloak/social/linkedin/LinkedInIdentityProvider.java index e25bcfa3bd..c30db0c7f1 100755 --- a/services/src/main/java/org/keycloak/social/linkedin/LinkedInIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/linkedin/LinkedInIdentityProvider.java @@ -25,6 +25,7 @@ import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; import java.net.MalformedURLException; @@ -52,24 +53,40 @@ public class LinkedInIdentityProvider extends AbstractOAuth2IdentityProvider imp config.setUserInfoUrl(PROFILE_URL); } + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return PROFILE_URL; + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id")); + + String username = extractUsernameFromProfileURL(getJsonProperty(profile, "publicProfileUrl")); + user.setUsername(username); + user.setName(getJsonProperty(profile, "formattedName")); + user.setEmail(getJsonProperty(profile, "emailAddress")); + user.setIdpConfig(getConfig()); + user.setIdp(this); + + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + + return user; + + } + + @Override protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { log.debug("doGetFederatedIdentity()"); try { JsonNode profile = SimpleHttp.doGet(PROFILE_URL, session).header("Authorization", "Bearer " + accessToken).asJson(); - - BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id")); - - String username = extractUsernameFromProfileURL(getJsonProperty(profile, "publicProfileUrl")); - user.setUsername(username); - user.setName(getJsonProperty(profile, "formattedName")); - user.setEmail(getJsonProperty(profile, "emailAddress")); - user.setIdpConfig(getConfig()); - user.setIdp(this); - - AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); - - return user; + return extractIdentityFromProfile(null, profile); } catch (Exception e) { throw new IdentityBrokerException("Could not obtain user profile from linkedIn.", e); } diff --git a/services/src/main/java/org/keycloak/social/microsoft/MicrosoftIdentityProvider.java b/services/src/main/java/org/keycloak/social/microsoft/MicrosoftIdentityProvider.java index 17dde5edb8..5df1ce961d 100755 --- a/services/src/main/java/org/keycloak/social/microsoft/MicrosoftIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/microsoft/MicrosoftIdentityProvider.java @@ -19,6 +19,7 @@ package org.keycloak.social.microsoft; import com.fasterxml.jackson.databind.JsonNode; import org.jboss.logging.Logger; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; @@ -27,8 +28,15 @@ import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.services.ErrorResponseException; +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URLEncoder; /** @@ -53,6 +61,27 @@ public class MicrosoftIdentityProvider extends AbstractOAuth2IdentityProvider im config.setUserInfoUrl(PROFILE_URL); } + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return PROFILE_URL; + } + + @Override + protected SimpleHttp buildUserInfoRequest(String subjectToken, String userInfoUrl) { + String URL = null; + try { + URL = PROFILE_URL + "?access_token=" + URLEncoder.encode(subjectToken, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + return SimpleHttp.doGet(URL, session); + } + @Override protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { try { @@ -62,31 +91,36 @@ public class MicrosoftIdentityProvider extends AbstractOAuth2IdentityProvider im } JsonNode profile = SimpleHttp.doGet(URL, session).asJson(); - String id = getJsonProperty(profile, "id"); - - String email = null; - if (profile.has("emails")) { - email = getJsonProperty(profile.get("emails"), "preferred"); - } - - BrokeredIdentityContext user = new BrokeredIdentityContext(id); - - user.setUsername(email != null ? email : id); - user.setFirstName(getJsonProperty(profile, "first_name")); - user.setLastName(getJsonProperty(profile, "last_name")); - if (email != null) - user.setEmail(email); - user.setIdpConfig(getConfig()); - user.setIdp(this); - - AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); - - return user; + return extractIdentityFromProfile(null, profile); } catch (Exception e) { throw new IdentityBrokerException("Could not obtain user profile from Microsoft Live ID.", e); } } + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + String id = getJsonProperty(profile, "id"); + + String email = null; + if (profile.has("emails")) { + email = getJsonProperty(profile.get("emails"), "preferred"); + } + + BrokeredIdentityContext user = new BrokeredIdentityContext(id); + + user.setUsername(email != null ? email : id); + user.setFirstName(getJsonProperty(profile, "first_name")); + user.setLastName(getJsonProperty(profile, "last_name")); + if (email != null) + user.setEmail(email); + user.setIdpConfig(getConfig()); + user.setIdp(this); + + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + + return user; + } + @Override protected String getDefaultScopes() { return DEFAULT_SCOPE; diff --git a/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java b/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java index fafa42552b..fa583860a4 100644 --- a/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/openshift/OpenshiftV3IdentityProvider.java @@ -1,15 +1,22 @@ package org.keycloak.social.openshift; import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.services.ErrorResponseException; +import javax.ws.rs.core.Response; import java.io.IOException; +import java.net.URLEncoder; import java.util.Optional; /** @@ -63,4 +70,21 @@ public class OpenshiftV3IdentityProvider extends AbstractOAuth2IdentityProvider< .asJson(); } + @Override + protected boolean supportsExternalExchange() { + return true; + } + + @Override + protected String getProfileEndpointForValidation(EventBuilder event) { + return getConfig().getUserInfoUrl(); + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + final BrokeredIdentityContext user = extractUserContext(profile.get("metadata")); + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + return user; + } + } diff --git a/services/src/main/java/org/keycloak/social/paypal/PayPalIdentityProvider.java b/services/src/main/java/org/keycloak/social/paypal/PayPalIdentityProvider.java index a3f460235d..17a8353bf9 100644 --- a/services/src/main/java/org/keycloak/social/paypal/PayPalIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/paypal/PayPalIdentityProvider.java @@ -25,6 +25,7 @@ import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; /** @@ -45,22 +46,37 @@ public class PayPalIdentityProvider extends AbstractOAuth2IdentityProvider Date: Fri, 13 Oct 2017 16:51:56 -0400 Subject: [PATCH 2/2] KEYCLOAK-5683, KEYCLOAK-5684, KEYCLOAK-5682, KEYCLOAK-5612, KEYCLOAK-5611 --- .../broker/provider/IdentityProvider.java | 3 + .../oidc/AbstractOAuth2IdentityProvider.java | 5 +- .../broker/oidc/OIDCIdentityProvider.java | 19 +- .../oidc/endpoints/TokenEndpoint.java | 25 +- .../resources/IdentityBrokerService.java | 1 + .../bitbucket/BitbucketIdentityProvider.java | 72 +++++- .../social/gitlab/GitLabIdentityProvider.java | 88 +++++-- .../twitter/TwitterIdentityProvider.java | 8 +- .../pages/social/BitbucketLoginPage.java | 43 ++++ .../pages/social/GitLabLoginPage.java | 46 ++++ .../testsuite/util/GreenMailRule.java | 13 +- ...bstractBrokerLinkAndTokenExchangeTest.java | 44 ++++ .../testsuite/broker/SocialLoginTest.java | 229 +++++++++++++++++- 13 files changed, 538 insertions(+), 58 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/social/BitbucketLoginPage.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/social/GitLabLoginPage.java diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java index c4f1c5c3b6..6564118269 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java @@ -34,6 +34,9 @@ import javax.ws.rs.core.UriInfo; */ public interface IdentityProvider extends Provider { + String EXTERNAL_IDENTITY_PROVIDER = "EXTERNAL_IDENTITY_PROVIDER"; + String FEDERATED_ACCESS_TOKEN = "FEDERATED_ACCESS_TOKEN"; + interface AuthenticationCallback { /** * This method should be called by provider after the JAXRS callback endpoint has finished authentication diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java index 5d6e94af4a..98d4e34643 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -27,6 +27,7 @@ import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.ExchangeExternalToken; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken; +import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.common.ClientConnection; import org.keycloak.events.Details; @@ -70,7 +71,6 @@ public abstract class AbstractOAuth2IdentityProvider 0 && currentTime > exp) { String response = refreshTokenForLogout(session, userSession); @@ -392,8 +393,20 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProviderStian Thorgersen @@ -42,6 +43,7 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im public static final String AUTH_URL = "https://bitbucket.org/site/oauth2/authorize"; public static final String TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token"; public static final String USER_URL = "https://api.bitbucket.org/2.0/user"; + public static final String USER_EMAIL_URL = "https://api.bitbucket.org/2.0/user/emails"; public static final String EMAIL_SCOPE = "email"; public static final String ACCOUNT_SCOPE = "account"; public static final String DEFAULT_SCOPE = ACCOUNT_SCOPE; @@ -53,7 +55,7 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im String defaultScope = config.getDefaultScope(); if (defaultScope == null || defaultScope.trim().equals("")) { - config.setDefaultScope(ACCOUNT_SCOPE); + config.setDefaultScope(ACCOUNT_SCOPE + " " + EMAIL_SCOPE); } } @@ -68,7 +70,31 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im } @Override - protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + protected BrokeredIdentityContext validateExternalTokenThroughUserInfo(EventBuilder event, String subjectToken, String subjectTokenType) { + event.detail("validation_method", "user info"); + SimpleHttp.Response response = null; + int status = 0; + try { + String userInfoUrl = getProfileEndpointForValidation(event); + response = buildUserInfoRequest(subjectToken, userInfoUrl).asResponse(); + status = response.getStatus(); + } catch (IOException e) { + logger.debug("Failed to invoke user info for external exchange", e); + } + if (status != 200) { + logger.debug("Failed to invoke user info status: " + status); + event.detail(Details.REASON, "user info call failure"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + } + JsonNode profile = null; + try { + profile = response.asJson(); + } catch (IOException e) { + event.detail(Details.REASON, "user info call failure"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + } String type = getJsonProperty(profile, "type"); if (type == null) { event.detail(Details.REASON, "no type data in user info response"); @@ -95,15 +121,46 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); } + String id = getJsonProperty(profile, "account_id"); + if (id == null) { + event.detail(Details.REASON, "user info call failure"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); + + } + return extractUserInfo(subjectToken, profile); + } + + private BrokeredIdentityContext extractUserInfo(String subjectToken, JsonNode profile) { BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "account_id")); + String username = getJsonProperty(profile, "username"); user.setUsername(username); + user.setName(getJsonProperty(profile, "display_name")); user.setIdpConfig(getConfig()); user.setIdp(this); AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); + try { + JsonNode emails = SimpleHttp.doGet(USER_EMAIL_URL, session).header("Authorization", "Bearer " + subjectToken).asJson(); + + // {"pagelen":10,"values":[{"is_primary":true,"is_confirmed":true,"type":"email","email":"bburke@redhat.com","links":{"self":{"href":"https://api.bitbucket.org/2.0/user/emails/bburke@redhat.com"}}}],"page":1,"size":1} + JsonNode emailJson = emails.get("values"); + if (emailJson != null) { + if (emailJson.isArray()) { + emailJson = emailJson.get(0); + } + if (emailJson != null && "email".equals(getJsonProperty(emailJson, "type"))) { + user.setEmail(getJsonProperty(emailJson, "email")); + + } + } + } catch (Exception ignore) { + logger.debug("failed to get email from BitBucket", ignore); + + } return user; } @@ -131,16 +188,7 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im throw new IdentityBrokerException("Could not obtain account information from bitbucket."); } - BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "account_id")); - - String username = getJsonProperty(profile, "username"); - user.setUsername(username); - user.setIdpConfig(getConfig()); - user.setIdp(this); - - AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); - - return user; + return extractUserInfo(accessToken, profile); } catch (Exception e) { if (e instanceof IdentityBrokerException) throw (IdentityBrokerException)e; throw new IdentityBrokerException("Could not obtain user profile from github.", e); diff --git a/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java b/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java index adf2e05399..b781cd4ea0 100755 --- a/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java @@ -24,6 +24,7 @@ import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.events.Details; @@ -93,37 +94,31 @@ public class GitLabIdentityProvider extends OIDCIdentityProvider implements Soc return exchangeExternalUserInfoValidationOnly(event, params); } - protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException { - String id = idToken.getSubject(); - BrokeredIdentityContext identity = new BrokeredIdentityContext(id); - String name = (String)idToken.getOtherClaims().get(IDToken.NAME); - String preferredUsername = (String)idToken.getOtherClaims().get(IDToken.NICKNAME); - String email = (String)idToken.getOtherClaims().get(IDToken.EMAIL); - - if (getConfig().getDefaultScope().contains(API_SCOPE)) { - String userInfoUrl = getUserInfoUrl(); - if (userInfoUrl != null && !userInfoUrl.isEmpty() && (id == null || name == null || preferredUsername == null || email == null)) { - JsonNode userInfo = SimpleHttp.doGet(userInfoUrl, session) - .header("Authorization", "Bearer " + accessToken).asJson(); - - name = getJsonProperty(userInfo, "name"); - preferredUsername = getJsonProperty(userInfo, "username"); - email = getJsonProperty(userInfo, "email"); - AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias()); - } + @Override + protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) { + String id = getJsonProperty(profile, "id"); + if (id == null) { + event.detail(Details.REASON, "id claim is null from user info json"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST); } - identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse); - identity.getContextData().put(VALIDATED_ID_TOKEN, idToken); - processAccessTokenResponse(identity, tokenResponse); + return gitlabExtractFromProfile(profile); + } + + private BrokeredIdentityContext gitlabExtractFromProfile(JsonNode profile) { + String id = getJsonProperty(profile, "id"); + BrokeredIdentityContext identity = new BrokeredIdentityContext(id); + + String name = getJsonProperty(profile, "name"); + String preferredUsername = getJsonProperty(profile, "username"); + String email = getJsonProperty(profile, "email"); + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, profile, getConfig().getAlias()); identity.setId(id); identity.setName(name); identity.setEmail(email); identity.setBrokerUserId(getConfig().getAlias() + "." + id); - if (tokenResponse.getSessionState() != null) { - identity.setBrokerSessionId(getConfig().getAlias() + "." + tokenResponse.getSessionState()); - } if (preferredUsername == null) { preferredUsername = email; @@ -138,6 +133,51 @@ public class GitLabIdentityProvider extends OIDCIdentityProvider implements Soc } + protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException { + + SimpleHttp.Response response = null; + int status = 0; + + for (int i = 0; i < 10; i++) { + try { + String userInfoUrl = getUserInfoUrl(); + response = SimpleHttp.doGet(userInfoUrl, session) + .header("Authorization", "Bearer " + accessToken).asResponse(); + status = response.getStatus(); + } catch (IOException e) { + logger.debug("Failed to invoke user info for external exchange", e); + } + if (status == 200) break; + response.close(); + try { + Thread.sleep(200); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + if (status != 200) { + logger.debug("Failed to invoke user info status: " + status); + throw new IdentityBrokerException("Gitlab user info call failure"); + } + JsonNode profile = null; + try { + profile = response.asJson(); + } catch (IOException e) { + throw new IdentityBrokerException("Gitlab user info call failure"); + } + String id = getJsonProperty(profile, "id"); + if (id == null) { + throw new IdentityBrokerException("Gitlab id claim is null from user info json"); + } + BrokeredIdentityContext identity = gitlabExtractFromProfile(profile); + identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse); + identity.getContextData().put(VALIDATED_ID_TOKEN, idToken); + processAccessTokenResponse(identity, tokenResponse); + + return identity; + } + + diff --git a/services/src/main/java/org/keycloak/social/twitter/TwitterIdentityProvider.java b/services/src/main/java/org/keycloak/social/twitter/TwitterIdentityProvider.java index afcbf4ecdc..3212f36b45 100755 --- a/services/src/main/java/org/keycloak/social/twitter/TwitterIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/twitter/TwitterIdentityProvider.java @@ -18,13 +18,13 @@ package org.keycloak.social.twitter; import org.jboss.logging.Logger; import org.keycloak.OAuth2Constants; -import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; import org.keycloak.broker.provider.AbstractIdentityProvider; import org.keycloak.broker.provider.AuthenticationRequest; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken; +import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.common.ClientConnection; import org.keycloak.events.Details; @@ -142,7 +142,7 @@ public class TwitterIdentityProvider extends AbstractIdentityProvider + */ +public class BitbucketLoginPage extends AbstractSocialLoginPage { + @FindBy(name = "username") + private WebElement usernameInput; + + @FindBy(name = "password") + private WebElement passwordInput; + + @FindBy(name = "commit") + private WebElement loginButton; + + @Override + public void login(String user, String password) { + usernameInput.sendKeys(user); + passwordInput.sendKeys(password); + passwordInput.sendKeys(Keys.RETURN); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/social/GitLabLoginPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/social/GitLabLoginPage.java new file mode 100644 index 0000000000..04b91e26b8 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/social/GitLabLoginPage.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other 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.testsuite.pages.social; + +import org.openqa.selenium.Keys; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Vaclav Muzikar + */ +public class GitLabLoginPage extends AbstractSocialLoginPage { + @FindBy(id = "user_login") + //@FindBy(name = "user[login]") + private WebElement usernameInput; + + @FindBy(id = "user_password") + //@FindBy(name = "user[password]") + private WebElement passwordInput; + + @FindBy(name = "commit") + private WebElement loginButton; + + @Override + public void login(String user, String password) { + usernameInput.sendKeys(user); + passwordInput.sendKeys(password); + passwordInput.sendKeys(Keys.RETURN); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/GreenMailRule.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/GreenMailRule.java index 7ebaa1da57..efc82f3efa 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/GreenMailRule.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/GreenMailRule.java @@ -35,9 +35,20 @@ public class GreenMailRule extends ExternalResource { private GreenMail greenMail; + private int port = 3025; + private String host = "localhost"; + + public GreenMailRule() { + } + + public GreenMailRule(int port, String host) { + this.port = port; + this.host = host; + } + @Override protected void before() throws Throwable { - ServerSetup setup = new ServerSetup(3025, "localhost", "smtp"); + ServerSetup setup = new ServerSetup(port, host, "smtp"); greenMail = new GreenMail(setup); greenMail.start(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java index d3b3258c0f..9bc951944d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java @@ -497,6 +497,11 @@ public abstract class AbstractBrokerLinkAndTokenExchangeTest extends AbstractSer rep.getConfig().put(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, String.valueOf(true)); rep.getConfig().put(OIDCIdentityProviderConfig.USE_JWKS_URL, String.valueOf(true)); rep.getConfig().put(OIDCIdentityProviderConfig.JWKS_URL, parentJwksUrl()); + String parentIssuer = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT) + .path("/realms") + .path(PARENT_IDP) + .build().toString(); + rep.getConfig().put("issuer", parentIssuer); adminClient.realm(CHILD_IDP).identityProviders().get(PARENT_IDP).update(rep); String exchangedUserId = null; @@ -596,6 +601,45 @@ public abstract class AbstractBrokerLinkAndTokenExchangeTest extends AbstractSer Assert.assertEquals(0, adminClient.realm(CHILD_IDP).getClientSessionStats().size()); + List links = childRealm.users().get(exchangedUserId).getFederatedIdentity(); + Assert.assertEquals(1, links.size()); + } + { + // check that we can exchange without specifying an SUBJECT_ISSUER + Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(ClientApp.DEPLOYMENT_NAME, "password")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.JWT_TOKEN_TYPE) + + )); + Assert.assertEquals(200, response.getStatus()); + AccessTokenResponse tokenResponse = response.readEntity(AccessTokenResponse.class); + String exchangedAccessToken = tokenResponse.getToken(); + JWSInput jws = new JWSInput(tokenResponse.getToken()); + AccessToken token = jws.readJsonContent(AccessToken.class); + response.close(); + + String exchanged2UserId = token.getSubject(); + String exchanged2Username = token.getPreferredUsername(); + + // assert that we get the same linked account as was previously imported + + Assert.assertEquals(exchangedUserId, exchanged2UserId); + Assert.assertEquals(exchangedUsername, exchanged2Username); + + // test logout + response = childLogoutWebTarget(httpClient) + .queryParam("id_token_hint", exchangedAccessToken) + .request() + .get(); + response.close(); + + Assert.assertEquals(0, adminClient.realm(CHILD_IDP).getClientSessionStats().size()); + + List links = childRealm.users().get(exchangedUserId).getFederatedIdentity(); Assert.assertEquals(1, links.size()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/SocialLoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/SocialLoginTest.java index e34dd784f3..66eaea23f4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/SocialLoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/SocialLoginTest.java @@ -3,20 +3,36 @@ package org.keycloak.testsuite.broker; import org.jboss.arquillian.graphene.Graphene; import org.jboss.arquillian.graphene.page.Page; import org.junit.After; +import org.junit.Assert; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Ignore; import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.models.ClientModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation; +import org.keycloak.representations.idm.authorization.DecisionStrategy; +import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; +import org.keycloak.services.resources.admin.permissions.AdminPermissions; import org.keycloak.social.openshift.OpenshiftV3IdentityProvider; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.auth.page.login.UpdateAccount; import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.social.AbstractSocialLoginPage; +import org.keycloak.testsuite.pages.social.BitbucketLoginPage; import org.keycloak.testsuite.pages.social.FacebookLoginPage; import org.keycloak.testsuite.pages.social.GitHubLoginPage; +import org.keycloak.testsuite.pages.social.GitLabLoginPage; import org.keycloak.testsuite.pages.social.GoogleLoginPage; import org.keycloak.testsuite.pages.social.LinkedInLoginPage; import org.keycloak.testsuite.pages.social.MicrosoftLoginPage; @@ -24,12 +40,21 @@ import org.keycloak.testsuite.pages.social.PayPalLoginPage; import org.keycloak.testsuite.pages.social.StackOverflowLoginPage; import org.keycloak.testsuite.pages.social.TwitterLoginPage; import org.keycloak.testsuite.util.IdentityProviderBuilder; +import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.URLUtils; import org.keycloak.testsuite.util.WaitUtils; +import org.keycloak.util.BasicAuthHelper; import org.openqa.selenium.By; import org.openqa.selenium.support.ui.ExpectedConditions; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; import java.io.FileInputStream; import java.util.LinkedList; import java.util.List; @@ -38,8 +63,10 @@ import java.util.Properties; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; +import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.BITBUCKET; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.FACEBOOK; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB; +import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITLAB; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GOOGLE; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.LINKEDIN; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.MICROSOFT; @@ -56,6 +83,7 @@ public class SocialLoginTest extends AbstractKeycloakTest { public static final String SOCIAL_CONFIG = "social.config"; public static final String REALM = "social"; + public static final String EXCHANGE_CLIENT = "exchange-client"; private static Properties config = new Properties(); @@ -74,7 +102,9 @@ public class SocialLoginTest extends AbstractKeycloakTest { MICROSOFT("microsoft", MicrosoftLoginPage.class), PAYPAL("paypal", PayPalLoginPage.class), STACKOVERFLOW("stackoverflow", StackOverflowLoginPage.class), - OPENSHIFT("openshift-v3", null); + OPENSHIFT("openshift-v3", null), + GITLAB("gitlab", GitLabLoginPage.class), + BITBUCKET("bitbucket", BitbucketLoginPage.class); private String id; private Class pageObjectClazz; @@ -95,11 +125,15 @@ public class SocialLoginTest extends AbstractKeycloakTest { private Provider currentTestProvider; + private static final boolean localConfig = false; + @BeforeClass public static void loadConfig() throws Exception { - assumeTrue(System.getProperties().containsKey(SOCIAL_CONFIG)); - - config.load(new FileInputStream(System.getProperty(SOCIAL_CONFIG))); + if (localConfig) { + } else { + assumeTrue(System.getProperties().containsKey(SOCIAL_CONFIG)); + config.load(new FileInputStream(System.getProperty(SOCIAL_CONFIG))); + } } @Before @@ -133,6 +167,34 @@ public class SocialLoginTest extends AbstractKeycloakTest { testRealms.add(rep); } + public static void setupClientExchangePermissions(KeycloakSession session) { + RealmModel realm = session.realms().getRealmByName(REALM); + ClientModel client = session.realms().getClientByClientId(EXCHANGE_CLIENT, realm); + // lazy init + if (client != null) return; + client = realm.addClient(EXCHANGE_CLIENT); + client.setSecret("secret"); + client.setPublicClient(false); + client.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + client.setEnabled(true); + client.setDirectAccessGrantsEnabled(true); + + ClientPolicyRepresentation clientPolicyRep = new ClientPolicyRepresentation(); + clientPolicyRep.setName("client-policy"); + clientPolicyRep.addClient(client.getId()); + AdminPermissionManagement management = AdminPermissions.management(session, realm); + management.users().setPermissionsEnabled(true); + ResourceServer server = management.realmResourceServer(); + Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(clientPolicyRep, server); + management.users().adminImpersonatingPermission().addAssociatedPolicy(clientPolicy); + management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE); + for (IdentityProviderModel idp : realm.getIdentityProviders()) { + management.idps().setPermissionsEnabled(idp, true); + management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy); + } + + } + @Test @Ignore // TODO: Fix and revamp this test @@ -155,20 +217,57 @@ public class SocialLoginTest extends AbstractKeycloakTest { currentTestProvider = GOOGLE; performLogin(); assertAccount(); + testTokenExchange(); } @Test - public void facebookLogin() { + public void bitbucketLogin() throws InterruptedException { + currentTestProvider = BITBUCKET; + performLogin(); + assertAccount(); + testTokenExchange(); + } + + // disabled as I can't get this to work with automated login + //@Test + public void gitLabLogin() throws InterruptedException { + currentTestProvider = GITLAB; + // I can't get automated login to work. inspected elements in browser, are not found in the GitLabLoginPage. + performLogin(); + assertAccount(); + testTokenExchange(); + } + + protected void manualLogin() throws InterruptedException { + System.out.println("****** START MANUAL LOGIN ******"); + System.out.println("****** START MANUAL LOGIN ******"); + System.out.println("****** START MANUAL LOGIN ******"); + Thread.sleep(2000); + for (int i = 0; i < 60; i++) { + List users = adminClient.realm(REALM).users().search(null, null, null); + if (users.size() > 0) return; + System.out.println("....waiting"); + Thread.sleep(1000); + } + + } + + @Test + public void facebookLogin() throws InterruptedException { currentTestProvider = FACEBOOK; performLogin(); assertAccount(); + testTokenExchange(); } + @Test - public void githubLogin() { + public void githubLogin() throws InterruptedException { + //Thread.sleep(100000000); currentTestProvider = GITHUB; performLogin(); assertAccount(); + testTokenExchange(); } @Test @@ -201,7 +300,7 @@ public class SocialLoginTest extends AbstractKeycloakTest { } @Test - public void stackoverflowLogin() { + public void stackoverflowLogin() throws InterruptedException { currentTestProvider = STACKOVERFLOW; performLogin(); assertUpdateProfile(false, false, true); @@ -211,6 +310,7 @@ public class SocialLoginTest extends AbstractKeycloakTest { private IdentityProviderRepresentation buildIdp(Provider provider) { IdentityProviderRepresentation idp = IdentityProviderBuilder.create().alias(provider.id()).providerId(provider.id()).build(); idp.setEnabled(true); + idp.setStoreToken(true); idp.getConfig().put("clientId", getConfig(provider, "clientId")); idp.getConfig().put("clientSecret", getConfig(provider, "clientSecret")); if (provider == STACKOVERFLOW) { @@ -289,4 +389,119 @@ public class SocialLoginTest extends AbstractKeycloakTest { updateAccountPage.submit(); } + + protected void testTokenExchange() { + testingClient.server().run(SocialLoginTest::setupClientExchangePermissions); + + List users = adminClient.realm(REALM).users().search(null, null, null); + Assert.assertEquals(1, users.size()); + String username = users.get(0).getUsername(); + Client httpClient = ClientBuilder.newClient(); + + WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) + .path("/realms") + .path(REALM) + .path("protocol/openid-connect/token"); + + // obtain social token + Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(EXCHANGE_CLIENT, "secret")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, username) + .param(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_ISSUER, currentTestProvider.id()) + + )); + Assert.assertEquals(200, response.getStatus()); + AccessTokenResponse tokenResponse = response.readEntity(AccessTokenResponse.class); + response.close(); + + String socialToken = tokenResponse.getToken(); + Assert.assertNotNull(socialToken); + + // remove all users + removeUser(); + + users = adminClient.realm(REALM).users().search(null, null, null); + Assert.assertEquals(0, users.size()); + + // now try external exchange where we trust social provider and import the external token. + response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(EXCHANGE_CLIENT, "secret")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, socialToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.SUBJECT_ISSUER, currentTestProvider.id()) + + )); + Assert.assertEquals(200, response.getStatus()); + tokenResponse = response.readEntity(AccessTokenResponse.class); + response.close(); + + users = adminClient.realm(REALM).users().search(null, null, null); + Assert.assertEquals(1, users.size()); + + Assert.assertEquals(username, users.get(0).getUsername()); + + // remove all users + removeUser(); + + users = adminClient.realm(REALM).users().search(null, null, null); + Assert.assertEquals(0, users.size()); + + ///// Test that we can update social token from session with stored tokens turned off. + + // turn off store token + IdentityProviderRepresentation idp = adminClient.realm(REALM).identityProviders().get(currentTestProvider.id).toRepresentation(); + idp.setStoreToken(false); + adminClient.realm(REALM).identityProviders().get(idp.getAlias()).update(idp); + + + // first exchange social token to get a user session that should store the social token there + response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(EXCHANGE_CLIENT, "secret")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, socialToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.SUBJECT_ISSUER, currentTestProvider.id()) + + )); + Assert.assertEquals(200, response.getStatus()); + tokenResponse = response.readEntity(AccessTokenResponse.class); + String keycloakToken = tokenResponse.getToken(); + response.close(); + + // now take keycloak token and make sure it can get back the social token from the user session since stored tokens are off + response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(EXCHANGE_CLIENT, "secret")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, keycloakToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_ISSUER, currentTestProvider.id()) + + )); + Assert.assertEquals(200, response.getStatus()); + tokenResponse = response.readEntity(AccessTokenResponse.class); + response.close(); + + Assert.assertEquals(socialToken, tokenResponse.getToken()); + + + // turn on store token + idp = adminClient.realm(REALM).identityProviders().get(currentTestProvider.id).toRepresentation(); + idp.setStoreToken(true); + adminClient.realm(REALM).identityProviders().get(idp.getAlias()).update(idp); + + httpClient.close(); + } + }