Search code examples
azure-active-directory.net-6.0microsoft-identity-web

How can I authenticate a user with Azure AD without specifying the tenant and client during app startup?


I am building a web app and want users to be able to authenticate with Azure AD. The organisation I am working for has two Azure AD Tenants, and they have created an App Registration in each. Both App Registrations are set as Single Tenant not Multitenant because we do not want users from other Azure AD Tenants to be able to authenticate and a Multitenant app registration allows users from any Azure AD tenant to authenticate. For clarity, I am working with multiple Single Tenant app registrations, not a single Multitenant app registration.

I have got working code for authenticating with a single Azure AD tenant (shown below) but this involves specifying the TenantId and ClientId in appsettings.json, so they cannot be changed after app startup. I would like to specify the TenantId and ClientId at the point of issuing the authentication challenge, so I can present the user with a choice of which Tenant they want to authenticate with - i.e. by the time I call return Challenge(...); in my controller I would know the TenantId and ClientId, but at the point of app startup I would have multiple possibilities.

I haven't found any examples of this approach online, so I don't know whether or not it's possible using Microsoft.Identity.Web. How can I authenticate a user with Azure AD without specifying the TenantId and ClientId during app startup?


In my controller the relevant action contains this code:

return Challenge(
    new AuthenticationProperties
    {
        RedirectUri = callbackPath, // this is built up earlier in the method
        Items =
        {
            new KeyValuePair<string, string>("LoginProvider", Microsoft.Identity.Web.Constants.AzureAd)
        }
    },
    Microsoft.Identity.Web.Constants.AzureAd);

Within appsettings.json I have a section which includes the tenant and client IDs (redacted for this question, but the file does include the real IDs):

"AzureAD": {
  "Instance": "https://login.microsoftonline.com/",
  "TenantId": "********-****-****-****-************",
  "ClientId": "********-****-****-****-************",
  "CallbackPath": "/signin-oidc",
  "SignedOutCallbackPath ": "/signout-callback-oidc"
}

In code called from Startup.cs I have the following code which uses the configuration from appsettings.json.

services
    .AddAuthentication()
    .AddIdentityServerAuthentication(options =>
    {
        options.Authority = authConfig.Authority;
        options.RequireHttpsMetadata = true;
    });

// This uses the "AzureAD" section from appsettings.json
services.AddMicrosoftIdentityWebAppAuthentication(configuration, openIdConnectScheme: Microsoft.Identity.Web.Constants.AzureAd);

services.Configure<OpenIdConnectOptions>(Microsoft.Identity.Web.Constants.AzureAd, options => options.SignInScheme = IdentityConstants.ExternalScheme);

Solution

  • I have found a solution to this using the technique shown in this blog: https://damienbod.com/2021/06/28/sign-in-using-multiple-clients-or-tenants-in-asp-net-core-and-azure-ad/ (archive).

    The key is to add multiple authentication schemes, one for each azure AD client. Once these schemes are added, they each then need separate OIDC configuration. The callback paths must be unique otherwise you will receive the error "System.Exception: Unable to unprotect the message.State." They must still match what is configured in the Azure app registration. Each scheme must have a unique name - I am using the client ID from the azure app registration so it is guaranteed to be unique, but any arbitrary string should be fine as long as it is used consistently.

    I build a method which I call within Startup.cs which takes in a collection of type AzureClient, which is a class I have created to contain the ClientId and TenantId as GUIDs. (I store these in the database, see this question for details about retrieving data from the database during startup.) This replaces the final code snippet in my question.

    private static void ConfigureAzureClients(
        IServiceCollection services,
        AuthConfig authConfig,
        IConfiguration configuration,
        ICollection<AzureClient> azureClients)
    {
        services
            .AddAuthentication(options =>
            {
                foreach (var azureClient in azureClients)
                {
                    options.AddScheme(
                        azureClient.ClientId.ToString(), // <--- this is the scheme name
                        builder => builder.HandlerType = typeof(OpenIdConnectHandler));
                }
            })
            .AddIdentityServerAuthentication(options =>
            {
                options.Authority = authConfig.Authority;
                options.RequireHttpsMetadata = true;
            });
    
        // Relies on a section in appsettings.json with the name "AzureAD" but the values for TenantId and ClientId don't matter
        services.AddMicrosoftIdentityWebAppAuthentication(
            configuration, openIdConnectScheme: Microsoft.Identity.Web.Constants.AzureAd);
    
        foreach (var azureClient in azureClients)
        {
            // The first argument must match the scheme name above
            services.Configure<OpenIdConnectOptions>(azureClient.ClientId.ToString(), options =>
            {
                options.SignInScheme = IdentityConstants.ExternalScheme;
                options.Authority = $"https://login.microsoftonline.com/{azureClient.TenantId}/v2.0/";
                options.ClientId = azureClient.ClientId.ToString();
                options.CallbackPath = $"/signin-oidc-{azureClient.ClientId}";
            });
        }
    }
    

    I have updated appsettings.json so it literally contains the asterisks shown in the question, rather than the actual tenant and client IDs.

    Finally, the challenge should now pass through the name of the authentication scheme, which for me is the client ID:

    return Challenge(
        new AuthenticationProperties
        {
            RedirectUri = callbackPath,
            Items =
            {
                new KeyValuePair<string, string>("LoginProvider", Microsoft.Identity.Web.Constants.AzureAd)
            }
        },
        clientId); // <--- the scheme name defined above