ConfigurationClientCredentials.java

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package com.azure.data.appconfiguration.implementation;

import com.azure.core.util.logging.ClientLogger;
import com.azure.core.util.CoreUtils;
import com.azure.data.appconfiguration.ConfigurationClientBuilder;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Credentials that authorizes requests to Azure App Configuration. It uses content within the HTTP request to
 * generate the correct "Authorization" header value. {@link ConfigurationCredentialsPolicy} ensures that the content
 * exists in the HTTP request so that a valid authorization value is generated.
 *
 * @see ConfigurationCredentialsPolicy
 * @see ConfigurationClientBuilder
 */
public class ConfigurationClientCredentials {
    private final ClientLogger logger = new ClientLogger(ConfigurationClientCredentials.class);

    private static final String HOST_HEADER = "Host";
    private static final String DATE_HEADER = "Date";
    private static final String CONTENT_HASH_HEADER = "x-ms-content-sha256";
    private static final String[] SIGNED_HEADERS = new String[]{HOST_HEADER, DATE_HEADER, CONTENT_HASH_HEADER };
    private static final String AUTHORIZATION_HEADER = "Authorization";

    private final CredentialInformation credentials;
    private final AuthorizationHeaderProvider headerProvider;

    /**
     * Creates an instance that is able to authorize requests to Azure App Configuration service.
     *
     * @param connectionString Connection string in the format "endpoint={endpoint_value};id={id_value};
     *     secret={secret_value}"
     * @throws NoSuchAlgorithmException When the HMAC-SHA256 MAC algorithm cannot be instantiated.
     * @throws InvalidKeyException When the {@code connectionString} secret is invalid and cannot instantiate the
     *     HMAC-SHA256 algorithm.
     */
    public ConfigurationClientCredentials(String connectionString)
        throws InvalidKeyException, NoSuchAlgorithmException {
        credentials = new CredentialInformation(connectionString);
        headerProvider = new AuthorizationHeaderProvider(credentials);
    }

    /**
     * Gets the base URI of the Azure App Configuration instance based on the provided connection string.
     *
     * @return The base url of the configuration service extracted from connection string provided.
     */
    public String getBaseUri() {
        return this.credentials.baseUri().toString();
    }

    /**
     * Gets a list of headers to add to a request to authenticate it to the Azure APp Configuration service.
     * @param url the request url
     * @param httpMethod the request HTTP method
     * @param contents the body content of the request
     * @return a flux of headers to add for authorization
     * @throws NoSuchAlgorithmException If the SHA-256 algorithm doesn't exist.
     */
    Mono<Map<String, String>> getAuthorizationHeadersAsync(URL url, String httpMethod, Flux<ByteBuffer> contents) {
        return contents
            .collect(() -> {
                try {
                    return MessageDigest.getInstance("SHA-256");
                } catch (NoSuchAlgorithmException e) {
                    throw logger.logExceptionAsError(Exceptions.propagate(e));
                }
            }, (messageDigest, byteBuffer) -> {
                    if (messageDigest != null) {
                        messageDigest.update(byteBuffer);
                    }
                })
            .flatMap(messageDigest -> Mono.just(headerProvider.getAuthenticationHeaders(
                url,
                httpMethod,
                messageDigest)));
    }

    private static class AuthorizationHeaderProvider {
        private final String signedHeadersValue = String.join(";", SIGNED_HEADERS);
        private static final String HMAC_SHA256 = "HMAC-SHA256 Credential=%s&SignedHeaders=%s&Signature=%s";
        private final CredentialInformation credentials;
        private final Mac sha256HMAC;

        AuthorizationHeaderProvider(CredentialInformation credentials)
            throws NoSuchAlgorithmException, InvalidKeyException {
            this.credentials = credentials;

            sha256HMAC = Mac.getInstance("HmacSHA256");
            sha256HMAC.init(new SecretKeySpec(credentials.secret(), "HmacSHA256"));
        }

        private Map<String, String> getAuthenticationHeaders(final URL url, final String httpMethod,
                                                             final MessageDigest messageDigest) {
            final Map<String, String> headers = new HashMap<>();
            final String contentHash = Base64.getEncoder().encodeToString(messageDigest.digest());

            // All three of these headers are used by ConfigurationClientCredentials to generate the
            // Authentication header value. So, we need to ensure that they exist.
            headers.put(HOST_HEADER, url.getHost());
            headers.put(CONTENT_HASH_HEADER, contentHash);

            if (headers.get(DATE_HEADER) == null) {
                String utcNow = OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME);
                headers.put(DATE_HEADER, utcNow);
            }

            addSignatureHeader(url, httpMethod, headers);
            return headers;
        }

        private void addSignatureHeader(final URL url, final String httpMethod, final Map<String, String> httpHeaders) {
            String pathAndQuery = url.getPath();
            if (url.getQuery() != null) {
                pathAndQuery += '?' + url.getQuery();
            }

            final String signed = Arrays.stream(SIGNED_HEADERS)
                .map(httpHeaders::get)
                .collect(Collectors.joining(";"));

            // String-To-Sign=HTTP_METHOD + '\n' + path_and_query + '\n' + signed_headers_values
            // Signed headers: "host;x-ms-date;x-ms-content-sha256"
            // The line separator has to be \n. Using %n with String.format will result in a 401 from the service.
            String stringToSign = httpMethod.toUpperCase(Locale.US) + "\n" + pathAndQuery + "\n" + signed;

            final String signature =
                Base64.getEncoder().encodeToString(sha256HMAC.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
            httpHeaders.put(AUTHORIZATION_HEADER,
                String.format(HMAC_SHA256, credentials.id(), signedHeadersValue, signature));
        }
    }

    private static class CredentialInformation {
        private static final String ENDPOINT = "endpoint=";
        private static final String ID = "id=";
        private static final String SECRET = "secret=";

        private final URL baseUri;
        private final String id;
        private final byte[] secret;

        URL baseUri() {
            return baseUri;
        }

        String id() {
            return id;
        }

        byte[] secret() {
            return secret;
        }

        CredentialInformation(String connectionString) {
            if (CoreUtils.isNullOrEmpty(connectionString)) {
                throw new IllegalArgumentException("'connectionString' cannot be null or empty.");
            }

            String[] args = connectionString.split(";");
            if (args.length < 3) {
                throw new IllegalArgumentException("invalid connection string segment count");
            }

            URL baseUri = null;
            String id = null;
            byte[] secret = null;

            for (String arg : args) {
                String segment = arg.trim();
                String lowerCase = segment.toLowerCase(Locale.US);

                if (lowerCase.startsWith(ENDPOINT)) {
                    try {
                        baseUri = new URL(segment.substring(ENDPOINT.length()));
                    } catch (MalformedURLException ex) {
                        throw new IllegalArgumentException(ex);
                    }
                } else if (lowerCase.startsWith(ID)) {
                    id = segment.substring(ID.length());
                } else if (lowerCase.startsWith(SECRET)) {
                    String secretBase64 = segment.substring(SECRET.length());
                    secret = Base64.getDecoder().decode(secretBase64);
                }
            }

            this.baseUri = baseUri;
            this.id = id;
            this.secret = secret;

            if (this.baseUri == null || CoreUtils.isNullOrEmpty(this.id) || this.secret == null) {
                throw new IllegalArgumentException("Could not parse 'connectionString'."
                    + " Expected format: 'endpoint={endpoint};id={id};secret={secret}'. Actual:" + connectionString);
            }
        }
    }
}