CheapASPNETHostingReview.com | ASP.NET Core and Azure AD have been kind of my passion for the last year. Naturally with ASP.NET Core 2.0 coming out I wanted to see what had changed in the area of authentication. I made an article on enabling Azure AD authentication in ASP.NET Core 1.0 almost a year ago.
ASP.NET Core 2.0 authentication changes
Many things have changed in 2.0. Authentication is one of them. I will go through changes to other parts in future articles.
In ASP.NET Core 1.X, authentication middleware were registered in the Configure
method in the Startup
class. That has had a major change. There is now only a single middleware that you add:
1 | app.UseAuthentication(); |
Other middleware, like cookie authentication middleware need not be added.
Instead, authentication is now done through services. You will add them in ConfigureServices
. Here for example we add cookie authentication.
1 2 | services.AddAuthentication() .AddCookie(); |
Another thing that has changed is that we can now define the default authentication/challenge/sign-in handler in one place. You can see an example of the new approach in the next section.
Setting up Azure AD authentication in Startup
In an MVC application that wants to use Azure AD authentication, we need two authentication handlers:
- Cookies
- Open Id Connect
We can add them to the service collection like this:
1 2 3 | services.AddAuthentication() .AddCookie(); .AddOpenIdConnect(); |
Cookies is responsible for two things:
- Signing the user in (creating the authentication cookie and returning it to the browser)
- Authenticating cookies in requests and creating user principals from them
Open Id Connect is responsible for only one thing really: Responding to challenges from [Authorize]
or ChallengeResult
returned from controllers.
When it receives a challenge, it sends the user to authenticate against the identity provider (in this case Azure AD). When the user gets redirected back to the app, it does a multitude of things to authenticate the returned info, and then requests the default sign-in handler to sign the user in.
So let’s configure the default handlers:
1 2 3 4 5 6 7 8 | services.AddAuthentication(auth => { auth.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; auth.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; auth.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(); |
Note there that by default, cookie authentication handles authentication and sign-in. Open Id Connect only handles challenges. In 1.X, these would have been defined in the middleware options (except for the default sign-in middleware).
Then we need to add the authentication middleware to Configure
as mentioned before:
1 | app.UseAuthentication(); |
For basic scenarios, we are actually almost done. Just one thing missing, the configuration!
Configuring Open Id Connect automatically/slightly manually
Previously we added Open Id Connect authentication like this:
1 | services.AddAuthentication().AddOpenIdConnect(); |
If you check it’s documentation, it says:
Adds OpenIdConnect authentication with options bound against the “OpenIdConnect” section from the IConfiguration in the service container.
So, it expects you have a section like this e.g. in appsettings.json:
1 2 3 4 5 6 7 8 9 10 | { "OpenIdConnect": { "ClientId": "bcd3f4c3-aaaa-aaaa-aaaa-e349f2b4bdac", "Authority": "https://login.microsoftonline.com/<tenant-id>/", "PostLogoutRedirectUri": "http://localhost:5000", "CallbackPath": "/signin-oidc", "ResponseType": "code id_token", "Resource": "https://graph.microsoft.com/" } } |
And in user secrets:
1 2 3 4 5 | { "OpenIdConnect": { "ClientSecret": "abcdefghi..." } } |
These will automatically map to properties in OpenIdConnectOptions.
So, configuration? Done.
Now, in some cases you want to e.g. define handlers that do something when you receive the authorization code.
There’s just one small thing. When you specify handlers like this:
1 2 3 4 5 6 7 8 9 10 | services.AddAuthentication().AddOpenIdConnectAuthentication(opts => { opts.Events = new OpenIdConnectEvents { OnAuthorizationCodeReceived = ctx => { return Task.CompletedTask; } }; }); |
Configuration is not bound in this case. You can do that however just by adding one line of code:
1 2 3 4 5 6 7 8 9 10 11 | services.AddAuthentication().AddOpenIdConnectAuthentication(opts => { Configuration.GetSection("OpenIdConnect").Bind(opts); opts.Events = new OpenIdConnectEvents { OnAuthorizationCodeReceived = ctx => { return Task.CompletedTask; } }; }); |
If you just need the authentication and no access tokens to APIs etc., the first option with no arguments that auto-binds to the configuration is best.
Otherwise, at least bind options from configuration so you don’t have to type configuration keys manually.
Here is a more complete example of configuring Open Id Connect:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | public void ConfigureServices(IServiceCollection services) { services.AddMvc(opts => { opts.Filters.Add(typeof(AdalTokenAcquisitionExceptionFilter)); }); services.AddAuthentication(auth => { auth.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; auth.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; auth.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(opts => { Configuration.GetSection("Authentication").Bind(opts); opts.Events = new OpenIdConnectEvents { OnAuthorizationCodeReceived = async ctx => { var request = ctx.HttpContext.Request; var currentUri = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path); var credential = new ClientCredential(ctx.Options.ClientId, ctx.Options.ClientSecret); var distributedCache = ctx.HttpContext.RequestServices.GetRequiredService<IDistributedCache>(); string userId = ctx.Ticket.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value; var cache = new AdalDistributedTokenCache(distributedCache, userId); var authContext = new AuthenticationContext(ctx.Options.Authority, cache); var result = await authContext.AcquireTokenByAuthorizationCodeAsync( ctx.ProtocolMessage.Code, new Uri(currentUri), credential, ctx.Options.Resource); ctx.HandleCodeRedemption(result.AccessToken, result.IdToken); } }; }); } |
We first bind the configuration section “Authentication” to the Open Id Connect options. Then we setup an event handler for when we get an authorization code from Azure AD. We then exchange it for an access token for Microsoft Graph API.
The token cache class that I made here uses the distributed cache to store tokens. In development that would be a memory-backed cache, but in production it could be backed by a Redis cache or an SQL database.
Now while the handler can acquire an access token, I prefer using ADAL/MSAL as tokens then get cached, and it handles token refresh automatically.
We also setup an exception filter for MVC so that if ADAL token acquisition fails (because the token was not found in cache), we redirect the user to Azure AD to get new tokens. The reason why we use the absolute newest bits (at the time of writing) in the sample app on GitHub, is because Automatic ChallengeBehavior
is finally gone in these versions 🙂 Now when you return a ChallengeResult
, you get the challenge behaviour every time. If you want to return a 403, you return a ForbidResult
.