LazyStack

LzHttpClient Class

The LzHttpClient class is responsible for making signed and unsigned calls to AWS ApiGateways. There are two types of signed HttpRequests used in LazyStack calls to AWS Gateways:

The AWS "special sauce" is isolated in the LzHttpClient. Let's examine the implementation of LzHttpClient to answer the following questions:

  • How does LzHttpClass know which ApiGateway to call?
  • How does LzHttpClass know what security scheme to apply on the call?
  • How is authenticated user information sent to the AWS Service?
  • How do we configure LzHttpClass to call the local WebApi? (where WebApi was generated by LazyStack)

First, take the time to read through the LzHttpClient class code.


using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace LazyStackAuth
{
    public class LzHttpClient : ILzHttpClient
    {
        public LzHttpClient(
            IConfiguration appConfig,
            AuthProviderCognito authProvider,
            string localApiName = null) :
#if DEBUG
        this(appConfig, authProvider, new HttpClient(GetInsecureHandler()), localApiName)
        { }
#else
        this(appConfig, authProvider, new HttpClient(), localApiName)
        { }
#endif

        public LzHttpClient(
            IConfiguration appConfig,
            AuthProviderCognito authProvider,
            HttpClient httpClient,
            string localApiName = null)
        {
            this.httpClient = httpClient;
            this.localApiName = localApiName;
            if (!string.IsNullOrEmpty(localApiName))
                this.localApi = appConfig.GetSection($"LocalApis:{localApiName}").Get<LocalApi>();
            this.awsSettings = appConfig.GetSection("Aws").Get<AwsSettings>();
            this.authProvider = authProvider;
            this.methodMap = appConfig.GetSection("MethodMap").GetChildren().ToDictionary(x => x.Key, x => x.Value);
        }

        readonly HttpClient httpClient;
        readonly AwsSettings awsSettings;
        readonly LocalApi localApi;
        readonly string localApiName = string.Empty;
        AuthProviderCognito authProvider;
        Dictionary<string, string> methodMap;


        // Note: CallerMember is inserted as a literal by the compiler in the IL so there is no 
        // performance penalty for using it.
        public async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage requestMessage,
            HttpCompletionOption httpCompletionOption,
            CancellationToken cancellationToken,
            [CallerMemberName] string callerMemberName = null)
        {

            if (!methodMap.TryGetValue(callerMemberName, out string apiGatewayName))
                throw new Exception($"Error: {callerMemberName} not found in AwsSettings MethodMap");

            if (!awsSettings.ApiGateways.TryGetValue(apiGatewayName, out AwsSettings.Api api))
                throw new Exception($"Error: {apiGatewayName} not found in AwsSettings ApiGateways dictionary");

            var securityLevel = api.SecurityLevel;

            if (!string.IsNullOrEmpty(localApiName))
            {
                var uriBuilder = new UriBuilder(localApi.Scheme, localApi.Host, localApi.Port);

                // Issue: the AspNetCore server rejects a query with the ? encoded as %3F 
                // so the following doesn't work 
                // uriBuilder.Path = requestMessage.RequestUri.ToString(); 
                // the assignment encodes path query as %3F instead of ?
                // Here we encode the path separately and then build a
                // a new Uri from the uriBuilder and the path.
                var path = requestMessage.RequestUri.ToString(); // Unencoded
                path = Uri.EscapeUriString(path); // Encoded properly
                requestMessage.RequestUri = new Uri(uriBuilder.Uri, path);
            }
            else
            {
                var awshost = $"{api.Id}.{api.Service}.{awsSettings.Region}.{api.Host}";

                var uriBuilder = (api.Port == 443)
                    ? new UriBuilder(api.Scheme, awshost)
                    : new UriBuilder(api.Scheme, awshost, api.Port);

                var path = (!string.IsNullOrEmpty(api.Stage))
                    ? "/" + api.Stage + "/" + requestMessage.RequestUri.ToString()
                    : requestMessage.RequestUri.ToString();

                // Issue: the AspNetCore server rejects a query with the ? encoded as %3F 
                // so the following doesn't work 
                // uriBuilder.Path = requestMessage.RequestUri.ToString(); 
                // the assignment encodes path query as %3F instead of ?
                // Here we encode the path separately and then build a
                // a new Uri from the uriBuilder and the path.
                path = Uri.EscapeUriString(path); // Encoded properly
                requestMessage.RequestUri = new Uri(uriBuilder.Uri, path);
            }

            Debug.WriteLine($"requestMessage.Path {requestMessage.RequestUri.ToString()}");
            try
            {
                HttpResponseMessage response = null;
                // Note: If the call is being made against a local host then that host
                // will by default not pay any attention to the authorization header attached by 
                // the JWT or AwsSignatureVersion4 cases below. We assign the Headers
                // anyway in case you want to implement handling these headers 
                // in your local host for testing or any other purpose.

                switch (securityLevel)
                {
                    case AwsSettings.SecurityLevel.None:
                        response = await httpClient.SendAsync(
                            requestMessage,
                            httpCompletionOption,
                            cancellationToken);
                        break;

                    case AwsSettings.SecurityLevel.JWT:
                        // Use JWT Token signing process
                        requestMessage.Headers.Add("Authorization", authProvider.CognitoUser.SessionTokens.IdToken);
                        response = await httpClient.SendAsync(
                            requestMessage,
                            httpCompletionOption,
                            cancellationToken);
                        break;

                    case AwsSettings.SecurityLevel.AwsSignatureVersion4:
                        // Use full request signing process
                        // Get Temporary ImmutableCredentials :  AccessKey, SecretKey, Token
                        // This will refresh immutable credentials if necessary
                        // Calling AwsSignatureVersion4 extension method -- this signs the request message
                        var iCreds = await authProvider.Credentials.GetCredentialsAsync();
                        requestMessage.Headers.Add("LzIdentity", authProvider.CognitoUser.SessionTokens.IdToken);
                        response = await httpClient.SendAsync(
                            requestMessage,
                            httpCompletionOption,
                            cancellationToken,
                            awsSettings.Region,
                            api.Service,
                            iCreds);
                        break;
                }
                return response;

            }
            catch (Exception e)
            {
                Debug.WriteLine($"Error: {e.Message}");
            }

            return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);

        }
#if DEBUG        
        //https://docs.microsoft.com/en-us/xamarin/cross-platform/deploy-test/connect-to-local-web-services
        //Attempting to invoke a local secure web service from an application running in the iOS simulator 
        //or Android emulator will result in a HttpRequestException being thrown, even when using the managed 
        //network stack on each platform.This is because the local HTTPS development certificate is self-signed, 
        //and self-signed certificates aren't trusted by iOS or Android.
        public static HttpClientHandler GetInsecureHandler()
        {
            var handler = new HttpClientHandler
            {
                ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
                {
                    if (cert.Issuer.Equals("CN=localhost"))
                        return true;
                    return errors == System.Net.Security.SslPolicyErrors.None;
                }
            };
            return handler;
        }
#endif
        public void Dispose()
        {
            httpClient.Dispose();
        }
    }
}
    

Now, let's examine and discuss the LzHttpClient class constructor(s) arguments:


    public LzHttpClient(
        IConfiguration appConfig,
        AuthProviderCognito authProvider,
        HttpClient httpClient,
        string localApiName = null)
    {
        this.httpClient = httpClient;
        this.localApiName = localApiName;
        if (!string.IsNullOrEmpty(localApiName))
            this.localApi = appConfig.GetSection($"LocalApis:{localApiName}").Get<LocalApi>();
        this.awsSettings = appConfig.GetSection("Aws").Get<AwsSettings>();
        this.authProvider = authProvider;
        this.methodMap = appConfig.GetSection("MethodMap").GetChildren().ToDictionary(x => x.Key, x => x.Value);
    }

See the PetStore tutorial project PetStoreConsoleApp for an example of how to properly initialize your application.

How does LzHttpClass know which ApiGateway to call?

LzHttpClient reads the "MessageMap" section of appConfig to get the MessageMap dictionary where the key is the ClientSDK method name and the value is the ApiGateway to call.

The [CallerMemberName] annotation on the callerMemberName argument to the SendAsync() method provides the name of the calling method.

How does LzHttpClass know what security scheme to apply on the call?

The LzHttpClass reads the "Aws" section in appConfig. The AwsSettings include connection and security information for each ApiGateway used in the stack. See the AwsSettings documentation for details on this configuration.

How is authenticated user information sent to the AWS Service?
  • HttpApiSecure

    The HttpApiSecure ApiGateway defines a AWS::Serverless::HttpApi resource configured to use Cognito authentication.

    This requires a JWT to be included in the HttpRequest. The LazyStackAuth library is used to authenticate against a Cognito User Pool and the acquires the JWT token. This token is placed in the HttpRequest Authorization header. The "sub" claim in the JWT token contains the Cognito user id. The LazyStack generated controller interfaces provide a default virtual method LzGetUserId() that extracts this claim from the header and places it into a public class property LzUserId that you may use in your controller implementation class.

  • ApiSecure

    The ApiSecure ApiGateway defines a AWS::Serverless::Api resource configured to use Cognito Identity Pool authorization based on temporary IAM credentials provided by AWS STS. Before getting the IAM credentials, the LazyStackAuth library is used to authenticate against a Cognito User Pool and acquires a JWT token to make the call to AWS STS.

    Requests against the AWS::Serverless::Api are "signed". The LzHttpClient performs a HttpRequest signing process that generates a signature that is placed into the HttpRequest Authorization header. The AWS::Serverless:Api recomputes this signature when the request is received and compares it to the one provided in the Authorization header. These signature computations must match for the request to be processed.

    The AWS::Serverless::Api does not pass the Authorization header along to the controller. However, we still want to be able to know what authenticated user made the call. This is accomplished by adding the JWT token we received from Cognito to the HttpRequest in a header called LzIdentity. The LzGetUserId() method extracts the "sub" claim from this header to get the Cognito user id.

How dow we configure LzHttpClass to call a the local WebApi?

LzHttpClient reads the "LocalApis" section of appConfg to get the local WebApi project configuration instead of ApiGateways. To enable that feature you pass the constructor the local api name in the localApiName parameter. By default, LazyStack configures two local WebApi hosts:


{
    "LocalApis": {
        "Local": {
        "Scheme": "https",
        "Host": "localhost",
        "Port": 5001
        },
        "LocalAndroid": {
        "Scheme": "https",
        "Host": "10.0.2.2",
        "Port":  5001
        }
    }
}

The LocalAndriod api is necessary when using the Andriod device emulator as the emulator includes a proxy on port 10.0.2.2 that redirects traffic to localhost.

In addition, both the iOS simulator and Android emulator require special handling for the https scheme as explained in the Microsoft documentation here.

Attempting to invoke a local secure web service from an application running in the iOS simulator or Android emulator will result in a HttpRequestException being thrown, even when using the managed network stack on each platform.This is because the local HTTPS development certificate is self-signed, and self-signed certificates aren't trusted by iOS or Android.

LzHttpClient includes the GetInsecureHandler() method to avoid this exception when calling the local WebApi.