Search code examples
asp.net-mvcazure-ad-b2cmulti-tenant

How to select userflow based on request's subdomain? (ASP.NET, Azure AD B2C, Microsoft.Identity.Web)


We have a website made using ASP.NET 6.0.408. This is a multitenant app, meaning it has one database for multiple customers. A customer is identified by the subdomain of the request. If a user accesses the site by entering customer1.myapp.com in his search bar, then we want to present him (if he's not already authenticated) with an authentication flow (called user flow or custom policy in Azure Ad B2C) specific to customer1. We succeeded doing that even if (as far as I can tell) there is no api to do that easily with Microsoft.Identity.Web.

Here's an extract of the code in Program.cs:

 builder.Services
            .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApp(options =>
            {
                builder.Configuration.Bind("AzureAd", options);
                options.SaveTokens = true;
                options.Events ??= new OpenIdConnectEvents();
                options.Events.OnRedirectToIdentityProvider += OnRedirectToIdentityProviderFunc;
                options.Events.OnRedirectToIdentityProviderForSignOut += OnRedirectToIdentityProviderForSignOutFunc;
            });
// ...

app.UseSession();
// Determine the subdomain, check if it corresponds to an existing customer, then put it in session.
app.UseSubdomainResolver();
app.UseAuthentication();
app.UseAuthorization();

// ...

 private static async Task OnRedirectToIdentityProviderFunc(RedirectContext context)
    {
        string? subdomain = context.HttpContext.Session.GetString("subdomain");
        if (subdomain == null)
        {
            await Task.CompletedTask.ConfigureAwait(false);
            return;
        }

        
        context.ProtocolMessage.IssuerAddress.Replace("b2c_1_default", $"b2c_1_{subdomain}");

        await Task.CompletedTask.ConfigureAwait(false);
    }

The workaround we found is to register an event handler OnRedirectToIdentityProviderFunc, in which we change the default userflow (defined in appsettings.json) mentionned in the IssuerAddress.

Fairly hacky but it works. Now we want to call our API which serves as a portal to the database (we have multiple client apps), which is protected using Azure Ad B2C. To call the protected API from our website, we need to call a second endpoint of Azure Ad B2C, after the user has been authentified, in order to obtain an access token which will be sent with all subsequent requests to the API. By default, the endpoint is https://myapp.b2clogin.com/tfp/solasauth.onmicrosoft.com/b2c_1_default/oauth2/v2.0/token, and Microsoft.Identity.Web exposes an API to request the access token, take care of refreshing that token etc.:

builder.Services
            .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApp(options =>
            {
                builder.Configuration.Bind("AzureAd", options);
                options.SaveTokens = true;
                options.Events ??= new OpenIdConnectEvents();
                options.Events.OnRedirectToIdentityProvider += OnRedirectToIdentityProviderFunc;
                options.Events.OnRedirectToIdentityProviderForSignOut += OnRedirectToIdentityProviderForSignOutFunc;
            })
            .EnableTokenAcquisitionToCallDownstreamApi(new string[] { builder.Configuration["API:Scope"] })
            .AddInMemoryTokenCaches();

Again, we search for a hacky way to change the token endpoint, but nothing found this time.

Here's the question: Is there really no way to change that endpoint before the UseAuthentication and UseAuthorization middlewares are called? And is there a better way to achieve the desired behavior using the technologies mentionned? If the answer is negative, do you know of any library that makes this fairly easy to do?

Notes:

  • Our website requires to be authenticated from the beginning, so there is no signin button to be clicked, with a controller action behind which takes care of launching the right userflow. Everything thus happens before the UseAuthentication and UseAuthorization middlewares.

Edit: Using authorization code flow

I tried using authorization code flow, but the documentation using Microsoft.Identity.Web keeps using the implicit or hybrid flow. I stumbled upon some SO posts and Github issues related to that:

I ended up unchecking these boxes in the app registration settings: enter image description here

and leaving the code intact. Still, I get the exact same behavior (which is very strange, I would have expected an error saying me that implicit flow is not possible; are changes made in the azure portal taking time to take effect?).

Am I doing something wrong? Or maybe Microsoft.Identity.Web doesn't easily allow the authorization code flow, and instead default to implicit/hybrid?


Solution

  • Solution

    Here's what worked: As advised by Dave D, I switched from implicit to (authorization) code flow by unchecking the two boxes mentionned in the post, and by enforcing Microsoft.Identity.Web the authorization code flow with (see the change of options.ResponseType):

    builder.Services
                .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                .AddMicrosoftIdentityWebApp(options =>
                {
                    builder.Configuration.Bind(configSection, options);
                    options.ResponseType = OpenIdConnectResponseType.Code;
                    // ...
                });
    

    Then I removed these calls:

    .EnableTokenAcquisitionToCallDownstreamApi(new string[] { builder.Configuration["API:Scope"] })
    .AddInMemoryTokenCaches();
    

    With these, the request to the token endpoint happens before the delegate I attached to options.Events.OnAuthorizationCodeReceived runs. Thus the token endpoint called is the one determined using the user flow I put in my appsettings.json (which I called B2C_1_Default), not necessarily the one I want, which depends on the request's subdomain.

    Without these calls, Microsoft.Identity.Web still calls the token endpoint (as evidenced by the fact that I successfully find an access token in a delegate to OnTokenResponseReceivedFunc, accessible via context.TokenEndpointResponse.AccessToken), I just need to specify the scope in the options:

    builder.Services
                .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                .AddMicrosoftIdentityWebApp(options =>
                {
                    builder.Configuration.Bind(configSection, options);
                    options.ResponseType = OpenIdConnectResponseType.Code;
                    options.Scope.Add(builder.Configuration["API:Scope"]);
                    // ...
                });
    

    It seems to work. The access token is available in the delegate to OnTokenResponseReceived, which I put in a distributed cache:

        private static async Task OnTokenResponseReceivedFunc(TokenResponseReceivedContext context)
        {
            // Store the access token in the distributed cache
            var distributedCache = context.HttpContext.RequestServices.GetRequiredService<IDistributedCache>();
            await distributedCache.SetStringAsync("access_token", context.TokenEndpointResponse.AccessToken);
    
            // Don't remove this line
            await Task.CompletedTask.ConfigureAwait(false);
        }
    
    

    I can the access the cache in my http client and controller actions.

    Done!

    PS:

    • I removed the line options.SaveTokens = true;, which only makes sense if using implicit flow.
    • I'll probably need to change the scope dynamically at some point. I suppose I'll find a property to change in the delegate to OnAuthorizationCodeReceived.