16

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.

David
  • 4,744
  • 5
  • 33
  • 64
  • 1
    Did you find a solution? – Michael Freidgeim Jan 26 '17 at 06:56
  • @MichaelFreidgeim, no. Right now, I just store the token in the Session data. – David Jan 26 '17 at 06:58
  • @MichaelFreidgeim I believe I have a viable solution in my answer below – Ben Cottrell Aug 04 '17 at 14:48
  • @David Can you please post some information on how you saved it to the session, since it has to be across applications (server & client)? Would be of great help, cannot figure out how else to send the token either. (The answer below - I don't think its right) – k25 Nov 14 '17 at 16:11
  • @k25, you only have to worry about it on the server side. If you start in the `OnAuthorizationCodeReceived`, then it's `context.HttpContext.Session.Set(KEY, Value);` – David Nov 14 '17 at 20:40
  • @David Thank you for responding! In your case do you have to pass it on to the client or is it only used within the identity server? In my case its the former - the client would use the token subsequently. Do let me know if you have any thoughts! – k25 Nov 14 '17 at 21:31
  • (The answer below - I don't think its right) - I guess I should rather say, not applicable in my case since its not a v2.0 app – k25 Nov 14 '17 at 22:35
  • 1
    @k25, I used another page for getting the token to the client. It was just a JSON response that could be polled, and had some security to ensure the user was valid. Also note that those tokens timeout faster than the general front-end one (around an hour, from what I recall) so take that into account that you'll have to figure out when to re-query for the token. My personal method was to track the time when I got it, and try again if it was a certain amount of time later. The server had the same info, and if it was passed the allotted time (which was same both sides) then it re-signed in. – David Nov 15 '17 at 02:30
  • 2
    @David Thank you for taking your time! Yes, finally I had to resort to do the same. I was so hoping to send this as part of AuthenticationProperties, but just couldn't figure out how to get it on the client side. – k25 Nov 15 '17 at 16:52

1 Answers1

20

(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:

    // Configure the OWIN pipeline to use cookie auth.
    app.UseCookieAuthentication(new CookieAuthenticationOptions());

    // Configure the OWIN pipeline to use OpenID Connect auth.
    var openIdConnectOptions = new OpenIdConnectOptions
    {
         ClientId = "{Your-ClientId}",
         ClientSecret = "{Your-ClientSecret}",
         Authority = "http://login.microsoftonline.com/{Your-TenantId}/v2.0",
         ResponseType = OpenIdConnectResponseType.CodeIdToken,
         TokenValidationParameters = new TokenValidationParameters
         {
             NameClaimType = "name",
         },
         GetClaimsFromUserInfoEndpoint = true,
         SaveTokens = true,
    };

    openIdConnectOptions.Scope.Add("offline_access");

    app.UseOpenIdConnectAuthentication(openIdConnectOptions);

And that's it! No OpenIdConnectOptions.Event callbacks. No calls to AcquireTokenAsync or AcquireTokenSilentAsync. No TokenCache. 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"):

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var graphClient = new GraphServiceClient(new DelegateAuthenticationProvider(async requestMessage =>
        {
            var accessToken = await HttpContext.Authentication.GetTokenAsync("access_token");
            requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
        }));

        var message = new Message
        {
            Subject = "Hello",
            Body = new ItemBody
            {
                Content = "World",
                ContentType = BodyType.Text,
            },
            ToRecipients = new[]
            {
                new Recipient
                {
                    EmailAddress = new EmailAddress
                    {
                        Address = "email@address.com",
                        Name = "Somebody",
                    }
                }
            },
        };

        var request = graphClient.Me.SendMail(message, true);
        await request.Request().PostAsync();

        return Ok();
    }

Side Note #1

At some point you might also need to get hold of the refresh_token too, in case the access_token expires:

HttpContext.Authentication.GetTokenAsync("refresh_token")

Side Note #2

My OpenIdConnectOptions actually includes a few more things which I've omitted here, for example:

    openIdConnectOptions.Scope.Add("email");
    openIdConnectOptions.Scope.Add("Mail.Send");

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:

  1. Force the user to sign in again. (Not silent)
  2. POST a request to the Azure AD service using the refresh_token to obtain a new access_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:

public class AzureAdRefreshTokenProxy
{
    private const string HostUrl = "https://login.microsoftonline.com/";
    private const string TokenUrl = $"{Your-Tenant-Id}/oauth2/v2.0/token";
    private const string ContentType = "application/x-www-form-urlencoded";

    // "HttpClient is intended to be instantiated once and re-used throughout the life of an application."
    // - MSDN Docs:
    // https://msdn.microsoft.com/en-us/library/system.net.http.httpclient(v=vs.110).aspx
    private static readonly HttpClient Http = new HttpClient {BaseAddress = new Uri(HostUrl)};

    public async Task<AzureAdTokenResponse> RefreshAccessTokenAsync(string refreshToken)
    {
        var body = $"client_id={Your-Client-Id}" +
                   $"&refresh_token={refreshToken}" +
                   "&grant_type=refresh_token" +
                   $"&client_secret={Your-Client-Secret}";
        var content = new StringContent(body, Encoding.UTF8, ContentType);

        using (var response = await Http.PostAsync(TokenUrl, content))
        {
            var responseContent = await response.Content.ReadAsStringAsync();
            return response.IsSuccessStatusCode
                ? JsonConvert.DeserializeObject<AzureAdTokenResponse>(responseContent)
                : throw new AzureAdTokenApiException(
                    JsonConvert.DeserializeObject<AzureAdErrorResponse>(responseContent));
        }
    }
}

The AzureAdTokenResponse and AzureAdErrorResponse classes used by JsonConvert:

[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class AzureAdTokenResponse
{
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "token_type", Required = Required.Default)]
    public string TokenType { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "expires_in", Required = Required.Default)]
    public int ExpiresIn { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "expires_on", Required = Required.Default)]
    public string ExpiresOn { get; set; } 
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "resource", Required = Required.Default)]
    public string Resource { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "access_token", Required = Required.Default)]
    public string AccessToken { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "refresh_token", Required = Required.Default)]
    public string RefreshToken { get; set; }
}

[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class AzureAdErrorResponse
{
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error", Required = Required.Default)]
    public string Error { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error_description", Required = Required.Default)]
    public string ErrorDescription { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error_codes", Required = Required.Default)]
    public int[] ErrorCodes { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "timestamp", Required = Required.Default)]
    public string Timestamp { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "trace_id", Required = Required.Default)]
    public string TraceId { get; set; }
    [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "correlation_id", Required = Required.Default)]
    public string CorrelationId { get; set; }
}

public class AzureAdTokenApiException : Exception
{
    public AzureAdErrorResponse Error { get; }

    public AzureAdTokenApiException(AzureAdErrorResponse error) :
        base($"{error.Error} {error.ErrorDescription}")
    {
        Error = error;
    }
}

Finally, my modifications to Startup.cs to refresh the access_token (Based on the answer I linked above)

        // Configure the OWIN pipeline to use cookie auth.
        app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            Events = new CookieAuthenticationEvents
            {
                OnValidatePrincipal = OnValidatePrincipal
            },
        });

The OnValidatePrincipal handler in Startup.cs (Again, from the linked answer above):

    private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
    {
        if (context.Properties.Items.ContainsKey(".Token.expires_at"))
        {
            if (!DateTime.TryParse(context.Properties.Items[".Token.expires_at"], out var expiresAt))
            {
                expiresAt = DateTime.Now;
            }

            if (expiresAt < DateTime.Now.AddMinutes(-5))
            {
                var refreshToken = context.Properties.Items[".Token.refresh_token"];
                var refreshTokenService = new AzureAdRefreshTokenService();
                var response = await refreshTokenService.RefreshAccessTokenAsync(refreshToken);

                context.Properties.Items[".Token.access_token"] = response.AccessToken;
                context.Properties.Items[".Token.refresh_token"] = response.RefreshToken;
                context.Properties.Items[".Token.expires_at"] = DateTime.Now.AddSeconds(response.ExpiresIn).ToString(CultureInfo.InvariantCulture);
                context.ShouldRenew = true;
            }
        }
    }

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 that resource 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)

Ben Cottrell
  • 5,741
  • 1
  • 27
  • 34
  • 1
    TokenCache is necessary under certain circumstances. If there is a refresh token it's used to obtain a new access token. – davidcarr May 12 '18 at 22:18
  • Also consider setting the resource property under the connection options to create token for a specific resource. – davidcarr May 12 '18 at 22:29
  • @davidcarr May I ask whether you're aware of any good examples using TokenCache with v2.0 of the Azure AD endpoint? v2.0 seemed to be still something of a work-in-progress at the time I posted this answer. The impression I had is that AcquireTokenAsync didn't work with v2.0. Also, with regards to the `resource` property, v2.0 kept returning me an error stating that the resource property wasn't supported. Quite honestly, I feel that my answer here is "hacky" and unnecessarily complex, but after weeks of googling and trial-and-error I couldn't find anything better. – Ben Cottrell May 13 '18 at 13:03
  • 1
    Nice article here. https://dzimchuk.net/adal-distributed-token-cache-in-asp-net-core/ – davidcarr May 14 '18 at 18:50
  • 1
    Also asp.net core also allows implicit flow (id_token token) so front channel is an option but again token refresh will be needed unless you can initiate a login flow everytime. – davidcarr May 14 '18 at 19:07
  • 1
    yeah direct comparison new world utilizes scopes https://learn.microsoft.com/en-gb/azure/active-directory/develop/active-directory-v2-compare – davidcarr May 14 '18 at 19:38
  • 1
    @davidcarr Much appreciated! Thanks for the links! – Ben Cottrell May 15 '18 at 23:01
  • 6
    Oh my god, I'm not alone! ALL the Microsoft samples on this stuff are unnecessarily obfuscated, which makes learning how to do something simple incredibly painful. – Scuba Steve Jul 25 '18 at 18:06
  • 1
    See here for changes to CookieAuthentication: https://github.com/aspnet/Security/issues/1310 – shlgug Sep 14 '18 at 07:09