package com.mastercard.developer.oauth; import java.net.URI; import java.nio.charset.Charset; import java.security.*; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; import java.util.logging.Level; import java.util.logging.Logger; /** * Performs OAuth1.0a compliant signing with body hash support for non-urlencoded content types. */ public class OAuth { private OAuth() { } public static final String EMPTY_STRING = ""; public static final String AUTHORIZATION_HEADER_NAME = "Authorization"; private static final Logger LOG = Logger.getLogger(OAuth.class.getName()); private static final String HASH_ALGORITHM = "SHA-256"; private static final int NONCE_LENGTH = 16; private static final String ALPHA_NUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; /** * Creates a Mastercard API compliant OAuth Authorization header * * @param uri Target URI for this request * @param method HTTP method of the request * @param payload Payload (nullable) * @param charset Charset encoding of the request * @param consumerKey Consumer key set up in a Mastercard Developer Portal project * @param signingKey The private key that will be used for signing the request that corresponds to the consumerKey * @return Valid OAuth1.0a signature with a body hash when payload is present */ public static String getAuthorizationHeader(URI uri, String method, String payload, Charset charset, String consumerKey, PrivateKey signingKey) { TreeMap<String, List<String>> queryParams = extractQueryParams(uri, charset); HashMap<String, String> oauthParams = new HashMap<>(); oauthParams.put("oauth_consumer_key", consumerKey); oauthParams.put("oauth_nonce", getNonce()); oauthParams.put("oauth_signature_method", "RSA-" + HASH_ALGORITHM.replace("-", "")); oauthParams.put("oauth_timestamp", getTimestamp()); oauthParams.put("oauth_version", "1.0"); oauthParams.put("oauth_body_hash", getBodyHash(payload, charset, HASH_ALGORITHM)); // Combine query and oauth_ parameters into lexicographically sorted string String paramString = toOauthParamString(queryParams, oauthParams); // Normalized URI without query params and fragment String baseUri = getBaseUriString(uri); // Signature base string String sbs = getSignatureBaseString(method, baseUri, paramString, charset); // Signature String signature = signSignatureBaseString(sbs, signingKey, charset); oauthParams.put("oauth_signature", Util.percentEncode(signature, charset)); return getAuthorizationString(oauthParams); } /** * Generate a valid signature base string as per * https://tools.ietf.org/html/rfc5849#section-3.4.1 * * @param httpMethod HTTP method of the request * @param baseUri Base URI that conforms with https://tools.ietf.org/html/rfc5849#section-3.4.1.2 * @param paramString OAuth parameter string that conforms with https://tools.ietf.org/html/rfc5849#section-3.4.1.3 * @param charset Charset encoding of the request * @return A correctly constructed and escaped signature base string */ static String getSignatureBaseString(String httpMethod, String baseUri, String paramString, Charset charset) { String sbs = // Uppercase HTTP method httpMethod.toUpperCase() + "&" + // Base URI Util.percentEncode(baseUri, charset) + "&" + // OAuth parameter string Util.percentEncode(paramString, charset); LOG.log(Level.FINE, "Generated SBS: {0}", sbs); return sbs; } /** * Parse query parameters out of the URL. * https://tools.ietf.org/html/rfc5849#section-3.4.1.3 * * @param uri URL containing all query parameters that need to be signed * @param charset Charset encoding of the request * @return Sorted map of query parameter key/value pairs. Values for parameters with the same name are added into a list. */ static TreeMap<String, List<String>> extractQueryParams(URI uri, Charset charset) { final String decodedQueryString = uri.getQuery(); final String rawQueryString = uri.getRawQuery(); if (decodedQueryString == null || decodedQueryString.isEmpty() || rawQueryString == null || rawQueryString.isEmpty()) { // No query params return new TreeMap<>(); } boolean mustEncode = !decodedQueryString.equals(rawQueryString); final TreeMap<String, List<String>> queryPairs = new TreeMap<>(); final String[] pairs = decodedQueryString.split("&"); for (String pair : pairs) { final int idx = pair.indexOf('='); String key = idx > 0 ? pair.substring(0, idx) : pair; if (!queryPairs.containsKey(key)) { key = mustEncode ? Util.percentEncode(key, charset) : key; queryPairs.put(key, new LinkedList<String>()); } String value = idx > 0 && pair.length() > idx + 1 ? pair.substring(idx + 1) : EMPTY_STRING; value = mustEncode ? Util.percentEncode(value, charset) : value; queryPairs.get(key).add(value); } return queryPairs; } /** * Lexicographically sort all parameters and concatenate them into a string as per * https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 * * @param queryParamsMap Sorted map of all oauth parameters that need to be signed * @param oauthParamsMap Map of OAuth parameters to be included in Authorization header * @return Correctly encoded and sorted OAuth parameter string */ static String toOauthParamString(SortedMap<String, List<String>> queryParamsMap, Map<String, String> oauthParamsMap) { TreeMap<String, List<String>> consolidatedParams = new TreeMap<>(queryParamsMap); // Add OAuth params to consolidated params map for (Map.Entry<String, String> entry : oauthParamsMap.entrySet()) { if (consolidatedParams.containsKey(entry.getKey())) { consolidatedParams.get(entry.getKey()).add(entry.getValue()); } else { consolidatedParams.put(entry.getKey(), Arrays.asList(entry.getValue())); } } StringBuilder oauthParams = new StringBuilder(); // Add all parameters to the parameter string for signing for (Map.Entry<String, List<String>> entry : consolidatedParams.entrySet()) { String key = entry.getKey(); // Keys with same name are sorted by their values if (entry.getValue().size() > 1) { Collections.sort(entry.getValue()); } for (String value : entry.getValue()) { oauthParams.append(key).append("=").append(value).append("&"); } } // Remove trailing ampersand int stringLength = oauthParams.length() - 1; if (oauthParams.charAt(stringLength) == '&') { oauthParams.deleteCharAt(stringLength); } return oauthParams.toString(); } /** * Generates a random string for replay protection as per * https://tools.ietf.org/html/rfc5849#section-3.3 * * @return random string of 16 characters. */ static String getNonce() { SecureRandom rnd = new SecureRandom(); StringBuilder sb = new StringBuilder(NONCE_LENGTH); for (int i = 0; i < NONCE_LENGTH; i++) { sb.append(ALPHA_NUMERIC_CHARS.charAt(rnd.nextInt(ALPHA_NUMERIC_CHARS.length()))); } return sb.toString(); } /** * Returns UNIX Timestamp as required per * https://tools.ietf.org/html/rfc5849#section-3.3 * * @return UNIX timestamp (UTC) */ private static String getTimestamp() { return Long.toString(System.currentTimeMillis() / 1000L); } /** * Normalizes the URL as per * https://tools.ietf.org/html/rfc5849#section-3.4.1.2 * * @param uri URL that will be called as part of this request * @return Normalized URL */ static String getBaseUriString(URI uri) { // Lowercase scheme and authority String scheme = uri.getScheme().toLowerCase(); String authority = uri.getAuthority().toLowerCase(); // Remove port if it matches the default for scheme if (("http".equals(scheme) && uri.getPort() == 80) || ("https".equals(scheme) && uri.getPort() == 443)) { int index = authority.lastIndexOf(':'); if (index >= 0) { authority = authority.substring(0, index); } } String path = uri.getRawPath(); if (path == null || path.length() <= 0) { path = "/"; } return scheme + "://" + authority + path; } /** * Generates a hash based on request payload as per * https://tools.ietf.org/id/draft-eaton-oauth-bodyhash-00.html * * @param payload Request payload * @param charset Charset encoding of the request * @return Base64 encoded cryptographic hash of the given payload */ static String getBodyHash(String payload, Charset charset, String hashAlg) { MessageDigest digest; try { digest = MessageDigest.getInstance(hashAlg); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("Unable to obtain " + hashAlg + " message digest", e); } digest.reset(); // "If the request does not have an entity body, the hash should be taken over the empty string" byte[] byteArray = null == payload ? "".getBytes() : payload.getBytes(charset); byte[] hash = digest.digest(byteArray); return Util.b64Encode(hash); } /** * Signs the signature base string using an RSA private key. The methodology is described at * https://tools.ietf.org/html/rfc5849#section-3.4.3 but Mastercard uses the stronger SHA-256 algorithm * as a replacement for the described SHA1 which is no longer considered secure. * * @param sbs Signature base string formatted as per https://tools.ietf.org/html/rfc5849#section-3.4.1 * @param signingKey Private key of the RSA key pair that was established with the service provider * @param charset Charset encoding of the request * @return RSA signature matching the contents of signature base string */ static String signSignatureBaseString(String sbs, PrivateKey signingKey, Charset charset) { try { Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(signingKey); byte[] sbsBytes = sbs.getBytes(charset); signer.update(sbsBytes); byte[] signatureBytes = signer.sign(); return Util.b64Encode(signatureBytes); } catch (GeneralSecurityException e) { throw new IllegalStateException("Unable to RSA-SHA256 sign the given string with the provided key", e); } } /** * Constructs a valid Authorization header as per * https://tools.ietf.org/html/rfc5849#section-3.5.1 * * @param oauthParams Map of OAuth parameters to be included in the Authorization header * @return Correctly formatted header */ private static String getAuthorizationString(Map<String, String> oauthParams) { StringBuilder header = new StringBuilder("OAuth "); for (Map.Entry<String, String> param : oauthParams.entrySet()) { header.append(param.getKey()).append("=\"") .append(param.getValue()).append("\","); } // Remove trailing , header.deleteCharAt(header.length() - 1); return header.toString(); } }