/** * Copyright 2017 StreamSets Inc. * * Licensed under the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.streamsets.pipeline.lib.http.oauth2; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.VisibleForTesting; import com.streamsets.pipeline.api.ConfigDef; import com.streamsets.pipeline.api.Dependency; import com.streamsets.pipeline.api.Stage; import com.streamsets.pipeline.api.ValueChooserModel; import com.streamsets.pipeline.api.el.ELEval; import com.streamsets.pipeline.api.el.ELEvalException; import com.streamsets.pipeline.api.el.ELVars; import com.streamsets.pipeline.api.impl.Utils; import com.streamsets.pipeline.lib.el.TimeEL; import com.streamsets.pipeline.lib.el.TimeNowEL; import com.streamsets.pipeline.lib.el.VaultEL; import com.streamsets.pipeline.lib.http.AuthenticationFailureException; import com.streamsets.pipeline.lib.http.RequestEntityProcessingChooserValues; import io.jsonwebtoken.Header; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import org.apache.commons.lang3.StringUtils; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.client.RequestEntityProcessing; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.ws.rs.NotFoundException; import javax.ws.rs.ProcessingException; import javax.ws.rs.client.Client; import javax.ws.rs.client.Entity; import javax.ws.rs.client.Invocation; import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import java.io.IOException; import java.net.UnknownHostException; import java.nio.channels.UnresolvedAddressException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import static com.streamsets.pipeline.lib.http.Errors.HTTP_25; import static com.streamsets.pipeline.lib.http.Errors.HTTP_26; public class OAuth2ConfigBean { public static final String CONFIG_GROUP = "OAUTH2"; public static final String ASSERTION_KEY = "assertion"; private static final Logger LOG = LoggerFactory.getLogger(OAuth2ConfigBean.class); public static final String CLIENT_ID_KEY = "client_id"; public static final String CLIENT_SECRET_KEY = "client_secret"; public static final String GRANT_TYPE_KEY = "grant_type"; public static final String CLIENT_CREDENTIALS_GRANT = "client_credentials"; public static final String JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; public static final String RESOURCE_OWNER_KEY = "username"; public static final String PASSWORD_KEY = "password";// NOSONAR public static final String RESOURCE_OWNER_GRANT = "password"; public static final String ACCESS_TOKEN_KEY = "access_token"; private static final String PREFIX = "conf.client.oauth2."; public static final String RSA = "RSA"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @ConfigDef( required = true, type = ConfigDef.Type.MODEL, label = "Credentials Grant Type", displayPosition = 10, elDefs = VaultEL.class, group = "#0", dependsOn = "useOAuth2^", triggeredByValue = "true" ) @ValueChooserModel(OAuth2GrantTypesChooserValues.class) public OAuth2GrantTypes credentialsGrantType; @ConfigDef( required = true, type = ConfigDef.Type.STRING, label = "Token URL", displayPosition = 20, group = "#0", dependsOn = "useOAuth2^", triggeredByValue = "true" ) public String tokenUrl; @ConfigDef( required = true, type = ConfigDef.Type.STRING, label = "Client ID", displayPosition = 30, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "authType^", triggeredByValues = "NONE"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "CLIENT_CREDENTIALS") } ) public String clientId; @ConfigDef( required = true, type = ConfigDef.Type.STRING, label = "Client Secret", displayPosition = 40, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "authType^", triggeredByValues = "NONE"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "CLIENT_CREDENTIALS") } ) public String clientSecret; @ConfigDef( required = true, type = ConfigDef.Type.STRING, label = "Username", displayPosition = 30, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "RESOURCE_OWNER") } ) public String username; @ConfigDef( required = true, type = ConfigDef.Type.STRING, label = "Password", displayPosition = 40, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "RESOURCE_OWNER") } ) public String password; /* * The next two are not required according to the protocol, but servers like IdentityServer 3 and Getty Images * require this even for resource owner credentials grant. So we have them with same labels, but they are not * required. */ @ConfigDef( required = false, type = ConfigDef.Type.STRING, label = "Client ID", displayPosition = 50, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "RESOURCE_OWNER") } ) public String resourceOwnerClientId; @ConfigDef( required = false, type = ConfigDef.Type.STRING, label = "Client Secret", displayPosition = 60, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "RESOURCE_OWNER") } ) public String resourceOwnerClientSecret; @ConfigDef( required = true, type = ConfigDef.Type.MODEL, label = "JWT Signing Algorithm", description = "The algorithm to use for signing the JWT", displayPosition = 30, group = "#0", defaultValue = "NONE", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "JWT"), } ) @ValueChooserModel(SigningAlgorithmsChooserValues.class) public SigningAlgorithms algorithm; @ConfigDef( required = true, type = ConfigDef.Type.STRING, label = "JWT Signing Key (Base64-encoded)", description = "Base64 encoded key for signing the JWT", displayPosition = 35, elDefs = VaultEL.class, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "JWT"), @Dependency(configName = "algorithm", triggeredByValues = { "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" }) } ) public String key; @ConfigDef( required = true, type = ConfigDef.Type.TEXT, label = "JWT Claims", description = "Claims to be used with JWT token request, represented as JSON", displayPosition = 40, elDefs = {TimeEL.class, VaultEL.class, TimeNowEL.class}, group = "#0", dependencies = { @Dependency(configName = "useOAuth2^", triggeredByValues = "true"), @Dependency(configName = "credentialsGrantType", triggeredByValues = "JWT") }, evaluation = ConfigDef.Evaluation.EXPLICIT ) public String jwtClaims; @ConfigDef( required = false, type = ConfigDef.Type.MODEL, label = "Request Transfer Encoding", defaultValue = "BUFFERED", displayPosition = 70, group = "#0", dependsOn = "useOAuth2^", triggeredByValue = "true" ) @ValueChooserModel(RequestEntityProcessingChooserValues.class) public RequestEntityProcessing transferEncoding; @ConfigDef( required = false, type = ConfigDef.Type.MAP, label = "Additional Key-Value pairs in token request body", description = "Additional key-value pairs to be sent to the token URL while requesting for a token", displayPosition = 80, elDefs = VaultEL.class, group = "#0", dependsOn = "useOAuth2^", triggeredByValue = "true" ) public Map<String, String> additionalValues = new HashMap<>(); @VisibleForTesting OAuth2HeaderFilter filter; private ELVars elVars; private PrivateKey privateKey; private ELEval timeEvaluator; public void init(Stage.Context context, List<Stage.ConfigIssue> issues, Client webClient) // NOSONAR throws AuthenticationFailureException, IOException { if (credentialsGrantType == OAuth2GrantTypes.JWT) { prepareEL(context, issues); if (isRSA()) { privateKey = parseRSAKey(key, context, issues); } } if (issues.isEmpty()) { String accessToken = obtainAccessToken(webClient); filter = new OAuth2HeaderFilter(parseAccessToken(accessToken)); webClient.register(filter); } } private boolean isRSA() { return algorithm == SigningAlgorithms.RS256 || algorithm == SigningAlgorithms.RS384 || algorithm == SigningAlgorithms.RS512; } private boolean isHMAC() { return algorithm == SigningAlgorithms.HS256 || algorithm == SigningAlgorithms.HS384 || algorithm == SigningAlgorithms.HS512; } private void prepareEL(Stage.Context context, List<Stage.ConfigIssue> issues) { try { context.parseEL(jwtClaims); timeEvaluator = context.createELEval("jwtClaims"); elVars = context.createELVars(); TimeNowEL.setTimeNowInContext(elVars, new Date()); TimeEL.setCalendarInContext(elVars, Calendar.getInstance()); timeEvaluator.eval(elVars, jwtClaims, String.class); } catch (ELEvalException ex) { LOG.warn("Invalid EL in JWT Claims", ex); issues.add(context.createConfigIssue(CONFIG_GROUP, PREFIX + "jwtClaims", HTTP_25)); } } private static PrivateKey parseRSAKey(String key, Stage.Context context, List<Stage.ConfigIssue> issues) { String privKeyPEM = key.replace("-----BEGIN PRIVATE KEY-----\n", ""); privKeyPEM = privKeyPEM.replace("-----END PRIVATE KEY-----", ""); // Base64 decode the data byte [] encoded = Base64.getDecoder().decode(privKeyPEM.getBytes()); // PKCS8 decode the encoded RSA private key PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); try { KeyFactory kf = KeyFactory.getInstance(RSA); return kf.generatePrivate(keySpec); } catch (NoSuchAlgorithmException ex) { LOG.error(Utils.format("'{}' algorithm not available", RSA), ex); issues.add(context.createConfigIssue(CONFIG_GROUP, PREFIX + "algorithm", HTTP_25)); } catch (InvalidKeySpecException ex) { LOG.error(Utils.format("'{}' algorithm not available", RSA), ex); issues.add(context.createConfigIssue(CONFIG_GROUP, PREFIX + "key", HTTP_26)); } return null; } @VisibleForTesting String obtainAccessToken(Client webClient) throws AuthenticationFailureException, IOException { //NOSONAR WebTarget tokenTarget = webClient.target(tokenUrl); Invocation.Builder builder = tokenTarget.request(); Response response = sendRequest(builder); // local var for debugging purposes return processResponse(response); } private Response sendRequest(Invocation.Builder builder) throws IOException { Response response; try { response = builder.property(ClientProperties.REQUEST_ENTITY_PROCESSING, transferEncoding) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED + "; charset=utf-8") .post(generateRequestEntity()); } catch (ProcessingException ex) { if (ex.getCause() instanceof UnresolvedAddressException || ex.getCause() instanceof UnknownHostException) { throw new NotFoundException(ex.getCause()); } throw ex; } return response; } private String processResponse(Response response) throws AuthenticationFailureException { final int status = response.getStatus(); if (status == 404) { throw new NotFoundException(); } final boolean statusOk = status >= 200 && status < 300; if (!statusOk) { throw new AuthenticationFailureException( Utils.format("Authentication failed with error Code: {} and error message: {}", status, response.readEntity(String.class))); } return response.readEntity(String.class); } @VisibleForTesting String parseAccessToken(String tokenJson) throws IOException { JsonNode node = OBJECT_MAPPER.reader().readTree(tokenJson); return node.findValue(ACCESS_TOKEN_KEY).asText(); } private Entity generateRequestEntity() throws IOException { MultivaluedMap<String, String> requestValues = new MultivaluedHashMap<>(); switch (credentialsGrantType) { case CLIENT_CREDENTIALS: insertClientCredentialsFields(requestValues); break; case RESOURCE_OWNER: insertResourceOwnerFields(requestValues); break; case JWT: insertJWTFields(requestValues); break; default: } for (Map.Entry<String, String> additionalValue : additionalValues.entrySet()) { requestValues.put(additionalValue.getKey(), Collections.singletonList(additionalValue.getValue())); } return Entity.form(requestValues); } private void insertClientCredentialsFields(MultivaluedMap<String, String> requestValues) { if (!StringUtils.isEmpty(clientId)) { requestValues.put(CLIENT_ID_KEY, Collections.singletonList(clientId)); requestValues.put(CLIENT_SECRET_KEY, Collections.singletonList(clientSecret)); } requestValues.put(GRANT_TYPE_KEY, Collections.singletonList(CLIENT_CREDENTIALS_GRANT)); } private void insertResourceOwnerFields(MultivaluedMap<String, String> requestValues) { requestValues.put(RESOURCE_OWNER_KEY, Collections.singletonList(username)); requestValues.put(PASSWORD_KEY, Collections.singletonList(password)); requestValues.put(GRANT_TYPE_KEY, Collections.singletonList(RESOURCE_OWNER_GRANT)); if (!StringUtils.isEmpty(resourceOwnerClientId)) { requestValues.put(CLIENT_ID_KEY, Collections.singletonList(resourceOwnerClientId)); } if (!StringUtils.isEmpty(resourceOwnerClientSecret)) { requestValues.put(CLIENT_SECRET_KEY, Collections.singletonList(resourceOwnerClientSecret)); } } @SuppressWarnings("unchecked") private void insertJWTFields(MultivaluedMap<String, String> requestValues) throws IOException { String parsedJwt; try { parsedJwt = timeEvaluator.eval(elVars, jwtClaims, String.class); } catch (Exception ex) { // NOSONAR throw new RuntimeException(ex); // NOSONAR Unlikely to ever happen since init would have failed. } Map<String, Object> claims = (Map<String, Object>) OBJECT_MAPPER.readValue(parsedJwt, Map.class); JwtBuilder builder = Jwts.builder().setClaims(claims); try { if (isRSA()) { builder.signWith(JWTUtils.getSignatureAlgorithm(algorithm), privateKey); } else if (isHMAC()) { builder.signWith(JWTUtils.getSignatureAlgorithm(algorithm), key); } Map<String, Object> header = new HashMap<>(1); header.put(Header.TYPE, Header.JWT_TYPE); builder.setHeader(header); String base64EncodedJWT = builder.compact(); requestValues.put(GRANT_TYPE_KEY, Collections.singletonList(JWT_GRANT_TYPE)); requestValues.put(ASSERTION_KEY, Collections.singletonList(base64EncodedJWT)); } catch (Exception ex) { throw new IOException(ex); } } public void reInit(Client webClient) throws AuthenticationFailureException, IOException { // NOSONAR filter.setShouldInsertHeader(false); // don't insert the header for requests to get new tokens. try { String newToken = obtainAccessToken(webClient); filter.setAuthToken(parseAccessToken(newToken)); } finally { filter.setShouldInsertHeader(true); } } }