Matteo's .NET Explorations

Tinkering with client-side Blazor and the AWS SDK for .NET

Advertisements

Being a .NET lover, a cross-platform hopeful and a JavaScript ignorant, I was immediately enthused about client-side Blazor. The promise of statically hosted web applications that run on every platform is very exciting. Being able to write them in .NET is the dream!

Being an engineer on the AWS SDK for .NET team, I tasked myself with figuring out how to use our libraries under Blazor in the browser.

Beware!

This post is not a design document about how to optimize the experience of .NET users, nor a guide on software development best practices. This is a proof of concept and a chronicle of the, sometimes tortuous, route I took to get Blazor and AWS to get along.

Because client-side Blazor is in a pre-release stage and the AWS SDK for .NET hasn’t been updated yet to easily support Blazor development, the steps described in this post are likely to become outdated in the next few months.

Defining the goal

A good starting point to demonstrate the viability of using the AWS SDK for .NET on WebAssembly is to reproduce the very first walkthrough AWS proposes to JavaScript developers who are learning to write an in-browser application.

The project covers:

I followed the whole guide, built and tested the JavaScript website. The plan is to later replace the JavaScript-based index.html with an equivalent Blazor website.

Facebook now requires your website to use https so, in order to get the JavaScript project to work, I also had to create a CloudFront distribution pointing to the S3 bucket and use the distribution’s URI instead of the bucket’s URI when following the guide steps.

All AWS resources (S3 bucket, IAM role and policy, CloudFront distribution…) will be reused without modifications when converting the website to Blazor.

Spoilers

This post is about the journey, not the destination. Still, before jumping into this lengthy narration, you may be interested in knowing that we will indeed succeed in converting our JavaScript sample to C# and we will have a functioning (and horribly looking) website.

Login prompt
File list

Setting up Blazor

I set up Blazor following the steps listed here for the Blazor WebAssembly template. Then I created a sample Blazor project using the command line:

dotnet new blazorwasm

I didn’t use the –hosted option because I want a simple client-only Blazor application.

Now that I have a working Blazor app, it is simple enough to hollow it out so that it is ready to host my code. Because this is a sample application with a single page, we will do most of the work in Index.razor (actual page), index.html (scripts) and Program.cs (dependency injection).

Referencing the SDK

As part of this exercise, I used Security Token Service (to convert the Facebook identity into AWS credentials) and S3. I added references to the corresponding NuGet packages to the project file.

<ItemGroup>
  <PackageReference Include="AWSSDK.Core" Version="3.3.105.0" />
  <PackageReference Include="AWSSDK.S3" Version="3.3.110.37" />
  <PackageReference Include="AWSSDK.SecurityToken" Version="3.3.104.45" />
</ItemGroup>

I would normally also reference AWSSDK.Extensions.NETCore.Setup to simplify dependency injection, but Blazor requires some exotic configuration, so I had to take care of dependency injection myself.

Update:
With version 3.3.105.0 of AWSSDK.Core the configuration process is now a lot more straightforward!
See other updates below.

First roadblock – HttpClient injection

The default behavior of the AWS SDK for .NET, when performing service calls, is to create instances of HttpClient and configure them. This is not compatible with the networking restrictions imposed by the browser.

Fortunately we can configure the AWS service clients with a custom HttpClientFactory that simply instructs the client to use Blazor’s injected HttpClient.

class HttpInjectorFactory : HttpClientFactory
{
  private readonly HttpClient InjectedClient;
  public HttpInjectorFactory(HttpClient injectedClient)
  {
    InjectedClient = injectedClient;
  }
  public override HttpClient CreateHttpClient(IClientConfig clientConfig)
    => InjectedClient;
  public override bool DisposeHttpClientsAfterUse(IClientConfig clientConfig)
    => false;
  public override bool UseSDKHttpClientCaching(IClientConfig clientConfig)
    => false;
}

Second roadblock – User-Agent header

The AWS SDK for .NET sets the User-Agent header of every http request to a specific string. Before the request is sent to the service, the header is used as one of the inputs used in calculating the message signature.

This is a problem because the browser overwrites the User-Agent header, resulting in an invalid signature that will cause the request to be rejected by the service.

This will likely require a code change in the AWS SDK for .NET in order to allow a different behavior when running inside the browser! But I didn’t want to change the code of the SDK yet, so I decided to work around the issue instead.

Kids, don’t try this at home!

Update:
With version 3.3.105.0 of AWSSDK.Core this whole section is unnecessary.
We can instead add the following line in Program.cs.

public static async Task Main(string[] args)
{
  AWSConfigs.UseAlternateUserAgentHeader = true;
  ...

We can now skip to “Web Identity Federation” below.

Service clients in the AWS SDK for .NET use a “pipeline”: a sequence of “handlers” that, in multiple steps, transform the request object provided by the user into an http message.

We can use the RuntimePipelineCustomizerRegistry to inject a new pipeline handler into every service client created in our application. Our pipeline handler swaps the User-Agent header for the x-amz-user-agent header that the browser won’t overwrite. Because headers are used when signing the message, our handler has to be placed before the one that performs the signing.

Any new release of the AWS SDK for .NET may change these classes and could break this implementation. Don’t use any of this in production code!

class UserAgentFixer : PipelineHandler
{
  public override Task<T> InvokeAsync<T>(IExecutionContext executionContext)
  {
    FixHeaders(executionContext);
    return base.InvokeAsync<T>(executionContext);
  }
  
  public override void InvokeSync(IExecutionContext executionContext)
  {
    FixHeaders(executionContext);
    base.InvokeSync(executionContext);
  }

  private void FixHeaders(IExecutionContext executionContext)
  {
    var headers = executionContext.RequestContext.Request.Headers;
    if (headers.TryGetValue(
      Amazon.Util.HeaderKeys.UserAgentHeader,
      out var value))
    {
      headers[Amazon.Util.HeaderKeys.XAmzUserAgentHeader] = value;
      headers.Remove(Amazon.Util.HeaderKeys.UserAgentHeader);
    }
  }
}

class UserAgentFixerCustomizer : IRuntimePipelineCustomizer
{
    public string UniqueName => "UserAgentFixer";
    public void Customize(Type type, RuntimePipeline pipeline)
    {
        pipeline.AddHandlerBefore<Signer>(new UserAgentFixer());
    }
}

All the classes that I have just used are in the Amazon.Runtime.Internal namespace. The AWS SDK for .NET doesn’t guarantee backward compatibility of Internal namespaces. Also the inner workings of the pipeline are not a documented feature. So a new release of the AWS SDK for .NET may change these classes and could break this implementation. Don’t use any of this in production code!

Web Identity Federation

Now that the AWS SDK for .NET behaves in a way that is compatible with client-side Blazor, I can start converting the JavaScript application to C#.

The trickiest part is how to handle authentication. I decided to use the Facebook SDK in the same way as the original JavaScript application. But, because I wanted to minimize the amount of JavaScript code I had to write, I invoked it from C# and immediately called back into C# code as soon as the authentication was done. Because this is a proof of concept, I kept it simple and wrote this directly in index.html.

<script id="facebook-jssdk" src="//connect.facebook.net/en_US/all.js">
</script>
<script type="text/javascript">
  window.blazorJsScripts = {
    fbInit: function (appId, dotNetInstance, callbackMethod) {
      FB.init({
        appId: appId
      });
      FB.login(function (response) {
        return dotNetInstance.invokeMethodAsync(callbackMethod, response);
      });
    },
  }
</script>

Once we have the web identity token returned by Facebook, we can pass it to STS to get the AWS credentials for an IAM role.

I have decided to put all this logic in a new credential class. RefreshingAWSCredentials is an abstract class provided by the AWS SDK for .NET which calls back into our code to get new credentials when the current ones are expired.

class RefreshingFacebookCredentials : RefreshingAWSCredentials
{
  private class JavascriptCompletableTask<T>
  {
    TaskCompletionSource<T> TaskCompletionSource
      = new TaskCompletionSource<T>();

    [JSInvokable]
    public void Complete(T value)
    {
        TaskCompletionSource.SetResult(value);
    }

    public Task<T> Task => TaskCompletionSource.Task;
  }

  private readonly IJSRuntime JSRuntime;
  //private readonly HttpClientFactory HttpClientFactory;
  private readonly string AppId;
  private readonly string RoleArn;
  private JsonElement? FacebookToken = null;

  public RefreshingFacebookCredentials(
    IJSRuntime jsRuntime,
    //HttpClientFactory httpClientFactory,
    string appId,
    string roleArn)
  {
    JSRuntime = jsRuntime;
    //HttpClientFactory = httpClientFactory;
    AppId = appId;
    RoleArn = roleArn;
  }

  public async Task<string> GetUserIdAsync()
  {
    var facebookResponse = await GetFacebookTokenAsync();
    var authResponse = facebookResponse.GetProperty("authResponse");
    return authResponse.GetProperty("userID").GetString();
  }

  private async Task<JsonElement> GetFacebookTokenAsync()
  {
    if (FacebookToken == null)
    {
      var javascriptCompletableTask
        = new JavascriptCompletableTask<JsonElement>();
      await JSRuntime.InvokeVoidAsync(
        "blazorJsScripts.fbInit",
        AppId,
        DotNetObjectReference.Create(javascriptCompletableTask),
        nameof(javascriptCompletableTask.Complete));
      FacebookToken = await javascriptCompletableTask.Task;
    }
    return FacebookToken.Value;
  }

  protected override async Task<CredentialsRefreshState> GenerateNewCredentialsAsync()
  {
    var facebookResponse = await GetFacebookTokenAsync();
    var authResponse = facebookResponse.GetProperty("authResponse");
    var accessToken = authResponse.GetProperty("accessToken").GetString();
    using (var client = new AmazonSecurityTokenServiceClient(
      new AnonymousAWSCredentials()
      //As of AWSSDK.Core 3.3.105 this is unnecessary
      //, new AmazonSecurityTokenServiceConfig
      //{ HttpClientFactory = HttpClientFactory }
      ))
    {
      var response = await client.AssumeRoleWithWebIdentityAsync(
        new Amazon.SecurityToken.Model.AssumeRoleWithWebIdentityRequest
        {
          ProviderId = "graph.facebook.com",
          RoleArn = RoleArn,
          WebIdentityToken = accessToken,
          RoleSessionName = Guid.NewGuid().ToString()
        });
      return new CredentialsRefreshState(
        new ImmutableCredentials(
          response.Credentials.AccessKeyId,
          response.Credentials.SecretAccessKey,
          response.Credentials.SessionToken),
        response.Credentials.Expiration);
    }
  }

  protected override CredentialsRefreshState GenerateNewCredentials()
  {
    throw new NotImplementedException();
  }
}

I can go back later and improve on this implementation by handling the expiration and refresh of the Facebook token. It would be a simple improvement, but it is outside of the scope of this proof of concept.

Dependency injection

Update:
With version 3.3.105.0 of AWSSDK.Core, injecting the HttpClient into all AWS service clients can be achieved with a single line of code.
I have updated the code below, but I left the old code in comments as a reference.

With all the pieces ready, setting up the dependency injection in Program.cs is straightforward.

public static async Task Main(string[] args)
{
  var builder = WebAssemblyHostBuilder.CreateDefault(args);
  builder.RootComponents.Add<App>("app");

  builder.Services.AddBaseAddressHttpClient();

  //As of AWSSDK.Core 3.3.105 this is unnecessary
  //RuntimePipelineCustomizerRegistry.Instance
  //  .Register(new UserAgentFixerCustomizer());
  //Instead we simply do
  AWSConfigs.UseAlternateUserAgentHeader = true;

  builder.Services.AddSingleton((serviceProvider) =>
    new RefreshingFacebookCredentials(
      serviceProvider.GetService<IJSRuntime>(),
      //As of AWSSDK.Core 3.3.105 this is unnecessary
      //new HttpInjectorFactory(serviceProvider.GetService<HttpClient>()),
      appId: "012345678901",
      roleArn: "arn:aws:iam::012345678901:role/TestBlazorS3FacebookApp"));

  builder.Services.AddSingleton<IAmazonS3>((serviceProvider) =>
    new AmazonS3Client(
      serviceProvider.GetService<RefreshingFacebookCredentials>(),
      new AmazonS3Config
      {
        RegionEndpoint = RegionEndpoint.USWest2,
        //As of AWSSDK.Core 3.3.105 this is unnecessary
        //HttpClientFactory
        //  = new HttpInjectorFactory(serviceProvider.GetService<HttpClient>())
      }));

  var host = builder.Build();
  
  //As of AWSSDK.Core 3.3.105 we do this instead of setting the
  //HttpClientFactory for each service client above.
  //We want to do this exactly here, before any AWS service client
  //can be created, to avoid them using the wrong HttpClient!
  var httpClient = host.Services.GetRequiredService<HttpClient>();
  AWSConfigs.HttpClientFactory = new HttpInjectorFactory(httpClient);

  await host.RunAsync();
}

Let’s write some Blazor

I then went in the Index.razor file and used the S3 service client as I would normally do in a .NET Framework or .NET Core application:

@page "/"
@using Amazon.S3
@using Amazon.S3.Model
@inject IAmazonS3 S3Client 
@inject RefreshingFacebookCredentials FacebookCredentials

<h1>S3 Objects</h1>

@if (Objects == null)
{
  <button class="btn btn-primary" @onclick="OnLoginClickAsync">
    Log in with Facebook</button>
}
else
{
  <table class="table">
    <thead>
      <tr>
        <th>Key</th>
        <th>Size</th>
      </tr>
    </thead>
    <tbody>
      @foreach (var s3Object in Objects)
      {
        <tr>
          <td>@s3Object.Key</td>
          <td>@s3Object.Size</td>
        </tr>
      }
    </tbody>
  </table>
}

@code {
  private string bucketName = "s3-bucket-name";
  private List<S3Object> Objects;

  public Task OnLoginClickAsync()
  {
    return UpdateObjectListAsync();
  }

  public async Task UpdateObjectListAsync()
  {
    var userId = await FacebookCredentials.GetUserIdAsync();
    var response = await S3Client.ListObjectsV2Async(new ListObjectsV2Request
    {
      BucketName = bucketName,
      Prefix = "facebook-" + userId
    });
    Objects = response.S3Objects;
  }
}

Deployment

I can test my new website with

dotnet run

In case I need to debug, the browser’s development tools allow me to see the JavaScript code and put breakpoints. I can use Console.WriteLine() from C# to write in the browser’s debugging console and unhandled exceptions show up in the console as well.

When we are done, we can easily deploy the website to S3 and replace the JavaScript sample website created before. We run

dotnet publish -c Release

and copy the content of the /bin/Release/netstandard2.1/publish/ProjectName/dist into the S3 bucket.

Finishing it up

If we want to achieve parity with the JavaScript sample, we have to add the ability to upload files to S3.

To make my file easier and avoid more JavaScript interop, I decided to use the Tewr.Blazor.FileReader package.

public static async Task Main(string[] args)
{
  ...
  builder.Services.AddFileReaderService(options => options.UseWasmSharedBuffer = true);
  ...

I then edited Index.razor adding the following code in the headers, html and code sections.

@using System.IO
@using Blazor.FileReader
@inject IFileReaderService FileReaderService

...

  <input type="file" id="file-chooser" @ref="FileChooserReference" />
  <button class="btn btn-primary" @onclick="OnUploadClickAsync">
    Upload to S3</button>

...

  private ElementReference FileChooserReference;

  public async Task OnUploadClickAsync()
  {
    var userId = await FacebookCredentials.GetUserIdAsync();

    var files = await FileReaderService.CreateReference(FileChooserReference)
      .EnumerateFilesAsync();
    foreach (var file in files)
    {
      var fileInfo = await file.ReadFileInfoAsync();

      using (var fileStream = await file.OpenReadAsync())
      {
        await S3Client.PutObjectAsync(new PutObjectRequest()
        {
          BucketName = bucketName,
          Key = $"facebook-{userId}/{fileInfo.Name}",
          CannedACL = S3CannedACL.PublicRead,
          ContentType = fileInfo.Type,
          InputStream = fileStream
        });
      }

      await UpdateObjectListAsync();
    }
  }

Update:
The sample code above used to read the FileStream into a MemorySteam and pass the MemorySteam to the PutObjectAsync call.
This was necessary because Tewr.Blazor.FileReader’s streams are async-only and the AWS SDK for .NET didn’t support that. This is now addressed in version 3.3.105.0 of AWSSDK.Core, so I removed the unnecessary code.

The devil is in CORS

Before doing anything more complex, it is worth knowing that not all calls to AWS services are allowed from within a browser. For example, if I had called S3’s ListBuckets, I would have received the following error:

Access to fetch at 'https://s3.us-west-2.amazonaws.com/' from origin
'https://localhost:44335' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested
resource. If an opaque response serves your needs, set the request's
mode to 'no-cors' to fetch the resource with CORS disabled.

My call was blocked by the CORS policy of S3. CORS is nicely explained here. If you followed along the JavaScript project setup, when we did the S3 configuration, we specified a CORSConfiguration for the bucket that allows the ListObject and PutObject operations to succeed (but not ListBuckets, because it is not a bucket-level operation).

A workaround for this would be to move the unsupported calls to an AWS Lambda function and call the function from our Blazor application.

What now

For the reader, the next step is to go write some cool application using Blazor and AWS!

For me, to start thinking about which code changes to the AWS SDK for .NET would allow most of the steps in this post to be skipped and the whole configuration to happen magically.

Advertisements

Advertisements