KEYCLOAK-14006 Allow administrator to add additional fields to be fetched with Facebook profile request

This commit is contained in:
Bartosz Siemieńczuk 2020-05-05 10:41:17 +02:00 committed by Bruno Oliveira da Silva
parent de9a0a0a4a
commit e2040f5d13
6 changed files with 99 additions and 14 deletions

View file

@ -18,34 +18,29 @@
package org.keycloak.social.facebook; package org.keycloak.social.facebook;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider; import org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider;
import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.services.ErrorResponseException; import org.keycloak.saml.common.util.StringUtil;
import javax.ws.rs.core.Response;
import java.io.IOException;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
public class FacebookIdentityProvider extends AbstractOAuth2IdentityProvider implements SocialIdentityProvider { public class FacebookIdentityProvider extends AbstractOAuth2IdentityProvider<FacebookIdentityProviderConfig> implements SocialIdentityProvider<FacebookIdentityProviderConfig> {
public static final String AUTH_URL = "https://graph.facebook.com/oauth/authorize"; public static final String AUTH_URL = "https://graph.facebook.com/oauth/authorize";
public static final String TOKEN_URL = "https://graph.facebook.com/oauth/access_token"; public static final String TOKEN_URL = "https://graph.facebook.com/oauth/access_token";
public static final String PROFILE_URL = "https://graph.facebook.com/me?fields=id,name,email,first_name,last_name"; public static final String PROFILE_URL = "https://graph.facebook.com/me?fields=id,name,email,first_name,last_name";
public static final String DEFAULT_SCOPE = "email"; public static final String DEFAULT_SCOPE = "email";
protected static final String PROFILE_URL_FIELDS_SEPARATOR = ",";
public FacebookIdentityProvider(KeycloakSession session, OAuth2IdentityProviderConfig config) { public FacebookIdentityProvider(KeycloakSession session, FacebookIdentityProviderConfig config) {
super(session, config); super(session, config);
config.setAuthorizationUrl(AUTH_URL); config.setAuthorizationUrl(AUTH_URL);
config.setTokenUrl(TOKEN_URL); config.setTokenUrl(TOKEN_URL);
@ -54,8 +49,11 @@ public class FacebookIdentityProvider extends AbstractOAuth2IdentityProvider imp
protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) {
try { try {
JsonNode profile = SimpleHttp.doGet(PROFILE_URL, session).header("Authorization", "Bearer " + accessToken).asJson(); final String fetchedFields = getConfig().getFetchedFields();
final String url = StringUtil.isNotNull(fetchedFields)
? String.join(PROFILE_URL_FIELDS_SEPARATOR, PROFILE_URL, fetchedFields)
: PROFILE_URL;
JsonNode profile = SimpleHttp.doGet(url, session).header("Authorization", "Bearer " + accessToken).asJson();
return extractIdentityFromProfile(null, profile); return extractIdentityFromProfile(null, profile);
} catch (Exception e) { } catch (Exception e) {
throw new IdentityBrokerException("Could not obtain user profile from facebook.", e); throw new IdentityBrokerException("Could not obtain user profile from facebook.", e);

View file

@ -0,0 +1,28 @@
package org.keycloak.social.facebook;
import java.util.Optional;
import org.apache.commons.lang.StringUtils;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.saml.common.util.StringUtil;
public class FacebookIdentityProviderConfig extends OIDCIdentityProviderConfig {
public FacebookIdentityProviderConfig(IdentityProviderModel model) {
super(model);
}
public FacebookIdentityProviderConfig() {
}
public String getFetchedFields() {
return Optional.ofNullable(getConfig().get("fetchedFields"))
.map(fieldsConfig -> fieldsConfig.replaceAll("\\s+",""))
.orElse("");
}
public void setFetchedFields(final String fetchedFields) {
getConfig().put("fetchedFields", fetchedFields);
}
}

View file

@ -36,7 +36,7 @@ public class FacebookIdentityProviderFactory extends AbstractIdentityProviderFac
@Override @Override
public FacebookIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { public FacebookIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
return new FacebookIdentityProvider(session, new OAuth2IdentityProviderConfig(model)); return new FacebookIdentityProvider(session, new FacebookIdentityProviderConfig(model));
} }
@Override @Override

View file

@ -8,16 +8,20 @@ import org.junit.Before;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer; import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
@ -65,11 +69,14 @@ import java.util.Properties;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assume.assumeTrue; import static org.junit.Assume.assumeTrue;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.BITBUCKET; 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.FACEBOOK;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.FACEBOOK_INCLUDE_BIRTHDAY;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB_PRIVATE_EMAIL; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB_PRIVATE_EMAIL;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITLAB; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITLAB;
@ -85,6 +92,8 @@ import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.PAYPAL;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.STACKOVERFLOW; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.STACKOVERFLOW;
import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.TWITTER; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.TWITTER;
import com.google.common.collect.ImmutableMap;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
* @author Vaclav Muzikar <vmuzikar@redhat.com> * @author Vaclav Muzikar <vmuzikar@redhat.com>
@ -109,6 +118,7 @@ public class SocialLoginTest extends AbstractKeycloakTest {
GOOGLE_HOSTED_DOMAIN("google", "google-hosted-domain", GoogleLoginPage.class), GOOGLE_HOSTED_DOMAIN("google", "google-hosted-domain", GoogleLoginPage.class),
GOOGLE_NON_MATCHING_HOSTED_DOMAIN("google", "google-hosted-domain", GoogleLoginPage.class), GOOGLE_NON_MATCHING_HOSTED_DOMAIN("google", "google-hosted-domain", GoogleLoginPage.class),
FACEBOOK("facebook", FacebookLoginPage.class), FACEBOOK("facebook", FacebookLoginPage.class),
FACEBOOK_INCLUDE_BIRTHDAY("facebook", FacebookLoginPage.class),
GITHUB("github", GitHubLoginPage.class), GITHUB("github", GitHubLoginPage.class),
GITHUB_PRIVATE_EMAIL("github", "github-private-email", GitHubLoginPage.class), GITHUB_PRIVATE_EMAIL("github", "github-private-email", GitHubLoginPage.class),
TWITTER("twitter", TwitterLoginPage.class), TWITTER("twitter", TwitterLoginPage.class),
@ -308,6 +318,17 @@ public class SocialLoginTest extends AbstractKeycloakTest {
testTokenExchange(); testTokenExchange();
} }
@Test
@UncaughtServerErrorExpected
public void facebookLoginWithEnhancedScope() throws InterruptedException {
setTestProvider(FACEBOOK_INCLUDE_BIRTHDAY);
addBirthdayMapper();
performLogin();
assertAccount();
assertBirthdayAttribute();
testTokenExchange();
}
@Test @Test
public void instagramLogin() throws InterruptedException { public void instagramLogin() throws InterruptedException {
setTestProvider(INSTAGRAM); setTestProvider(INSTAGRAM);
@ -401,9 +422,29 @@ public class SocialLoginTest extends AbstractKeycloakTest {
if (provider == PAYPAL) { if (provider == PAYPAL) {
idp.getConfig().put("sandbox", getConfig(provider, "sandbox")); idp.getConfig().put("sandbox", getConfig(provider, "sandbox"));
} }
if (provider == FACEBOOK_INCLUDE_BIRTHDAY) {
idp.getConfig().put("defaultScope", "public_profile,email,user_birthday");
idp.getConfig().put("fetchedFields", "birthday");
}
return idp; return idp;
} }
private void addBirthdayMapper() {
IdentityProviderResource identityProvider = adminClient.realm(REALM).identityProviders().get(currentTestProvider.id);
IdentityProviderRepresentation identityProviderRepresentation = identityProvider.toRepresentation();
//Add birthday mapper
IdentityProviderMapperRepresentation mapperRepresentation = new IdentityProviderMapperRepresentation();
mapperRepresentation.setName(currentTestProvider.id + "-birthday-mapper");
mapperRepresentation.setIdentityProviderAlias(identityProviderRepresentation.getAlias());
mapperRepresentation.setIdentityProviderMapper(currentTestProvider.id + "-user-attribute-mapper");
mapperRepresentation.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString())
.put(AbstractJsonUserAttributeMapper.CONF_JSON_FIELD, "birthday")
.put(AbstractJsonUserAttributeMapper.CONF_USER_ATTRIBUTE, currentTestProvider.id + "_birthday")
.build());
identityProvider.addMapper(mapperRepresentation).close();
}
private String getConfig(Provider provider, String key) { private String getConfig(Provider provider, String key) {
String providerKey = provider.configId() + "." + key; String providerKey = provider.configId() + "." + key;
return System.getProperty("social." + providerKey, config.getProperty(providerKey, config.getProperty("common." + key))); return System.getProperty("social." + providerKey, config.getProperty(providerKey, config.getProperty("common." + key)));
@ -449,6 +490,15 @@ public class SocialLoginTest extends AbstractKeycloakTest {
assertEquals(getConfig("profile.email"), accountPage.getEmail()); assertEquals(getConfig("profile.email"), accountPage.getEmail());
} }
private void assertBirthdayAttribute() {
List<UserRepresentation> users = adminClient.realm(REALM).users().search(null, null, null);
assertEquals(1, users.size());
assertNotNull(users.get(0).getAttributes());
final String birthdayAttributeKey = currentTestProvider.id + "_birthday";
assertNotNull(users.get(0).getAttributes().get(birthdayAttributeKey));
assertEquals(getConfig("profile.birthday"), users.get(0).getAttributes().get(birthdayAttributeKey).get(0));
}
private void assertUpdateProfile(boolean firstName, boolean lastName, boolean email) { private void assertUpdateProfile(boolean firstName, boolean lastName, boolean email) {
assertTrue(URLUtils.currentUrlDoesntStartWith(accountPage.toString())); assertTrue(URLUtils.currentUrlDoesntStartWith(accountPage.toString()));

View file

@ -582,6 +582,8 @@ offlineAccess=Request refresh token
identity-provider.google-offlineAccess.tooltip=Set 'access_type' query parameter to 'offline' when redirecting to google authorization endpoint, to get a refresh token back. Useful if planning to use Token Exchange to retrieve Google token to access Google APIs when the user is not at the browser. identity-provider.google-offlineAccess.tooltip=Set 'access_type' query parameter to 'offline' when redirecting to google authorization endpoint, to get a refresh token back. Useful if planning to use Token Exchange to retrieve Google token to access Google APIs when the user is not at the browser.
hostedDomain=Hosted Domain hostedDomain=Hosted Domain
identity-provider.google-hostedDomain.tooltip=Set 'hd' query parameter when logging in with Google. Google will list accounts only for this domain. Keycloak validates that the returned identity token has a claim for this domain. When '*' is entered, any hosted account can be used. identity-provider.google-hostedDomain.tooltip=Set 'hd' query parameter when logging in with Google. Google will list accounts only for this domain. Keycloak validates that the returned identity token has a claim for this domain. When '*' is entered, any hosted account can be used.
identity-provider.facebook-fetchedFields.label=Additional user's profile fields
identity-provider.facebook-fetchedFields.tooltip=Provide additional fields which would be fetched using the profile request. This will be appended to the default set of 'id,name,email,first_name,last_name'.
sandbox=Target Sandbox sandbox=Target Sandbox
identity-provider.paypal-sandbox.tooltip=Target PayPal's sandbox environment identity-provider.paypal-sandbox.tooltip=Target PayPal's sandbox environment
update-profile-on-first-login=Update Profile on First Login update-profile-on-first-login=Update Profile on First Login

View file

@ -0,0 +1,7 @@
<div class="form-group">
<label class="col-md-2 control-label" for="fetchedFields">{{:: 'identity-provider.facebook-fetchedFields.label' | translate}}</label>
<div class="col-md-6">
<input ng-model="identityProvider.config.fetchedFields" id="fetchedFields" class="form-control" />
</div>
<kc-tooltip>{{:: 'identity-provider.facebook-fetchedFields.tooltip' | translate}}</kc-tooltip>
</div>