0

I have an app writen in .NET CORE 6.0 What I'm trying to achive is that based on what URL is loaded to load specific AzureAdB2C settings. I don't want to have them in appsettings.json

For ex. multiple subdomains use this app, aaaa.test.com | bbbb.test.com | etc

When someone access aaaa.test.com I want to be able to load the specific AzureAdB2C settings, othes settings for bbbb.test.com and so on.

I was able to find solutions for multiple AzureAdB2C settings added in appsettings.json but I need a more dynamic way to load them (from SQL for ex).

I've spent a lot of time trying to find a solution but with no success...

Hard to say, I've have tryied a lots of approches with no success...

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "TokenLogin";
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie("TokenLogin", options =>
{
    options.ExpireTimeSpan = DateTime.Now.Subtract(DateTime.UtcNow).Add(TimeSpan.FromDays(2));
    options.Cookie.MaxAge = options.ExpireTimeSpan;
    options.SlidingExpiration = true;
})
.AddMicrosoftIdentityWebApp(options =>
{
    builder.Configuration.Bind("AzureADB2C", options);


    //-------> things that I've tried
    //var xxx = builder.Services.BuildServiceProvider().GetService<IHttpContextAccessor>().HttpContext;
    //var authOptions = xxx.RequestServices.GetRequiredService<IOptionsMonitor<AzureADB2C>>();
    ////var authOptions = xxx.RequestServices.GetRequiredService<AzureADB2C>();

    //options.Instance = authOptions.CurrentValue.Instance;
    //options.ClientId = authOptions.CurrentValue.ClientId;
    //options.CallbackPath = authOptions.CurrentValue.CallbackPath;
    //options.Domain = authOptions.CurrentValue.Domain;
    //options.SignUpSignInPolicyId = authOptions.CurrentValue.SignUpSignInPolicyId;
    //options.ResetPasswordPolicyId = authOptions.CurrentValue.ResetPasswordPolicyId;
    //options.EditProfilePolicyId = authOptions.CurrentValue.EditProfilePolicyId;

    
    //options.SignInScheme = OpenIdConnectDefaults.AuthenticationScheme;
    //options.Events ??= new OpenIdConnectEvents();
    //options.Events.OnRedirectToIdentityProvider += OnRedirectToIdentityProviderFunc;
    //options.Events.OnRedirectToIdentityProvider = context =>
    //{
        // Your code here
        //return Task.CompletedTask;
    //};

    //options.Instance = authOptions.Instance;
    //options.ClientId = authOptions.ClientId;
    //options.CallbackPath = authOptions.CallbackPath;
    //options.Domain = authOptions.Domain;
    //options.SignUpSignInPolicyId = authOptions.SignUpSignInPolicyId;
    //options.ResetPasswordPolicyId = authOptions.ResetPasswordPolicyId;
    //options.EditProfilePolicyId = authOptions.EditProfilePolicyId;

    //authOptions.OnChange(newOptions => {
    //    if (!settingsWereLoaded)
    //    {
    //        //options.Instance = authOptions.CurrentValue.Instance;
    //        //options.ClientId = authOptions.CurrentValue.ClientId;
    //        //options.Domain = authOptions.CurrentValue.Domain;
    //        options.Instance = "https://xxxx.b2clogin.com/tfp/";
    //        options.ClientId = "XXXXX-XXXX-XXXX-XXXX-XXXX";
    //        options.Domain = "XXXX.onmicrosoft.com";

    //        settingsWereLoaded = true;
    //    }
    //});
});

1 Answers1

0

After a lot of research I wasn't able to find a solution, still happy for someone to provide one. Main thing that I wanted to achieve was to be able to update the AzureADB2C settings at the runtime and with this based on a criteria (url / a dynamic prop etc)to be able to use different settings.

Using this approach you'll need to test all flows from AzureAdB2C (as Forgot password / new user etc) and make sure all of these are covered.

I have used another approach, I've removed the:

builder.Services.AddAuthentication().AddMicrosoftIdentityWebApp(...);

and use Cookies instead:

builder.Services.AddAuthentication(options =>
{
    OpenIdConnectDefaults.AuthenticationScheme;
    options.DefaultScheme = "TokenLogin";
    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie("TokenLogin", options =>
{
    options.ExpireTimeSpan = DateTime.Now.Subtract(DateTime.UtcNow).Add(TimeSpan.FromDays(2));
    options.Cookie.MaxAge = options.ExpireTimeSpan;
    options.SlidingExpiration = true;
});

I have created a Midelware: SignInIdDcMiddleware.cs and register it. The redirect URI needs to be whitelisted in Azure.

app.Map("/signin-oidc", app =>
{
    app.UseMiddleware<SignInIdDcMiddleware>();
});

The code for SignInIdDcMiddleware.cs is below, and with the info from here, you can pass the info to a controller that will deal with the authentication using the cookies.

using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Text;

namespace TestDynamicAzureB2C.Middleware
{
    public class SignInIdDcMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly IConfiguration _configuration;

        public SignInIdDcMiddleware(RequestDelegate next, IConfiguration configuration)
        {
            _next = next;
            _configuration = configuration;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            string responseBody = "";
            // Read form data
            var formData = await context.Request.ReadFormAsync();
            // Create a list to store key-value pairs
            var formDataList = new List<KeyValuePair<string, string>>();

            // Iterate through form data and add to the list
            foreach (var field in formData)
            {
                foreach (var value in field.Value)
                {
                    formDataList.Add(new KeyValuePair<string, string>(field.Key, value));
                }
            }

            #region Error

            if (formDataList.Any(p => p.Key == "error"))
            {
                foreach (var value in formDataList)
                {
                    responseBody += value + Environment.NewLine;
                }

                // to check rest of the flows (Forgot Password & New Account)

                // Display responseBody as a string
                context.Response.ContentType = "text/plain; charset=utf-8";
                await context.Response.WriteAsync(responseBody, Encoding.UTF8);

                return;
            }

            #endregion

            #region Validate ID Token

            bool isTokenValid = false;
            string idToken = "";

            if (formDataList.Any(p => p.Key == "id_token"))
            {
                string state = WebUtility.UrlDecode(formDataList.Single(p => p.Key == "state").Value).ToLower().Replace("tn=", "").Replace("cid=", "");
                string[] stateData = state.Split("&");

                string tenantName = stateData[0],
                       policyName = "b2c_1_signupsignin",
                       clientId = stateData[1];

                idToken = formDataList.Single(p => p.Key == "id_token").Value;

                var openIdConfigUrl = $"https://{tenantName}.b2clogin.com/{tenantName}.onmicrosoft.com/{policyName}/v2.0/.well-known/openid-configuration";
                var publicKeys = await GetPublicKeysAsync(openIdConfigUrl);

                var issuer = $"https://{tenantName}.b2clogin.com/{tenantName}.onmicrosoft.com/{policyName}/v2.0/";

                isTokenValid = ValidateIdToken(idToken, publicKeys, issuer, clientId);
            }
            
            #endregion

            foreach (var value in formDataList)
            {
                responseBody += value + Environment.NewLine;
            }

            responseBody += $"IsTokenValid: {isTokenValid}" + Environment.NewLine;

            if (isTokenValid)
            {
                List<System.Security.Claims.Claim> claims = GetUserClaims(idToken);
                if (claims is not null && claims.Any()) 
                {
                    foreach (var value in claims)
                    {
                        responseBody += value + Environment.NewLine;
                    }
                }
                else
                {
                    responseBody += "UserClaims: N/A" + Environment.NewLine;
                }
            }

            // Display responseBody as a string
            context.Response.ContentType = "text/plain; charset=utf-8";
            await context.Response.WriteAsync(responseBody, Encoding.UTF8);
        }

        public async Task<IEnumerable<SecurityKey>> GetPublicKeysAsync(string openIdConfigUrl)
        {
            using var httpClient = new HttpClient();
            var openIdConfigResponse = await httpClient.GetStringAsync(openIdConfigUrl);

            // this will not work due to a bug
            //var openIdConfig = JsonConvert.DeserializeObject<OpenIdConnectConfiguration>(openIdConfigResponse);

            var openIdConfig = JsonConvert.DeserializeObject<OpenIdConnectConfiguration_Extension>(openIdConfigResponse);
            var jwksResponse = await httpClient.GetStringAsync(openIdConfig.jwks_uri);

            var jwks = JsonConvert.DeserializeObject<JsonWebKeySet>(jwksResponse);
            return jwks.Keys;
        }

        public bool ValidateIdToken(string idToken, IEnumerable<SecurityKey> publicKeys, string issuer, string audience)
        {
            var tokenHandler = new JwtSecurityTokenHandler();
            var validationParameters = new TokenValidationParameters
            {
                // not sure why is not working with this set to true
                // posible explation https://stackoverflow.com/questions/70927709/issue-validation-failed-error-for-azure-app-registration-even-in-sample-applica
                ValidateIssuer = false,
                ValidIssuer = issuer,

                ValidateAudience = true,
                ValidAudience = audience,

                IssuerSigningKeys = publicKeys,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            };

            try
            {
                SecurityToken validatedToken;
                tokenHandler.ValidateToken(idToken, validationParameters, out validatedToken);
                return true;
            }
            catch
            {
                return false;
            }
        }

        public List<System.Security.Claims.Claim> GetUserClaims(string idToken)
        {
            // Decode the ID Token
            var tokenHandler = new JwtSecurityTokenHandler();
            var jwtToken = tokenHandler.ReadJwtToken(idToken);

            // Access claims
            var userClaims = jwtToken.Claims.ToList();

            return userClaims;
        }
    }    

    public class OpenIdConnectConfiguration_Extension
    {
        public string jwks_uri { get; set; }
    }
}

The code for the controller AuthController.cs below, please deal with secure the information, this is just a POC.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
using Newtonsoft.Json;
using System.Text;
using Microsoft.Extensions.Configuration;
using System.Threading.Tasks;
using System;
using System.Collections.Generic;

namespace TestDynamicAzureB2C.Controller
{
    // added it here for POC
    public class LoginData
    {
        public string Key { get; set; }
        public string Value { get; set; }
    }

    [Route("[controller]/[action]")]
    public class AuthController : ControllerBase
    {

        private readonly IConfiguration _config;

        public AuthController(IConfiguration config)
        {
            _config = config;
        }

        [HttpGet]
        public async Task<IActionResult> Login([FromQuery] string data, [FromQuery] string redirect)
        {
            try
            {
                // Decode the base64 string to a JSON string
                var jsonString = Encoding.UTF8.GetString(Convert.FromBase64String(data));

                // Deserialize the JSON string to a list of LoginData objects
                var loginDataList = JsonConvert.DeserializeObject<List<LoginData>>(jsonString);
                List<Claim> claims = new List<Claim>();

                foreach (LoginData gd in loginDataList)
                {
                    claims.Add(new Claim(gd.Key, gd.Value));
                }
                var claimsIdentity = new ClaimsIdentity(claims, "TokenLogin");

                var authProperties = new AuthenticationProperties
                {
                    AllowRefresh = true,
                    ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1),
                    IsPersistent = true
                };
                await HttpContext.SignInAsync("TokenLogin", new ClaimsPrincipal(claimsIdentity), authProperties);

                if (redirect != null)
                    return Redirect(redirect);
                else
                    return Redirect("/");
            }
            catch (Exception e) 
            {
                return BadRequest(new { Success = false, Status = 400, Erorr = e.Message });
            }
        }

        [HttpGet]
        public async Task<IActionResult> Logout()
        {
            await HttpContext.SignOutAsync("TokenLogin");
            // this endpoint below needs to be created and managed by you
            return Redirect("/MicrosoftIdentity/Account/SignOut");
        }
    }
}

And in a *.razor file you can build the AzureADB2C sign in url:

public class B2CData
{
    public string URL { get; set; }
    public string Policy { get; set; }
    public string ClientId { get; set; }
    public string RedirectURI { get; set; }
    public string ResponseType { get; set; }
    public string Scope { get; set; }
    public string ResponseMode { get; set; }
    public string Nonce { get; set; }
    public string XClientSKU { get; set; }
    public string XClientVer { get; set; }
    public string State { get; set; }
}

public class B2CDataLogout
{
    public string URL { get; set; }
    public string Policy { get; set; }
    public string RedirectURI { get; set; }
}

void LogIn()
{
    B2CData data = new B2CData()
        {
            URL = "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/{0}/oauth2/v2.0/authorize",
            Policy = "b2c_1_signupsignin",
            ClientId = "XXXXXX-XXXXX-XXXX-XXX-XXXXX",
            RedirectURI = "https://localhost:44321/signin-oidc",
            ResponseType = "id_token",
            Scope = "openid%20profile",
            ResponseMode = "form_post",
            Nonce = Guid.NewGuid().ToString(),
            XClientSKU = "ID_NET6_0",
            XClientVer = "6.32.0.0",
            State = "tn=<tenant-name>&cid=XXXXX-XXXX-XXXX-XXXX-XXXXXX"
        };

    string redirectURL =
        $"{string.Format(data.URL, data.Policy)}?" +
        $"client_id={data.ClientId}&" +
        $"redirect_uri={WebUtility.UrlEncode(data.RedirectURI)}&" +
        $"response_type={data.ResponseType}&" +
        $"scope={data.Scope}&" +
        $"response_mode={data.ResponseMode}&" +
        $"nonce={data.Nonce}&" +
        $"x-client-SKU={data.XClientSKU}&" +
        $"x-client-ver={data.XClientVer}&" +
        $"state={WebUtility.UrlEncode(data.State)}";

    NavigationManager.NavigateTo(redirectURL, true);
}

void LogOut()
{
    B2CDataLogout data = new B2CDataLogout()
        {
            URL = "https://<tenant-name>.b2clogin.com/<tenant-name>.onmicrosoft.com/{0}/oauth2/v2.0/logout",
            Policy = "b2c_1_signupsignin",
            RedirectURI = "https%3A%2F%2Flocalhost%3A44321%2F",
        };

    string redirectURL =
    $"{string.Format(data.URL, data.Policy)}?" +
    $"post_logout_redirect_uri={data.RedirectURI}";

    NavigationManager.NavigateTo(redirectURL, true);
}