dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.37k stars 9.99k forks source link

Workaround for Blazor WASM to connect to Azure Cosmos with .NET 5 #26942

Closed dotnetspark closed 4 years ago

dotnetspark commented 4 years ago

Hi @danroth27. I have a vanilla Blazor WASM (standalone) app which basically as I type autocompletes results from Azure CosmosDB. The Autocomplete razor file is a modified version of the one here. After upgrading to .NET 5 PNS exception started to be thrown (see #26450).

Autocomplete.razor

@using SampleCosmosDB.Components
@inherits InputText

<input @bind="BoundValue" @bind:event="oninput" @onblur="Hide" autocomplete="off" @attributes="@AdditionalAttributes" class="@CssClass" />

<div role="listbox" class="autocomplete-suggestions @(visible ? "visible" : "")">
    @foreach (var choice in currentChoices.Take(3))
    {
        <div role="option" @onclick="() => ChooseAsync(choice)">@choice.Name</div>
    }
</div>

@code {
    bool visible;
    AutocompleteProductItem[] currentChoices = Array.Empty<AutocompleteProductItem>();

    [Parameter] public Func<string, Task<IEnumerable<AutocompleteProductItem>>> Choices { get; set; }
    [Parameter] public EventCallback<string> OnItemChosen { get; set; }

    string BoundValue
    {
        get => CurrentValueAsString;
        set
        {
            CurrentValueAsString = value;
            _ = UpdateAutocompleteOptionsAsync();
        }
    }

    async Task UpdateAutocompleteOptionsAsync()
    {
        if (EditContext.GetValidationMessages(FieldIdentifier).Any())
        {
            // If the input is invalid, don't show any autocomplete options
            currentChoices = Array.Empty<AutocompleteProductItem>();
        }
        else
        {
            var valueSnapshot = CurrentValueAsString;
            var suppliedChoices = (await Choices(valueSnapshot)).ToArray();

            // By the time we get here, the user might have typed other characters
            // Only use the result if this still represents the latest entry
            if (CurrentValueAsString == valueSnapshot)
            {
                currentChoices = suppliedChoices;
                visible = currentChoices.Any();
                StateHasChanged();
            }
        }
    }

    void Hide() => visible = false;

    Task ChooseAsync(AutocompleteProductItem choice)
    {
        CurrentValueAsString = choice.Name;
        Hide();
        return OnItemChosen.InvokeAsync(choice.Id);
    }
}

AutocompleteProductItem

namespace SampleCosmosDB.Components
{
    public class AutocompleteProductItem
    {
        public string Id { get; set; }
        public string Name { get; set; }
    }
}

Index.razor

@page "/"
@using Microsoft.Extensions.Localization
@using SampleCosmosDB.Components
@using Microsoft.Azure.Cosmos
@using Microsoft.Azure.Cosmos.Linq
@using System.Linq
@inject NavigationManager Navigation
@inject IStringLocalizer<App> Localize

<main class="container">
    <EditForm class="home-options" Model="this" OnValidSubmit="FindProduct">
        <DataAnnotationsValidator />

        <p>@Localize["EnterProductName"]</p>
        <Autocomplete @bind-Value="@ProductName" 
                       Choices="@GetProductNameAutocompleteItems"
                       OnItemChosen="EditProduct" 
                       class="find-by-product-name" 
                       placeholder="@Localize["ProductNamePlaceHolder"]" />
        <ValidationMessage For="() => ProductName" />
    </EditForm>
</main>

@code {
    public string ProductName { get; set; }

    async Task<IEnumerable<AutocompleteProductItem>> GetProductNameAutocompleteItems(string prefix)
    {
        var autocompleteProductItems = new List<AutocompleteProductItem>();
        try
        {
            var iterator = CosmosClient.GetContainer("InventoryDataStore", "Products")
                .GetItemQueryIterator<dynamic>(queryText: "SELECT * FROM c");
            while (iterator.HasMoreResults)
            {
                var results = await iterator.ReadNextAsync();
                foreach (var product in results)
                {
                    autocompleteProductItems.AddRange(
                        from document in await iterator.ReadNextAsync()
                        select new AutocompleteProductItem {Id = document["id"], Name = document["name"]});
                }
            }
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
        }

        return autocompleteProductItems;
    }

    void EditProduct(string id)
    {
        if (!string.IsNullOrEmpty(id))
        {
            Navigation.NavigateTo($"product/{id}");
        }
    }

    void FindProduct()
    {
        //TODO
    }
}

ViewProduct.razor

@page "/product/view/{id}"

<h3>View Product @Id</h3>

@code {
    [Parameter] public string Id { get; set; }
}
danroth27 commented 4 years ago

@ylr-research Could you please share the configuration of the CosmosClient?

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

            builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
            builder.Services.AddHttpClient();
            builder.Services.AddSingleton<CosmosClient>(serviceProvider =>
            {
                IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();

                CosmosClientOptions cosmosClientOptions = new CosmosClientOptions
                {
                    HttpClientFactory = httpClientFactory.CreateClient,
                    ConnectionMode = ConnectionMode.Gateway
                };

                return new CosmosClient("<connectionstring>;", cosmosClientOptions);
            });
            builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

            await builder.Build().RunAsync();
        }
danroth27 commented 4 years ago

@ylr-research Because Blazor WebAssembly apps are public clients that can't keep secrets you need to use resource tokens. @JeremyLikness wrote up a nice blog post on how to do this: https://blog.jeremylikness.com/blog/azure-ad-secured-serverless-cosmosdb-from-blazor-webassembly/. However, I don't think we've tested this setup with .NET 5 yet, but I believe it should work. Can you confirm whether you are using this approach and it if works for you?

dotnetspark commented 4 years ago

@danroth27 I'm not using his approach. I'll give it a try now with RC2 and let you know how it goes.

dotnetspark commented 4 years ago

@danroth27, I do have a question though. How was it working before, when the APIs were Mono based? I used to just add a AzureCosmos nuget package, register a client and everything worked. Blazor WASM can't keep a secret now neither before. Most importantly, it is my understanding this issue is to be resolved with .NET 6 wave. Would that means I won't need to follow the above approach and keep doing it as before?

danroth27 commented 4 years ago

How was it working before, when the APIs were Mono based?

For Blazor WebAssembly 3.2 we shipped the Mono implementations of the crypto APIs. So if you tried to use HMAC with 3.2 it would work. However, using HMAC means you need to have access to a secret key, which there is no way to protect in a public client app like a browser app. So it worked, but it would expose the key. I'm guessing the key is embedded in the connection string?

In .NET 5, as part of unifying on the .NET 5 framework libraries, we had to remove the crypto implementations because of issues with being able to service them. We generally prefer to rely on the platform crypto implementations, which get serviced with the platform. Browsers do surface crypto support, but in a form that doesn't match well with the .NET crypto APIs. That's what we're planning to address in .NET 6. With .NET 5 you need to access the browser's crypto support through JS interop.

But even with crypto support coming back in .NET 6, there still won't be a safe way to store a secret in the browser, so generating HMACs still won't be recommended. The approach using resource tokens will still be the preferred approach.

dotnetspark commented 4 years ago

Fair enough. Thanks for your prompt response.

ghost commented 4 years ago

This issue has been resolved and has not had any activity for 1 day. It will be closed for housekeeping purposes.

See our Issue Management Policies for more information.