19

I have been using a library I created from samples allowing me to authenticate a .NET core web app with Azure Active Directory and to take advantage of the various OpenIdConnectOptions events (e.g. OnTokenValidated) to add certain claims to the principal as well as add that data to an identity-like database so that APIs can make policy-based determinations of the caller based on their token.

But I would just rather use the Microsoft.AspNetCore.Authentication.AzureAD.UI NuGet package than my customized variation, I am just not sure how to reach in and access the event on the OpenIdConnectOptions.

I don't know if it's not something that can be done, or I just haven't got enough of a handle on dependency injection to figure out how to do that.

Or should I consider adding claims, etc. in a different part of the process?

public static AuthenticationBuilder AddAzureAD(
    this AuthenticationBuilder builder,
    string scheme,
    string openIdConnectScheme,
    string cookieScheme,
    string displayName,
    Action<AzureADOptions> configureOptions) {

    AddAdditionalMvcApplicationParts(builder.Services);
    builder.AddPolicyScheme(scheme, displayName, o => {
        o.ForwardDefault = cookieScheme;
        o.ForwardChallenge = openIdConnectScheme;
    });

    builder.Services.Configure(
        TryAddOpenIDCookieSchemeMappings(scheme, openIdConnectScheme, cookieScheme));

    builder.Services.TryAddSingleton<IConfigureOptions<AzureADOptions>, AzureADOptionsConfiguration>();

    // They put in their custom OpenIdConnect configuration, but I can't see how to get at the events.
    builder.Services.TryAddSingleton<IConfigureOptions<OpenIdConnectOptions>, OpenIdConnectOptionsConfiguration>();

    builder.Services.TryAddSingleton<IConfigureOptions<CookieAuthenticationOptions>, CookieOptionsConfiguration>();

    builder.Services.Configure(scheme, configureOptions);

    builder.AddOpenIdConnect(openIdConnectScheme, null, o => { });
    builder.AddCookie(cookieScheme, null, o => { });

    return builder;
}
Marc LaFleur
  • 31,987
  • 4
  • 37
  • 63
Daniel Przybylski
  • 462
  • 1
  • 6
  • 18

2 Answers2

30

I might be a little late to the party here, but I've come across the same issue and found that the AzureAD authentication middleware is very sparsely documented. Adding the solution here for others struggling with the same question.

As you can see at the bottom of the code snippet in the question, the AzureAD provider actually relies on OpenIdConnect and Cookie auth providers under the hoods, and does not implement any authentication logic itself.

To accomplish this, two additional authentication schemes are added, using the names defined as AzureADDefaults.OpenIdScheme and AzureADDefaults.CookieScheme, respectively.

(Although the names can also be customized when using the AddAzureAD(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, string scheme, string openIdConnectScheme, string cookieScheme, string displayName, Action<Microsoft.AspNetCore.Authentication.AzureAD.UI.AzureADOptions> configureOptions) overload).

That, in turn, allows to configure the effective OpenIdConnectOptions and CookieAuthenticationOptions by using the scheme names from above, including access to OpenIdConnectEvents.

See this complete example:

        services.AddAuthentication(AzureADDefaults.AuthenticationScheme)
            .AddAzureAD(options => Configuration.Bind("AzureAd", options));

        services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
        {
            options.Events = new OpenIdConnectEvents
            {
                OnRedirectToIdentityProvider = async ctxt =>
                {
                    // Invoked before redirecting to the identity provider to authenticate. This can be used to set ProtocolMessage.State
                    // that will be persisted through the authentication process. The ProtocolMessage can also be used to add or customize
                    // parameters sent to the identity provider.
                    await Task.Yield();
                },
                OnMessageReceived = async ctxt =>
                {
                    // Invoked when a protocol message is first received.
                    await Task.Yield();
                },
                OnTicketReceived = async ctxt =>
                {
                    // Invoked after the remote ticket has been received.
                    // Can be used to modify the Principal before it is passed to the Cookie scheme for sign-in.
                    // This example removes all 'groups' claims from the Principal (assuming the AAD app has been configured
                    // with "groupMembershipClaims": "SecurityGroup"). Group memberships can be checked here and turned into
                    // roles, to be persisted in the cookie.
                    if (ctxt.Principal.Identity is ClaimsIdentity identity)
                    {
                        ctxt.Principal.FindAll(x => x.Type == "groups")
                            .ToList()
                            .ForEach(identity.RemoveClaim);
                    }                        
                    await Task.Yield();
                },
            };
        });

        services.Configure<CookieAuthenticationOptions>(AzureADDefaults.CookieScheme, options =>
        {
            options.Events = new CookieAuthenticationEvents
            {
                // ...
            };
        });
mthierba
  • 5,587
  • 1
  • 27
  • 29
  • This is what I was looking for! This actually solved my issue. Thanks! – Nelson Rodriguez Apr 12 '19 at 22:01
  • the CookieAuthenticationOptions portion is a VERY important piece not to miss. from there you can handle events such as OnRedirectToAccessDenied and customize your response. thanks @mthierba – JasonlPrice Sep 18 '19 at 13:47
  • 3
    If you land on this wonderful answer and are using Azure B2C code (created from the Visual Studio template), just change "AzureADDefaults.OpenIdScheme" to "AzureADB2CDefaults.OpenIdScheme" and this will work for you too :) – Michael Washington Sep 12 '20 at 13:36
  • You, sir, are my hero. – BFree Oct 20 '20 at 17:30
7

I think based on official Microsoft code base you can also create a separate handler class like this one:

public class AzureAdOpendIdHandler : IConfigureNamedOptions<OpenIdConnectOptions>
{
    private readonly AzureAdConfig _azureOptions;
    readonly IMediator _mediator;

    public AzureAdConfig GetAzureAdConfig() => _azureOptions;

    public AzureAdOpendIdHandler(IOptions<SiteConfig> siteConfig, IMediator mediator)
    {
        _azureOptions = siteConfig.Value.AzureAdConfig;
        _mediator = mediator;
    }

    public void Configure(string name, OpenIdConnectOptions options)
    {
        options.ClientId = _azureOptions.ClientId;
        options.UseTokenLifetime = true;
        options.CallbackPath = _azureOptions.CallbackPath;
        options.RequireHttpsMetadata = false;
        options.ResponseType = OpenIdConnectResponseType.CodeIdToken;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            // Ensure that User.Identity.Name is set correctly after login
            NameClaimType = "name",
            ValidateIssuer = false,
        };

        options.Events = new OpenIdConnectEvents
        {
            OnTokenValidated = context =>
            {
                var idToken = context.SecurityToken;
                string userIdentifier = idToken.Subject;
                string userEmail =
                    idToken.Claims.SingleOrDefault(c => c.Type == JwtRegisteredClaimNames.Email)?.Value
                    ?? idToken.Claims.SingleOrDefault(c => c.Type == "preferred_username")?.Value;

                string firstName = idToken.Claims.SingleOrDefault(c => c.Type == JwtRegisteredClaimNames.GivenName)?.Value;
                string lastName = idToken.Claims.SingleOrDefault(c => c.Type == JwtRegisteredClaimNames.FamilyName)?.Value;
                string name = idToken.Claims.SingleOrDefault(c => c.Type == "name")?.Value;

                // manage roles, modify token and claims etc.

                return Task.CompletedTask;
            },
            OnTicketReceived = context =>
            {
                // If your authentication logic is based on users then add your logic here
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = context =>
            {
                context.Response.Redirect("/Home/Error");
                context.HandleResponse(); // Suppress the exception
                return Task.CompletedTask;
            },
        };
    }

    public void Configure(OpenIdConnectOptions options)
    {
        Configure(Options.DefaultName, options);
    }
}

Then you can just register in your Startup.cs it somewhere near the very end of the ConfigureServices(IServiceCollection services) method

like this:

services.AddAuthentication(AzureADDefaults.AuthenticationScheme)
                .AddAzureAD(options => Configuration.Bind(nameof(AzureAdConfig), options));

// registration of other services
services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, AzureAdOpendIdHandler>();

This way you are allowed to run some more complex logic in handlers of the options.Events as you are allowed to inject whatever your service you want.

GoldenAge
  • 2,918
  • 5
  • 25
  • 63
  • 1
    Is there any way to inject a scoped service, that can be used by any of the options.Events? I have tried to add this handler as a scoped, but that fails, and it won't allow me to inject a scoped service into a singleton. – user3280560 Sep 08 '21 at 23:36
  • yea, it's kinda problematic but the workaround could be to just retrieve the service from the httpContext sth like this; `context.HttpContext.RequestServices.GetRequiredService();` – GoldenAge Sep 10 '21 at 09:14
  • You can use injected services inside an event handler by creating your own instance of scope inside the handler and getting the needed service from the scope's ServiceProvider. – Tom Aug 16 '23 at 16:20
  • For example: ``` using (IServiceScope scope = context.HttpContext.RequestServices.CreateScope()) { SignInManager _signInManager = scope.ServiceProvider.GetService>(); now use the service } ``` – Tom Aug 16 '23 at 16:28