KEYCLOAK-1487 Multivalued support for UserAttribute protocol mapper. End-to-end LDAP example test including application
This commit is contained in:
parent
09994d1730
commit
605c88a029
9 changed files with 216 additions and 6 deletions
|
@ -60,6 +60,7 @@ public class LDAPConstants {
|
|||
public static final String SAM_ACCOUNT_NAME = "sAMAccountName";
|
||||
public static final String EMAIL = "mail";
|
||||
public static final String POSTAL_CODE = "postalCode";
|
||||
public static final String STREET = "street";
|
||||
public static final String MEMBER = "member";
|
||||
public static final String MEMBER_OF = "memberOf";
|
||||
public static final String OBJECT_CLASS = "objectclass";
|
||||
|
|
|
@ -15,12 +15,15 @@ import java.util.List;
|
|||
public class ProtocolMapperUtils {
|
||||
public static final String USER_ATTRIBUTE = "user.attribute";
|
||||
public static final String USER_SESSION_NOTE = "user.session.note";
|
||||
public static final String MULTIVALUED = "multivalued";
|
||||
public static final String USER_MODEL_PROPERTY_LABEL = "User Property";
|
||||
public static final String USER_MODEL_PROPERTY_HELP_TEXT = "Name of the property method in the UserModel interface. For example, a value of 'email' would reference the UserModel.getEmail() method.";
|
||||
public static final String USER_MODEL_ATTRIBUTE_LABEL = "User Attribute";
|
||||
public static final String USER_MODEL_ATTRIBUTE_HELP_TEXT = "Name of stored user attribute which is the name of an attribute within the UserModel.attribute map.";
|
||||
public static final String USER_SESSION_MODEL_NOTE_LABEL = "User Session Note";
|
||||
public static final String USER_SESSION_MODEL_NOTE_HELP_TEXT = "Name of stored user session note within the UserSessionModel.note map.";
|
||||
public static final String MULTIVALUED_LABEL = "Multivalued";
|
||||
public static final String MULTIVALUED_HELP_TEXT = "Indicates if attribute supports multiple values. If true, then the list of all values of this attribute will be set as claim. If false, then just first value will be set as claim";
|
||||
|
||||
public static String getUserModelValue(UserModel user, String propertyName) {
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.keycloak.protocol.oidc.mappers;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
|
@ -19,6 +20,8 @@ import java.util.Map;
|
|||
* @version $Revision: 1 $
|
||||
*/
|
||||
public class OIDCAttributeMapperHelper {
|
||||
private static final Logger logger = Logger.getLogger(OIDCAttributeMapperHelper.class);
|
||||
|
||||
public static final String TOKEN_CLAIM_NAME = "claim.name";
|
||||
public static final String TOKEN_CLAIM_NAME_LABEL = "Token Claim Name";
|
||||
public static final String JSON_TYPE = "Claim JSON Type";
|
||||
|
@ -31,6 +34,26 @@ public class OIDCAttributeMapperHelper {
|
|||
|
||||
public static Object mapAttributeValue(ProtocolMapperModel mappingModel, Object attributeValue) {
|
||||
if (attributeValue == null) return null;
|
||||
|
||||
if (attributeValue instanceof List) {
|
||||
List<Object> valueAsList = (List<Object>) attributeValue;
|
||||
if (valueAsList.size() == 0) return null;
|
||||
|
||||
if (isMultivalued(mappingModel)) {
|
||||
List<Object> result = new ArrayList<>();
|
||||
for (Object valueItem : valueAsList) {
|
||||
result.add(mapAttributeValue(mappingModel, valueItem));
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
if (valueAsList.size() > 1) {
|
||||
logger.warnf("Multiple values found '%s' for protocol mapper '%s' but expected just single value", attributeValue.toString(), mappingModel.getName());
|
||||
}
|
||||
|
||||
attributeValue = valueAsList.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
String type = mappingModel.getConfig().get(JSON_TYPE);
|
||||
if (type == null) return attributeValue;
|
||||
if (type.equals("boolean")) {
|
||||
|
@ -53,8 +76,9 @@ public class OIDCAttributeMapperHelper {
|
|||
}
|
||||
|
||||
public static void mapClaim(IDToken token, ProtocolMapperModel mappingModel, Object attributeValue) {
|
||||
if (attributeValue == null) return;
|
||||
attributeValue = mapAttributeValue(mappingModel, attributeValue);
|
||||
if (attributeValue == null) return;
|
||||
|
||||
String protocolClaim = mappingModel.getConfig().get(TOKEN_CLAIM_NAME);
|
||||
String[] split = protocolClaim.split("\\.");
|
||||
Map<String, Object> jsonObject = token.getOtherClaims();
|
||||
|
@ -102,6 +126,11 @@ public class OIDCAttributeMapperHelper {
|
|||
return "true".equals(mappingModel.getConfig().get(INCLUDE_IN_ACCESS_TOKEN));
|
||||
}
|
||||
|
||||
|
||||
public static boolean isMultivalued(ProtocolMapperModel mappingModel) {
|
||||
return "true".equals(mappingModel.getConfig().get(ProtocolMapperUtils.MULTIVALUED));
|
||||
}
|
||||
|
||||
public static void addAttributeConfig(List<ProviderConfigProperty> configProperties) {
|
||||
ProviderConfigProperty property;
|
||||
property = new ProviderConfigProperty();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.keycloak.protocol.oidc.mappers;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.ClientSessionModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
|
@ -35,6 +36,13 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
|
|||
configProperties.add(property);
|
||||
OIDCAttributeMapperHelper.addAttributeConfig(configProperties);
|
||||
|
||||
property = new ProviderConfigProperty();
|
||||
property.setName(ProtocolMapperUtils.MULTIVALUED);
|
||||
property.setLabel(ProtocolMapperUtils.MULTIVALUED_LABEL);
|
||||
property.setHelpText(ProtocolMapperUtils.MULTIVALUED_HELP_TEXT);
|
||||
property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
|
||||
configProperties.add(property);
|
||||
|
||||
}
|
||||
|
||||
public static final String PROVIDER_ID = "oidc-usermodel-attribute-mapper";
|
||||
|
@ -76,7 +84,7 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
|
|||
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) {
|
||||
UserModel user = userSession.getUser();
|
||||
String attributeName = mappingModel.getConfig().get(ProtocolMapperUtils.USER_ATTRIBUTE);
|
||||
String attributeValue = user.getFirstAttribute(attributeName);
|
||||
List<String> attributeValue = user.getAttribute(attributeName);
|
||||
if (attributeValue == null) return;
|
||||
OIDCAttributeMapperHelper.mapClaim(token, mappingModel, attributeValue);
|
||||
}
|
||||
|
@ -92,12 +100,18 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O
|
|||
String userAttribute,
|
||||
String tokenClaimName, String claimType,
|
||||
boolean consentRequired, String consentText,
|
||||
boolean accessToken, boolean idToken) {
|
||||
return OIDCAttributeMapperHelper.createClaimMapper(name, userAttribute,
|
||||
boolean accessToken, boolean idToken, boolean multivalued) {
|
||||
ProtocolMapperModel mapper = OIDCAttributeMapperHelper.createClaimMapper(name, userAttribute,
|
||||
tokenClaimName, claimType,
|
||||
consentRequired, consentText,
|
||||
accessToken, idToken,
|
||||
PROVIDER_ID);
|
||||
|
||||
if (multivalued) {
|
||||
mapper.getConfig().put(ProtocolMapperUtils.MULTIVALUED, "true");
|
||||
}
|
||||
|
||||
return mapper;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package org.keycloak.testsuite.federation;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.keycloak.KeycloakSecurityContext;
|
||||
import org.keycloak.representations.IDToken;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class LDAPExampleServlet extends HttpServlet {
|
||||
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
|
||||
KeycloakSecurityContext securityContext = (KeycloakSecurityContext) req.getAttribute(KeycloakSecurityContext.class.getName());
|
||||
IDToken idToken = securityContext.getIdToken();
|
||||
|
||||
PrintWriter out = resp.getWriter();
|
||||
out.println("<html><head><title>LDAP Portal</title></head><body>");
|
||||
out.println("<table border><tr><th>Attribute name</th><th>Attribute values</th></tr>");
|
||||
|
||||
out.printf("<tr><td>%s</td><td>%s</td></tr>", "preferred_username", idToken.getPreferredUsername());
|
||||
out.println();
|
||||
out.printf("<tr><td>%s</td><td>%s</td></tr>", "name", idToken.getName());
|
||||
out.println();
|
||||
out.printf("<tr><td>%s</td><td>%s</td></tr>", "email", idToken.getEmail());
|
||||
out.println();
|
||||
|
||||
for (Map.Entry<String, Object> claim : idToken.getOtherClaims().entrySet()) {
|
||||
Object value = claim.getValue();
|
||||
|
||||
if (value instanceof List) {
|
||||
List<String> asList = (List<String>) value;
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (String item : asList) {
|
||||
result.append(item + "<br>");
|
||||
}
|
||||
value = result.toString();
|
||||
}
|
||||
|
||||
out.printf("<tr><td>%s</td><td>%s</td></tr>", claim.getKey(), value);
|
||||
out.println();
|
||||
}
|
||||
|
||||
out.println("</table></body></html>");
|
||||
out.flush();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,26 +1,40 @@
|
|||
package org.keycloak.testsuite.federation;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.ws.rs.core.UriBuilder;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.FixMethodOrder;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.RuleChain;
|
||||
import org.junit.rules.TestRule;
|
||||
import org.junit.runners.MethodSorters;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.federation.ldap.LDAPFederationProvider;
|
||||
import org.keycloak.federation.ldap.LDAPFederationProviderFactory;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.LDAPConstants;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserFederationProvider;
|
||||
import org.keycloak.models.UserFederationProviderModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
||||
import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
|
||||
import org.keycloak.services.managers.RealmManager;
|
||||
import org.keycloak.testsuite.OAuthClient;
|
||||
import org.keycloak.testsuite.adapter.AdapterTest;
|
||||
import org.keycloak.testsuite.pages.LoginPage;
|
||||
import org.keycloak.testsuite.rule.KeycloakRule;
|
||||
import org.keycloak.testsuite.rule.LDAPRule;
|
||||
import org.keycloak.testsuite.rule.WebResource;
|
||||
import org.keycloak.testsuite.rule.WebRule;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
|
@ -28,6 +42,9 @@ import org.keycloak.testsuite.rule.LDAPRule;
|
|||
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
||||
public class LDAPMultipleAttributesTest {
|
||||
|
||||
protected String APP_SERVER_BASE_URL = "http://localhost:8081";
|
||||
protected String LOGIN_URL = OIDCLoginProtocolService.authUrl(UriBuilder.fromUri(APP_SERVER_BASE_URL + "/auth")).build("test").toString();
|
||||
|
||||
private static LDAPRule ldapRule = new LDAPRule();
|
||||
|
||||
private static UserFederationProviderModel ldapModel = null;
|
||||
|
@ -41,6 +58,24 @@ public class LDAPMultipleAttributesTest {
|
|||
|
||||
ldapModel = appRealm.addUserFederationProvider(LDAPFederationProviderFactory.PROVIDER_NAME, ldapConfig, 0, "test-ldap", -1, -1, 0);
|
||||
FederationTestUtils.addZipCodeLDAPMapper(appRealm, ldapModel);
|
||||
FederationTestUtils.addUserAttributeMapper(appRealm, ldapModel, "streetMapper", "street", LDAPConstants.STREET);
|
||||
|
||||
// Create ldap-portal client
|
||||
ClientModel ldapClient = appRealm.addClient("ldap-portal");
|
||||
ldapClient.addRedirectUri("/ldap-portal");
|
||||
ldapClient.addRedirectUri("/ldap-portal/*");
|
||||
ldapClient.setManagementUrl("/ldap-portal");
|
||||
ldapClient.addProtocolMapper(UserAttributeMapper.createClaimMapper("postalCode", "postal_code", "postal_code", "String", true, "", true, true, true));
|
||||
ldapClient.addProtocolMapper(UserAttributeMapper.createClaimMapper("street", "street", "street", "String", true, "", true, true, false));
|
||||
ldapClient.addScopeMapping(appRealm.getRole("user"));
|
||||
ldapClient.setSecret("password");
|
||||
|
||||
// Deploy ldap-portal client
|
||||
URL url = getClass().getResource("/ldap/ldap-app-keycloak.json");
|
||||
keycloakRule.createApplicationDeployment()
|
||||
.name("ldap-portal").contextPath("/ldap-portal")
|
||||
.servletClass(LDAPExampleServlet.class).adapterConfigPath(url.getPath())
|
||||
.role("user").deployApplication();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -49,6 +84,18 @@ public class LDAPMultipleAttributesTest {
|
|||
.outerRule(ldapRule)
|
||||
.around(keycloakRule);
|
||||
|
||||
@Rule
|
||||
public WebRule webRule = new WebRule(this);
|
||||
|
||||
@WebResource
|
||||
protected WebDriver driver;
|
||||
|
||||
@WebResource
|
||||
protected OAuthClient oauth;
|
||||
|
||||
@WebResource
|
||||
protected LoginPage loginPage;
|
||||
|
||||
@Test
|
||||
public void testModel() {
|
||||
KeycloakSession session = keycloakRule.startSession();
|
||||
|
@ -105,6 +152,40 @@ public class LDAPMultipleAttributesTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ldapPortalEndToEndTest() {
|
||||
// Login as bwilson
|
||||
driver.navigate().to(APP_SERVER_BASE_URL + "/ldap-portal");
|
||||
Assert.assertTrue(driver.getCurrentUrl().startsWith(LOGIN_URL));
|
||||
loginPage.login("bwilson", "password");
|
||||
Assert.assertTrue(driver.getCurrentUrl().startsWith(APP_SERVER_BASE_URL + "/ldap-portal"));
|
||||
String pageSource = driver.getPageSource();
|
||||
System.out.println(pageSource);
|
||||
Assert.assertTrue(pageSource.contains("bwilson") && pageSource.contains("Bruce"));
|
||||
Assert.assertTrue(pageSource.contains("street") && pageSource.contains("Elm 5"));
|
||||
Assert.assertTrue(pageSource.contains("postal_code") && pageSource.contains("88441") && pageSource.contains("77332"));
|
||||
|
||||
// Logout
|
||||
String logoutUri = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(APP_SERVER_BASE_URL + "/auth"))
|
||||
.queryParam(OAuth2Constants.REDIRECT_URI, APP_SERVER_BASE_URL + "/ldap-portal").build("test").toString();
|
||||
driver.navigate().to(logoutUri);
|
||||
|
||||
// Login as jbrown
|
||||
driver.navigate().to(APP_SERVER_BASE_URL + "/ldap-portal");
|
||||
Assert.assertTrue(driver.getCurrentUrl().startsWith(LOGIN_URL));
|
||||
loginPage.login("jbrown", "password");
|
||||
Assert.assertTrue(driver.getCurrentUrl().startsWith(APP_SERVER_BASE_URL + "/ldap-portal"));
|
||||
pageSource = driver.getPageSource();
|
||||
System.out.println(pageSource);
|
||||
Assert.assertTrue(pageSource.contains("jbrown") && pageSource.contains("James Brown"));
|
||||
Assert.assertFalse(pageSource.contains("street"));
|
||||
Assert.assertTrue(pageSource.contains("postal_code") && pageSource.contains("88441"));
|
||||
Assert.assertFalse(pageSource.contains("77332"));
|
||||
|
||||
// Logout
|
||||
driver.navigate().to(logoutUri);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -69,7 +69,9 @@ import javax.ws.rs.core.Response;
|
|||
import javax.ws.rs.core.UriBuilder;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
@ -622,13 +624,16 @@ public class AccessTokenTest {
|
|||
user.setSingleAttribute("postal_code", "02115");
|
||||
user.setSingleAttribute("country", "USA");
|
||||
user.setSingleAttribute("phone", "617-777-6666");
|
||||
List<String> departments = Arrays.asList("finance", "development");
|
||||
user.setAttribute("departments", departments);
|
||||
ClientModel app = realm.getClientByClientId("test-app");
|
||||
ProtocolMapperModel mapper = AddressMapper.createAddressMapper(true, true);
|
||||
app.addProtocolMapper(mapper);
|
||||
app.addProtocolMapper(HardcodedClaim.create("hard", "hard", "coded", "String", false, null, true, true));
|
||||
app.addProtocolMapper(HardcodedClaim.create("hard-nested", "nested.hard", "coded-nested", "String", false, null, true, true));
|
||||
app.addProtocolMapper(UserAttributeMapper.createClaimMapper("custom phone", "phone", "home_phone", "String", true, "", true, true));
|
||||
app.addProtocolMapper(UserAttributeMapper.createClaimMapper("nested phone", "phone", "home.phone", "String", true, "", true, true));
|
||||
app.addProtocolMapper(UserAttributeMapper.createClaimMapper("custom phone", "phone", "home_phone", "String", true, "", true, true, false));
|
||||
app.addProtocolMapper(UserAttributeMapper.createClaimMapper("nested phone", "phone", "home.phone", "String", true, "", true, true, false));
|
||||
app.addProtocolMapper(UserAttributeMapper.createClaimMapper("departments", "departments", "department", "String", true, "", true, true, true));
|
||||
app.addProtocolMapper(HardcodedRole.create("hard-realm", "hardcoded"));
|
||||
app.addProtocolMapper(HardcodedRole.create("hard-app", "app.hardcoded"));
|
||||
app.addProtocolMapper(RoleNameMapper.create("rename-app-role", "test-app.customer-user", "realm-user"));
|
||||
|
@ -655,6 +660,9 @@ public class AccessTokenTest {
|
|||
Assert.assertEquals("coded-nested", nested.get("hard"));
|
||||
nested = (Map)idToken.getOtherClaims().get("home");
|
||||
Assert.assertEquals("617-777-6666", nested.get("phone"));
|
||||
List<String> departments = (List<String>)idToken.getOtherClaims().get("department");
|
||||
Assert.assertEquals(2, departments.size());
|
||||
Assert.assertTrue(departments.contains("finance") && departments.contains("development"));
|
||||
|
||||
AccessToken accessToken = getAccessToken(tokenResponse);
|
||||
Assert.assertEquals(accessToken.getName(), "Tom Brady");
|
||||
|
@ -671,6 +679,9 @@ public class AccessTokenTest {
|
|||
Assert.assertEquals("coded-nested", nested.get("hard"));
|
||||
nested = (Map)accessToken.getOtherClaims().get("home");
|
||||
Assert.assertEquals("617-777-6666", nested.get("phone"));
|
||||
departments = (List<String>)idToken.getOtherClaims().get("department");
|
||||
Assert.assertEquals(2, departments.size());
|
||||
Assert.assertTrue(departments.contains("finance") && departments.contains("development"));
|
||||
Assert.assertTrue(accessToken.getRealmAccess().getRoles().contains("hardcoded"));
|
||||
Assert.assertTrue(accessToken.getRealmAccess().getRoles().contains("realm-user"));
|
||||
Assert.assertFalse(accessToken.getResourceAccess("test-app").getRoles().contains("customer-user"));
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"realm": "test",
|
||||
"resource": "ldap-portal",
|
||||
"realm-public-key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB",
|
||||
"auth-server-url": "http://localhost:8081/auth",
|
||||
"ssl-required" : "external",
|
||||
"credentials": {
|
||||
"secret": "password"
|
||||
}
|
||||
}
|
|
@ -29,6 +29,7 @@ cn: James
|
|||
sn: Brown
|
||||
mail: jbrown@keycloak.org
|
||||
postalCode: 88441
|
||||
userPassword: password
|
||||
|
||||
dn: uid=bwilson,ou=People,dc=keycloak,dc=org
|
||||
objectclass: top
|
||||
|
@ -42,3 +43,5 @@ sn: Schneider
|
|||
mail: bwilson@keycloak.org
|
||||
postalCode: 88441
|
||||
postalCode: 77332
|
||||
street: Elm 5
|
||||
userPassword: password
|
||||
|
|
Loading…
Reference in a new issue