Hi!
Here is the situation:
I have one MVC5 application with Identity2 on iis7 wich serves multiple web sites.
host name is the key for certain web site.
site.com,
anothersite.com
and so on
i've decided to use external login with google on all my sites and every site should be google client with personal clientid and clientsecret.
for example:
site.com - clientid=123123, clientsecret=xxxaaabbb
anothersite.com - clientid=890890, clientsecret=zzzqqqeee
but there is a little problem --
AuthenticationOptions
are set at the start of application and i did'n find any way to replace it at runtime.
so, after reading Creating Custom OAuth Middleware for MVC 5
and Writing an Owin Authentication Middleware
i've realized that i should override AuthenticationHandler.ApplyResponseChallengeAsync()
and put this piece of code in the begining of this method:
Options.ClientId = OAuth2Helper.GetProviderAppId("google");
Options.ClientSecret = OAuth2Helper.GetProviderAppSecret("google");
i've decided to use only google, so we will talk about google middleware.
AuthenticationHandler
are returned byAuthenticationMiddleWare.CreateHandler()
and in my case they areGoogleOAuth2AuthenticationHandler
andGoogleOAuth2AuthenticationMiddleware
.
I've foundGoogleOAuth2AuthenticationMiddleware
at the http://katanaproject.codeplex.com/ and take it in my project like thispublic class GoogleAuthenticationMiddlewareExtended : GoogleOAuth2AuthenticationMiddleware { private readonly ILogger _logger; private readonly HttpClient _httpClient; public GoogleAuthenticationMiddlewareExtended( OwinMiddleware next, IAppBuilder app, GoogleOAuth2AuthenticationOptions options) : base(next, app, options) { _logger = app.CreateLogger<GoogleOAuth2AuthenticationMiddleware>(); _httpClient = new HttpClient(ResolveHttpMessageHandler(Options)); _httpClient.Timeout = Options.BackchannelTimeout; _httpClient.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB } protected override AuthenticationHandler<GoogleOAuth2AuthenticationOptions> CreateHandler() { return new GoogleOAuth2AuthenticationHandlerExtended(_httpClient, _logger); } private static HttpMessageHandler ResolveHttpMessageHandler(GoogleOAuth2AuthenticationOptions options) { HttpMessageHandler handler = options.BackchannelHttpHandler ?? new WebRequestHandler(); // If they provided a validator, apply it or fail. if (options.BackchannelCertificateValidator != null) { // Set the cert validate callback var webRequestHandler = handler as WebRequestHandler; if (webRequestHandler == null) { throw new InvalidOperationException("Exception_ValidatorHandlerMismatch"); } webRequestHandler.ServerCertificateValidationCallback = options.BackchannelCertificateValidator.Validate; } return handler; } }
then i've create my own Handler with modified ApplyResponseChallengeAsync. i've got a bad news at this point -
GoogleOAuth2AuthenticationHandler
is internal and i had to take it entirely and put in my project like this (again katanaproject.codeplex.com)public class GoogleOAuth2AuthenticationHandlerExtended : AuthenticationHandler<GoogleOAuth2AuthenticationOptions> { private const string TokenEndpoint = "https://accounts.google.com/o/oauth2/token"; private const string UserInfoEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo?access_token="; private const string AuthorizeEndpoint = "https://accounts.google.com/o/oauth2/auth"; private readonly ILogger _logger; private readonly HttpClient _httpClient; public GoogleOAuth2AuthenticationHandlerExtended(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; _logger = logger; } // i've got some surpises here protected override async Task<AuthenticationTicket> AuthenticateCoreAsync() { AuthenticationProperties properties = null; try { string code = null; string state = null; IReadableStringCollection query = Request.Query; IList<string> values = query.GetValues("code"); if (values != null && values.Count == 1) { code = values[0]; } values = query.GetValues("state"); if (values != null && values.Count == 1) { state = values[0]; } properties = Options.StateDataFormat.Unprotect(state); if (properties == null) { return null; } // OAuth2 10.12 CSRF if (!ValidateCorrelationId(properties, _logger)) { return new AuthenticationTicket(null, properties); } string requestPrefix = Request.Scheme + "://" + Request.Host; string redirectUri = requestPrefix + Request.PathBase + Options.CallbackPath; // Build up the body for the token request var body = new List<KeyValuePair<string, string>>(); body.Add(new KeyValuePair<string, string>("grant_type", "authorization_code")); body.Add(new KeyValuePair<string, string>("code", code)); body.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri)); body.Add(new KeyValuePair<string, string>("client_id", Options.ClientId)); body.Add(new KeyValuePair<string, string>("client_secret", Options.ClientSecret)); // Request the token HttpResponseMessage tokenResponse = await _httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(body)); tokenResponse.EnsureSuccessStatusCode(); string text = await tokenResponse.Content.ReadAsStringAsync(); // Deserializes the token response JObject response = JObject.Parse(text); string accessToken = response.Value<string>("access_token"); string expires = response.Value<string>("expires_in"); string refreshToken = response.Value<string>("refresh_token"); if (string.IsNullOrWhiteSpace(accessToken)) { _logger.WriteWarning("Access token was not found"); return new AuthenticationTicket(null, properties); } // Get the Google user HttpResponseMessage graphResponse = await _httpClient.GetAsync( UserInfoEndpoint + Uri.EscapeDataString(accessToken), Request.CallCancelled); graphResponse.EnsureSuccessStatusCode(); // i will show content of this var later text = await graphResponse.Content.ReadAsStringAsync(); JObject user = JObject.Parse(text); //because of permanent exception in GoogleOAuth2AuthenticatedContext constructor i prepare user data with my extension JObject correctUser = OAuth2Helper.PrepareGoogleUserInfo(user); // i've replaced this with selfprepared user2 //var context = new GoogleOAuth2AuthenticatedContext(Context, user, accessToken, refreshToken, expires); var context = new GoogleOAuth2AuthenticatedContext(Context, correctUser, accessToken, refreshToken, expires); context.Identity = new ClaimsIdentity( Options.AuthenticationType, ClaimsIdentity.DefaultNameClaimType, ClaimsIdentity.DefaultRoleClaimType); if (!string.IsNullOrEmpty(context.Id)) { context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, context.Id, ClaimValueTypes.String, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.GivenName)) { context.Identity.AddClaim(new Claim(ClaimTypes.GivenName, context.GivenName, ClaimValueTypes.String, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.FamilyName)) { context.Identity.AddClaim(new Claim(ClaimTypes.Surname, context.FamilyName, ClaimValueTypes.String, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Name)) { context.Identity.AddClaim(new Claim(ClaimTypes.Name, context.Name, ClaimValueTypes.String, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Email)) { context.Identity.AddClaim(new Claim(ClaimTypes.Email, context.Email, ClaimValueTypes.String, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Profile)) { context.Identity.AddClaim(new Claim("urn:google:profile", context.Profile, ClaimValueTypes.String, Options.AuthenticationType)); } context.Properties = properties; await Options.Provider.Authenticated(context); return new AuthenticationTicket(context.Identity, context.Properties); } catch (Exception ex) { _logger.WriteError("Authentication failed", ex); return new AuthenticationTicket(null, properties); } } protected override Task ApplyResponseChallengeAsync() { // finaly! here it is. i just want to put this two lines here. thats all Options.ClientId = OAuth2Helper.GetProviderAppId("google"); Options.ClientSecret = OAuth2Helper.GetProviderAppSecret("google"); /* default code ot the method */ } // no changes public override async Task<bool> InvokeAsync() { /* default code here */ } // no changes private async Task<bool> InvokeReplyPathAsync() { /* default code here */ } // no changes private static void AddQueryString(IDictionary<string, string> queryStrings, AuthenticationProperties properties, string name, string defaultValue = null) { /* default code here */ } }
After all i get some surprises.
after myhost/signin-google i get myhost/Account/ExternalLoginCallback?error=access_denied and 302 redirect back to login page with no success.
that is because of few Exception in internal methods ofGoogleOAuth2AuthenticatedContext
constructor.GivenName = TryGetValue(user, "name", "givenName"); FamilyName = TryGetValue(user, "name", "familyName");
and
Email = TryGetFirstValue(user, "emails", "value");
and here is the google response which we translate to JObject user
{
"sub": "XXXXXXXXXXXXXXXXXX",
"name": "John Smith",
"given_name": "John",
"family_name": "Smith",
"profile": "https://plus.google.com/XXXXXXXXXXXXXXXXXX",
"picture": "https://lh5.googleusercontent.com/url-to-the-picture/photo.jpg",
"email": "usermail@domain.com",
"email_verified": true,
"gender": "male",
"locale": "ru",
"hd": "google application website"
}
name
is string and TryGetValue(user, "name", "givenName")
will fail as TryGetValue(user, "name", "familyName")
emails
is missed
thats why i used helper wich translate user to correct correctUser
- correctUser is ok but i still have no success. why? after myhost/signin-google i get myhost/Account/ExternalLoginCallback and 302 redirect back to login page with no success.
id
in google response is actualy sub
so
• Id property of AuthenticatedContext is not filled
• ClaimTypes.NameIdentifier
never created
• AccountController.ExternalLoginCallback(string returnUrl) will always redirect us because of loginInfo is null
GetExternalLoginInfo takes AuthenticateResult wich should not be null
and it checks result.Identity
for ClaimTypes.NameIdentifier existence
renaming sub
into id
do the work.
now everything is ok.
it seems that microsoft implementation of katana differs from katana source because if i use default everything is work without any magic.
if you can correct me, if you know more easiest way to make owin work with AuthenticationOptions determined at runtime based on host name, please tell me
I've recently battled with trying to get multi-tennancy working with the same OAuth provider but with different accounts. I know you wanted to update the options dynamically at runtime but you might not need to do that, hopefully this helps...
I think the reason that you don't have this working, even with overriding all of those classes is because each configured google OAuth account needs to have a unique CallbackPath. This is what determines which registered provider and options will execute on the callback.
Instead of trying to do this dynamically, you can declare each OAuth provider at startup and ensure they have unique AuthenticationType and unique CallbackPath, for example:
Then where you are calling
IOwinContext.Authentication.Challenge
you make sure to pass it your correctly named AuthenticationType for the current tenant you want to authenticate. Example:HttpContext.GetOwinContext().Authentication.Challenge(properties, "Google-AnotherSite.com");
The next step is to update your callback path in Google's Developers Console to match your custom callback paths. By default it is "signin-google" but each of these needs to be unique among your declared providers so that the provider knows it needs to handle the specific callback on that path.
I actually just blogged about all of this here in more detail: http://shazwazza.com/post/configuring-aspnet-identity-oauth-login-providers-for-multi-tenancy/