diff --git a/pom.xml b/pom.xml index 9e30674..7201f7e 100644 --- a/pom.xml +++ b/pom.xml @@ -13,8 +13,8 @@ UTF-8 - 8.0.1 - 1.8 + 9.0.2 + 11 @@ -34,11 +34,11 @@ org.apache.maven.plugins maven-jar-plugin - 3.1.1 + 3.2.0 - org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services + java.net.http,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services diff --git a/src/main/java/fr/sii/keycloak/HttpHandler.java b/src/main/java/fr/sii/keycloak/HttpHandler.java new file mode 100644 index 0000000..c27a16d --- /dev/null +++ b/src/main/java/fr/sii/keycloak/HttpHandler.java @@ -0,0 +1,59 @@ +package fr.sii.keycloak; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.HttpHeaders; +import org.apache.http.client.utils.URIBuilder; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +class HttpHandler { + + static JsonNode getJsonNode(String baseUrl, String contentType, Map headers, Map queryParameters, Map formParameters) { + HttpResponse response = getResponse(baseUrl, contentType, headers, queryParameters, formParameters); + + if (response.statusCode() != 200) { + throw new JsonRemoteClaimException("Wrong status received for remote claim - Expected: 200, Received: " + response.statusCode(), baseUrl); + } + try { + return new ObjectMapper().readTree(response.body()); + } catch (IOException e) { + throw new JsonRemoteClaimException("Error when parsing response for remote claim", baseUrl, e); + } + } + + private static HttpResponse getResponse(String baseUrl, String contentType, Map headers, Map queryParameters, Map formParameters) { + try { + HttpClient httpClient = HttpClient.newHttpClient(); + URIBuilder uriBuilder = new URIBuilder(baseUrl); + + // Build queryParameters + queryParameters.forEach(uriBuilder::setParameter); + URI uri = uriBuilder.build(); + + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri); + + // Build formParameters + if (formParameters != null) { + builder.POST(Utils.getFormData(formParameters)); + } + + // Build headers + builder.header(HttpHeaders.CONTENT_TYPE , contentType); + headers.forEach(builder::header); + + // Call + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException | IOException e) { + throw new JsonRemoteClaimException("Error when accessing remote claim", baseUrl, e); + } catch (URISyntaxException e) { + throw new JsonRemoteClaimException("Wrong uri syntax ", baseUrl, e); + } + } +} diff --git a/src/main/java/fr/sii/keycloak/JsonRemoteClaim.java b/src/main/java/fr/sii/keycloak/JsonRemoteClaim.java index 8d53378..6930e0d 100644 --- a/src/main/java/fr/sii/keycloak/JsonRemoteClaim.java +++ b/src/main/java/fr/sii/keycloak/JsonRemoteClaim.java @@ -1,21 +1,14 @@ package fr.sii.keycloak; import com.fasterxml.jackson.databind.JsonNode; +import org.apache.http.HttpHeaders; import org.keycloak.models.*; import org.keycloak.protocol.oidc.mappers.*; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.IDToken; +import org.keycloak.utils.MediaType; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; -import javax.ws.rs.client.Invocation; -import javax.ws.rs.client.WebTarget; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; /** @@ -31,8 +24,12 @@ public class JsonRemoteClaim extends AbstractOIDCProtocolMapper implements OIDCA private final static String REMOTE_PARAMETERS = "remote.parameters"; private final static String REMOTE_PARAMETERS_USERNAME = "remote.parameters.username"; private final static String REMOTE_PARAMETERS_CLIENTID = "remote.parameters.clientid"; + private static final String REMOTE_PARAMETERS_USER_ATTRIBUTES = "remote.parameters.user.attributes"; + private static final String REMOTE_HEADERS_BEARER_TOKEN = "remote.headers.bearer.token"; + private static final String CLIENT_AUTH_URL = "client.auth.url"; + private static final String CLIENT_AUTH_ID = "client.auth.id"; + private static final String CLIENT_AUTH_PASS = "client.auth.pass"; - private static Client client = ClientBuilder.newClient(); /** * Inner configuration to cache retrieved authorization for multiple tokens @@ -69,6 +66,14 @@ public class JsonRemoteClaim extends AbstractOIDCProtocolMapper implements OIDCA property.setDefaultValue("false"); configProperties.add(property); + // User attributes + property = new ProviderConfigProperty(); + property.setName(REMOTE_PARAMETERS_USER_ATTRIBUTES); + property.setLabel("User attributes"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Send custom user attributes as query parameter. Separate value by '&' sign."); + configProperties.add(property); + // URL property = new ProviderConfigProperty(); property.setName(REMOTE_URL); @@ -92,6 +97,38 @@ public class JsonRemoteClaim extends AbstractOIDCProtocolMapper implements OIDCA property.setType(ProviderConfigProperty.STRING_TYPE); property.setHelpText("List of headers to send separated by '&'. Separate header name and value by an equals sign '=', the value can contain equals signs (ex: Authorization=az89d)."); configProperties.add(property); + + // Bearer token + property = new ProviderConfigProperty(); + property.setName(REMOTE_HEADERS_BEARER_TOKEN); + property.setLabel("Send bearer token"); + property.setType(ProviderConfigProperty.BOOLEAN_TYPE); + property.setHelpText("Send the bearer token as auth header."); + configProperties.add(property); + + // Client auth url + property = new ProviderConfigProperty(); + property.setName(CLIENT_AUTH_URL); + property.setLabel("Client auth URL"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Full URL of the keycloak client auth endpoint."); + configProperties.add(property); + + // Client id + property = new ProviderConfigProperty(); + property.setName(CLIENT_AUTH_ID); + property.setLabel("Client id"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Client id to create a tech token."); + configProperties.add(property); + + // Client password + property = new ProviderConfigProperty(); + property.setName(CLIENT_AUTH_PASS); + property.setLabel("Client password"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Client password to create a tech token."); + configProperties.add(property); } @Override @@ -123,7 +160,7 @@ public String getId() { protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) { JsonNode claims = clientSessionCtx.getAttribute(REMOTE_AUTHORIZATION_ATTR, JsonNode.class); if (claims == null) { - claims = getRemoteAuthorizations(mappingModel, userSession); + claims = getRemoteAuthorizations(mappingModel, userSession, clientSessionCtx); clientSessionCtx.setAttribute(REMOTE_AUTHORIZATION_ATTR, claims); } @@ -139,26 +176,30 @@ protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSes */ @Override protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) { - JsonNode claims = getRemoteAuthorizations(mappingModel, userSession); + JsonNode claims = getRemoteAuthorizations(mappingModel, userSession, null); OIDCAttributeMapperHelper.mapClaim(token, mappingModel, claims); } - private Map getQueryParameters(ProtocolMapperModel mappingModel, UserSessionModel userSession) { + private Map getQueryParameters(ProtocolMapperModel mappingModel, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { final String configuredParameter = mappingModel.getConfig().get(REMOTE_PARAMETERS); final boolean sendUsername = "true".equals(mappingModel.getConfig().get(REMOTE_PARAMETERS_USERNAME)); final boolean sendClientID = "true".equals(mappingModel.getConfig().get(REMOTE_PARAMETERS_CLIENTID)); + final String configuredUserAttributes = mappingModel.getConfig().get(REMOTE_PARAMETERS_USER_ATTRIBUTES); // Get parameters - final Map formattedParameters = buildMapFromStringConfig(configuredParameter); + final Map formattedParameters = Utils.buildMapFromStringConfig(configuredParameter); // Get client ID if (sendClientID) { - String clientID = userSession.getAuthenticatedClientSessions().values().stream() - .map(AuthenticatedClientSessionModel::getClient) - .map(ClientModel::getClientId) - .distinct() - .collect( Collectors.joining( "," ) ); - formattedParameters.put("client_id", clientID); + if (clientSessionCtx != null) { + formattedParameters.put("client_id", clientSessionCtx.getClientSession().getClient().getId()); + } else { + formattedParameters.put("client_id", userSession.getAuthenticatedClientSessions().values().stream() + .map(AuthenticatedClientSessionModel::getClient) + .map(ClientModel::getClientId) + .distinct() + .collect(Collectors.joining(","))); + } } // Get username @@ -166,76 +207,55 @@ private Map getQueryParameters(ProtocolMapperModel mappingModel, formattedParameters.put("username", userSession.getLoginUsername()); } + // Get custom user attributes + if (configuredUserAttributes != null && !"".equals(configuredUserAttributes.trim())) { + List userAttributes = Arrays.asList(configuredUserAttributes.trim().split("&")); + userAttributes.forEach(attribute -> formattedParameters.put(attribute, userSession.getUser().getFirstAttribute(attribute))); + } return formattedParameters; } private Map getheaders(ProtocolMapperModel mappingModel, UserSessionModel userSession) { final String configuredHeaders = mappingModel.getConfig().get(REMOTE_HEADERS); + final boolean sendBearerToken = "true".equals(mappingModel.getConfig().get(REMOTE_HEADERS_BEARER_TOKEN)); // Get headers - return buildMapFromStringConfig(configuredHeaders); - } - - private Map buildMapFromStringConfig(String config) { - final Map map = new HashMap<>(); - - //FIXME: using MULTIVALUED_STRING_TYPE would be better but it doesn't seem to work - if (config != null && !"".equals(config.trim())) { - String[] configList = config.trim().split("&"); - String[] keyValue; - for (String configEntry : configList) { - keyValue = configEntry.split("=", 2); - if (keyValue.length == 2) { - map.put(keyValue[0], keyValue[1]); - } - } + Map stringStringMap = Utils.buildMapFromStringConfig(configuredHeaders); + if (sendBearerToken) { + String signedRequestToken = getClientToken(mappingModel); + stringStringMap.put(HttpHeaders.AUTHORIZATION, "Bearer " + signedRequestToken); } - - return map; + return stringStringMap; } - private JsonNode getRemoteAuthorizations(ProtocolMapperModel mappingModel, UserSessionModel userSession) { + private String getClientToken(ProtocolMapperModel mappingModel) { // Get parameters - Map parameters = getQueryParameters(mappingModel, userSession); + Map parameters = new HashMap<>(); // Get headers - Map headers = getheaders(mappingModel, userSession); + Map headers = new HashMap<>(); - // Call remote service - Response response; - final String url = mappingModel.getConfig().get(REMOTE_URL); - try { - WebTarget target = client.target(url); - // Build parameters - for (Map.Entry param : parameters.entrySet()) { - target = target.queryParam(param.getKey(), param.getValue()); - } - Invocation.Builder builder = target.request(MediaType.APPLICATION_JSON); - // Build headers - for (Map.Entry header : headers.entrySet()) { - builder = builder.header(header.getKey(), header.getValue()); - } - // Call - response = builder.get(); - } catch(RuntimeException e) { - // exceptions are thrown to prevent token from being delivered without all information - throw new JsonRemoteClaimException("Error when accessing remote claim", url, e); - } + Map formParameters = new HashMap<>(); + formParameters.put("grant_type", "client_credentials"); + formParameters.put("client_id", mappingModel.getConfig().get(CLIENT_AUTH_ID)); + formParameters.put("client_secret", mappingModel.getConfig().get(CLIENT_AUTH_PASS)); - // Check response status - if (response.getStatus() != 200) { - response.close(); - throw new JsonRemoteClaimException("Wrong status received for remote claim - Expected: 200, Received: " + response.getStatus(), url); + // Call remote service + String baseUrl = mappingModel.getConfig().get(CLIENT_AUTH_URL); + JsonNode jsonNode = HttpHandler.getJsonNode(baseUrl, MediaType.APPLICATION_FORM_URLENCODED, headers, parameters, formParameters); + if (!jsonNode.has("access_token")) { + throw new JsonRemoteClaimException("Access token not found", baseUrl); } + return jsonNode.findValue("access_token").asText(); + } - // Bind JSON response - try { - return response.readEntity(JsonNode.class); - } catch(RuntimeException e) { - // exceptions are thrown to prevent token from being delivered without all information - throw new JsonRemoteClaimException("Error when parsing response for remote claim", url, e); - } finally { - response.close(); - } + private JsonNode getRemoteAuthorizations(ProtocolMapperModel mappingModel, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { + // Get parameters + Map parameters = getQueryParameters(mappingModel, userSession, clientSessionCtx); + // Get headers + Map headers = getheaders(mappingModel, userSession); + // Call remote service + String baseUrl = mappingModel.getConfig().get(REMOTE_URL); + return HttpHandler.getJsonNode(baseUrl, MediaType.APPLICATION_JSON, headers, parameters, null); } -} \ No newline at end of file +} diff --git a/src/main/java/fr/sii/keycloak/Utils.java b/src/main/java/fr/sii/keycloak/Utils.java new file mode 100644 index 0000000..da2716a --- /dev/null +++ b/src/main/java/fr/sii/keycloak/Utils.java @@ -0,0 +1,41 @@ +package fr.sii.keycloak; + +import java.net.URLEncoder; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +class Utils { + + static Map buildMapFromStringConfig(String config) { + final Map map = new HashMap<>(); + + //FIXME: using MULTIVALUED_STRING_TYPE would be better but it doesn't seem to work + if (config != null && !"".equals(config.trim())) { + String[] configList = config.trim().split("&"); + String[] keyValue; + for (String configEntry : configList) { + keyValue = configEntry.split("=", 2); + if (keyValue.length == 2) { + map.put(keyValue[0], keyValue[1]); + } + } + } + + return map; + } + + static HttpRequest.BodyPublisher getFormData(Map data) { + StringBuilder builder = new StringBuilder(); + data.forEach((key, value) -> { + if (builder.length() > 0) { + builder.append("&"); + } + builder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)); + builder.append("="); + builder.append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + }); + return HttpRequest.BodyPublishers.ofString(builder.toString()); + } +}