Merge pull request #3231 from TeliaSoneraNorge/pr/KEYCLOAK-3422

KEYCLOAK-3422 support pairwise subject identifier in oidc
This commit is contained in:
Marek Posolda 2016-09-14 21:51:48 +02:00 committed by GitHub
commit 5afe93552a
31 changed files with 995 additions and 103 deletions

View file

@ -17,17 +17,6 @@
package org.keycloak;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.representations.IDToken;
@ -36,6 +25,13 @@ import org.keycloak.representations.adapters.config.AdapterConfig;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -138,6 +134,18 @@ public class JsonParserTest {
Assert.assertNull(clientRep.getJwks());
}
@Test
public void testReadOIDCClientRepWithPairwise() throws IOException {
String stringRep = "{\"subject_type\": \"pairwise\", \"jwks_uri\": \"https://op.certification.openid.net:60720/export/jwk_60720.json\", \"contacts\": [\"roland.hedberg@umu.se\"], \"application_type\": \"web\", \"grant_types\": [\"authorization_code\"], \"post_logout_redirect_uris\": [\"https://op.certification.openid.net:60720/logout\"], \"redirect_uris\": [\"https://op.certification.openid.net:60720/authz_cb\"], \"response_types\": [\"code\"], \"require_auth_time\": true, \"default_max_age\": 3600}";
OIDCClientRepresentation clientRep = JsonSerialization.readValue(stringRep, OIDCClientRepresentation.class);
Assert.assertEquals("pairwise", clientRep.getSubjectType());
Assert.assertTrue(clientRep.getRequireAuthTime());
Assert.assertEquals(3600, clientRep.getDefaultMaxAge().intValue());
Assert.assertEquals(1, clientRep.getRedirectUris().size());
Assert.assertEquals("https://op.certification.openid.net:60720/authz_cb", clientRep.getRedirectUris().get(0));
Assert.assertNull(clientRep.getJwks());
}
@Test
public void testReadOIDCClientRepWithJWKS() throws IOException {
String stringRep = "{\"token_endpoint_auth_method\": \"private_key_jwt\", \"subject_type\": \"public\", \"jwks_uri\": null, \"jwks\": {\"keys\": [{\"use\": \"enc\", \"e\": \"AQAB\", \"d\": \"lZQv0_81euRLeUYU84Aodh0ar7ymDlzWP5NMra4Jklkb-lTBWkI-u4RMsPqGYyW3KHRoL_pgzZXSzQx8RLQfER6timRWb--NxMMKllZubByU3RqH2ooNuocJurspYiXkznPW1Mg9DaNXL0C2hwWPQHTeUVISpjgi5TCOV1ccWVyksFruya_VNL1CIByB-L0GL1rqbKv32cDwi2A3_jJa61cpzfLSIBe-lvCO6tuiDsR4qgJnUwnndQFwEI_4mLmD3iNWXrc8N-poleV8mBfMqBB5fWwy_ZTFCpmQ5AywGmctaik_wNhMoWuA4tUfY6_1LdKld-5Cjq55eLtuJjtvuQ\", \"n\": \"tx3Hjdbc19lkTiohbJrNj4jf2_90MEE122CRrwtFu6saDywKcG7Bi7w2FMAK2oTkuWfqhWRb5BEGmnSXdiCEPO5d-ytqP3nwlZXHaCDYscpP8bB4YLhvCn7R8Efw6gwQle24QPRP3lYoFeuUbDUq7GKA5SfaZUvWoeWjqyLIaBspKQsC26_Umx1E4IXLrMSL6nkRnrYcVZBAXrYCeTP1XtsV38_lZVJfHSaJaUy4PKaj3yvgm93EV2CXybPti7CCMXZ34VqqWiF64pQjZsPu3ZTr7ha_TTQq499-zYRQNDvIVsBDLQQIgrbctuGqj6lrXb31Jj3JIEYqH_4h5X9d0Q\", \"q\": \"1q-r-bmMFbIzrLK2U3elksZq8CqUqZxlSfkGMZuVkxgYMS-e4FPzEp2iirG-eO11aa0cpMMoBdTnVdGJ_ZUR93w0lGf9XnQAJqxP7eOsrUoiW4VWlWH4WfOiLgpO-pFtyTz_JksYYaotc_Z3Zy-Szw6a39IDbuYGy1qL-15oQuc\", \"p\": \"2lrYPppRbcQWu4LtWN6tOVUrtCOPv1eLTKTc7q8vCMcem1Ox5QFB7KnUtNZ5Ni7wnZUeVDfimNebtjNsGvDSrpgIlo9dEnFBQsQIkzZ2SkoYfgmF8hNdi6P-BfRjdgYouy4c6xAnGDgSMTip1YnPRyvbMaoYT9E_tEcBW5wOeoc\", \"kid\": \"a0\", \"kty\": \"RSA\"}, {\"use\": \"sig\", \"e\": \"AQAB\", \"d\": \"DodXDEtkovWWGsMEXYy_nEEMCWyROMOebCnCv0ey3i4M4bh2dmwqgz0e-IKQAFlGiMkidGL1lNbq0uFS04FbuRAR06dYw1cbrNbDdhrWFxKTd1L5D9p-x-gW-YDWhpI8rUGRa76JXkOSxZUbg09_QyUd99CXAHh-FXi_ZkIKD8hK6FrAs68qhLf8MNkUv63DTduw7QgeFfQivdopePxyGuMk5n8veqwsUZsklQkhNlTYQqeM1xb2698ZQcNYkl0OssEsSJKRjXt-LRPowKrdvTuTo2p--HMI0pIEeFs7H_u5OW3jihjvoFClGPynHQhgWmQzlQRvWRXh6FhDVqFeGQ\", \"n\": \"zfZzttF7HmnTYwSMPdxKs5AoczbNS2mOPz-tN1g4ljqI_F1DG8cgQDcN_VDufxoFGRERo2FK6WEN41LhbGEyP6uL6wW6Cy29qE9QZcvY5mXrncndRSOkNcMizvuEJes_fMYrmP_lPiC6kWiqItTk9QBWqJfiYKhCx9cSDXsBmJXn3KWQCVHvj1ANFWW0CWLMKlWN-_NMNLIWJN_pEAocTZMzxSFBK1b5_5J8ZS7hfWRF6MQmjsJcz2jzA21SQZNpre3kwnTGRSwo05sAS-TyeadDqQPWgbqX69UzcGq5irhzN8cpZ_JaTk3Y_uV6owanTZLVvCgdjaAnMYeZhb0KFw\", \"q\": \"5E5XKK5njT-zzRqqTeY2tgP9PJBACeaH_xQRHZ_1ydE7tVd7HdgdaEHfQ1jvKIHFkknWWOBAY1mlBc4YDirLShB_voShD8C-Hx3nF5sne5fleVfU-sZy6Za4B2U75PcE62oZgCPauOTAEm9Xuvrt5aMMovyzR8ecJZhm9bw7naU\", \"p\": \"5vJHCSM3H3q4RltYzENC9RyZZV8EUmpkv9moyguT5t-BUGA-T4W_FGIxzOPXRWOckIplKkoDKhavUeNmTZMCUcue0nkICSJpvNE4Nb2p5PZk_QqSdQNvCasQtdojEG0AmfVD85SU551CYxJdLdDFOqyK2entpMr8lhokem189As\", \"kid\": \"a1\", \"kty\": \"RSA\"}, {\"d\": \"S4_OufhLBgXFMgIDMI1zlVe2uCExpcEAQ80J_lXfS8I\", \"use\": \"sig\", \"crv\": \"P-256\", \"kty\": \"EC\", \"y\": \"DBdNyq30mXmUs_BIvKMqaTTNO7HDhCi0YiC8GciwNYk\", \"x\": \"cYwzBoyjRjxj334bRTqanONf7DUYK-6TgiuN0DixJAk\", \"kid\": \"a2\"}, {\"d\": \"33TnYgdJtWAiVosKqUnz0zSmvWTbsx5-6pceynW6Xck\", \"use\": \"enc\", \"crv\": \"P-256\", \"kty\": \"EC\", \"y\": \"Cula95Eix1Ia77St3OULe6-UKWs5I06nmdfUzhXUQTs\", \"x\": \"wk8HBVxNNzj1gJBxPmmx9XYW1L61ObBGzxpRa6_OqWU\", \"kid\": \"a3\"}]}, \"application_type\": \"web\", \"contacts\": [\"roland.hedberg@umu.se\"], \"post_logout_redirect_uris\": [\"https://op.certification.openid.net:60784/logout\"], \"redirect_uris\": [\"https://op.certification.openid.net:60784/authz_cb\"], \"response_types\": [\"code\"], \"require_auth_time\": true, \"grant_types\": [\"authorization_code\"], \"default_max_age\": 3600}";

View file

@ -1041,6 +1041,8 @@ public class RepresentationToModel {
client.updateDefaultRoles(resourceRep.getDefaultRoles());
}
if (resourceRep.getProtocolMappers() != null) {
// first, remove all default/built in mappers
Set<ProtocolMapperModel> mappers = client.getProtocolMappers();
@ -1049,6 +1051,9 @@ public class RepresentationToModel {
for (ProtocolMapperRepresentation mapper : resourceRep.getProtocolMappers()) {
client.addProtocolMapper(toModel(mapper));
}
}
if (resourceRep.getClientTemplate() != null) {

View file

@ -22,21 +22,42 @@ package org.keycloak.protocol;
*/
public class ProtocolMapperConfigException extends Exception {
private String messageKey;
private Object[] parameters;
public ProtocolMapperConfigException(String message) {
super(message);
}
public ProtocolMapperConfigException(String message, String messageKey) {
super(message);
this.messageKey = messageKey;
}
public ProtocolMapperConfigException(String message, Throwable cause) {
super(message, cause);
}
public ProtocolMapperConfigException(String message, String messageKey, Throwable cause) {
super(message, cause);
this.messageKey = messageKey;
}
public ProtocolMapperConfigException(String message, Object ... parameters) {
super(message);
this.parameters = parameters;
}
public ProtocolMapperConfigException(String messageKey, String message, Object ... parameters) {
super(message);
this.messageKey = messageKey;
this.parameters = parameters;
}
public String getMessageKey() {
return messageKey;
}
public Object[] getParameters() {
return parameters;
}

View file

@ -17,12 +17,13 @@
package org.keycloak.protocol.oidc;
import java.util.HashMap;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.ClientModel;
import org.keycloak.protocol.oidc.utils.SubjectType;
import org.keycloak.representations.idm.ClientRepresentation;
import java.util.HashMap;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -32,6 +33,11 @@ public class OIDCAdvancedConfigWrapper {
private static final String REQUEST_OBJECT_SIGNATURE_ALG = "request.object.signature.alg";
private static final String SUBJECT_TYPE = "oidc.subject_type";
private static final String SECTOR_IDENTIFIER_URI = "oidc.sector_identifier_uri";
private static final String PUBLIC = "public";
private static final String PAIRWISE = "pairwise";
private final ClientModel clientModel;
private final ClientRepresentation clientRep;
@ -74,6 +80,27 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(REQUEST_OBJECT_SIGNATURE_ALG, algStr);
}
public void setSubjectType(SubjectType subjectType) {
if (subjectType == null) {
setAttribute(SUBJECT_TYPE, SubjectType.PUBLIC.toString());
return;
}
setAttribute(SUBJECT_TYPE, subjectType.toString());
}
public SubjectType getSubjectType() {
String subjectType = getAttribute(SUBJECT_TYPE);
return subjectType == null ? SubjectType.PUBLIC : Enum.valueOf(SubjectType.class, subjectType);
}
public void setSectorIdentifierUri(String sectorIdentifierUri) {
setAttribute(SECTOR_IDENTIFIER_URI, sectorIdentifierUri);
}
public String getSectorIdentifierUri() {
return getAttribute(SECTOR_IDENTIFIER_URI);
}
private String getAttribute(String attrKey) {
if (clientModel != null) {

View file

@ -19,31 +19,15 @@ package org.keycloak.protocol.oidc;
import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.common.util.UriUtils;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientTemplateModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.*;
import org.keycloak.protocol.AbstractLoginProtocolFactory;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.mappers.AddressMapper;
import org.keycloak.protocol.oidc.mappers.FullNameMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.UserPropertyMapper;
import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.protocol.oidc.mappers.*;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientTemplateRepresentation;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.AuthenticationManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
import java.util.*;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -146,6 +130,9 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
ProtocolMapperModel address = AddressMapper.createAddressMapper();
builtins.add(address);
ProtocolMapperModel pairwise = SHA265PairwiseSubMapper.createPairwiseMapper();
builtins.add(pairwise);
model = UserSessionNoteMapper.createClaimMapper(KerberosConstants.GSS_DELEGATION_CREDENTIAL_DISPLAY_NAME,
KerberosConstants.GSS_DELEGATION_CREDENTIAL,
KerberosConstants.GSS_DELEGATION_CREDENTIAL, "String",

View file

@ -56,7 +56,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
public static final List<String> DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
public static final List<String> DEFAULT_SUBJECT_TYPES_SUPPORTED = list("public");
public static final List<String> DEFAULT_SUBJECT_TYPES_SUPPORTED = list("public", "pairwise");
public static final List<String> DEFAULT_RESPONSE_MODES_SUPPORTED = list("query", "fragment", "form_post");

View file

@ -19,10 +19,9 @@ package org.keycloak.protocol.oidc.endpoints;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.HttpResponse;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.ClientConnection;
import org.keycloak.OAuthErrorException;
import org.keycloak.RSATokenVerifier;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.VerificationException;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@ -30,20 +29,15 @@ import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.*;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.Urls;
import org.keycloak.utils.MediaType;
import javax.ws.rs.GET;
@ -54,7 +48,6 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.Map;
@ -179,8 +172,8 @@ public class UserInfoEndpoint {
tokenManager.transformUserInfoAccessToken(session, userInfo, realm, clientModel, userModel, userSession, clientSession);
Map<String, Object> claims = new HashMap<String, Object>();
claims.putAll(userInfo.getOtherClaims());
claims.put("sub", userModel.getId());
claims.putAll(userInfo.getOtherClaims());
Response.ResponseBuilder responseBuilder;
OIDCAdvancedConfigWrapper cfg = OIDCAdvancedConfigWrapper.fromClientModel(clientModel);

View file

@ -0,0 +1,125 @@
package org.keycloak.protocol.oidc.mappers;
import org.keycloak.models.*;
import org.keycloak.protocol.ProtocolMapperConfigException;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperValidator;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import java.util.LinkedList;
import java.util.List;
/**
* Set the 'sub' claim to pairwise .
*
* @author <a href="mailto:martin.hardselius@gmail.com">Martin Hardselius</a>
*/
public abstract class AbstractPairwiseSubMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper {
public static final String PROVIDER_ID_SUFFIX = "-pairwise-sub-mapper";
public static String getId(String prefix) {
return prefix + PROVIDER_ID_SUFFIX;
}
public abstract String getIdPrefix();
/**
* Generates a pairwise subject identifier.
*
* @param mappingModel
* @param sectorIdentifier client sector identifier
* @param localSub local subject identifier (user id)
* @return A pairwise subject identifier
*/
public abstract String generateSub(ProtocolMapperModel mappingModel, String sectorIdentifier, String localSub);
/**
* Override to add additional provider configuration properties. By default, a pairwise sub mapper will only contain configuration for a sector identifier URI.
*
* @return A list of provider configuration properties.
*/
public List<ProviderConfigProperty> getAdditionalConfigProperties() {
return new LinkedList<>();
}
/**
* Override to add additional configuration validation. Called when instance of mapperModel is created/updated for this protocolMapper through admin endpoint.
*
* @param session
* @param realm
* @param mapperContainer client or clientTemplate
* @param mapperModel
* @throws ProtocolMapperConfigException if configuration provided in mapperModel is not valid
*/
public void validateAdditionalConfig(KeycloakSession session, RealmModel realm, ProtocolMapperContainerModel mapperContainer, ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException {
}
@Override
public final String getDisplayCategory() {
return AbstractOIDCProtocolMapper.TOKEN_MAPPER_CATEGORY;
}
@Override
public final IDToken transformIDToken(IDToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionModel clientSession) {
setSubject(token, generateSub(mappingModel, getSectorIdentifier(clientSession.getClient(), mappingModel), userSession.getUser().getId()));
return token;
}
@Override
public final AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionModel clientSession) {
setSubject(token, generateSub(mappingModel, getSectorIdentifier(clientSession.getClient(), mappingModel), userSession.getUser().getId()));
return token;
}
@Override
public final AccessToken transformUserInfoToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionModel clientSession) {
setSubject(token, generateSub(mappingModel, getSectorIdentifier(clientSession.getClient(), mappingModel), userSession.getUser().getId()));
return token;
}
private void setSubject(IDToken token, String pairwiseSub) {
token.getOtherClaims().put("sub", pairwiseSub);
}
@Override
public final List<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> configProperties = new LinkedList<>();
configProperties.add(PairwiseSubMapperHelper.createSectorIdentifierConfig());
configProperties.addAll(getAdditionalConfigProperties());
return configProperties;
}
private String getSectorIdentifier(ClientModel client, ProtocolMapperModel mappingModel) {
String sectorIdentifierUri = PairwiseSubMapperHelper.getSectorIdentifierUri(mappingModel);
if (sectorIdentifierUri != null && !sectorIdentifierUri.isEmpty()) {
return PairwiseSubMapperUtils.resolveValidSectorIdentifier(sectorIdentifierUri);
}
return PairwiseSubMapperUtils.resolveValidSectorIdentifier(client.getRootUrl(), client.getRedirectUris());
}
@Override
public final void validateConfig(KeycloakSession session, RealmModel realm, ProtocolMapperContainerModel mapperContainer, ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException {
ClientModel client = null;
if (mapperContainer instanceof ClientModel) {
client = (ClientModel) mapperContainer;
PairwiseSubMapperValidator.validate(session, client, mapperModel);
}
validateAdditionalConfig(session, realm, mapperContainer, mapperModel);
if (client != null) {
// Propagate changes to the sector identifier uri
OIDCAdvancedConfigWrapper configWrapper = OIDCAdvancedConfigWrapper.fromClientModel(client);
configWrapper.setSectorIdentifierUri(PairwiseSubMapperHelper.getSectorIdentifierUri(mapperModel));
}
}
@Override
public final String getId() {
return getIdPrefix() + PROVIDER_ID_SUFFIX;
}
}

View file

@ -0,0 +1,47 @@
package org.keycloak.protocol.oidc.mappers;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.ServicesLogger;
public class PairwiseSubMapperHelper {
private static final ServicesLogger logger = ServicesLogger.ROOT_LOGGER;
public static final String SECTOR_IDENTIFIER_URI = "sectorIdentifierUri";
public static final String SECTOR_IDENTIFIER_URI_LABEL = "sectorIdentifierUri.label";
public static final String SECTOR_IDENTIFIER_URI_HELP_TEXT = "sectorIdentifierUri.tooltip";
public static final String PAIRWISE_SUB_ALGORITHM_SALT = "pairwiseSubAlgorithmSalt";
public static final String PAIRWISE_SUB_ALGORITHM_SALT_LABEL = "pairwiseSubAlgorithmSalt.label";
public static final String PAIRWISE_SUB_ALGORITHM_SALT_HELP_TEXT = "pairwiseSubAlgorithmSalt.tooltip";
public static String getSectorIdentifierUri(ProtocolMapperModel mappingModel) {
return mappingModel.getConfig().get(SECTOR_IDENTIFIER_URI);
}
public static String getSalt(ProtocolMapperModel mappingModel) {
return mappingModel.getConfig().get(PAIRWISE_SUB_ALGORITHM_SALT);
}
public static void setSalt(ProtocolMapperModel mappingModel, String salt) {
mappingModel.getConfig().put(PAIRWISE_SUB_ALGORITHM_SALT, salt);
}
public static ProviderConfigProperty createSectorIdentifierConfig() {
ProviderConfigProperty property = new ProviderConfigProperty();
property.setName(SECTOR_IDENTIFIER_URI);
property.setType(ProviderConfigProperty.STRING_TYPE);
property.setLabel(SECTOR_IDENTIFIER_URI_LABEL);
property.setHelpText(SECTOR_IDENTIFIER_URI_HELP_TEXT);
return property;
}
public static ProviderConfigProperty createSaltConfig() {
ProviderConfigProperty property = new ProviderConfigProperty();
property.setName(PAIRWISE_SUB_ALGORITHM_SALT);
property.setType(ProviderConfigProperty.STRING_TYPE);
property.setLabel(PAIRWISE_SUB_ALGORITHM_SALT_LABEL);
property.setHelpText(PAIRWISE_SUB_ALGORITHM_SALT_HELP_TEXT);
return property;
}
}

View file

@ -0,0 +1,119 @@
package org.keycloak.protocol.oidc.mappers;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.ServicesLogger;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.*;
public class SHA265PairwiseSubMapper extends AbstractPairwiseSubMapper {
public static final String PROVIDER_ID = "sha256";
private static final String HASH_ALGORITHM = "SHA-256";
private static final String ALPHA_NUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final ServicesLogger logger = ServicesLogger.ROOT_LOGGER;
private final Charset charset;
public SHA265PairwiseSubMapper() throws NoSuchAlgorithmException {
charset = Charset.forName("UTF-8");
MessageDigest.getInstance(HASH_ALGORITHM);
}
public static ProtocolMapperModel createPairwiseMapper() {
return createPairwiseMapper(null);
}
public static ProtocolMapperModel createPairwiseMapper(String sectorIdentifierUri) {
Map<String, String> config;
ProtocolMapperModel pairwise = new ProtocolMapperModel();
pairwise.setName("pairwise subject identifier");
pairwise.setProtocolMapper(AbstractPairwiseSubMapper.getId(PROVIDER_ID));
pairwise.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
pairwise.setConsentRequired(false);
config = new HashMap<>();
config.put(PairwiseSubMapperHelper.SECTOR_IDENTIFIER_URI, sectorIdentifierUri);
pairwise.setConfig(config);
return pairwise;
}
public static ProtocolMapperModel createPairwiseMapper(String sectorIdentifierUri, String salt) {
Map<String, String> config;
ProtocolMapperModel pairwise = new ProtocolMapperModel();
pairwise.setName("pairwise subject identifier");
pairwise.setProtocolMapper(AbstractPairwiseSubMapper.getId(PROVIDER_ID));
pairwise.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
pairwise.setConsentRequired(false);
config = new HashMap<>();
config.put(PairwiseSubMapperHelper.SECTOR_IDENTIFIER_URI, sectorIdentifierUri);
config.put(PairwiseSubMapperHelper.PAIRWISE_SUB_ALGORITHM_SALT, salt);
pairwise.setConfig(config);
return pairwise;
}
@Override
public String getHelpText() {
return "Calculates a pairwise subject identifier using a salted sha-256 hash.";
}
@Override
public List<ProviderConfigProperty> getAdditionalConfigProperties() {
List<ProviderConfigProperty> configProperties = new LinkedList<>();
configProperties.add(PairwiseSubMapperHelper.createSaltConfig());
return configProperties;
}
@Override
public String generateSub(ProtocolMapperModel mappingModel, String sectorIdentifier, String localSub) {
String saltStr = getSalt(mappingModel);
Charset charset = Charset.forName("UTF-8");
byte[] salt = saltStr.getBytes(charset);
String pairwiseSub = generateSub(sectorIdentifier, localSub, salt);
logger.infof("local sub = '%s', pairwise sub = '%s'", localSub, pairwiseSub);
return pairwiseSub;
}
private String generateSub(String sectorIdentifier, String localSub, byte[] salt) {
MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance(HASH_ALGORITHM);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e.getMessage(), e);
}
sha256.update(sectorIdentifier.getBytes(charset));
sha256.update(localSub.getBytes(charset));
byte[] hash = sha256.digest(salt);
return UUID.nameUUIDFromBytes(hash).toString();
}
private String getSalt(ProtocolMapperModel mappingModel) {
String salt = PairwiseSubMapperHelper.getSalt(mappingModel);
if (salt == null || salt.trim().isEmpty()) {
salt = createSalt(32);
PairwiseSubMapperHelper.setSalt(mappingModel, salt);
}
return salt;
}
private String createSalt(int len) {
Random rnd = new SecureRandom();
StringBuilder sb = new StringBuilder(len);
for (int i = 0; i < len; i++)
sb.append(ALPHA_NUMERIC.charAt(rnd.nextInt(ALPHA_NUMERIC.length())));
return sb.toString();
}
@Override
public String getDisplayType() {
return "Pairwise subject identifier";
}
@Override
public String getIdPrefix() {
return PROVIDER_ID;
}
}

View file

@ -0,0 +1,159 @@
package org.keycloak.protocol.oidc.utils;
import org.keycloak.protocol.oidc.mappers.AbstractPairwiseSubMapper;
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.services.ServicesLogger;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class PairwiseSubMapperUtils {
private static final ServicesLogger logger = ServicesLogger.ROOT_LOGGER;
/**
* Returns a set of valid redirect URIs from the root url and redirect URIs registered on a client.
*
* @param clientRootUrl
* @param clientRedirectUris
* @return
*/
public static Set<String> resolveValidRedirectUris(String clientRootUrl, Set<String> clientRedirectUris) {
Set<String> validRedirects = new HashSet<String>();
for (String redirectUri : clientRedirectUris) {
if (redirectUri.startsWith("/")) {
redirectUri = relativeToAbsoluteURI(clientRootUrl, redirectUri);
logger.debugv("replacing relative valid redirect with: {0}", redirectUri);
}
if (redirectUri != null) {
validRedirects.add(redirectUri);
}
}
return validRedirects.stream()
.filter(r -> r != null && !r.trim().isEmpty())
.collect(Collectors.toSet());
}
/**
* Tries to resolve a valid sector identifier from a sector identifier URI.
*
* @param sectorIdentifierUri
* @return a sector identifier iff. the sector identifier URI is a valid URI, contains a valid scheme and contains a valid host component.
*/
public static String resolveValidSectorIdentifier(String sectorIdentifierUri) {
URI uri;
try {
uri = new URI(sectorIdentifierUri);
} catch (URISyntaxException e) {
logger.debug("Invalid sector identifier URI", e);
return null;
}
if (uri.getScheme() == null) {
logger.debugv("Invalid sector identifier URI: {0}", sectorIdentifierUri);
return null;
}
/*if (!uri.getScheme().equalsIgnoreCase("https")) {
logger.debugv("The sector identifier URI scheme must be HTTPS. Was '{0}'", uri.getScheme());
return null;
}*/
if (uri.getHost() == null) {
logger.debug("The sector identifier URI must specify a host");
return null;
}
return uri.getHost();
}
/**
* Tries to resolve a valid sector identifier from the redirect URIs registered on a client.
*
* @param clientRootUrl Root url registered on the client.
* @param clientRedirectUris Redirect URIs registered on the client.
* @return a sector identifier iff. all the registered redirect URIs are located at the same host, otherwise {@code null}.
*/
public static String resolveValidSectorIdentifier(String clientRootUrl, Set<String> clientRedirectUris) {
Set<String> hosts = new HashSet<>();
for (String redirectUri : resolveValidRedirectUris(clientRootUrl, clientRedirectUris)) {
try {
URI uri = new URI(redirectUri);
hosts.add(uri.getHost());
} catch (URISyntaxException e) {
logger.debugv("client redirect uris contained an invalid uri: {0}", redirectUri);
}
}
if (hosts.isEmpty()) {
logger.debug("could not infer any valid sector_identifiers from client redirect uris");
return null;
}
if (hosts.size() > 1) {
logger.debug("the client redirect uris contained multiple hosts");
return null;
}
return hosts.iterator().next();
}
/**
* Checks if the the registered client redirect URIs matches the set of redirect URIs from the sector identifier URI.
*
* @param clientRootUrl root url registered on the client.
* @param clientRedirectUris redirect URIs registered on the client.
* @param sectorRedirects value of the sector identifier URI.
* @return {@code true} iff. the all the redirect URIs can be described by the {@code sectorRedirects}, i.e if the registered redirect URIs is a subset of the {@code sectorRedirects}, otherwise {@code false}.
*/
public static boolean matchesRedirects(String clientRootUrl, Set<String> clientRedirectUris, Set<String> sectorRedirects) {
Set<String> validRedirects = resolveValidRedirectUris(clientRootUrl, clientRedirectUris);
for (String redirect : validRedirects) {
if (!matchesRedirect(sectorRedirects, redirect)) return false;
}
return true;
}
private static boolean matchesRedirect(Set<String> validRedirects, String redirect) {
for (String validRedirect : validRedirects) {
if (validRedirect.endsWith("*") && !validRedirect.contains("?")) {
// strip off the query component - we don't check them when wildcards are effective
String r = redirect.contains("?") ? redirect.substring(0, redirect.indexOf("?")) : redirect;
// strip off *
int length = validRedirect.length() - 1;
validRedirect = validRedirect.substring(0, length);
if (r.startsWith(validRedirect)) return true;
// strip off trailing '/'
if (length - 1 > 0 && validRedirect.charAt(length - 1) == '/') length--;
validRedirect = validRedirect.substring(0, length);
if (validRedirect.equals(r)) return true;
} else if (validRedirect.equals(redirect)) return true;
}
return false;
}
private static String relativeToAbsoluteURI(String rootUrl, String relative) {
if (rootUrl == null || rootUrl.isEmpty()) {
return null;
}
relative = rootUrl + relative;
return relative;
}
public static ProtocolMapperRepresentation getPairwiseSubMapperRepresentation(ClientRepresentation client) {
List<ProtocolMapperRepresentation> mappers = client.getProtocolMappers();
if (mappers == null) {
return null;
}
for (ProtocolMapperRepresentation mapper : mappers) {
if (mapper.getProtocolMapper().endsWith(AbstractPairwiseSubMapper.PROVIDER_ID_SUFFIX)) return mapper;
}
return null;
}
public static String getSubjectIdentifierUri(ProtocolMapperRepresentation pairwiseMapper) {
return pairwiseMapper.getConfig().get(PairwiseSubMapperHelper.SECTOR_IDENTIFIER_URI);
}
}

View file

@ -0,0 +1,113 @@
package org.keycloak.protocol.oidc.utils;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapperConfigException;
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* @author <a href="mailto:martin.hardselius@gmail.com">Martin Hardselius</a>
*/
public class PairwiseSubMapperValidator {
public static final String PAIRWISE_MALFORMED_CLIENT_REDIRECT_URI = "pairwiseMalformedClientRedirectURI";
public static final String PAIRWISE_CLIENT_REDIRECT_URIS_MISSING_HOST = "pairwiseClientRedirectURIsMissingHost";
public static final String PAIRWISE_CLIENT_REDIRECT_URIS_MULTIPLE_HOSTS = "pairwiseClientRedirectURIsMultipleHosts";
public static final String PAIRWISE_MALFORMED_SECTOR_IDENTIFIER_URI = "pairwiseMalformedSectorIdentifierURI";
public static final String PAIRWISE_FAILED_TO_GET_REDIRECT_URIS = "pairwiseFailedToGetRedirectURIs";
public static final String PAIRWISE_REDIRECT_URIS_MISMATCH = "pairwiseRedirectURIsMismatch";
public static void validate(KeycloakSession session, ClientModel client, ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException {
String sectorIdentifierUri = PairwiseSubMapperHelper.getSectorIdentifierUri(mapperModel);
String rootUrl = client.getRootUrl();
Set<String> redirectUris = client.getRedirectUris();
validate(session, rootUrl, redirectUris, sectorIdentifierUri);
}
public static void validate(KeycloakSession session, String rootUrl, Set<String> redirectUris, String sectorIdentifierUri) throws ProtocolMapperConfigException {
if (sectorIdentifierUri == null || sectorIdentifierUri.isEmpty()) {
validateClientRedirectUris(rootUrl, redirectUris);
return;
}
validateSectorIdentifierUri(sectorIdentifierUri);
validateSectorIdentifierUri(session, rootUrl, redirectUris, sectorIdentifierUri);
}
private static void validateClientRedirectUris(String rootUrl, Set<String> redirectUris) throws ProtocolMapperConfigException {
Set<String> hosts = new HashSet<>();
for (String redirectUri : PairwiseSubMapperUtils.resolveValidRedirectUris(rootUrl, redirectUris)) {
try {
URI uri = new URI(redirectUri);
hosts.add(uri.getHost());
} catch (URISyntaxException e) {
throw new ProtocolMapperConfigException("Client contained an invalid redirect URI.",
PAIRWISE_MALFORMED_CLIENT_REDIRECT_URI, e);
}
}
if (hosts.isEmpty()) {
throw new ProtocolMapperConfigException("Client redirect URIs must contain a valid host component.",
PAIRWISE_CLIENT_REDIRECT_URIS_MISSING_HOST);
}
if (hosts.size() > 1) {
throw new ProtocolMapperConfigException("Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.", PAIRWISE_CLIENT_REDIRECT_URIS_MULTIPLE_HOSTS);
}
}
private static void validateSectorIdentifierUri(String sectorIdentifierUri) throws ProtocolMapperConfigException {
URI uri;
try {
uri = new URI(sectorIdentifierUri);
} catch (URISyntaxException e) {
throw new ProtocolMapperConfigException("Invalid Sector Identifier URI.",
PAIRWISE_MALFORMED_SECTOR_IDENTIFIER_URI, e);
}
if (uri.getScheme() == null || uri.getHost() == null) {
throw new ProtocolMapperConfigException("Invalid Sector Identifier URI.",
PAIRWISE_MALFORMED_SECTOR_IDENTIFIER_URI);
}
}
private static void validateSectorIdentifierUri(KeycloakSession session, String rootUrl, Set<String> redirectUris, String sectorIdentifierUri) throws ProtocolMapperConfigException {
Set<String> sectorRedirects = getSectorRedirects(session, sectorIdentifierUri);
if (!PairwiseSubMapperUtils.matchesRedirects(rootUrl, redirectUris, sectorRedirects)) {
throw new ProtocolMapperConfigException("Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI.",
PAIRWISE_REDIRECT_URIS_MISMATCH);
}
}
private static Set<String> getSectorRedirects(KeycloakSession session, String sectorIdentifierUri) throws ProtocolMapperConfigException {
InputStream is = null;
try {
is = session.getProvider(HttpClientProvider.class).get(sectorIdentifierUri);
List<String> sectorRedirects = JsonSerialization.readValue(is, TypedList.class);
return new HashSet<>(sectorRedirects);
} catch (IOException e) {
throw new ProtocolMapperConfigException("Failed to get redirect URIs from the Sector Identifier URI.",
PAIRWISE_FAILED_TO_GET_REDIRECT_URIS, e);
} finally {
if (is != null) {
try {
is.close();
} catch (IOException ignored) {
}
}
}
}
public static class TypedList extends ArrayList<String> {}
}

View file

@ -0,0 +1,13 @@
package org.keycloak.protocol.oidc.utils;
public enum SubjectType {
PUBLIC,
PAIRWISE;
public static SubjectType parse(String subjectTypeStr) {
if (subjectTypeStr == null) {
return PUBLIC;
}
return Enum.valueOf(SubjectType.class, subjectTypeStr.toUpperCase());
}
}

View file

@ -434,4 +434,8 @@ public interface ServicesLogger extends BasicLogger {
@Message(id=97, value="Invalid request")
void invalidRequest(@Cause Throwable t);
@LogMessage(level = ERROR)
@Message(id=98, value="Failed to get redirect uris from sector identifier URI: %s")
void failedToGetRedirectUrisFromSectorIdentifierUri(@Cause Throwable t, String sectorIdentifierUri);
}

View file

@ -19,22 +19,22 @@ package org.keycloak.services.clientregistration;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.ClientInitialAccessModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientRegistrationTrustedHostModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.*;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.mappers.AbstractPairwiseSubMapper;
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
import org.keycloak.protocol.oidc.mappers.SHA265PairwiseSubMapper;
import org.keycloak.protocol.oidc.utils.SubjectType;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ForbiddenException;
import org.keycloak.services.resources.admin.AdminRoot;
import org.keycloak.services.validation.ClientValidator;
import org.keycloak.services.validation.PairwiseClientValidator;
import org.keycloak.services.validation.ValidationMessages;
import javax.ws.rs.core.Response;
import java.util.Properties;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -55,7 +55,7 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
auth.requireCreate();
ValidationMessages validationMessages = new ValidationMessages();
if (!ClientValidator.validate(client, validationMessages)) {
if (!ClientValidator.validate(client, validationMessages) || !PairwiseClientValidator.validate(session, client, validationMessages)) {
String errorCode = validationMessages.fieldHasError("redirectUris") ? ErrorCodes.INVALID_REDIRECT_URI : ErrorCodes.INVALID_CLIENT_METADATA;
throw new ErrorResponseException(
errorCode,
@ -66,6 +66,10 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
try {
ClientModel clientModel = RepresentationToModel.createClient(session, session.getContext().getRealm(), client, true);
OIDCAdvancedConfigWrapper configWrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
if (configWrapper.getSubjectType().equals(SubjectType.PAIRWISE)) {
addPairwiseSubMapper(clientModel, configWrapper.getSectorIdentifierUri());
}
client = ModelToRepresentation.toRepresentation(clientModel);
@ -119,7 +123,7 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
}
ValidationMessages validationMessages = new ValidationMessages();
if (!ClientValidator.validate(rep, validationMessages)) {
if (!ClientValidator.validate(rep, validationMessages) || !PairwiseClientValidator.validate(session, rep, validationMessages)) {
String errorCode = validationMessages.fieldHasError("redirectUris") ? ErrorCodes.INVALID_REDIRECT_URI : ErrorCodes.INVALID_CLIENT_METADATA;
throw new ErrorResponseException(
errorCode,
@ -128,6 +132,7 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
);
}
updateSubjectType(rep, client);
RepresentationToModel.updateClient(rep, client);
rep = ModelToRepresentation.toRepresentation(client);
@ -140,6 +145,43 @@ public abstract class AbstractClientRegistrationProvider implements ClientRegist
return rep;
}
private void updateSubjectType(ClientRepresentation rep, ClientModel client) {
OIDCAdvancedConfigWrapper repConfigWrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(rep);
SubjectType repSubjectType = repConfigWrapper.getSubjectType();
OIDCAdvancedConfigWrapper clientConfigWrapper = OIDCAdvancedConfigWrapper.fromClientModel(client);
SubjectType clientSubjectType = clientConfigWrapper.getSubjectType();
if (repSubjectType.equals(SubjectType.PAIRWISE) && clientSubjectType.equals(SubjectType.PAIRWISE)) {
updateSectorIdentifier(client, repConfigWrapper.getSectorIdentifierUri());
}
if (repSubjectType.equals(SubjectType.PAIRWISE) && clientSubjectType.equals(SubjectType.PUBLIC)) {
addPairwiseSubMapper(client, repConfigWrapper.getSectorIdentifierUri());
}
if (repSubjectType.equals(SubjectType.PUBLIC) && clientSubjectType.equals(SubjectType.PAIRWISE)) {
removePairwiseSubMapper(client);
}
}
private void updateSectorIdentifier(ClientModel client, String sectorIdentifierUri) {
client.getProtocolMappers().stream().filter(mapping -> mapping.getProtocolMapper().endsWith(AbstractPairwiseSubMapper.PROVIDER_ID_SUFFIX)).forEach(mapping -> {
mapping.getConfig().put(PairwiseSubMapperHelper.SECTOR_IDENTIFIER_URI, sectorIdentifierUri);
});
}
private void addPairwiseSubMapper(ClientModel client, String sectorIdentifierUri) {
client.addProtocolMapper(SHA265PairwiseSubMapper.createPairwiseMapper(sectorIdentifierUri));
}
private void removePairwiseSubMapper(ClientModel client) {
for (ProtocolMapperModel mapping : client.getProtocolMappers()) {
if (mapping.getProtocolMapper().endsWith(AbstractPairwiseSubMapper.PROVIDER_ID_SUFFIX)) {
client.removeProtocolMapper(mapping);
}
}
}
public void delete(String clientId) {
event.event(EventType.CLIENT_DELETE).client(clientId);

View file

@ -32,6 +32,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.oidc.utils.JWKSUtils;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.SubjectType;
import org.keycloak.representations.idm.CertificateRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
@ -53,6 +54,7 @@ public class DescriptionConverter {
public static ClientRepresentation toInternal(KeycloakSession session, OIDCClientRepresentation clientOIDC) throws ClientRegistrationException {
ClientRepresentation client = new ClientRepresentation();
client.setClientId(clientOIDC.getClientId());
client.setName(clientOIDC.getClientName());
client.setRedirectUris(clientOIDC.getRedirectUris());
@ -113,6 +115,12 @@ public class DescriptionConverter {
configWrapper.setRequestObjectSignatureAlg(algorithm);
}
SubjectType subjectType = SubjectType.parse(clientOIDC.getSubjectType());
configWrapper.setSubjectType(subjectType);
if (subjectType.equals(SubjectType.PAIRWISE)) {
configWrapper.setSectorIdentifierUri(clientOIDC.getSectorIdentifierUri());
}
return client;
}
@ -172,6 +180,11 @@ public class DescriptionConverter {
response.setRequestObjectSigningAlg(config.getRequestObjectSignatureAlg().toString());
}
response.setSubjectType(config.getSubjectType().toString().toLowerCase());
if (config.getSubjectType().equals(SubjectType.PAIRWISE)) {
response.setSectorIdentifierUri(config.getSectorIdentifierUri());
}
return response;
}

View file

@ -50,6 +50,7 @@ import org.keycloak.services.resources.KeycloakApplication;
import org.keycloak.services.ErrorResponse;
import org.keycloak.common.util.Time;
import org.keycloak.services.validation.ClientValidator;
import org.keycloak.services.validation.PairwiseClientValidator;
import org.keycloak.services.validation.ValidationMessages;
import javax.ws.rs.Consumes;
@ -127,7 +128,7 @@ public class ClientResource {
}
ValidationMessages validationMessages = new ValidationMessages();
if (!ClientValidator.validate(rep, validationMessages)) {
if (!ClientValidator.validate(rep, validationMessages) || !PairwiseClientValidator.validate(session, rep, validationMessages)) {
Properties messages = AdminRoot.getMessages(session, realm, auth.getAuth().getToken().getLocale());
throw new ErrorResponseException(
validationMessages.getStringMessages(),

View file

@ -31,6 +31,7 @@ import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.validation.ClientValidator;
import org.keycloak.services.validation.PairwiseClientValidator;
import org.keycloak.services.validation.ValidationMessages;
import javax.ws.rs.*;
@ -120,7 +121,7 @@ public class ClientsResource {
auth.requireManage();
ValidationMessages validationMessages = new ValidationMessages();
if (!ClientValidator.validate(rep, validationMessages)) {
if (!ClientValidator.validate(rep, validationMessages) || !PairwiseClientValidator.validate(session, rep, validationMessages)) {
Properties messages = AdminRoot.getMessages(session, realm, auth.getAuth().getToken().getLocale());
throw new ErrorResponseException(
validationMessages.getStringMessages(),

View file

@ -20,15 +20,7 @@ import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.NotFoundException;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.mappers.FederationConfigValidationException;
import org.keycloak.mappers.UserFederationMapper;
import org.keycloak.mappers.UserFederationMapperFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ProtocolMapperContainerModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserFederationMapperModel;
import org.keycloak.models.*;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.protocol.ProtocolMapper;
@ -39,19 +31,11 @@ import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.resources.admin.RealmAuth.Resource;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.text.MessageFormat;
import java.util.LinkedList;
import java.util.List;
@ -269,7 +253,7 @@ public class ProtocolMappersResource {
} catch (ProtocolMapperConfigException ex) {
logger.error(ex.getMessage());
Properties messages = AdminRoot.getMessages(session, realm, auth.getAuth().getToken().getLocale());
throw new ErrorResponseException(ex.getMessage(), MessageFormat.format(messages.getProperty(ex.getMessage(), ex.getMessage()), ex.getParameters()),
throw new ErrorResponseException(ex.getMessage(), MessageFormat.format(messages.getProperty(ex.getMessageKey(), ex.getMessage()), ex.getParameters()),
Response.Status.BAD_REQUEST);
}
}

View file

@ -0,0 +1,41 @@
package org.keycloak.services.validation;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.ProtocolMapperConfigException;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperValidator;
import org.keycloak.protocol.oidc.utils.SubjectType;
import org.keycloak.representations.idm.ClientRepresentation;
import java.util.HashSet;
import java.util.Set;
/**
* @author <a href="mailto:martin.hardselius@gmail.com">Martin Hardselius</a>
*/
public class PairwiseClientValidator {
public static boolean validate(KeycloakSession session, ClientRepresentation client, ValidationMessages messages) {
OIDCAdvancedConfigWrapper configWrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
if (configWrapper.getSubjectType().equals(SubjectType.PAIRWISE)) {
String sectorIdentifierUri = configWrapper.getSectorIdentifierUri();
String rootUrl = client.getRootUrl();
Set<String> redirectUris = new HashSet<>();
if (client.getRedirectUris() != null) redirectUris.addAll(client.getRedirectUris());
return validate(session, rootUrl, redirectUris, sectorIdentifierUri, messages);
}
return true;
}
public static boolean validate(KeycloakSession session, String rootUrl, Set<String> redirectUris, String sectorIdentifierUri, ValidationMessages messages) {
try {
PairwiseSubMapperValidator.validate(session, rootUrl, redirectUris, sectorIdentifierUri);
} catch (ProtocolMapperConfigException e) {
messages.add(e.getMessage(), e.getMessageKey());
return false;
}
return true;
}
}

View file

@ -57,8 +57,11 @@ public class ValidationMessages {
}
public boolean fieldHasError(String fieldId) {
if (fieldId == null) {
return false;
}
for (ValidationMessage message : messages) {
if (message.getFieldId().equals(fieldId)) {
if (fieldId.equals(message.getFieldId())) {
return true;
}
}

View file

@ -34,5 +34,5 @@ org.keycloak.protocol.saml.mappers.UserSessionNoteStatementMapper
org.keycloak.protocol.saml.mappers.GroupMembershipMapper
org.keycloak.protocol.oidc.mappers.UserClientRoleMappingMapper
org.keycloak.protocol.oidc.mappers.UserRealmRoleMappingMapper
org.keycloak.protocol.oidc.mappers.SHA265PairwiseSubMapper

View file

@ -18,10 +18,8 @@
package org.keycloak.testsuite.rest;
import org.keycloak.Config.Scope;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.adapters.action.AdminAction;
import org.keycloak.representations.adapters.action.LogoutAction;
import org.keycloak.representations.adapters.action.PushNotBeforeAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction;
@ -29,6 +27,7 @@ import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import java.security.KeyPair;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;
@ -70,6 +69,7 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv
private KeyPair signingKeyPair;
private String oidcRequest;
private List<String> sectorIdentifierRedirectUris;
public KeyPair getSigningKeyPair() {
return signingKeyPair;
@ -86,5 +86,13 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv
public void setOidcRequest(String oidcRequest) {
this.oidcRequest = oidcRequest;
}
public List<String> getSectorIdentifierRedirectUris() {
return sectorIdentifierRedirectUris;
}
public void setSectorIdentifierRedirectUris(List<String> sectorIdentifierRedirectUris) {
this.sectorIdentifierRedirectUris = sectorIdentifierRedirectUris;
}
}
}

View file

@ -17,19 +17,6 @@
package org.keycloak.testsuite.rest.resource;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.Map;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.BadRequestException;
import org.keycloak.OAuth2Constants;
@ -42,6 +29,19 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.testsuite.rest.TestApplicationResourceProviderFactory;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -134,4 +134,19 @@ public class TestingOIDCEndpointsApplicationResource {
public String getOIDCRequest() {
return clientData.getOidcRequest();
}
@GET
@Path("/set-sector-identifier-redirect-uris")
@Produces(MediaType.APPLICATION_JSON)
public void setSectorIdentifierRedirectUris(@QueryParam("redirectUris") List<String> redirectUris) {
clientData.setSectorIdentifierRedirectUris(new ArrayList<>());
clientData.getSectorIdentifierRedirectUris().addAll(redirectUris);
}
@GET
@Path("/get-sector-identifier-redirect-uris")
@Produces(MediaType.APPLICATION_JSON)
public List<String> getSectorIdentifierRedirectUris() {
return clientData.getSectorIdentifierRedirectUris();
}
}

View file

@ -17,10 +17,10 @@
package org.keycloak.testsuite.client.resources;
import javax.ws.rs.core.UriBuilder;
import org.keycloak.testsuite.util.OAuthClient;
import javax.ws.rs.core.UriBuilder;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -45,4 +45,10 @@ public class TestApplicationResourceUrls {
return builder.build().toString();
}
public static String pairwiseSectorIdentifierUri() {
UriBuilder builder = oidcClientEndpoints()
.path(TestOIDCEndpointsApplicationResource.class, "getSectorIdentifierRedirectUris");
return builder.build().toString();
}
}

View file

@ -17,15 +17,15 @@
package org.keycloak.testsuite.client.resources;
import java.util.Map;
import org.keycloak.jose.jwk.JSONWebKeySet;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import org.keycloak.jose.jwk.JSONWebKeySet;
import java.util.List;
import java.util.Map;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -55,4 +55,15 @@ public interface TestOIDCEndpointsApplicationResource {
@Path("/get-oidc-request")
@Produces(org.keycloak.utils.MediaType.APPLICATION_JWT)
String getOIDCRequest();
@GET
@Path("/set-sector-identifier-redirect-uris")
@Produces(MediaType.APPLICATION_JSON)
void setSectorIdentifierRedirectUris(@QueryParam("redirectUris") List<String> redirectUris);
@GET
@Path("/get-sector-identifier-redirect-uris")
@Produces(MediaType.APPLICATION_JSON)
List<String> getSectorIdentifierRedirectUris();
}

View file

@ -55,11 +55,7 @@ import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResou
import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource;
import org.keycloak.testsuite.util.OAuthClient;
import java.security.PrivateKey;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.*;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
@ -286,6 +282,137 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
Assert.assertEquals(response.getClientId(), accessToken.getAudience()[0]);
}
@Test
public void createPairwiseClient() throws Exception {
OIDCClientRepresentation clientRep = createRep();
clientRep.setSubjectType("pairwise");
OIDCClientRepresentation response = reg.oidc().create(clientRep);
Assert.assertEquals("pairwise", response.getSubjectType());
}
@Test
public void updateClientToPairwise() throws Exception {
OIDCClientRepresentation response = create();
Assert.assertEquals("public", response.getSubjectType());
reg.auth(Auth.token(response));
response.setSubjectType("pairwise");
OIDCClientRepresentation updated = reg.oidc().update(response);
Assert.assertEquals("pairwise", updated.getSubjectType());
}
@Test
public void updateSectorIdentifierUri() throws Exception {
OIDCClientRepresentation clientRep = createRep();
clientRep.setSubjectType("pairwise");
OIDCClientRepresentation response = reg.oidc().create(clientRep);
Assert.assertEquals("pairwise", response.getSubjectType());
Assert.assertNull(response.getSectorIdentifierUri());
reg.auth(Auth.token(response));
// Push redirect uris to the sector identifier URI
List<String> sectorRedirects = new ArrayList<>();
sectorRedirects.addAll(response.getRedirectUris());
TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);
response.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());
OIDCClientRepresentation updated = reg.oidc().update(response);
Assert.assertEquals("pairwise", updated.getSubjectType());
Assert.assertEquals(TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), updated.getSectorIdentifierUri());
}
@Test
public void createPairwiseClientWithSectorIdentifierURI() throws Exception {
OIDCClientRepresentation clientRep = createRep();
// Push redirect uris to the sector identifier URI
List<String> sectorRedirects = new ArrayList<>();
sectorRedirects.addAll(clientRep.getRedirectUris());
TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);
clientRep.setSubjectType("pairwise");
clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());
OIDCClientRepresentation response = reg.oidc().create(clientRep);
Assert.assertEquals("pairwise", response.getSubjectType());
Assert.assertEquals(TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), response.getSectorIdentifierUri());
}
@Test
public void createPairwiseClientWithRedirectsToMultipleHostsWithoutSectorIdentifierURI() throws Exception {
OIDCClientRepresentation clientRep = createRep();
List<String> redirects = new ArrayList<>();
redirects.add("http://redirect1");
redirects.add("http://redirect2");
clientRep.setSubjectType("pairwise");
clientRep.setRedirectUris(redirects);
assertCreateFail(clientRep, 400, "Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.");
}
@Test
public void createPairwiseClientWithRedirectsToMultipleHosts() throws Exception {
OIDCClientRepresentation clientRep = createRep();
// Push redirect URIs to the sector identifier URI
List<String> redirects = new ArrayList<>();
redirects.add("http://redirect1");
redirects.add("http://redirect2");
TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
oidcClientEndpointsResource.setSectorIdentifierRedirectUris(redirects);
clientRep.setSubjectType("pairwise");
clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());
clientRep.setRedirectUris(redirects);
OIDCClientRepresentation response = reg.oidc().create(clientRep);
Assert.assertEquals("pairwise", response.getSubjectType());
Assert.assertEquals(TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), response.getSectorIdentifierUri());
Assert.assertNames(response.getRedirectUris(), "http://redirect1", "http://redirect2");
}
@Test
public void createPairwiseClientWithSectorIdentifierURIContainingMismatchedRedirects() throws Exception {
OIDCClientRepresentation clientRep = createRep();
// Push redirect uris to the sector identifier URI
List<String> sectorRedirects = new ArrayList<>();
sectorRedirects.add("http://someotherredirect");
TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);
clientRep.setSubjectType("pairwise");
clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());
assertCreateFail(clientRep, 400, "Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI.");
}
@Test
public void createPairwiseClientWithInvalidSectorIdentifierURI() throws Exception {
OIDCClientRepresentation clientRep = createRep();
clientRep.setSubjectType("pairwise");
clientRep.setSectorIdentifierUri("malformed");
assertCreateFail(clientRep, 400, "Invalid Sector Identifier URI.");
}
@Test
public void createPairwiseClientWithUnreachableSectorIdentifierURI() throws Exception {
OIDCClientRepresentation clientRep = createRep();
clientRep.setSubjectType("pairwise");
clientRep.setSectorIdentifierUri("http://localhost/dummy");
assertCreateFail(clientRep, 400, "Failed to get redirect URIs from the Sector Identifier URI.");
}
@Test
public void testSignaturesRequired() throws Exception {
OIDCClientRepresentation clientRep = createRep();

View file

@ -95,7 +95,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT);
assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment");
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "public");
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "pairwise", "public");
Assert.assertNames(oidcConfig.getIdTokenSigningAlgValuesSupported(), Algorithm.RS256.toString());
Assert.assertNames(oidcConfig.getUserInfoSigningAlgValuesSupported(), Algorithm.RS256.toString());
Assert.assertNames(oidcConfig.getRequestObjectSigningAlgValuesSupported(), Algorithm.none.toString(), Algorithm.RS256.toString());

View file

@ -164,6 +164,12 @@ usermodel.clientRoleMapping.rolePrefix.label=Client Role prefix
usermodel.clientRoleMapping.rolePrefix.tooltip=A prefix for each client role (optional).
usermodel.realmRoleMapping.rolePrefix.label=Realm Role prefix
usermodel.realmRoleMapping.rolePrefix.tooltip=A prefix for each Realm Role (optional).
sectorIdentifierUri.label=Sector Identifier URI
sectorIdentifierUri.tooltip=Providers that use pairwise sub values and support Dynamic Client Registration SHOULD use the sector_identifier_uri parameter. It provides a way for a group of websites under common administrative control to have consistent pairwise sub values independent of the individual domain names. It also provides a way for Clients to change redirect_uri domains without having to reregister all of their users.
pairwiseSubAlgorithmSalt.label=Salt
pairwiseSubAlgorithmSalt.tooltip=Salt used when calculating the pairwise subject identifier. If left blank, a salt will be generated.
# client details
clients.tooltip=Clients are trusted browser apps and web services in a realm. These clients can request a login. You can also define client specific roles.

View file

@ -14,4 +14,11 @@ ldapErrorCantWriteOnlyForReadOnlyLdap=Can't set write only when LDAP provider mo
ldapErrorCantWriteOnlyAndReadOnly=Can't set write-only and read-only together
clientRedirectURIsFragmentError=Redirect URIs must not contain an URI fragment
clientRootURLFragmentError=Root URL must not contain an URL fragment
clientRootURLFragmentError=Root URL must not contain an URL fragment
pairwiseMalformedClientRedirectURI=Client contained an invalid redirect URI.
pairwiseClientRedirectURIsMissingHost=Client redirect URIs must contain a valid host component.
pairwiseClientRedirectURIsMultipleHosts=Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.
pairwiseMalformedSectorIdentifierURI=Malformed Sector Identifier URI.
pairwiseFailedToGetRedirectURIs=Failed to get redirect URIs from the Sector Identifier URI.
pairwiseRedirectURIsMismatch=Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI.

View file

@ -1703,6 +1703,12 @@ module.controller('ClientProtocolMapperCtrl', function($scope, realm, serverInfo
mapper = angular.copy($scope.mapper);
$location.url("/realms/" + realm.realm + '/clients/' + client.id + "/mappers/" + $scope.model.mapper.id);
Notifications.success("Your changes have been saved.");
}, function(error) {
if (error.status == 400 && error.data.error_description) {
Notifications.error(error.data.error_description);
} else {
Notifications.error('Unexpected error when updating protocol mapper');
}
});
};