Menu

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 previous post to get more information in general about Micro-Frontend architecture and it's pros & cons.

Consuming Web Components via Backend for Frontend

Example solution has four main components:

1) Web Component consumer

2) BFF for Web Component consumer

3) Identity Provider (OIDC)

4) Web Component

undefined

Technical implementation

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

Web Component

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

undefined

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 Products team. This API is .NET 6 powered Minimal API which requires specific authorization scope and returns 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

Enable cookie based authentication

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

undefined

Authentication and authorization

This service collection extension configures cookie based authentication (OIDC). OpenId Connect settings are dynamically binded 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 binded from the following app setting section. Note! Authorization Scope of Web Component API is needed to request during 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 Web Component requests to the actual API (Product API in this case). YARP request transformation extracts access token (=bearer) from the cookie and attached it to proxyed 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 backend. Login endpoint starts authentication process (=browser redirection) against Authorize endpoint of 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 Web Component is consumed directly from the producer (=another server). Like stated in the previous blog post javascript file (Web Component) must be fetched via proxy that javascript is treated as same-origin. 

undefined

Web Component container

This React component is basically a container which renders script tag of Web Component. Note! Web Component javascript file must be fetched via proxy (/product-listing-webcomponent-proxy) to enable that javascript is 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 dynamically 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 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 "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 Web Component consumer already consumes another API endpoint with route /products and then this causes overlapping.

Comments