Search code examples
javaamazon-web-servicesrestauthorization

How to make Amazon AWS API call from Java?


What are my options if I want to make a call to Amazon AWS Rest API from Java.

When implementing my own request, generating the AWS4-HMAC-SHA256 Authorization header would be the hardest.

Essentially, this is the header I need to generate:

Authorization: AWS4-HMAC-SHA256 Credential=AKIAJTOUYS27JPVRDUYQ/20200602/us-east-1/route53/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ba85affa19fa4a8735ce952e50d41c8c93406a11d22b88cc98b109b529bcc15e

Solution

  • Not saying that this is a complete list, but I would consider using established libraries like:

    • Official AWS SDK v1, or v2 - current and comprehensive but depends on netty.io and many other jars.
    • Apache JClouds - depends on JAXB which is not longer a part of JDK but now available at maven central separately.

    But sometimes, all you want is to make a simple call, and you don't want to bring many dependencies into your application. You may want to implement the rest call yourself. Generating the right AWS Authorization header is the hardest bit to implement.

    Here is the code to do that in pure java OpenJDK with no external dependencies.

    It implements Amazon AWS API Signature Version 4 signing process.

    AmazonRequestSignatureV4Utils.java
    package com.frusal.amazonsig4;
    
    import java.nio.charset.StandardCharsets;
    import java.security.MessageDigest;
    import java.util.*;
    import java.util.Map.Entry;
    import java.util.stream.Collectors;
    
    import javax.crypto.Mac;
    import javax.crypto.spec.SecretKeySpec;
    
    /**
     * Copyright 2020 Alex Vasiliev, licensed under the Apache 2.0 license: https://opensource.org/licenses/Apache-2.0
     */
    public class AmazonRequestSignatureV4Utils {
    
        /**
         * Generates signing headers for HTTP request in accordance with Amazon AWS API Signature version 4 process.
         * <p>
         * Following steps outlined here: <a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html">docs.aws.amazon.com</a>
         * <p>
         * Simple usage example is here: {@link AmazonRequestSignatureV4Example}
         * <p>
         * This method takes many arguments as read-only, but adds necessary headers to @{code headers} argument, which is a map.
         * The caller should make sure those parameters are copied to the actual request object.
         * <p>
         * The ISO8601 date parameter can be created by making a call to:<br>
         * - {@code java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").format(ZonedDateTime.now(ZoneOffset.UTC))}<br>
         * or, if you prefer joda:<br>
         * - {@code org.joda.time.format.ISODateTimeFormat.basicDateTimeNoMillis().print(DateTime.now().withZone(DateTimeZone.UTC))}
         *
         * @param method - HTTP request method, (GET|POST|DELETE|PUT|...), e.g., {@link java.net.HttpURLConnection#getRequestMethod()}
         * @param host - URL host, e.g., {@link java.net.URL#getHost()}.
         * @param path - URL path, e.g., {@link java.net.URL#getPath()}.
         * @param query - URL query, (parameters in sorted order, see the AWS spec) e.g., {@link java.net.URL#getQuery()}.
         * @param headers - HTTP request header map. This map is going to have entries added to it by this method. Initially populated with
         *     headers to be included in the signature. Like often compulsory 'Host' header. e.g., {@link java.net.HttpURLConnection#getRequestProperties()}.
         * @param body - The binary request body, for requests like POST.
         * @param isoDateTime - The time and date of the request in ISO8601 basic format, see comment above.
         * @param awsIdentity - AWS Identity, e.g., "AKIAJTOUYS27JPVRDUYQ"
         * @param awsSecret - AWS Secret Key, e.g., "I8Q2hY819e+7KzBnkXj66n1GI9piV+0p3dHglAzQ"
         * @param awsRegion - AWS Region, e.g., "us-east-1"
         * @param awsService - AWS Service, e.g., "route53"
         */
        public static void calculateAuthorizationHeaders(
                String method, String host, String path, String query, Map<String, String> headers,
                byte[] body,
                String isoDateTime,
                String awsIdentity, String awsSecret, String awsRegion, String awsService
        ) {
            try {
                String bodySha256 = hex(sha256(body));
                String isoJustDate = isoDateTime.substring(0, 8); // Cut the date portion of a string like '20150830T123600Z';
    
                headers.put("Host", host);
                headers.put("X-Amz-Content-Sha256", bodySha256);
                headers.put("X-Amz-Date", isoDateTime);
    
                // (1) https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
                List<String> canonicalRequestLines = new ArrayList<>();
                canonicalRequestLines.add(method);
                canonicalRequestLines.add(path);
                canonicalRequestLines.add(query);
                List<String> hashedHeaders = new ArrayList<>();
                List<String> headerKeysSorted = headers.keySet().stream().sorted(Comparator.comparing(e -> e.toLowerCase(Locale.US))).collect(Collectors.toList());
                for (String key : headerKeysSorted) {
                    hashedHeaders.add(key.toLowerCase(Locale.US));
                    canonicalRequestLines.add(key.toLowerCase(Locale.US) + ":" + normalizeSpaces(headers.get(key)));
                }
                canonicalRequestLines.add(null); // new line required after headers
                String signedHeaders = hashedHeaders.stream().collect(Collectors.joining(";"));
                canonicalRequestLines.add(signedHeaders);
                canonicalRequestLines.add(bodySha256);
                String canonicalRequestBody = canonicalRequestLines.stream().map(line -> line == null ? "" : line).collect(Collectors.joining("\n"));
                String canonicalRequestHash = hex(sha256(canonicalRequestBody.getBytes(StandardCharsets.UTF_8)));
    
                // (2) https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
                List<String> stringToSignLines = new ArrayList<>();
                stringToSignLines.add("AWS4-HMAC-SHA256");
                stringToSignLines.add(isoDateTime);
                String credentialScope = isoJustDate + "/" + awsRegion + "/" + awsService + "/aws4_request";
                stringToSignLines.add(credentialScope);
                stringToSignLines.add(canonicalRequestHash);
                String stringToSign = stringToSignLines.stream().collect(Collectors.joining("\n"));
    
                // (3) https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
                byte[] kDate = hmac(("AWS4" + awsSecret).getBytes(StandardCharsets.UTF_8), isoJustDate);
                byte[] kRegion = hmac(kDate, awsRegion);
                byte[] kService = hmac(kRegion, awsService);
                byte[] kSigning = hmac(kService, "aws4_request");
                String signature = hex(hmac(kSigning, stringToSign));
    
                String authParameter = "AWS4-HMAC-SHA256 Credential=" + awsIdentity + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature;
                headers.put("Authorization", authParameter);
    
            } catch (Exception e) {
                if (e instanceof RuntimeException) {
                    throw (RuntimeException) e;
                } else {
                    throw new IllegalStateException(e);
                }
            }
        }
    
        private static String normalizeSpaces(String value) {
            return value.replaceAll("\\s+", " ").trim();
        }
    
        public static String hex(byte[] a) {
            StringBuilder sb = new StringBuilder(a.length * 2);
            for(byte b: a) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
         }
    
        private static byte[] sha256(byte[] bytes) throws Exception {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            digest.update(bytes);
            return digest.digest();
        }
    
        public static byte[] hmac(byte[] key, String msg) throws Exception {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key, "HmacSHA256"));
            return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));
        }
    
    }
    

    And the usage example:

    AmazonRequestSignatureV4Utils.java
    package com.frusal.amazonsig4;
    
    import java.net.HttpURLConnection;
    import java.net.URL;
    import java.nio.charset.StandardCharsets;
    import java.time.ZoneOffset;
    import java.time.ZonedDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.LinkedHashMap;
    import java.util.Map;
    import java.util.stream.Collectors;
    
    public class AmazonRequestSignatureV4Example {
    
        public static void main(String[] args) throws Exception {
            String route53HostedZoneId = "Z08118721NNU878C4PBNA";
            String awsIdentity = "AKIAJTOUYS27JPVRDUYQ";
            String awsSecret = "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";
            String awsRegion = "us-east-1";
            String awsService = "route53";
    
            URL url = new URL("https://route53.amazonaws.com/2013-04-01/hostedzone/" + route53HostedZoneId + "/rrset");
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("POST");
            System.out.println(connection.getRequestMethod() + " " + url);
    
            String body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
                    "<ChangeResourceRecordSetsRequest xmlns=\"https://route53.amazonaws.com/doc/2013-04-01/\">\n" +
                    "<ChangeBatch>\n" +
                    // " <Comment>optional comment about the changes in this change batch request</Comment>\n" +
                    "   <Changes>\n" +
                    "      <Change>\n" +
                    "         <Action>UPSERT</Action>\n" +
                    "         <ResourceRecordSet>\n" +
                    "            <Name>c001cxxx.frusal.com.</Name>\n" +
                    "            <Type>A</Type>\n" +
                    "            <TTL>300</TTL>\n" +
                    "            <ResourceRecords>\n" +
                    "               <ResourceRecord>\n" +
                    "                  <Value>157.245.232.185</Value>\n" +
                    "               </ResourceRecord>\n" +
                    "            </ResourceRecords>\n" +
                    // " <HealthCheckId>optional ID of a Route 53 health check</HealthCheckId>\n" +
                    "         </ResourceRecordSet>\n" +
                    "      </Change>\n" +
                    "   </Changes>\n" +
                    "</ChangeBatch>\n" +
                    "</ChangeResourceRecordSetsRequest>";
            byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
    
            Map<String, String> headers = new LinkedHashMap<>();
            String isoDate = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").format(ZonedDateTime.now(ZoneOffset.UTC));
            AmazonRequestSignatureV4Utils.calculateAuthorizationHeaders(
                    connection.getRequestMethod(),
                    connection.getURL().getHost(),
                    connection.getURL().getPath(),
                    connection.getURL().getQuery(),
                    headers,
                    bodyBytes,
                    isoDate,
                    awsIdentity,
                    awsSecret,
                    awsRegion,
                    awsService);
    
            // Unsigned headers
            headers.put("Content-Type", "text/xml; charset=utf-8"); // I guess it get modified somewhere on the way... Let's just leave it out of the signature.
    
            // Log headers and body
            System.out.println(headers.entrySet().stream().map(e -> e.getKey() + ": " + e.getValue()).collect(Collectors.joining("\n")));
            System.out.println(body);
    
            // Send
            headers.forEach((key, val) -> connection.setRequestProperty(key, val));
            connection.setDoOutput(true);
            connection.getOutputStream().write(bodyBytes);
            connection.getOutputStream().flush();
    
            int responseCode = connection.getResponseCode();
            System.out.println("connection.getResponseCode()=" + responseCode);
    
            String responseContentType = connection.getHeaderField("Content-Type");
            System.out.println("responseContentType=" + responseContentType);
    
            System.out.println("Response BODY:");
            if (connection.getErrorStream() != null) {
                System.out.println(new String(connection.getErrorStream().readAllBytes(), StandardCharsets.UTF_8));
            } else {
                System.out.println(new String(connection.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
            }
        }
    }
    
    And the trace it would generate:
    POST https://route53.amazonaws.com/2013-04-01/hostedzone/Z08118721NNU878C4PBNA/rrset
    Host: route53.amazonaws.com
    X-Amz-Content-Sha256: 46c7521da55bcf9e99fa6e12ec83997fab53128b5df0fb12018a6b76fb2bf891
    X-Amz-Date: 20200602T035618Z
    Authorization: AWS4-HMAC-SHA256 Credential=AKIAJTOUYS27JPVRDUYQ/20200602/us-east-1/route53/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=6a59090f837cf71fa228d2650e9b82e9769e0ec13e9864e40bd2f81c682ef8cb
    Content-Type: text/xml; charset=utf-8
    <?xml version="1.0" encoding="UTF-8"?>
    <ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
    <ChangeBatch>
       <Changes>
          <Change>
             <Action>UPSERT</Action>
             <ResourceRecordSet>
                <Name>c001cxxx.frusal.com.</Name>
                <Type>A</Type>
                <TTL>300</TTL>
                <ResourceRecords>
                   <ResourceRecord>
                      <Value>157.245.232.185</Value>
                   </ResourceRecord>
                </ResourceRecords>
             </ResourceRecordSet>
          </Change>
       </Changes>
    </ChangeBatch>
    </ChangeResourceRecordSetsRequest>
    connection.getResponseCode()=200
    responseContentType=text/xml
    Response BODY:
    <?xml version="1.0"?>
    <ChangeResourceRecordSetsResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeInfo><Id>/change/C011827119UYGF04GVIP6</Id><Status>PENDING</Status><SubmittedAt>2020-06-02T03:56:25.822Z</SubmittedAt></ChangeInfo></ChangeResourceRecordSetsResponse>
    

    Edit: Updated to sort the headers. Thanks @Gray for the find!

    For the latest version of this code, please see java-amazon-request-signature-v4 repository at GitHub.