com.atlassian.connect.spring.internal.jwt.HttpRequestCanonicalizer Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of atlassian-connect-spring-boot-jwt Show documentation
Show all versions of atlassian-connect-spring-boot-jwt Show documentation
Provides JSON Web Token handling for Atlassian Connect for Spring Boot
The newest version!
package com.atlassian.connect.spring.internal.jwt;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public class HttpRequestCanonicalizer {
/**
* Instructions for computing the query hash parameter ("qsh") from a HTTP request.
* -------------------------------------------------------------------------------------
*
* Overview:
* query hash = hash(canonical-request)
* canonical-request = canonical-method + '&' + canonical-URI + '&' + canonical-query-string
* 1. Compute canonical method.
* Simply the upper-case of the method name (e.g. "GET", "PUT").
* 2. Append the character '&'
* 3. Compute canonical URI.
* Discard the protocol, server, port, context path and query parameters from the full URL.
* For requests targeting add-ons discard the `baseUrl` in the add-on descriptor.
* (Removing the context path allows a reverse proxy to redirect incoming requests for "jira.example.com/getsomething"
* to "example.com/jira/getsomething" without breaking authentication. The requester cannot know that the reverse proxy
* will prepend the context path "/jira" to the originally requested path "/getsomething".)
* Empty-string is not permitted; use "/" instead.
* Do not suffix with a '/' character unless it is the only character.
* Url-encode any '&' characters in the path.
* E.g. in "http://server:80/some/path/?param=value" the canonical URI is "/some/path"
* and in "http://server:80" the canonical URI is "/".
* 4. Append the character '&'.
* 5. Compute the canonical query string.
* Sort the query parameters primarily by their percent-encoded names and secondarily by their percent-encoded values.
* Sorting is by codepoint: sort(["a", "A", "b", "B"]) => ["A", "B", "a", "b"].
* For each parameter append its percent-encoded name, the '=' character and then its percent-encoded value.
* In the case of repeated parameters append the ',' character and subsequent percent-encoded values.
* Ignore the JWT query string parameter, if present.
* Some particular values to be aware of: "+" is encoded as "%20",
* "*" as "%2A" and
* "~" as "~".
* (These values used for consistency with OAuth1.)
* An example: for a GET request to the not-yet-percent-encoded URL "http://localhost:2990/path/to/service?zee_last=param&repeated=parameter 1&first=param&repeated=parameter 2"
* the canonical request is "GET&/path/to/service&first=param&repeated=parameter%201,parameter%202&zee_last=param".
* 6. Convert the canonical request string to bytes.
* The encoding used to represent characters as bytes is UTF-8.
* 7. Hash the canonical request bytes using the SHA-256 algorithm.
* E.g. The SHA-256 hash of "foo" is "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae".
*
*/
public static final String QUERY_STRING_HASH_CLAIM_NAME = "qsh";
/**
* When the JWT message is specified in the query string of a URL then this is the parameter name.
*
* E.g. "jwt" in:
*
* http://server:80/some/path?otherparam=value&jwt=eyJhbGciOiJIUzI1NiIsI.eyJleHAiOjEzNzg5NCI6MTM3ODk1MjQ4OH0.cDihfcsKW_We_EY21tIs55dVwjU
*
*/
public static final String JWT_PARAM_NAME = "jwt";
/**
* As appears between "value1" and "param2" in the URL "http://server/path?param1=value1¶m2=value2".
*/
public static final char QUERY_PARAMS_SEPARATOR = '&';
/**
* The character between "a" and "b%20c" in "some_param=a,b%20c"
*/
private static final String ENCODED_PARAM_VALUE_SEPARATOR = ",";
/**
* For separating the method, URI etc in a canonical request string.
*/
private static final char CANONICAL_REQUEST_PART_SEPARATOR = '&';
private HttpRequestCanonicalizer() {}
/**
* Assemble the components of the HTTP request into the correct format so that they can be signed or hashed.
*
* @param request {@link CanonicalHttpRequest} that provides the necessary components
* @return {@link String} encoding the canonical form of this request as required for constructing query string hash values
*/
public static String canonicalize(CanonicalHttpRequest request) {
return String.format("%s%s%s%s%s", canonicalizeMethod(request), CANONICAL_REQUEST_PART_SEPARATOR, canonicalizeUri(request), CANONICAL_REQUEST_PART_SEPARATOR, canonicalizeQueryParameters(request));
}
/**
* Canonicalize the given {@link CanonicalHttpRequest} and hash it.
* This request hash can be included as a JWT claim to verify that request components are genuine.
*
* @param request {@link CanonicalHttpRequest} to be canonicalized and hashed
* @return {@link String} hash suitable for use as a JWT claim value
* @throws NoSuchAlgorithmException if the hashing algorithm does not exist at runtime
*/
public static String computeCanonicalRequestHash(CanonicalHttpRequest request) throws NoSuchAlgorithmException {
// prevent the code in this method being repeated in every call site that needs a request hash,
// encapsulate the knowledge of the type of hash that we are using
return computeSha256Hash(canonicalize(request));
}
/**
* Compute the SHA-256 hash of hashInput.
* E.g. The SHA-256 has of "foo" is "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae".
*
* @param hashInput {@link String} to be hashed.
* @return {@link String} hash
* @throws NoSuchAlgorithmException if the hashing algorithm does not exist at runtime
*/
private static String computeSha256Hash(String hashInput) throws NoSuchAlgorithmException {
if (null == hashInput) {
throw new IllegalArgumentException("hashInput cannot be null");
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashInputBytes = hashInput.getBytes();
digest.update(hashInputBytes, 0, hashInputBytes.length);
return new String(Hex.encode(digest.digest()));
}
private static String canonicalizeUri(CanonicalHttpRequest request) {
String path = Optional.of(StringUtils.trimTrailingCharacter(request.getRelativePath(), '/'))
.filter(s -> !ObjectUtils.isEmpty(s)).orElse("/");
final String separatorAsString = String.valueOf(CANONICAL_REQUEST_PART_SEPARATOR);
// If the separator is not URL encoded then the following URLs have the same query-string-hash:
// https://djtest9.jira-dev.com/rest/api/2/project&a=b?x=y
// https://djtest9.jira-dev.com/rest/api/2/project?a=b&x=y
path = path.replaceAll(separatorAsString, percentEncode(separatorAsString));
return path.startsWith("/") ? path : "/" + path;
}
private static String canonicalizeMethod(CanonicalHttpRequest request) {
return StringUtils.capitalize(request.getMethod());
}
private static String canonicalizeQueryParameters(CanonicalHttpRequest request) {
String result = "";
if (null != request.getParameterMap()) {
List parameterList = new ArrayList<>(request.getParameterMap().size());
for (Map.Entry parameter : request.getParameterMap().entrySet()) {
if (!JWT_PARAM_NAME.equals(parameter.getKey())) {
parameterList.add(new ComparableParameter(parameter));
}
}
Collections.sort(parameterList);
result = percentEncode(getParameters(parameterList));
}
return result;
}
/**
* Retrieve the original parameters from a sorted collection.
*/
private static List> getParameters(Collection parameters) {
if (parameters == null) {
return Collections.emptyList();
}
List> list = new ArrayList<>(parameters.size());
for (ComparableParameter parameter : parameters) {
list.add(parameter.getParameter());
}
return list;
}
/**
* Construct a form-urlencoded document containing the given sequence of
* name/parameter pairs.
*/
private static String percentEncode(Iterable extends Map.Entry> parameters) {
ByteArrayOutputStream b = new ByteArrayOutputStream();
// IOException should not be throws as we are not messing around with it between creation and use
// (e.g. by closing it) but the methods on the OutputStream interface don't know that
try {
percentEncode(parameters, b);
return b.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* Write a form-urlencoded document into the given stream, containing the
* given sequence of name/parameter pairs.
*/
private static void percentEncode(Iterable extends Map.Entry> parameters, OutputStream into) throws IOException {
if (parameters != null) {
boolean first = true;
for (Map.Entry parameter : parameters) {
if (first) {
first = false;
} else {
into.write(QUERY_PARAMS_SEPARATOR);
}
into.write(percentEncode(safeToString(parameter.getKey())).getBytes());
into.write('=');
List percentEncodedValues = new ArrayList<>(parameter.getValue().length);
for (String value : parameter.getValue()) {
percentEncodedValues.add(percentEncode(value));
}
String valueString = String.join(ENCODED_PARAM_VALUE_SEPARATOR, percentEncodedValues);
into.write(valueString.getBytes());
}
}
}
/**
* {@link URLEncoder}#encode() but encode some characters differently to URLEncoder, to match OAuth1 and VisualVault.
*
* @param value {@link String} to be percent-encoded
* @return encoded {@link String}
*/
private static String percentEncode(String value) {
if (value == null) {
return "";
}
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20")
.replace("*", "%2A")
.replace("%7E", "~");
}
private static String safeToString(Object from) {
return null == from ? null : from.toString();
}
/**
* An efficiently sortable wrapper around a parameter.
*/
private static class ComparableParameter implements Comparable {
private final Map.Entry parameter;
private final String key;
ComparableParameter(Map.Entry parameter) {
this.parameter = parameter;
String name = safeToString(parameter.getKey());
List sortedValues = Arrays.asList(parameter.getValue());
Collections.sort(sortedValues);
String value = String.join(",", sortedValues);
this.key = percentEncode(name) + ' ' + percentEncode(value);
// ' ' is used because it comes before any character
// that can appear in a percentEncoded string.
}
public int compareTo(ComparableParameter that) {
return this.key.compareTo(that.key);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ComparableParameter that = (ComparableParameter) o;
return Objects.equals(parameter, that.parameter) && Objects.equals(key, that.key);
}
@Override
public int hashCode() {
return Objects.hash(parameter, key);
}
public Map.Entry getParameter() {
return parameter;
}
@Override
public String toString() {
return key;
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy