ProxyAuthenticator.java

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.azure.core.http.okhttp.implementation;

import com.azure.core.http.HttpMethod;
import com.azure.core.util.AuthorizationChallengeHandler;
import com.azure.core.util.CoreUtils;
import com.azure.core.util.logging.ClientLogger;
import okhttp3.Authenticator;
import okhttp3.Challenge;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.Route;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;

import static com.azure.core.util.AuthorizationChallengeHandler.PROXY_AUTHENTICATION_INFO;
import static com.azure.core.util.AuthorizationChallengeHandler.PROXY_AUTHORIZATION;

/**
 * This class handles authorizing requests being sent through a proxy which require authentication.
 */
public final class ProxyAuthenticator implements Authenticator {
    private static final String VALIDATION_ERROR_TEMPLATE = "The '%s' returned in the 'Proxy-Authentication-Info' "
        + "header doesn't match the value sent in the 'Proxy-Authorization' header. Sent: %s, received: %s.";

    private static final String BASIC = "basic";
    private static final String DIGEST = "digest";
    private static final String PREEMPTIVE_AUTHENTICATE = "Preemptive Authenticate";

    /*
     * Proxies use 'CONNECT' as the HTTP method.
     */
    private static final String PROXY_METHOD = HttpMethod.CONNECT.name();

    /*
     * Proxies are always the root path.
     */
    private static final String PROXY_URI_PATH = "/";

    /*
     * Digest authentication to a proxy uses the 'CONNECT' method, these can't have a request body.
     */
    private static final Supplier<byte[]> NO_BODY = () -> new byte[0];

    private static final String CNONCE = "cnonce";
    private static final String NC = "nc";

    private final ClientLogger logger = new ClientLogger(ProxyAuthenticator.class);

    private final AuthorizationChallengeHandler challengeHandler;

    /**
     * Constructs a {@link ProxyAuthenticator} which handles authenticating against proxy servers.
     *
     * @param username Username used in authentication challenges.
     * @param password Password used in authentication challenges.
     */
    public ProxyAuthenticator(String username, String password) {
        this.challengeHandler = new AuthorizationChallengeHandler(username, password);
    }

    /**
     * Creates an {@link Interceptor} which will attempt to capture authentication info response headers to update the
     * {@link ProxyAuthenticator} in preparation for future authentication challenges.
     *
     * @return An {@link Interceptor} that attempts to read headers from the response.
     */
    public Interceptor getProxyAuthenticationInfoInterceptor() {
        return new ProxyAuthenticationInfoInterceptor(challengeHandler);
    }

    /**
     * @param route Route being used to reach the server.
     * @param response Response from the server requesting authentication.
     * @return The initial request with an authorization header applied.
     */
    @Override
    public Request authenticate(Route route, Response response) {
        String authorizationHeader = challengeHandler
            .attemptToPipelineAuthorization(PROXY_METHOD, PROXY_URI_PATH, NO_BODY);

        // Pipelining was successful, use the generated authorization header.
        if (!CoreUtils.isNullOrEmpty(authorizationHeader)) {
            return response.request().newBuilder()
                .header(PROXY_AUTHORIZATION, authorizationHeader)
                .build();
        }

        // If this is a pre-emptive challenge quit now if pipelining doesn't produce anything.
        if (PREEMPTIVE_AUTHENTICATE.equalsIgnoreCase(response.message())) {
            return response.request();
        }

        boolean hasBasicChallenge = false;
        List<Map<String, String>> digestChallenges = new ArrayList<>();

        for (Challenge challenge : response.challenges()) {
            if (BASIC.equalsIgnoreCase(challenge.scheme())) {
                hasBasicChallenge = true;
            } else if (DIGEST.equalsIgnoreCase(challenge.scheme())) {
                digestChallenges.add(challenge.authParams());
            }
        }

        // Prefer digest challenges over basic.
        if (digestChallenges.size() > 0) {
            authorizationHeader = challengeHandler
                .handleDigest(PROXY_METHOD, PROXY_URI_PATH, digestChallenges, NO_BODY);
        }

        /*
         * If Digest proxy was attempted but it wasn't able to be computed and the server sent a Basic
         * challenge as well apply the basic authorization header.
         */
        if (authorizationHeader == null && hasBasicChallenge) {
            authorizationHeader = challengeHandler.handleBasic();
        }

        Request.Builder requestBuilder = response.request().newBuilder();

        if (authorizationHeader != null) {
            requestBuilder.header(PROXY_AUTHORIZATION, authorizationHeader);
        }

        return requestBuilder.build();
    }

    /**
     * This class handles intercepting the response returned from the server when proxying.
     */
    private class ProxyAuthenticationInfoInterceptor implements Interceptor {
        private final AuthorizationChallengeHandler challengeHandler;

        /**
         * Constructs an {@link Interceptor} which intercepts responses from the server when using proxy authentication
         * in an attempt to retrieve authentication info response headers.
         *
         * @param challengeHandler {@link AuthorizationChallengeHandler} that consumes authentication info response
         * headers.
         */
        ProxyAuthenticationInfoInterceptor(AuthorizationChallengeHandler challengeHandler) {
            this.challengeHandler = challengeHandler;
        }

        /**
         * Attempts to intercept the 'Proxy-Authentication-Info' response header sent from the server. If the header is
         * set it will be used to validate the request and response and update the pipelined challenge in the passed
         * {@link AuthorizationChallengeHandler}.
         *
         * @param chain Interceptor chain.
         * @return Response returned from the server.
         * @throws IOException If an I/O error occurs.
         */
        @Override
        public Response intercept(Chain chain) throws IOException {
            Response response = chain.proceed(chain.request());

            String proxyAuthenticationInfoHeader = response.header(PROXY_AUTHENTICATION_INFO);
            if (!CoreUtils.isNullOrEmpty(proxyAuthenticationInfoHeader)) {
                Map<String, String> authenticationInfoPieces = AuthorizationChallengeHandler
                    .parseAuthenticationOrAuthorizationHeader(proxyAuthenticationInfoHeader);
                Map<String, String> authorizationPieces = AuthorizationChallengeHandler
                    .parseAuthenticationOrAuthorizationHeader(chain.request().header(PROXY_AUTHORIZATION));

                /*
                 * If the authentication info response contains a cnonce or nc value it MUST match the value sent in the
                 * authorization header. This is the server performing validation to the client that it received the
                 * information.
                 */
                validateProxyAuthenticationInfoValue(CNONCE, authenticationInfoPieces, authorizationPieces);
                validateProxyAuthenticationInfoValue(NC, authenticationInfoPieces, authorizationPieces);

                challengeHandler.consumeAuthenticationInfoHeader(authenticationInfoPieces);
            }

            return response;
        }
    }

    /*
     * Validates that the value received in the 'Proxy-Authentication-Info' matches the value sent in the
     * 'Proxy-Authorization' header. If the values don't match an 'IllegalStateException' will be thrown with a message
     * outlining that the values didn't match.
     */
    private void validateProxyAuthenticationInfoValue(String name, Map<String, String> authenticationInfoPieces,
        Map<String, String> authorizationPieces) {
        if (authenticationInfoPieces.containsKey(name)) {
            String sentValue = authorizationPieces.get(name);
            String receivedValue = authenticationInfoPieces.get(name);

            if (!receivedValue.equalsIgnoreCase(sentValue)) {
                throw logger.logExceptionAsError(new IllegalStateException(
                    String.format(VALIDATION_ERROR_TEMPLATE, name, sentValue, receivedValue)));
            }
        }
    }
}