This is the fourth part of a series of blog posts related to Azure AD best practices. They are all related to a talk I gave at Tech Days Finland as well as in the Microsoft Identity Developer Community Office Hours.

This time our topic is what I like to call N-tenant apps. They are a class of multi-tenant applications that only support a specific set of tenants. As an example, an organization might have multiple Azure AD tenants to isolate different parts of the enterprise or different types of users. Some companies I've worked with have a separate Azure AD tenant for external users.

But you might be making an app that then needs to allow login from any of those. An N-tenant app.

The sample app related to this article can be found on GitHub. The solution includes an app with vulnerabilities (TwoTenantApp) as well as an app that has fixed those issues (TwoTenantAppDoneBetter).

Issues with the app registration

A problem you will face with these apps is that there is no way to register an app in Azure AD and say "only allow login from these 2 tenants". It's either "only this tenant" or "any tenant".

Well you could register a single-tenant app in each tenant, but that would create more problems. Having to juggle different client ids etc. would be quite hard.

So we will have to register the app as multi-tenant.

Limiting tenant access

Since Azure AD will not limit which tenants' users can login to the app, we need to implement the checks on the app's side.

So how would we do that? Well, the best way is to specify the valid token issuers to the token validator as in the ASP.NET Core sample app here:

// Inside .AddOpenIdConnect()
o.TokenValidationParameters = new TokenValidationParameters
{
    // NOTE: We should not turn issuer validation off
    // We should instead list the valid issuers
    // You can find your issuer URI at: https://login.microsoftonline.com/tenant-id-here/v2.0/.well-known/openid-configuration
    // It's in the "issuer" property
    NameClaimType = "name",
    ValidIssuers = new[] // THIS IS IMPORTANT Only accept tokens from these tenants
    {
        $"https://login.microsoftonline.com/{authSettings.EmployeeTenantId}/v2.0",
        $"https://login.microsoftonline.com/{authSettings.PartnerTenantId}/v2.0"
    }
};

You can see the full source code on GitHub.

Typically multi-tenant apps turn issuer validation off, and you might mistakenly do that here. It is however very important that you don't turn off issuer validation if you know the valid tenants in advance. It's a very easy way of enforcing authentication from a limited set of tenants.

Now if you don't know the valid tenants in advance, that's another story. They might for example be stored in a database, and we can't just check it once on startup. Then you will need to rely on validation checks that run after regular token validation. You can see for example how the roles are assigned in the sample ASP.NET Core app based on the tenant:

o.Events = new OpenIdConnectEvents
{
    OnTokenValidated = ctx =>
    {
        string tid = ctx.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
        string userType;
        // Extra check, the exception should never be thrown
        if (tid == authSettings.EmployeeTenantId)
        {
            userType = "employee";
        }
        else if (tid == authSettings.PartnerTenantId)
        {
            userType = "partner";
        }
        else
        {
            throw new Exception("Tenant id not allowed");
        }

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Role, userType)
        };
        var appIdentity = new ClaimsIdentity(claims);

        ctx.Principal.AddIdentity(appIdentity);

        return Task.CompletedTask;
    }
};

You can do any checks you need in this callback and error out if something isn't right.

Authentication

In this scenario authentication may not be as easy as you think. Let's say we have two tenants, one for employees and one for partners like in the sample app. The partners are added as Guests to the partner tenant.

Remember that Azure AD has various endpoints:

  • https://login.microsoftonline.com/tenant-id-goes-here/oauth2/v2.0/authorize
    • Authentication with specific AAD tenant
  • https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize
    • Authentication with any AAD tenant
  • https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize
    • Authentication with personal Microsoft account
  • https://login.microsoftonline.com/common/oauth2/v2.0/authorize
    • Authentication with any AAD tenant or personal Microsoft account

Any guesses what will happen if we now use the "organizations" endpoint for login? None of the partners will be able to login. Why? Because we only accept two valid token issuers: the employee or partner tenant. Which tenant did they authenticate with when we sent them to the "organizations" endpoint? Their own organization's tenant. We don't accept that as an issuer.

So we can't use the organizations endpoint because we need to support Guest users in this scenario. We need to use the tenant-specific endpoint. If you don't need to support Guests, you can use the organizations endpoint.

But we have two tenants, so how do we use the tenant-specific endpoints? Well, we can provide two buttons for login. One for employee login and one for partner login. The sample app does this and modifies the authorization URL just before the redirect:

o.Events = new OpenIdConnectEvents
{
    OnRedirectToIdentityProvider = ctx =>
    {
        // Replace the common endpoint with tenant-specific endpoint
        // Trying to login with an account from another tenant causes an error in Azure AD
        // NOTE: The flaw with this is that the user can just change the URL!
        if (ctx.Properties.Parameters.TryGetValue("userType", out var userTypeObj)
            && userTypeObj is string userType)
        {
            if (userType == "employee")
            {
                ctx.ProtocolMessage.IssuerAddress = authSettings.EmployeeAuthorizationEndpoint;
                return Task.CompletedTask;
            }
            else if (userType == "partner")
            {
                ctx.ProtocolMessage.IssuerAddress = authSettings.PartnerAuthorizationEndpoint;
                return Task.CompletedTask;
            }
        }

        throw new ArgumentException("Invalid user type specified");
    }
};

As the comment says, the user can of course just modify this URL. Another sample app in the same solution shows how an implementation can have mistakes, and it is quite easy to log in to the app with a user from another tenant.

You need to remember that specifying that the user should authenticate with a particular tenant is not enough. The token must be verified after authentication that it has been issued by a valid tenant.

Summary

Implementing an app that allows authentication with a limited set of Azure AD tenants has some issues not present in general multi-tenant apps.

Define the app as multi-tenant in Azure AD. If you know the valid tenants in advance, define them as valid token issuers for your token validator (and do not disable issuer validation). If you don't know them in advance, add checks that run during or after token validation.

If you need to support Guest users in the tenants, you need to use the tenant-specific endpoints. This may require you to implement multiple login buttons/options or different subdomains within the app. You could collect their username/email before redirecting them to Azure AD, and figure out their tenant from that. Something that allows you to know which tenant they should use to log in.

Thanks for reading! Next time our topic is related to secrets leaking to version control and how to avoid that.