Microsoft Entra ID: external authentication methods
Implement a Microsoft Entra ID external authentication method using ASP.NET Core and OpenIddict
The FIDO2/passkeys MFA Server is implemented as an ASP.NET Core Web application. Within this application, ASP.NET Core Identity is utilized to store and manage user data in an Azure SQL database. For FIDO2 authentication, the MFA server leverages the passwordless-lib fido2-net-lib. Additionally, OpenIddict is employed to implement the OpenID Connect flow.
See the Microsoft Entra ID: external authentication methods documentation.
Microsoft Graph is used to create the Microsoft Entra ID: external authentication method.
Requires the delegated Policy.ReadWrite.AuthenticationMethod permission
POST
{
"@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"
}
}
The https://developer.microsoft.com/en-us/graph/graph-explorer can be used to run this.
(OpenIddict, fido2-net-lib, ASP.NET Core Identity)
The Microsoft Entra ID external authentication provider relies on an OpenID Connect server implementation for interaction. In our implementation, we utilize OpenIddict to achieve this functionality. It’s worth noting that any OpenID Connect implementation can be used, provided that you have the ability to customize the claims returned in the id_token. Additionally, we persist user data to the database using ASP.NET Core Identity.
See OpenIddict
OpenID Connect implicit flow is used and in OpenIddict, this can be configured in the Worker class. An id_token_hint is used to send the user data from Microsoft Entra ID.
Here is an example of a possible OpenIddict client setup:
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "oidc-implicit-mfa",
ConsentType = ConsentTypes.Implicit,
DisplayName = "OIDC Implicit Flow for MFA",
DisplayNames =
{
[CultureInfo.GetCultureInfo("fr-FR")] = "Application cliente MVC"
},
RedirectUris =
{
new Uri("https://login.microsoftonline.com/common/federation/externalauthprovider")
},
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.Implicit,
Permissions.ResponseTypes.IdToken,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles
}
});
Microsoft Entra ID uses the redirect_uri:
https://login.microsoftonline.com/common/federation/externalauthprovider
The FIDO2/passkeys authentication was implement using the fido2-net-lib nuget package.
This was implemented using the AspNetCoreIdentityFido2Passwordless implementation. You need to replace all the ASP.NET Core Identity Razor Pages and add the WebAuthn js scripts from the wwwroot.
The Fido2 appsettings configuration must be changed to match the server deployment.
"Fido2": {
// This must match the deployment domain
"ServerDomain": "fidomfaserver.azurewebsites.net",
"ServerName": "FidoMfaServer",
"Origins": [ "https://fidomfaserver.azurewebsites.net" ],
"TimestampDriftTolerance": 300000,
"MDSAccessKey": null
},
The default Implicit flow client handling needs to be adapted for the Microsoft Entra ID external authentication flow. This is implemented in the AuthorizationController. This server is only used for this purpose, if implementing this on an existing OpenID Connect server, you would need to leave the default for the other flows.
The code implements the validation like in the Microsoft Entra ID documentation. It is important to validate the id_token_hint (including the signature) and to create the returned id_token with the extra claims and changed claims required by Microsoft Entra ID external authentication methods.
case ConsentTypes.Implicit:
case ConsentTypes.External when authorizations.Any():
case ConsentTypes.Explicit when authorizations.Any() && !request.HasPrompt(Prompts.Consent):
var principal = await _signInManager.CreateUserPrincipalAsync(user);
// Note: in this sample, the granted scopes match the requested scope
// but you may want to allow the user to uncheck specific scopes.
// For that, simply restrict the list of scopes before calling SetScopes.
principal.SetScopes(request.GetScopes());
principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());
// Automatically create a permanent authorization to avoid requiring explicit consent
// for future authorization or token requests containing the same scopes.
var authorization = authorizations.LastOrDefault();
if (authorization == null)
{
authorization = await _authorizationManager.CreateAsync(
principal: principal,
subject: await _userManager.GetUserIdAsync(user),
client: await _applicationManager.GetIdAsync(application),
type: AuthorizationTypes.Permanent,
scopes: principal.GetScopes());
}
principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));
//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 = ValidateIdTokenHintRequestPayload.ValidateTokenAndSignature(
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);
principal.AddClaim("acr", "possessionorinherence");
var sub = idTokenHintValidationResult.ClaimsPrincipal
.Claims.First(d => d.Type == "sub");
principal.RemoveClaims("sub");
principal.AddClaim(sub.Type, sub.Value);
var claims = principal.Claims.ToList();
claims.Add(new Claim("amr", "[\"fido\"]", JsonClaimValueTypes.JsonArray));
ClaimsPrincipal cp = new();
cp.AddIdentity(new ClaimsIdentity(claims, principal.Identity.AuthenticationType));
foreach (var claim in cp.Claims)
{
claim.SetDestinations(GetDestinations(claim, cp));
}
var (Valid, Reason, Error) = ValidateIdTokenHintRequestPayload
.IsValid(idTokenHintValidationResult.ClaimsPrincipal,
_idTokenHintValidationConfiguration,
user.EntraIdOid,
user.UserName);
if (!Valid)
{
return UnauthorizedValidationParametersFailed(Reason, Error);
}
return SignIn(cp, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
The appsettings need to match your Microsoft Entra ID tenant and the used Azure App registration
"IdTokenHintValidationConfiguration": {
"MetadataAddress": "https://login.microsoftonline.com/--your-tenant-id--/v2.0/.well-known/openid-configuration",
"Issuer": "https://login.microsoftonline.com/--your-tenant-id--/v2.0",
// client_id from the app we allow, i.e. MerillApp App Registration
// We can enable or disable this validation if app aud are to be accepted.
"Audience": "--your-client-id.app-using-the-mfa--",
// If this is true, Audience (App registration client_id) is validated.
"ValidateAudience": "False",
"TenantId": "--your-tenant-id--"
},
The IdTokenHintValidation Folder in the FidoMfaServer project implements the different flow validations.
- Only a single FIDO2/passkeys key can be registered per user. In a productive system, multiple key registration must be possible.
- User would need a recovery in a productive system.
- Need to add a SCIM import of users
- Register page should only allow validated Microsoft Entra ID users and use the OID from the id_token
https://documentation.openiddict.com/