Using some code gleaned from here, I've come up with a rough implementation.
Here is a short summary of what is happening:
- I use the Cordova GooglePlus plugin to log the user in at the client-side. This will supply us with an OAuth access token.
- I have a new method on my AccountController which I have called 'RegisterExternalToken'. I make a call to this function from my mobile application, and I supply the access token.
- The 'RegisterExternalToken' method will validate the access token by calling the following endpoint: https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=XYZ123
- The tokeninfo endpoint returns a HTTP 200 response containing the details of the user profile. I check this then add an identity claim.
- I check with the ASP.Net Identity UserManager to see if the user is registered already. If not, I register and create a new account. Otherwise, I just sign the user in.
- Much like the existing ASP.NET Identity 'GrantResourceOwnerCredentials' method on the /Token endpoint, I then generate a new access token and return it in a JSON response object which mirrors the object that gets returned via the ASP.NET /token endpoint.
At the client-side, I parse the JSON to retrieve the access token the same way I do for a normal non-external login and supply this access token as the bearer token in the header of all subsequent authenticated requests. I also needed to decorate each of my API controllers with the following attributes:
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[HostAuthentication(DefaultAuthenticationTypes.ApplicationCookie)]
AccountController.cs
// POST /api/Account/RegisterExternalToken
[OverrideAuthentication]
[AllowAnonymous]
[Route("RegisterExternalToken")]
public async Task<IHttpActionResult> RegisterExternalToken(RegisterExternalTokenBindingModel model)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var externalLogin = await ExternalLoginData.FromToken(model.Provider, model.Token);
if (externalLogin == null) return InternalServerError();
if (externalLogin.LoginProvider != model.Provider)
{
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
return InternalServerError();
}
var user = await UserManager.FindAsync(new UserLoginInfo(externalLogin.LoginProvider,
externalLogin.ProviderKey));
var hasRegistered = user != null;
ClaimsIdentity identity;
if (hasRegistered)
{
identity = await UserManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
var claims = externalLogin.GetClaims();
identity.AddClaims(claims);
Authentication.SignIn(identity);
}
else
{
user = new ApplicationUser
{
Id = Guid.NewGuid().ToString(),
UserName = model.Email,
Email = model.Email
};
var result = await UserManager.CreateAsync(user);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
// Specific to my own app, I am generating a new customer account for a newly registered user
await CreateCustomer(user);
var info = new ExternalLoginInfo
{
DefaultUserName = model.Email,
Login = new UserLoginInfo(model.Provider, externalLogin.ProviderKey)
};
result = await UserManager.AddLoginAsync(user.Id, info.Login);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
identity = await UserManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
var claims = externalLogin.GetClaims();
identity.AddClaims(claims);
Authentication.SignIn(identity);
}
var authenticationProperties = ApplicationOAuthProvider.CreateProperties(model.Email);
var authenticationTicket = new AuthenticationTicket(identity, authenticationProperties);
var currentUtc = new SystemClock().UtcNow;
authenticationTicket.Properties.IssuedUtc = currentUtc;
authenticationTicket.Properties.ExpiresUtc = currentUtc.Add(TimeSpan.FromDays(365));
var accessToken = Startup.OAuthOptions.AccessTokenFormat.Protect(authenticationTicket);
Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
// Generate JSON response object
var token = new JObject(
new JProperty("userName", user.UserName),
new JProperty("id", user.Id),
new JProperty("access_token", accessToken),
new JProperty("token_type", "bearer"),
new JProperty("expires_in", TimeSpan.FromDays(365).TotalSeconds.ToString()),
new JProperty(".issued", currentUtc.ToString("ddd, dd MMM yyyy HH':'mm':'ss 'GMT'")),
new JProperty(".expires", currentUtc.Add(TimeSpan.FromDays(365)).ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'"))
);
return Ok(token);
}
catch (Exception e)
{
return BadRequest("Unable to login due to unspecified error.");
}
ExternalLoginData.cs - (I moved the original version of this from the AccountController.cs to it's own separate file)
public class ExternalLoginData
{
public string LoginProvider { get; set; }
public string ProviderKey { get; set; }
public string UserName { get; set; }
public IList<Claim> GetClaims()
{
IList<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, ProviderKey, null, LoginProvider));
if (UserName != null)
{
claims.Add(new Claim(ClaimTypes.Name, UserName, null, LoginProvider));
}
return claims;
}
public static ExternalLoginData FromIdentity(ClaimsIdentity identity)
{
var providerKeyClaim = identity?.FindFirst(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(providerKeyClaim?.Issuer) || string.IsNullOrEmpty(providerKeyClaim.Value))
{
return null;
}
if (providerKeyClaim.Issuer == ClaimsIdentity.DefaultIssuer)
{
return null;
}
return new ExternalLoginData
{
LoginProvider = providerKeyClaim.Issuer,
ProviderKey = providerKeyClaim.Value,
UserName = identity.FindFirstValue(ClaimTypes.Name)
};
}
public static async Task<ExternalLoginData> FromToken(string provider, string accessToken)
{
string verifyTokenEndPoint = "";
string verifyAppEndPoint = "";
if (provider == "Google")
{
verifyTokenEndPoint = $"https://www.googleapis.com/oauth2/v3/tokeninfo?access_token={accessToken}";
}
else
{
return null;
}
var client = new HttpClient();
var uri = new Uri(verifyTokenEndPoint);
var response = await client.GetAsync(uri);
ClaimsIdentity identity = null;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
dynamic verifyAppJsonObject = (JObject) JsonConvert.DeserializeObject(content);
identity = new ClaimsIdentity(OAuthDefaults.AuthenticationType);
if (provider == "Google")
{
// TODO: Verify contents of verifyAppJsonObject
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, Startup.GoogleClientId, ClaimValueTypes.String, "Google", "Google"));
}
}
var providerKeyClaim = identity?.FindFirst(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(providerKeyClaim?.Issuer) || string.IsNullOrEmpty(providerKeyClaim.Value))
{
return null;
}
if (providerKeyClaim.Issuer == ClaimsIdentity.DefaultIssuer)
{
return null;
}
return new ExternalLoginData
{
LoginProvider = providerKeyClaim.Issuer,
ProviderKey = providerKeyClaim.Value,
UserName = identity.FindFirstValue(ClaimTypes.Name)
};
}
}
In the above code, Startup.GoogleClientId is just the string value of the Google Client ID used here:
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
ClientId = GoogleClientId,
ClientSecret = "####"
});
Client side in my Ionic app I am calling the method like so:
loginWithGoogle(socialLogin : RegisterExternalTokenBindingModel){
return new Promise((resolve, reject) => {
this.http.post(
`${this.baseUrl}/api/Account/RegisterExternalToken`,
socialLogin,
new RequestOptions()
).subscribe(
result => {
resolve(result.json());
},
error => {
console.log("Login error: "+ error.text());
}
)
})
}
Here I just parse the access token and set the value in my UserAccountService class and save it to localStorage as well:
loginWithGoogle(socialLogin : RegisterExternalTokenBindingModel){
return this.apiService.loginWithGoogle(socialLogin)
.then(
success => {
let accessToken = JsonPath.query(success, 'access_token');
this.accessToken = accessToken;
this.storage.set(this.storageAccessToken, this.accessToken);
return new LoginResult(true, accessToken);
},
failure => {
// TODO: Error handling
}
);
}