Reputation: 313
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 by AuthenticationMiddleWare.CreateHandler()
and in my case they are GoogleOAuth2AuthenticationHandler
and GoogleOAuth2AuthenticationMiddleware
.
I've found GoogleOAuth2AuthenticationMiddleware
at the http://katanaproject.codeplex.com/
and take it in my project like this
public 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 of GoogleOAuth2AuthenticatedContext
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": "[email protected]",
"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
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
Upvotes: 3
Views: 3244
Reputation: 811
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:
//Provider #1
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
AuthenticationType = "Google-Site.Com",
ClientId = "abcdef...",
ClientSecret = "zyxwv....",
CallbackPath = new PathString("/sitecom-signin-google")
});
//Provider #2
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
AuthenticationType = "Google-AnotherSite.com",
ClientId = "abcdef...",
ClientSecret = "zyxwv....",
CallbackPath = new PathString("/anothersitecom-signin-google")
});
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/
Upvotes: 2