Problem Statement
I am using .NET Core, and I'm trying to make a web application talk to a web API. Both require authentication using the [Authorize]
attribute on all of their classes. In order to be able to talk between them server-to-server, I need to retrieve the validation token. I've been able to do that thanks to a Microsoft tutorial.
Problem
In the tutorial, they use a call to AcquireTokenByAuthorizationCodeAsync
in order to save the token in the cache, so that in other places, the code can just do a AcquireTokenSilentAsync
, which doesn't require going to the Authority to validate the user.
This method does not lookup token cache, but stores the result in it, so it can be looked up using other methods such as AcquireTokenSilentAsync
The issue comes in when the user is already logged in. The method stored at OpenIdConnectEvents.OnAuthorizationCodeReceived
never gets called, since there is no authorization being received. That method only gets called when there's a fresh login.
There is another event called: CookieAuthenticationEvents.OnValidatePrincipal
when the user is only being validated via a cookie. This works, and I can get the token, but I have to use AcquireTokenAsync
, since I don't have the authorization code at that point. According to the documentation, it
Acquires security token from the authority.
This makes calling AcquireTokenSilentAsync
fail, since the token has not been cached. And I'd rather not always use AcquireTokenAsync
, since that always goes to the Authority.
Question
How can I tell the token gotten by AcquireTokenAsync
to be cached so that I can use AcquireTokenSilentAsync
everywhere else?
Relevant code
This all comes from the Startup.cs file in the main, Web Application project.
This is how the event handling is done:
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
Events = new CookieAuthenticationEvents()
{
OnValidatePrincipal = OnValidatePrincipal,
}
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
ClientId = ClientId,
Authority = Authority,
PostLogoutRedirectUri = Configuration["AzureAd:PostLogoutRedirectUri"],
ResponseType = OpenIdConnectResponseType.CodeIdToken,
CallbackPath = Configuration["Authentication:AzureAd:CallbackPath"],
GetClaimsFromUserInfoEndpoint = false,
Events = new OpenIdConnectEvents()
{
OnRemoteFailure = OnAuthenticationFailed,
OnAuthorizationCodeReceived = OnAuthorizationCodeReceived,
}
});
And these are the events behind:
private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
string userObjectId = (context.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);
AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
AuthenticationResult authResult = await authContext.AcquireTokenAsync(ClientResourceId, clientCred);
// How to store token in authResult?
}
private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)
{
// Acquire a Token for the Graph API and cache it using ADAL. In the TodoListController, we'll use the cache to acquire a token to the Todo List API
string userObjectId = (context.Ticket.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);
AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));
AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
context.ProtocolMessage.Code, new Uri(context.Properties.Items[OpenIdConnectDefaults.RedirectUriForCodePropertiesKey]), clientCred, GraphResourceId);
// Notify the OIDC middleware that we already took care of code redemption.
context.HandleCodeRedemption();
}
// Handle sign-in errors differently than generic errors.
private Task OnAuthenticationFailed(FailureContext context)
{
context.HandleResponse();
context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
return Task.FromResult(0);
}
Any other code can be found in the linked tutorial, or ask and I will add it to the question.
(Note: I had been struggling with this exact issue for several days. I followed the same Microsoft Tutorial as the one linked in the question, and tracked various problems like a wild goose chase; it turns out the sample contains a whole bunch of seemingly unnecessary steps when using the latest version of the
Microsoft.AspNetCore.Authentication.OpenIdConnect
package.).I eventually had a breakthrough moment when I read this page: http://docs.identityserver.io/en/release/quickstarts/5_hybrid_and_api_access.html
The solution essentially involves letting OpenID Connect auth put the various tokens (
access_token
,refresh_token
) into the cookie.Firstly, I'm using a Converged Application created at https://apps.dev.microsoft.com and v2.0 of the Azure AD endpoint. The App has an Application Secret (password/public key) and uses
Allow Implicit Flow
for a Web platform.(For some reason it seems as if v2.0 of the endpoint doesn't work with Azure AD only applications. I'm not sure why, and I'm not sure if it really matters anyway.)
Relevant lines from the Startup.Configure method:
And that's it! No
OpenIdConnectOptions.Event
callbacks. No calls toAcquireTokenAsync
orAcquireTokenSilentAsync
. NoTokenCache
. None of those things seem to be necessary.The magic seems to happen as part of
OpenIdConnectOptions.SaveTokens = true
Here's an example where I'm using the access token to send an e-mail on behalf of the user using their Office365 account.
I have a WebAPI controller action which obtains their access token using
HttpContext.Authentication.GetTokenAsync("access_token")
:Side Note #1
At some point you might also need to get hold of the
refresh_token
too, in case the access_token expires:Side Note #2
My
OpenIdConnectOptions
actually includes a few more things which I've omitted here, for example:I've used these for working with the
Microsoft.Graph
API to send an e-mail on behalf of the currently logged in user.(Those delegated permissions for Microsoft Graph are set up on the app too).
Update - How to 'silently' Refresh the Azure AD Access Token
So far, this answer explains how to use the cached access token but not what to do when the token expires (typically after 1 hour).
The options seem to be:
refresh_token
to obtain a newaccess_token
(silent).How to Refresh the Access Token using v2.0 of the Endpoint
After more digging, I found part of the answer in this SO Question:
How to handle expired access token in asp.net core using refresh token with OpenId Connect
It seems like the Microsoft OpenIdConnect libraries do not refresh the access token for you. Unfortunately the answer in the question above is missing the crucial detail about precisely how to refresh the token; presumably because it depends on specific details about Azure AD which OpenIdConnect doesn't care about.
The accepted answer to the above question suggests sending a request directly to the Azure AD Token REST API instead of using one of the Azure AD libraries.
Here's the relevant documentation (Note: this covers a mix of v1.0 and v2.0)
Here's a proxy based on the API docs:
The
AzureAdTokenResponse
andAzureAdErrorResponse
classes used byJsonConvert
:Finally, my modifications to Startup.cs to refresh the
access_token
(Based on the answer I linked above)The
OnValidatePrincipal
handler in Startup.cs (Again, from the linked answer above):Finally, a solution with OpenIdConnect using v2.0 of the Azure AD API.
Interestingly, it seems that v2.0 does not ask for a
resource
to be included in the API request; the documentation suggests it's necessary, but the API itself simply replies thatresource
is not supported. This is probably a good thing - presumably it means that the access token works for all resources (it certainly works with the Microsoft Graph API)