How to build Micro-Frontend Architecture with Web Components and BFF (part 2/2)?

This is an extension to the previous post about "How to build Micro-Frontend Architecture with Web Components and BFF (part 1/2)" with code samples. Read the previous post to get more information in general about Micro-Frontend architecture and its pros & cons.

Consuming Web Components via Backend for Frontend

The example solution has four main components:

1) Web Component consumer

2) BFF for Web Component consumer

3) Identity Provider (OIDC)

4) Web Component

Technical implementation

Let's first go through sample Web Component implementation and after that how to embed this component to another application.

Web Component

The product Listing web component is independently developed and maintained by a team that owns the Product domain area. Web Component javascript file and API are hosted in another server.

Product Listing Web Component

This Web Component is created to list products. Product Listing UI fetches products from the API endpoint and renders product listing as a JSON.

class ProductListing extends HTMLElement {
    async connectedCallback() {
        const products = await this.getProducts();
        console.log("Filter was",this.getAttribute("filter"));
        this.renderProducts(products);
    }

    async getProducts() {
        console.log("Fetching products...");
        const res = await fetch("/products")
        const products = await res.json();
        console.log("Products found", products);
        return products;
    }

    async renderProducts(products) {
        this.innerHTML = '<strong>Products Web component</strong>';
        var pre = document.createElement('pre');
        pre.textContent = JSON.stringify(products, 0, 2);
        this.appendChild(pre);
    }
}

customElements.define('product-listing', ProductListing);

Products API

Products API is owned by the Products team. This API is .NET 6-powered Minimal API which requires a specific authorization scope and returns a list of products.

app.MapGet("/products", [Authorize](IHttpContextAccessor httpContextAccessor) => new[]{ 
    new 
    { 
        Name = "Product 1", 
        Id = 1, 
        Price = 100,
        CreatedBy = httpContextAccessor.HttpContext?.User?.Identity?.Name,
        CreatedAtUtc = DateTime.UtcNow,
        Categories = new[]{ "Category 1" }
    },
    new 
    { 
        Name = "Product 2",
        Id = 2, 
        Price = 200,
        CreatedBy = httpContextAccessor.HttpContext?.User?.Identity?.Name,
        CreatedAtUtc = DateTime.UtcNow,
        Categories = new[]{ "Category 1", "Category 2" }
    }
}).RequireAuthorization("ProductsPolicy");

Authentication extension configures Bearer token-based authentication scheme.

public static void AddAuthenticationForApi(this IServiceCollection services, IConfiguration configuration, OpenIdConnectEvents events = null)
        { 
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
                {
                    configuration.GetSection("Authentication").Bind(options);
                    options.TokenValidationParameters = new TokenValidationParameters()
                    {
                        ValidateAudience = false,
                        ValidTypes = new[] { "at+jwt" },
                        NameClaimType = "name",
                        RoleClaimType = "role"
                    };
                });
        }

Authentication settings.

 "Authentication": {
    "Authority": "https://demo.duendesoftware.com",
    "MapInboundClaims": false
  }

Authorization policies for Web Component API.

builder.Services.AddAuthorization(o => o.AddPolicy("ProductsPolicy",
                                  b => b.RequireClaim("scope", "api")));

Web Component consumer

Cookie-based authentication is required to apply the "No tokens in browser" policy. 

Authentication and authorization

This service collection extension configures cookie-based authentication (OIDC). OpenId Connect settings are dynamically bound from the appsettings file.

  public static class ServiceCollectionExtensions
    {
        public static void AddAuthentication(this IServiceCollection services, IConfiguration configuration, OpenIdConnectEvents events = null)
        {
            var authenticationConfig = configuration.GetSection("Authentication") ?? throw new ArgumentException("Authentication section is missing!");

            services.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
                options.DefaultSignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
            {
                options.Cookie.Name = authenticationConfig["CookieName"] ?? "__BFF";
                options.Cookie.SameSite = SameSiteMode.Strict;
                options.Cookie.HttpOnly = false;
                options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
            })
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
            {
                // Bind OIDC authentication configuration
                configuration.GetSection("Authentication").Bind(options);

                options.Events = events;

                // Add dynamically scope values from configuration
                var scopes = configuration.GetSection("Authentication")["Scope"].Split(" ").ToList();
                options.Scope.Clear();
                scopes.ForEach(scope => options.Scope.Add(scope));

                options.TokenValidationParameters = new()
                {
                    NameClaimType = "name",
                    RoleClaimType = "role"
                };
            });       
        }
    }

Authentication settings are dynamically bound from the following app setting section. Note! Authorization Scope of Web Component API is needed to request during the Authorization flow. In this case, we use scope "api" for that purpose.

"Authentication": {
    "Authority": "https://demo.duendesoftware.com",
    "CookieName": "__BFF",
    "ClientId": "interactive.confidential",
    "ClientSecret": "secret",
    "ResponseType": "code",
    "ResponseMode": "query",
    "GetClaimsFromUserInfoEndpoint": true,
    "MapInboundClaims": false,
    "SaveTokens": true,
    "Scope": "openid profile api offline_access"
  }

Enabling reverse proxy

YARP-based reverse proxy enables to re-route of Web Component requests to the actual API (Product API in this case). YARP request transformation extracts the access token (=bearer) from the cookie and attaches it to the proxied request.

 public static class ServiceCollectionExtensions
    {

        public static void AddReverseProxy(this IServiceCollection services, ConfigurationManager configuration)
        {
            var reverseProxyConfig = configuration.GetSection("ReverseProxy") ?? throw new ArgumentException("ReverseProxy section is missing!");

            services.AddReverseProxy()
                .LoadFromConfig(reverseProxyConfig)
                .AddTransforms(builderContext =>
                {
                    builderContext.AddRequestTransform(async transformContext =>
                    {
                        var accessToken = await transformContext.HttpContext.GetTokenAsync("access_token");
                        if (accessToken != null)
                        {
                            transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                        }                      
                    });
                });
        }
        
    }

This proxy configuration enables to re-route requests from /products to https://localhost:7158/products.

"ReverseProxy": {
    "Routes": {
      "route1": {
        "ClusterId": "productsApi",
        "AuthorizationPolicy": "RequireAuthenticatedUserPolicy",
        "Match": {
          "Path": "/products/{**catch-all}"
        },
        "AllowAnonymous": false
      }
    },
    "Clusters": {
      "productsApi": {
        "Destinations": {
          "productsApi/destination1": {
            "Address": "https://localhost:7158"
          }
        }
      }
    }
  }

BFF endpoints

Endpoint Route Builder extension creates endpoints for handling login and logout in the backend. The login endpoint starts the authentication process (=browser redirection) against the Authorize endpoint of the OIDC Provider.

public static class EndpointRouteBuilderExtensions
    {
        public static void AddBffEndpoints(this IEndpointRouteBuilder endpoints, IConfiguration configuration)
        {
            endpoints.MapGet("/login", async (HttpContext httpContext, string redirectUri) =>
            {
                await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = redirectUri });
            });
            endpoints.MapGet("/logout", async (HttpContext httpContext, string redirectUri) =>
            {
                await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
                var props = new AuthenticationProperties
                {
                    RedirectUri = redirectUri ?? "/"
                };
                await httpContext.SignOutAsync(props);
            });       
        }
    }

Embed Web Component

In this sample, the Web Component is consumed directly from the producer (=another server). As stated in the previous blog post javascript file (Web Component) must be fetched via proxy so that javascript is treated as same-origin. 

Web Component container

This React component is basically a container that renders the script tag of the Web Component. Note! Web Component javascript file must be fetched via proxy (/product-listing-webcomponent-proxy) to enable that javascript to be treated as same-origin. 

import React, { Component } from 'react';

export class ProductListingWebComponent extends Component {

    constructor(props) {
        super(props);
        this.state = { loading: true };
    }

    componentDidMount() {
        var webComponentScript = document.getElementById("webcomponent")

        if (webComponentScript === null) {
            const script = document.createElement("script");
            script.id = "webcomponent";
            script.src = "/product-listing-webcomponent-proxy";
            script.async = true;
            script.onload = () => this.scriptLoaded();
            document.body.appendChild(script);
        }

        this.setState({ loading: false });
    }

    scriptLoaded() {
        console.log("External web component script loaded.");
    }

    renderExternalWebComponent() {
        return (
            <div>
                <product-listing id="product-listing" />
            </div>
        );
    }

    render() {

        let contents = this.state.loading
            ? <p><em>Loading...</em></p>
            : this.renderExternalWebComponent();

        return (
            <div>
                <p>This component demonstrates rendering external web component.</p>
                {contents}
            </div>
        );
  }
}

Proxy endpoint to fetch Web Component's javascript file

Endpoint Route Builder extension is extended with a logic to create dynamic Web Component script loading endpoints. This endpoint ensures that javascript is treated as a same-origin.

public static class EndpointRouteBuilderExtensions
    {
        public static void AddBffEndpoints(this IEndpointRouteBuilder endpoints, IConfiguration configuration)
        {
            var webComponentScripts = configuration.GetSection("WebcomponentScripts").Get<List<WebComponentScript>>();

            if (webComponentScripts != null)
            {
                foreach (var webComponentScript in webComponentScripts)
                {
                    endpoints.MapGet(webComponentScript.EndpointName, async ([FromServices] IHttpClientFactory httpClientFactory) =>
                    {
                        var client = httpClientFactory.CreateClient();
                        var response = await client.GetAsync(webComponentScript.ScriptUrl);
                        var responseStream = await response.Content.ReadAsStreamAsync();
                        var reader = new StreamReader(responseStream);
                        return reader.ReadToEnd();
                    });
                }
            }         
        }
    }

App Settings contains an array of Web Component scripts. 

"WebcomponentScripts": [
    {
      "EndpointName": "js-proxy-productlisting",
      "ScriptUrl": "https://localhost:7158/js/product-listing.js"
    }
  ]

Summary

It's fairly straightforward to embed Web Component via BFF Proxy and also apply the "No tokens in browser" policy.

One possible problem where you should be aware of is overlapping API routes in Web Component consumer application. It's possible that the Web Component consumer already consumes another API endpoint with route /products and then this causes overlapping.

Comments