Implement a Microsoft Entra ID external authentication method using ASP.NET Core and OpenIddict

The article shows how to implement a Microsoft Entra ID external authentication method (EAM) using ASP.NET Core, OpenIddict and FIDO2/passkeys. The application using ASP.NET Core Identity to manage the accounts and the passkeys.

Code: https://github.com/damienbod/MfaServer

The following flow diagram from the Microsoft docs explains how EAM works. Refer to the documentation for a full explanation.

src: https://learn.microsoft.com/en-gb/entra/identity/authentication/concept-authentication-external-method-provider

Setup Microsoft Entra ID

To setup the external authentication method (EAM), the following needs to be created:

  1. MFA server (EAM) deployed to a public URL
  2. MFA server (EAM) has an OIDC discovery endpoint
  3. MFA server (EAM) defines a OIDC public Implicit flow
  4. Azure App registration public multi-tenant client using authorization_endpoint as the redirect URL
  5. EAM created and added to the ME-ID authentication methods.

The external authentication methods (EAM) server should be created and the following three values are required:

  • –app-registration-clientId–
  • –your-client_id-from-external-provider–
  • –your-external-provider-url–/.well-known/openid-configuration

Setup Microsoft Entra ID App registration

The Microsoft Entra ID App client registration is a multi-tenant registration and required the authorize endpoint as the redirect URL.

The Microsoft Entra ID docs can be found here:

https://learn.microsoft.com/en-gb/entra/identity/authentication/concept-authentication-external-method-provider#configure-a-new-external-authentication-provider-with-microsoft-entra-id

API Permissions

The openid permission must be defined as well a profile scope if you want to request user data.

RedirectUrl authorization_endpoint

The Redirect URL is the authorization_endpoint of the OIDC MFA server. This must be set in the Azure app registration. This can be found using the OpenID connect well known endpoints in the browser.

–your-external-provider-url–/.well-known/openid-configuration

EAM setup using Microsoft Graph

Microsoft Graph can be used to create the Microsoft Entra ID external authentication method (EAM).

This requires the delegated Policy.ReadWrite.AuthenticationMethod permission

POST

https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations

{
    "@odata.type": "#microsoft.graph.externalAuthenticationMethodConfiguration",
    "displayName": "--name-of-provider--", // Displayed in login
    "state": "enabled"
    "appId": "--app-registration-clientId--", // external authentication app registration, see docs
    "openIdConnectSetting": {
        "clientId": "--your-client_id-from-external-provider--",
        "discoveryUrl": "--your-external-provider-url--/.well-known/openid-configuration"
    },
    "includeTarget": { // switch this if only specific users are required
        "targetType": "group",
        "id": "all_users"
    }
}

EAM setup using the Azure portal

The Azure portal can also be used to setup an EAM server. In the authentication methods, you can use the Add external method button.

The three values from the Azure App registration and the EAM server can now be used to specific the external method.

Once created, you can view this in the overview.

EAM MFA Server using OpenIddict and ASP.NET Core

The demo MFA server can be found in the github repository, linked at the top of the blog. The server is implemented using OpenID connect. I used OpenIddict to implement this. The user management is implemented using ASP.NET Core Identity and the FIDO2/passkeys support is implemented using fido2-net-lib.

The default Identity and OIDC flows are changed to remove the consent and only use passkeys. A user can register using his or her OID from Microsoft Entra ID and the preferred passkey.

ME-ID sends a OIDC Implicit flow request with specific claims and an id_token_hint with an id_token. the claims and the id_token must be fully validated. The tenant id (tid) must also be explicitly validation if you want to avoid phishing attacks.

The id_token can be validated as follows:

using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Net.Mail;
using System.Security.Claims;

namespace FidoMfaServer.IdTokenHintValidation;

public static class ValidateIdTokenHintRequestPayload
{
    public static (bool Valid, string Reason, string Error) IsValid(ClaimsIdentity claimsIdTokenPrincipal,
        IdTokenHintValidationConfiguration configuration,
        string userEntraIdOid,
        string userName)
    {
        // oid from id_token_hint must match User OID
        var oid = claimsIdTokenPrincipal.FindFirst("oid").Value;
        if (!oid!.Equals(userEntraIdOid))
        {
            return (false, "oid parameter has an incorrect value",
                EntraIdTokenRequestConsts.ERROR_INVALID_CLIENT);
        };

        // aud must match allowed audience if for a specifc app
        if (configuration.ValidateAudience)
        {
            var aud = claimsIdTokenPrincipal.FindFirst("aud").Value;
            if (!aud!.Equals(configuration.Audience))
            {
                return (false, "client_id parameter has an incorrect value",
                    EntraIdTokenRequestConsts.ERROR_INVALID_CLIENT);
            };
        }

        // tid must match allowed tenant
        var tid = claimsIdTokenPrincipal.FindFirst("tid").Value;
        if (!tid!.Equals(configuration.TenantId))
        {
            return (false, "tid parameter has an incorrect value",
                EntraIdTokenRequestConsts.ERROR_INVALID_CLIENT);
        };

        // preferred_username from id_token_hint
        var preferred_username = GetPreferredUserName(claimsIdTokenPrincipal);
        if (!preferred_username!.ToLower().Equals(userName.ToLower()))
        {
            return (false, "preferred_username parameter has an incorrect value",
                EntraIdTokenRequestConsts.ERROR_INVALID_CLIENT);
        };

        return (true, string.Empty, string.Empty);
    }

    public static async Task<(bool Valid, string Reason, TokenValidationResult TokenValidationResult)> ValidateTokenAndSignatureAsync(
        string jwtToken,
        IdTokenHintValidationConfiguration idTokenConfiguration,
        ICollection<SecurityKey> signingKeys,
        bool testingMode)
    {
        try
        {
            var validationParameters = new TokenValidationParameters
            {
                RequireExpirationTime = true,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.FromMinutes(1),
                RequireSignedTokens = true,
                ValidateIssuerSigningKey = true,
                IssuerSigningKeys = signingKeys,
                ValidateIssuer = true,
                ValidIssuer = idTokenConfiguration.Issuer,
                ValidateAudience = idTokenConfiguration.ValidateAudience,
                ValidAudience = idTokenConfiguration.Audience
            };

            if (testingMode)
            {
                //validationParameters.ValidateIssuerSigningKey = false;
                //validationParameters.ValidateIssuer = false;
                validationParameters.ValidateLifetime = false;
            }

            var tokenValidator = new JsonWebTokenHandler
            {
                MapInboundClaims = false
            };

            var tokenValidationResult = await tokenValidator.ValidateTokenAsync(jwtToken, validationParameters);

            if (!tokenValidationResult.IsValid)
            {
                return (tokenValidationResult.IsValid, tokenValidationResult.Exception!.Message, tokenValidationResult);
            }

            return (tokenValidationResult.IsValid, string.Empty, tokenValidationResult);
        }
        catch (Exception ex)
        {
            return (false, $"Id Token Authorization failed {ex.Message}", null);
        }
    }

    public static string GetPreferredUserName(ClaimsIdentity claimsIdentity)
    {
        var preferred_username = claimsIdentity.Claims.FirstOrDefault(t => t.Type == "preferred_username");
        return preferred_username?.Value ?? string.Empty;
    }

    public static string GetAzpacr(ClaimsIdentity claimsIdentity)
    {
        var azpacrClaim = claimsIdentity.Claims.FirstOrDefault(t => t.Type == "azpacr");
        return azpacrClaim?.Value ?? string.Empty;
    }

    public static string GetAzp(ClaimsIdentity claimsIdentity)
    {
        var azpClaim = claimsIdentity.Claims.FirstOrDefault(t => t.Type == "azp");
        return azpClaim?.Value ?? string.Empty;
    }

    public static bool IsEmailValid(string email)
    {
        if (!MailAddress.TryCreate(email, out var mailAddress))
            return false;

        // And if you want to be more strict:
        var hostParts = mailAddress.Host.Split('.');
        if (hostParts.Length == 1)
            return false; // No dot.
        if (hostParts.Any(p => p == string.Empty))
            return false; // Double dot.
        if (hostParts[^1].Length < 2)
            return false; // TLD only one letter.

        if (mailAddress.User.Contains(' '))
            return false;
        if (mailAddress.User.Split('.').Any(p => p == string.Empty))
            return false; // Double dot or dot at end of user part.

        return true;
    }
}

When the user has successfully authenticated using the required MFA and the id_token is valid, the result is returned to Microsoft Entra ID again with required claims which match the request claims. For example the amr is returned as an array claim, the acr contains a value matching the request value, the sub claim is the exact same as the request claim and so on. The Microsoft specifics the requirements in the docs.

This could be implemented using OpenIddict like the following code block displays. It is important to also validate the id_token fully in the method . The signature must be validated and the user OID must match and so on. The claims MUST be returned in the id_token and not the user info endpoint.

 //get well known endpoints and validate access token sent in the assertion
 var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
     _idTokenHintValidationConfiguration.MetadataAddress,
     new OpenIdConnectConfigurationRetriever());

 var wellKnownEndpoints = await configurationManager.GetConfigurationAsync();

 var idTokenHintValidationResult = await ValidateIdTokenHintRequestPayload.ValidateTokenAndSignatureAsync(
     request.IdTokenHint,
     _idTokenHintValidationConfiguration,
     wellKnownEndpoints.SigningKeys,
     _testingMode);

 if (!idTokenHintValidationResult.Valid)
 {
     return UnauthorizedValidationParametersFailed(idTokenHintValidationResult.Reason,
         "id_token_hint validation failed");
 }

 var requestedClaims = System.Text.Json.JsonSerializer.Deserialize<claims>(request.Claims);

 // The acr claims for the authentication request. This value should match one of the values from the request sent to initiate this request.
 // Only one acr claim should be returned.
 principal.AddClaim("acr", "possessionorinherence");

 var sub = idTokenHintValidationResult.TokenValidationResult.ClaimsIdentity
     .Claims.First(d => d.Type == "sub");

 principal.RemoveClaims("sub");
 principal.AddClaim(sub.Type, sub.Value);

 var claims = principal.Claims.ToList();

 // The amr claims for the authentication method used in authentication.
 // This value should be returned as an array, and only one method claim should be returned.
 // Openiddict between 5.0.1 => 5.5.0 does not support this.
 claims.Add(new Claim("amr", "[\"fido\"]", JsonClaimValueTypes.JsonArray));

 var cp = new ClaimsPrincipal(
     new ClaimsIdentity(claims, principal.Identity.AuthenticationType));

 foreach (var claim in cp.Claims)
 {
     claim.SetDestinations(GetDestinations(claim, cp));
 }

 var (Valid, Reason, Error) = ValidateIdTokenHintRequestPayload
     .IsValid(idTokenHintValidationResult.TokenValidationResult.ClaimsIdentity,
     _idTokenHintValidationConfiguration,
     user.EntraIdOid,
     user.UserName);

 if (!Valid)
 {
     return UnauthorizedValidationParametersFailed(Reason, Error);
 }

 return SignIn(cp, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

Testing the MFA server

It is hard to test the EAM server implementation directly from Azure. I created a test application to validate that the server handles the OIDC Implicit flow requests correctly and responds correctly with the correct claims like in the specification.

If implementing this is a production setup, the demo server requires a lot of changes for user and account management.

Notes

Microsoft Entra ID external authentication methods makes it possible to integrate with third party MFA servers. This can be really useful for SSPR, smart cards MFA, custom MFA and other such requirements. the external MFA would need to be set as the default or only MFA for users in Microsoft Entra ID for a good user experience. You would also need to integrate this into the continuous access policies and set the authentication strength correctly.

Links

https://learn.microsoft.com/en-gb/entra/identity/authentication/concept-authentication-external-method-provider

https://learn.microsoft.com/en-gb/entra/identity/authentication/how-to-authentication-external-method-manage

https://techcommunity.microsoft.com/t5/microsoft-entra-blog/public-preview-external-authentication-methods-in-microsoft/ba-p/4078808

https://documentation.openiddict.com

https://github.com/passwordless-lib/fido2-net-lib

https://mysignins.microsoft.com

https://developer.microsoft.com/en-us/graph/graph-explorer

https://github.com/damienbod/AspNetCoreIdentityFido2Mfa

2 comments

  1. […] Implement a Microsoft Entra ID external authentication method using ASP.NET Core and OpenIddict – Damien Bowden […]

  2. […] Implement a Microsoft Entra ID external authentication method using ASP.NET Core and OpenIddict (Damien Bowden) […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.