Search code examples
asp.net-web-apiazure-ad-b2cmulti-tenant.net-8.0

How to handle Dynamic Authority in ADB2C Multi-tenant in .NET 8.0 Web API Where each client site on different tenant


I am working on an ASP.NET 8.0 Web API and reacts app projects. These projects are registered in azure Active Directory B2C (ADB2C) under different tenants. Please refer #region Authentication & Security under full code snapshot

Tenant A: Web API project -> .NET 8.0 Web API

Tenant B: React Client App (consume APIs from Tenant A)

Tenant C: React Client App (consume APIs from Tenant A)

Tenant N: React Client App (consume APIs from Tenant A)

I need to make an API call from the React App to the .NET Web API, which is working fine. I have configure multiple clients in appsetting and seems all good,

however I am getting issue setting up Authority dynamically based on user from specific tenant. To test i put hardcore authority soon after

  builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
 .AddJwtBearer(options =>

and it works

but it won't work if I place it after options.TokenValidationParameters to configure based on client,

     options.Authority = $"{client.Instance}/{client.TenantName}/{client.Policy}/v2.0/";
     options.MetadataAddress = $"{options.Authority}.well-known/openid-configuration";

i believe is too late by then, I need to dynamically configure Authority based on client login

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Reflection;
using System.Security.Claims;
using TXN.GV.Application.BackOffice;
using TXN.GV.Domain.Entity;
using TXN.GV.Enterprise.Data.DataContexts;

namespace MyApp.Web.APIs.Configuration
{
  public static class ServicesConfigurator
  {
    public static void Configure(WebApplicationBuilder builder, IConfiguration configuration)
    {
        builder.Services.AddControllers();

        builder.Services.AddEndpointsApiExplorer();

        #region Swagger
        builder.Services.AddSwaggerGen(options =>
        {
            options.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" 
     });

            options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
            {
                Description = "JWT Authorization Bearer {token}",
                Name = "Authorization",
                In = ParameterLocation.Header,
                Type = SecuritySchemeType.ApiKey,
                Scheme = "Bearer"
            });

            options.AddSecurityRequirement(new OpenApiSecurityRequirement()
            {
                {
                    new OpenApiSecurityScheme
                    {
                        Reference = new OpenApiReference
                        {
                            Type = ReferenceType.SecurityScheme,
                            Id = "Bearer"
                        },
                        Scheme = "oauth2",
                        Name = "Bearer",
                        In = ParameterLocation.Header,
                    },
                    new List<string>()
                }
            });
        });
        #endregion

        #region Data - DbContext
        builder.Services.AddDbContext<AppDbContext>(options =>
        {
            options.UseSqlServer(builder.Configuration.GetConnectionString("SqlConnectionString"));
        });
        #endregion

        #region MediataR Container 
        //Global Visa Application BackOffice
        var assemblyBackOfficeName = "TXN.GV.Application.BackOffice";
        var assemblyBackOffice = Assembly.Load(assemblyBackOfficeName);
        builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(assemblyBackOffice));
        
        #endregion

        #region AutoMapper Configuration
        builder.Services.AddAutoMapper(typeof(ApplicationAssembly));
        #endregion

        #region CORS
        builder.Services.AddCors(options =>
        {
            options.AddPolicy("AllowAnyOrigin", builder =>
            {
                builder.AllowAnyOrigin()
                       .AllowAnyMethod()
                       .AllowAnyHeader();
            });
        });

        #endregion

        #region Authentication & Security


        var azureAdB2CConfig = builder.Configuration.GetSection("AzureAdB2C");
        var clientID = builder.Configuration.GetSection("AzureAdB2C").GetSection("ClientId").Value;
        var signUpInPolicy = builder.Configuration.GetSection("AzureAdB2C").GetSection("SignUpSignInPolicy").Value;
        var tenantName = builder.Configuration.GetSection("AzureAdB2C").GetSection("TenantName").Value;
        var clientsConfig = configuration.GetSection("AzureAdB2C:Clients").Get<List<ClientConfig>>();

        builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                var tenantBInstance = "https://appOnline.b2clogin.com";
                var tenantBName = "appOnline.onmicrosoft.com";
                options.Authority = $"{tenantBInstance}/{tenantBName}/{signUpInPolicy}/v2.0/";
                options.MetadataAddress = $"{options.Authority}.well-known/openid-configuration";
                // Explicitly set the valid issuer
                options.TokenValidationParameters = new TokenValidationParameters
                {

                    ValidateIssuer = true,
                    ValidateIssuerSigningKey = true,
                    ValidateAudience = true,
                    ValidAudience = azureAdB2CConfig["ClientId"],
                    ValidateLifetime = true,       
                    IssuerValidator = (issuer, securityToken, validationParameters) =>
                    {
                        var client = clientsConfig.FirstOrDefault(client =>
                            issuer.Equals($"{client.Instance}/{client.TenantId}/v2.0/", StringComparison.OrdinalIgnoreCase));
                        if (client != null)
                        {
      //NEED HELP HERE
                            //options.Authority = $"{client.Instance}/{client.TenantName}/{client.Policy}/v2.0/";
                            //options.Authority = $"{tenantBInstance}/{tenantBName}/{signUpInPolicy}/v2.0/";
                            //options.MetadataAddress = $"{options.Authority}.well-known/openid-configuration";

                            Console.WriteLine($"Valid issuer found: {issuer}");
                            Console.WriteLine($"Dynamic Authority set: {options.Authority}");
                            return issuer;
                        }
                        else
                        {
                            Console.WriteLine($"Invalid issuer. Received: {issuer}");
                            foreach (var conf in clientsConfig)
                            {
                                Console.WriteLine($"Expected Issuer for debug: {conf.Instance}/{conf.TenantId}/v2.0/");
                            }
                            throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {issuer}");
                        }
                    }

                };
                options.Events = new JwtBearerEvents()
                {
                    OnAuthenticationFailed = AuthenticationFailed,
                    OnMessageReceived = OnMessageReceived,
                    OnTokenValidated = OnTokenValidated
                };
            });

    }

    private static Task AuthenticationFailed(AuthenticationFailedContext context)
    {
        Console.WriteLine($"Authentication failed: {context.Exception.Message}");
        return Task.CompletedTask;
    }

    private static Task OnMessageReceived(MessageReceivedContext context)
    {
        var accessToken = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();
        context.Token = accessToken;
        Console.WriteLine($"Token received: {context.Token}");

        return Task.CompletedTask;
    }


    private static Task OnTokenValidated(TokenValidatedContext context)
    {
        var token = context.SecurityToken;
        var claims = context.Principal.Claims;

        try
        {
            Console.WriteLine($"User ID: {context.Principal.FindFirstValue(ClaimTypes.NameIdentifier)}");

            if (context.Request.Path.HasValue)
            {
                var userClaims = new UserClaims();

                var claimsIdentity = context.Principal.Identity as ClaimsIdentity;

                if (claimsIdentity == null || !claimsIdentity.Claims.Any()) { throw new ApplicationException("Identity shouldn't be null and must have claims."); }

                else
                { userClaims = ExtractUserClaims(claimsIdentity); }
            }
        }
        catch (Exception ex)
        {

        }

        return Task.CompletedTask;
    }

    private static UserClaims ExtractUserClaims(ClaimsIdentity identity)
    {
        var userClaims = new UserClaims
        {
            UserId = Guid.TryParse(identity.FindFirst(ClaimTypes.NameIdentifier)?.Value, out Guid userId) ? userId : Guid.Empty,
            Iss = identity.FindFirst("iss")?.Value,
            FirstName = identity.FindFirst(ClaimTypes.GivenName)?.Value,
            LastName = identity.FindFirst(ClaimTypes.Surname)?.Value,
            DisplayName = identity.FindFirst("name")?.Value,
            Email = identity.FindFirst("emails")?.Value,
            IsUserAuthenticated = identity.IsAuthenticated,
            IsUserNew = identity.HasClaim(claim => claim.Type == "newUser" && claim.Value == "true"),
            IssuedAt = DateTimeOffset.FromUnixTimeSeconds(long.Parse(identity.FindFirst("iat")?.Value ?? "0")).UtcDateTime,
            Expiration = DateTimeOffset.FromUnixTimeSeconds(long.Parse(identity.FindFirst("exp")?.Value ?? "0")).UtcDateTime,
            NotBefore = DateTimeOffset.FromUnixTimeSeconds(long.Parse(identity.FindFirst("nbf")?.Value ?? "0")).UtcDateTime
        };

        return userClaims;
    }

    #endregion



}

  public class ClientConfig
  {
    public string ClientId { get; set; }
    public string TenantName { get; set; }
    public Guid TenantId { get; set; }
    public string Policy { get; set; }
    public string Instance { get; set; }
  }
}

appsetting.json

"AzureAdB2C": {
"Instance": "https://Machine.b2clogin.com",
"TenantName": "Machine",
"Tenant": "Machine.b2clogin.com",
"Domain": "Machine.onmicrosoft.com",
"ClientId": "00000000-0000-0000-0000-000000000008",
"TenantId": "00000000-0000-0000-0000-0000000000019",
"SignUpSignInPolicy": "B2C_1_SignUpIn",
"ClientSecret": "xxx",
"CallbackPath": "/signin-oidc",
"Scope": "Core.API.All",
"Clients": [
  {
    "ClientId": "00000000-0000-0000-0000-000000000007",
    "TenantName": "FranceVisaOnline",
    "TenantId": "00000000-0000-0000-0000-000000000006",
    "Policy": "B2C_1_SignUpIn",
    "Instance": "https://FranceVisaOnline.b2clogin.com"
  },
  {
    "ClientId": "00000000-0000-0000-0000-000000000003",
    "TenantName": "VisaOnline",
    "TenantId": "00000000-0000-0000-0000-000000000004",
    "Policy": "B2C_1_SignUpIn",
    "Instance": "https://VisaOnline.b2clogin.com"
  },
  {
    "ClientId": "00000000-0000-0000-0000-000000000002",
    "TenantName": "ItalyVisaOnline",
    "TenantId": "00000000-0000-0000-0000-000000000001",
    "Policy": "B2C_1_SignUpIn",
    "Instance": "https://ItalyVisaOnline.b2clogin.com"
   }
  ]
 },

Solution

  • I have found the answer from following video

      https://www.youtube.com/watch?v=2rhhlwKO_Xw&t=38s
    
    • Create multiple schema for each ADB2C registered App Tenants.
    • AddPolicyScheme within Security Authentication which can point to right tenant config based on received token
    • Register each tenant under AddJwtBearer("unique schema Name", options =>