So recently I started looking at using WebAPI2, and well, the documentation on what's really going on here, sucks. My goal here is to allow a user to log-in via OAuth2, pull the access token and the refresh token and handle them safely. This post is really just a place for me to take notes as I dig into this.
Firstly, this is none trivial in Microsoft's implementation. After digging into this, I must ask if they even thought about how this would be done. From what I can tell, they are expecting that if you want to get extra information from a provider that you do it in the OnAuthenticated method on the AuthenticationProvider, and then add it to the claim. And if that's all you need, by all means, do that.
So given that bit of info we know we can add the extra parameters to the Challenge request. We do need to be careful here, while Google is fairly liberal with their tokens and letting the user do stuff, but they do have limits to there generosity.
Per the Google OpenID Connect Documentation:
So we need to address both considerations. My solution? Well, don't specify the offline requirements in every request. Only when we link the account do we ask for the refresh token, by setting access_type=offline & prompt=consent Google will ask the user for consent again and then will return you an updated refresh token even if it's not the first time you have asked for one. NICE.
I have seen a few options out there. All of the ones I found start with customizing the OnAuthenticated method on Authentication Options. So I too started there
Option 1 is to set a property on the Authentication Context
Option 2 is to add a claim
So how do we pick one over the other? To answer that, let's look at the GoogleOAuth2AuthenticationHandler class it's self. (Thank you Microsoft for opening the source up to everyone!)
WARNING: GOING DEEP INTO KATANA SOURCE CODE
The method we are interested in here is AuthenticateCoreAsync(), some interesting bits in here.
Ultimately the interesting stuff for AuthenticationResponseGrant ends up in Microsoft.Owin.Security.Cookies.CookieAuthenticationHandler.ApplyResponseGrantAsync() from a Helper class the does "LookupSignIn", (Like I said, it's some crazy hoops to get here and really not relevant to this post, so I am going to skip that part.)
So now we can see where we are storing what and if it's protected or not. Here is the part of ApplyResponseGrantAsync() we are most interested in:
We are going to call into any custom session store that exists and put everything in there, and so if you added a token to your claim or to the properties, then it's sent to your session store and if you are not properly protecting that, it is ripe for the taking.
Part two: string cookieValue = Options.TicketDataFormat.Protect(model);
So two things that can be here, one we didn't have a session store so now we are putting the entire AuthenticationTicket into the cookie, but at least it's encrypted with DPAPI and should be fairly safe. But what we see is that it doesn't matter were we put the Tokens, it's returned into the cookie.
Second option, if you have a session store, we are only putting the session key down to the client, so no sensitive data is even sent. (NOTE: this is new in version 3.0.0 of Katana)
Firstly, this is none trivial in Microsoft's implementation. After digging into this, I must ask if they even thought about how this would be done. From what I can tell, they are expecting that if you want to get extra information from a provider that you do it in the OnAuthenticated method on the AuthenticationProvider, and then add it to the claim. And if that's all you need, by all means, do that.
Step one: Requesting the Token
For Google we need the include the access type of offline in our request. It was talked about on the Katana Project at GoogleOAuth2Authentication never really get RefreshTokenSo given that bit of info we know we can add the extra parameters to the Challenge request. We do need to be careful here, while Google is fairly liberal with their tokens and letting the user do stuff, but they do have limits to there generosity.
Per the Google OpenID Connect Documentation:
- Be sure to store the refresh token safely and permanently, because you can only obtain a refresh token the first time that you perform the code exchange flow.
- There are limits on the number of refresh token that are issued—one limit per client/user combination, and another per user across all clients. If your application requests too many refresh tokens, it may run into these limits, in which case older refresh tokens stop working.
So we need to address both considerations. My solution? Well, don't specify the offline requirements in every request. Only when we link the account do we ask for the refresh token, by setting access_type=offline & prompt=consent Google will ask the user for consent again and then will return you an updated refresh token even if it's not the first time you have asked for one. NICE.
Step two: Getting the Token
Sweet, Google is returning a refresh token, but how do I get it?I have seen a few options out there. All of the ones I found start with customizing the OnAuthenticated method on Authentication Options. So I too started there
var googleOAuth2AuthenticationOptions = new GoogleOAuth2AuthenticationOptions { ClientId = "YOUR CLIENTID", ClientSecret = "YOUR SECRET", Provider = new GoogleOAuth2AuthenticationProvider { OnAuthenticated = OnAuthenticated } }; googleOAuth2AuthenticationOptions.Scope.Add("https://www.googleapis.com/auth/contacts.readonly"); googleOAuth2AuthenticationOptions.Scope.Add("https://www.googleapis.com/auth/userinfo.profile"); app.UseGoogleAuthentication(googleOAuth2AuthenticationOptions);So now what? Now we need to create the OnAuthenticated method.
Option 1 is to set a property on the Authentication Context
private static Task OnAuthenticated(GoogleOAuth2AuthenticatedContext context) { context.Properties.Dictionary["access_token"] = context.AccessToken; context.Properties.Dictionary["refresh_token"] = context.RefreshToken; return Task.FromResult<object>(null); }
Option 2 is to add a claim
private static Task OnAuthenticated(GoogleOAuth2AuthenticatedContext context) { if (!string.IsNullOrEmpty(context.AccessToken)) { context.Identity.AddClaim(new Claim("access_token", context.AccessToken, ClaimValueTypes.String)); } if (!string.IsNullOrEmpty(context.RefreshToken)) { context.Identity.AddClaim(new Claim("refresh_token", context.RefreshToken, ClaimValueTypes.String)); } return Task.FromResult<object>(null); }
So how do we pick one over the other? To answer that, let's look at the GoogleOAuth2AuthenticationHandler class it's self. (Thank you Microsoft for opening the source up to everyone!)
WARNING: GOING DEEP INTO KATANA SOURCE CODE
The method we are interested in here is AuthenticateCoreAsync(), some interesting bits in here.
- The properties dictionary is stored in the "state" attribute that is sent to Google in the authentication request.
- thankfully it's encrypted with DpapiDataProtector, CallDataProtectionProvider or a custom data protector that you have setup, which one is picked that is for another post on another day.
- At the very end of the method you see a call to "await Options.Provider.Authenticated(context);" That's where our provider method we declared above is called.
- From there an AuthenticationTicket is returned with the Identity (i.e. the claim we built) and the properties dictionary.
One of the places that our Handler's AuthenticateAsync() method is called from is Microsoft.Owin.Security.Infrastructure.OwinRequestExtensions.
public async Task AuthenticateAsync(string[] authenticationTypes, AuthenticateCallback callback, object state) { if (authenticationTypes == null) { callback(null, null, _handler.BaseOptions.Description.Properties, state); } else if (authenticationTypes.Contains(_handler.BaseOptions.AuthenticationType, StringComparer.Ordinal)) { AuthenticationTicket ticket = await _handler.AuthenticateAsync(); if (ticket != null && ticket.Identity != null) { callback(ticket.Identity, ticket.Properties.Dictionary, _handler.BaseOptions.Description.Properties, state); } } if (Chained != null) { await Chained(authenticationTypes, callback, state); } }In here we see both the Identity and the Properties dictionary are passed to a call back delegate. So what is this call back? Now we are getting really deep, this Hook class is added to the Owin "security.Authenticate" Environment, we pick this guy back up in Microsoft.Owin.Security.AuthenticationManager where we find our callback in "AuthenticateAsyncCallback" here the identity and the properties are added to a list of "AuthenticateResult" via the state object and returned from the AuthenticateSync() method.
So we just went on a nice little expedition, but what did we learn?
- We now know how Katana invokes the Authentication Handlers
- We now know that the AuthenticationTicket we return from our AuthenticationHandler is mapped to a Microsoft.Owin.Security.AuthenticateResult for anyone calling one of the AuthenticateAsync methods on AuthenticationManager. (this is important, and comes back to us later)
What we do know is the basics of the authentication flow.
- User makes a request to our site, we see they are not authenticated and so we return something like this
private class ChallengeResult : HttpUnauthorizedResult { public ChallengeResult(string provider, string redirectUri) { LoginProvider = provider; RedirectUri = redirectUri; } public string LoginProvider { get; private set; } public string RedirectUri { get; private set; } public override void ExecuteResult(ControllerContext context) { var properties = new AuthenticationProperties() { RedirectUri = RedirectUri }; context.HttpContext.GetOwinContext().Authentication.Challenge(properties, LoginProvider); } }
- That will cause the system to redirect the user, the redirect is triggered by the call to Authentication.Challenge() above, and is ultimately handled in our AuthenticationHandler from ApplyResponseChallengeAsync()
- We redirect the user to Google in my use case
- They do their authentication and return back to... well, more correctly the request is handled by Microsoft.Owin.Security.Google.GoogleOAuth2AuthenticationMiddleware. We registered the middleware when we called "app.UseGoogleAuthentication(googleOAuth2AuthenticationOptions);" in our startup class.
private async Task InvokeReplyPathAsync() { ... AuthenticationTicket ticket = await AuthenticateAsync(); if (ticket == null) { _logger.WriteWarning("Invalid return state, unable to redirect."); Response.StatusCode = 500; return true; } ... if (context.SignInAsAuthenticationType != null && context.Identity != null) { ClaimsIdentity grantIdentity = context.Identity; if (!string.Equals(grantIdentity.AuthenticationType, context.SignInAsAuthenticationType, StringComparison.Ordinal)) { grantIdentity = new ClaimsIdentity(grantIdentity.Claims, context.SignInAsAuthenticationType, grantIdentity.NameClaimType, grantIdentity.RoleClaimType); } Context.Authentication.SignIn(context.Properties, grantIdentity); } ... }Oh, and look in there, we are calling AuthenticatAsync(), now we are getting somewhere, and then sure thing, we found the call to Authentication.SignIn(). Inside of AuthenticationManager.Signin() we are storing the properties and identity on a property called "AuthenticationResponseGrant" that through some hoops ends up on the Owin Environment dictionary.
NOTE: see all the calls to context.SignInAsAuthenticationType? If you dig into app.UseExternalSignInCookie(); you will see a line app.SetDefaultSignInAsAuthenticationType(DefaultAuthenticationTypes.ExternalCookie); well, this is where that comes into play. If you change this, GoogleOAuth2AuthenticationHandler will write it's session to what ever type is specified.
Ultimately the interesting stuff for AuthenticationResponseGrant ends up in Microsoft.Owin.Security.Cookies.CookieAuthenticationHandler.ApplyResponseGrantAsync() from a Helper class the does "LookupSignIn", (Like I said, it's some crazy hoops to get here and really not relevant to this post, so I am going to skip that part.)
So now we can see where we are storing what and if it's protected or not. Here is the part of ApplyResponseGrantAsync() we are most interested in:
protected override async Task ApplyResponseGrantAsync() { AuthenticationResponseGrant signin = Helper.LookupSignIn(Options.AuthenticationType); ... var signInContext = new CookieResponseSignInContext( Context, Options, Options.AuthenticationType, signin.Identity, signin.Properties, cookieOptions); ... model = new AuthenticationTicket(signInContext.Identity, signInContext.Properties); if (Options.SessionStore != null) { if (_sessionKey != null) { await Options.SessionStore.RemoveAsync(_sessionKey); } _sessionKey = await Options.SessionStore.StoreAsync(model); ClaimsIdentity identity = new ClaimsIdentity( new[] { new Claim(SessionIdClaim, _sessionKey) }, Options.AuthenticationType); model = new AuthenticationTicket(identity, null); } string cookieValue = Options.TicketDataFormat.Protect(model); Options.CookieManager.AppendResponseCookie( Context, Options.CookieName, cookieValue, signInContext.CookieOptions); ... }Part one: if (Options.SessionStore != null)
We are going to call into any custom session store that exists and put everything in there, and so if you added a token to your claim or to the properties, then it's sent to your session store and if you are not properly protecting that, it is ripe for the taking.
Part two: string cookieValue = Options.TicketDataFormat.Protect(model);
So two things that can be here, one we didn't have a session store so now we are putting the entire AuthenticationTicket into the cookie, but at least it's encrypted with DPAPI and should be fairly safe. But what we see is that it doesn't matter were we put the Tokens, it's returned into the cookie.
Second option, if you have a session store, we are only putting the session key down to the client, so no sensitive data is even sent. (NOTE: this is new in version 3.0.0 of Katana)
Conclusion:
If you need to expose the Tokens beyond the scope of the AuthenticationProvider, from the data storage perspective it doesn't make a big difference if you put them in the properties or the claim. So then you have to ask yourself, what am I going to do with the claim?