There are plenty of articles and documentations online on how to implement OAuth2 External Authentication (w/LinkedIn, Google, Facebook, etc.) within ASP.NET API application. It’s always better to understand the fundamentals and see/do it yourself. Speaking of fundamentals, I am going to refer you to Anders Abel’s article- Understanding the Owin External Authentication Pipeline. Today, I would focus on the parts and pieces those make the OWIN External Authentication Middleware and how they (events) are connected each other. You are welcome to see the demo of this solution at azure.aspnet4you.com.
Where do we start? That’s the good question! Well, API would be consumed by one or more end user applications. In my case, it is azure.aspnet4you.com that is built on Angular 5. It’s a natural choice to start the API conversation from Angular 5 app. We are going to use logged-in user’s identity, external identity with LinkedIn as OAuth2 provider, to protect our API. So, where do we start? I guess, there should be a Login (External Login to be precise) button that would trigger the flow!
A lot of examples and JavaScript or TypeScript packages are around for implementing OAuth2 in Angular but we are going to take a different approach. We are going to take the user to ASP.NET server side application and start the OAuth2 flow from there. Why so? We don’t have to worry about hiding client’s secrete at the Angular (no place to hide there anyway!) and we can better manage the flow at the server-side. Let’s look at the flow diagram to see the communications among the OAuth2 participants-
As promised, we are going to let the user click “Sign in with LinkedIn” button at the Angular5 UI! For this exercise, I have removed local login and registration features in ASP.NET to keep the solution simple. So, no fear, you can click on Sign in with LinkedIn button! We are not going to persist user’s claims in the Azure Tables which is what I am using for Storage.
The action will direct (popup) the user to ExternalLogin action on AccountController in the ASP.NET server-side with a return url. We need to provide a return url so that ASP.Net can come back to Angular with a token after successful authentication. You can see the code behind of the action here-
ExternalLogin:
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public ActionResult ExternalLogin(string provider, string returnUrl) { // Request a redirect to the external login provider return new ChallengeResult(provider, Url.Action("ExternalLoginCallback", "Account", new { ReturnUrl = returnUrl })); }
ExternalLogin will hookup the external provider by invoking ExecuteResult method on ChallengeResult (a private class at the AccountController).
ChallengeResult
private class ChallengeResult : HttpUnauthorizedResult { public ChallengeResult(string provider, string redirectUri) : this(provider, redirectUri, null) { } public ChallengeResult(string provider, string redirectUri, string userId) { LoginProvider = provider; RedirectUri = redirectUri; UserId = userId; } public string LoginProvider { get; set; } public string RedirectUri { get; set; } public string UserId { get; set; } public override void ExecuteResult(ControllerContext context) { var properties = new AuthenticationProperties() { RedirectUri = RedirectUri }; if (UserId != null) { properties.Dictionary[XsrfKey] = UserId; } context.HttpContext.GetOwinContext().Authentication.Challenge(properties, LoginProvider); } } <div></div>
The first method to invoke in the OWIN External Authentication Middleware (LinkedInAuthenticationHandler) is ApplyResponseChallengeAsync. This method is invoked each time but it is activated only when status code is 401 and AuthenticationResponseChallenge for the authentication type of the current middleware (LinkedIn). Methods/events in the pipeline don’t call each other directly. They are invoked by the Authentication Middleware.
ApplyResponseChallengeAsync:
protected override Task ApplyResponseChallengeAsync() { if (Response.StatusCode != 401) { return Task.FromResult<object>(null); } var challenge = Helper.LookupChallenge(Options.AuthenticationType, Options.AuthenticationMode); if (challenge == null) return Task.FromResult<object>(null); var baseUri = Request.Scheme + Uri.SchemeDelimiter + this.GetHostName() + Request.PathBase; var currentUri = baseUri + Request.Path + Request.QueryString; var redirectUri = baseUri + Options.CallbackPath; var properties = challenge.Properties; if (string.IsNullOrEmpty(properties.RedirectUri)) { properties.RedirectUri = currentUri; } // OAuth2 10.12 CSRF GenerateCorrelationId(properties); // comma separated var scope = string.Join(",", Options.Scope); // allow scopes to be specified via the authentication properties for this request, when specified they will already be comma separated if (properties.Dictionary.ContainsKey("scope")) { scope = properties.Dictionary["scope"]; } var state = Options.StateDataFormat.Protect(properties); var authorizationEndpoint = "https://www.linkedin.com/uas/oauth2/authorization" + "?response_type=code" + "&client_id=" + Uri.EscapeDataString(Options.ClientId) + "&redirect_uri=" + Uri.EscapeDataString(redirectUri) + "&scope=" + Uri.EscapeDataString(scope) + "&state=" + Uri.EscapeDataString(state); var redirectContext = new LinkedInApplyRedirectContext( Context, Options, properties, authorizationEndpoint); Options.Provider.ApplyRedirect(redirectContext); return Task.FromResult<object>(null); }
The handler also monitors all incoming requests to see if it is a request for the callback path, by overriding the InvokeAsync (InvokeReplyPathAsync is private method called by InvokeAsync ) method. InvokeAsync method does actual work when CallbackPath and Request path are same. It happens when handler receives the callback from provider (LinkedIn) as specified in ApplyResponseChallengeAsync.
If the path is indeed the callback path of the authentication middleware, the AuthenticateAsync method of the base class is called. AuthenticateAsync causes the authentication logic in AuthenticateCoreAsync to be performed for the current request at most once and returns the results. Calling Authenticate more than once will always return the original value. This method should always be called instead of calling AuthenticateCoreAsync directly.
AuthenticateCoreAsync:
protected override async Task<AuthenticationTicket> AuthenticateCoreAsync() { AuthenticationProperties properties = null; try { string code = null; string state = null; var query = Request.Query; var 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); } var requestPrefix = Request.Scheme + "://" + this.GetHostName(); var redirectUri = requestPrefix + Request.PathBase + Options.CallbackPath; // Build up the body for the token request var body = new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("grant_type", "authorization_code"), new KeyValuePair<string, string>("code", code), new KeyValuePair<string, string>("redirect_uri", redirectUri), new KeyValuePair<string, string>("client_id", Options.ClientId), new KeyValuePair<string, string>("client_secret", Options.ClientSecret) }; // Request the token var tokenResponse = await _httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(body)); tokenResponse.EnsureSuccessStatusCode(); var text = await tokenResponse.Content.ReadAsStringAsync(); // Deserializes the token response dynamic response = JsonConvert.DeserializeObject<dynamic>(text); var accessToken = (string)response.access_token; var expires = (string) response.expires_in; // Get the LinkedIn user var userInfoEndpoint = UserInfoEndpoint + "~:("+ string.Join(",", Options.ProfileFields.Distinct().ToArray()) +")" + "?oauth2_access_token=" + Uri.EscapeDataString(accessToken); var userRequest = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint); userRequest.Headers.Add("x-li-format", "json"); var graphResponse = await _httpClient.SendAsync(userRequest, Request.CallCancelled); graphResponse.EnsureSuccessStatusCode(); text = await graphResponse.Content.ReadAsStringAsync(); var user = JObject.Parse(text); var context = new LinkedInAuthenticatedContext(Context, user, accessToken, expires) { Identity = new ClaimsIdentity( Options.AuthenticationType, ClaimsIdentity.DefaultNameClaimType, ClaimsIdentity.DefaultRoleClaimType) }; if (!string.IsNullOrEmpty(context.Id)) { context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, context.Id, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Email)) { context.Identity.AddClaim(new Claim(ClaimTypes.Email, context.Email, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.GivenName)) { context.Identity.AddClaim(new Claim(ClaimTypes.GivenName, context.GivenName, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.FamilyName)) { context.Identity.AddClaim(new Claim(ClaimTypes.Surname, context.FamilyName, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Name)) { context.Identity.AddClaim(new Claim(ClaimTypes.Name, context.Name, XmlSchemaString, Options.AuthenticationType)); context.Identity.AddClaim(new Claim("urn:linkedin:name", context.Name, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Industry)) { context.Identity.AddClaim(new Claim("urn:linkedin:industry", context.Industry, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Positions)) { context.Identity.AddClaim(new Claim("urn:linkedin:positions", context.Positions, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Summary)) { context.Identity.AddClaim(new Claim("urn:linkedin:summary", context.Summary, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Headline)) { context.Identity.AddClaim(new Claim("urn:linkedin:headline", context.Headline, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.Link)) { context.Identity.AddClaim(new Claim("urn:linkedin:url", context.Link, XmlSchemaString, Options.AuthenticationType)); } if (!string.IsNullOrEmpty(context.AccessToken)) { context.Identity.AddClaim(new Claim("urn:linkedin:accesstoken", context.AccessToken, XmlSchemaString, Options.AuthenticationType)); } if (context.ExpiresIn.HasValue) { context.Identity.AddClaim(new Claim(ClaimTypes.Expiration, context.ExpiresIn.Value.ToString(), XmlSchemaString, Options.AuthenticationType)); } context.Properties = properties; await Options.Provider.Authenticated(context); return new AuthenticationTicket(context.Identity, context.Properties); } catch (Exception ex) { _logger.WriteError(ex.Message); } return new AuthenticationTicket(null, properties); }
AuthenticateCoreAsync is where we exchange the authorization code for access token and retrieve additional claims from provider (LinkedIn). After AuthenticationTicket is created with the claims, AuthenticateCoreAsync comes back to InvokeReplyPathAsync (InvokeReplyPathAsync is private method of InvokeAsync).
Finally, InvokeReplyPathAsync SignIn the identity by the OWIN Middleware and returns to the redirect url as specified in ExternalLogin which is ExternalLoginCallback.
InvokeAsync/InvokeReplyPathAsync:
public override async Task<bool> InvokeAsync() { return await InvokeReplyPathAsync(); } private async Task<bool> InvokeReplyPathAsync() { if (!Options.CallbackPath.HasValue || Options.CallbackPath != Request.Path) return false; // TODO: error responses var ticket = await AuthenticateAsync(); if (ticket == null) { _logger.WriteWarning("Invalid return state, unable to redirect."); Response.StatusCode = 500; return true; } var context = new LinkedInReturnEndpointContext(Context, ticket) { SignInAsAuthenticationType = Options.SignInAsAuthenticationType, RedirectUri = ticket.Properties.RedirectUri }; await Options.Provider.ReturnEndpoint(context); if (context.SignInAsAuthenticationType != null && context.Identity != null) { var 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); } if (context.IsRequestCompleted || context.RedirectUri == null) return context.IsRequestCompleted; var redirectUri = context.RedirectUri; if (context.Identity == null) { // add a redirect hint that sign-in failed in some way redirectUri = WebUtilities.AddQueryString(redirectUri, "error", "access_denied"); } Response.Redirect(redirectUri); context.RequestCompleted(); return context.IsRequestCompleted; }
Almost there! We are back to ExternalLoginCallback action on AccountController because that’s what we specified at the ExternalLogin action at the beginning. ExternalLoginCallback is where ApplicationUserManager persists the user into data store. I am using Azure Tables as storage to store UserLogin and IdentityUser. Finally, we create the Jwt Token with the Identity claims (includes access token) and return to Angular 5.
Azure Tables:
ExternalLoginCallback:
[AllowAnonymous] public async Task<ActionResult> ExternalLoginCallback(string returnUrl) { var loginInfo = await AuthenticationManager.GetExternalLoginInfoAsync(); if (loginInfo == null) { return RedirectToAction("Login", new { returnUrl = returnUrl }); } // Sign in the user with this external login provider if the user already has a login var user = await UserManager.FindAsync(loginInfo.Login); if (user != null) { user.Claims = loginInfo.ExternalIdentity.Claims.ToList(); await SignInAsync(user, isPersistent: false); } else { // If the user does not have an account, then create an account user = new AzureIdentityUser() { UserName = loginInfo.Email, Email = loginInfo.Email, Claims = loginInfo.ExternalIdentity.Claims.ToList() }; IdentityResult result = await UserManager.CreateAsync(user); if (result.Succeeded) { result = await UserManager.AddLoginAsync(user.Id, loginInfo.Login); if (result.Succeeded) { await SignInAsync(user, isPersistent: false); } } } Uri uri = null; if (string.IsNullOrEmpty(returnUrl) == false) { uri = new Uri(returnUrl); } JwtTokenHelper jwtTokenHelper = new JwtTokenHelper(); List<Claim> claims = new List<Claim>(); foreach (Claim claim in loginInfo.ExternalIdentity.Claims) { switch (claim.Type) { case ClaimTypes.Email: claims.Add(claim); break; case ClaimTypes.GivenName: claims.Add(claim); break; case ClaimTypes.Surname: claims.Add(claim); break; case ClaimTypes.Expiration: claims.Add(claim); break; case "urn:linkedin:accesstoken": claims.Add(claim); break; default: break; } } Claim expClaim = claims.Where(c => c.Type == ClaimTypes.Expiration).FirstOrDefault(); DateTime dExpires = DateTime.Now.AddHours(12); TimeSpan? expires = null; if (expClaim !=null) { expires = TimeSpan.Parse(expClaim.Value); if(expires.HasValue) { dExpires = DateTime.Now.Add(expires.Value); } } string jwtToken = jwtTokenHelper.GenerateJwtToken(loginInfo.Login.LoginProvider, uri?.Host, claims, null, dExpires); UriBuilder uriBuilder = new UriBuilder(returnUrl); NameValueCollection queryCollection = HttpUtility.ParseQueryString(uriBuilder.Query); queryCollection["access_token"] = jwtToken; uriBuilder.Query = queryCollection.ToString(); return RedirectToAction("OAuth2Confirmation", new { returnUrl = uriBuilder.ToString() }); } [AllowAnonymous] public void OAuth2Confirmation(string returnUrl) { Response.RedirectPermanent(returnUrl, true); }
Back to Angular UI with the Jwt Token and we use TypeScript to persist the Jwt Token in the cookie (not in local storage).
Debug the Jwt Token at jwt.io to inspect the token. Remember, this token belongs to logged-in user and they can do whatever with it!
Now that we have Jwt Token, we can call API and pass the token in the Authorization header as Bearer token.
API tab now displays addresses from protected API.
How does the API validate the jwt token at the server-side? We configured the API startup to use UseCustomJwtBearerToken which is nothing but an extension to wire up CustomJwtSecurityTokenHandler to validate the token.
UseCustomJwtBearerToken:
using Microsoft.Owin.Security.Jwt; using Microsoft.Owin.Security; using Owin; using System; using System.Text; using System.IdentityModel.Tokens; using System.IdentityModel.Selectors; using System.Configuration; namespace api.aspnet4you.mvc5 { public static class OwinAppExtension { private static string AllowedAudiences = ConfigurationManager.AppSettings[GlobalConstants.AllowedAudiences]; private static string AllowedIssuers = ConfigurationManager.AppSettings[GlobalConstants.AllowedIssuers]; public static void UseCustomJwtBearerToken(this IAppBuilder app) { string[] audiences = AllowedAudiences.Split(new char[] {',' }, StringSplitOptions.RemoveEmptyEntries); string[] issuers = AllowedIssuers.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); string audienceSecreteKey = GlobalConstants.JwtTopSecrete512; string audienceSecreteBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(audienceSecreteKey)); byte[] audienceSecrete = Convert.FromBase64String(audienceSecreteBase64); app.UseJwtBearerAuthentication( new JwtBearerAuthenticationOptions() { AuthenticationMode = AuthenticationMode.Active, TokenValidationParameters = new System.IdentityModel.Tokens.TokenValidationParameters() { IssuerSigningKey = new InMemorySymmetricSecurityKey(audienceSecrete), ValidateIssuerSigningKey = true, ValidateLifetime = true, ValidateIssuer = true, ValidateAudience = true, ValidAudiences = audiences, ValidIssuers = issuers, ValidateActor = true, CertificateValidator = X509CertificateValidator.None, IssuerSigningKeyResolver = CustomSigningResolver }, TokenHandler = new CustomJwtSecurityTokenHandler() { UseCustomValidator = false} } ); } private static SecurityKey CustomSigningResolver(string token, SecurityToken securityToken, SecurityKeyIdentifier keyIdentifier, TokenValidationParameters validationParameters) { SecurityKey securityKey = validationParameters.IssuerSigningKey; return securityKey; } } }
CustomJwtSecurityTokenHandler:
using System.IdentityModel.Tokens; using System.Security.Claims; namespace api.aspnet4you.mvc5 { internal class CustomJwtSecurityTokenHandler : JwtSecurityTokenHandler { public bool UseCustomValidator { get; set; } public override ClaimsPrincipal ValidateToken(string securityToken, TokenValidationParameters validationParameters, out SecurityToken validatedToken) { //TODO:Check the token against cache and/or with issuer. return BaseValidateToken(securityToken, validationParameters, out validatedToken); } public ClaimsPrincipal BaseValidateToken(string securityToken, TokenValidationParameters validationParameters, out SecurityToken validatedToken) { ClaimsPrincipal claimsPrincipal= base.ValidateToken(securityToken, validationParameters, out validatedToken); return claimsPrincipal; } } }
Let me share OWIN Startup code to make this article complete!
Startup:
using api.aspnet4you.mvc5.Models; using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security; using Microsoft.Owin.Security.Cookies; using Owin; using Owin.Security.Providers.LinkedIn; using System.Configuration; namespace api.aspnet4you.mvc5 { public partial class Startup { private static string LinkedInClientId = ConfigurationManager.AppSettings[GlobalConstants.LinkedInClientId]; private static string LinkedInClientSecrete = ConfigurationManager.AppSettings[GlobalConstants.LinkedInClientSecrete]; private static string LinkedInCallbackPath = ConfigurationManager.AppSettings[GlobalConstants.LinkedInCallbackPath]; private static string CookieLoginPath = ConfigurationManager.AppSettings[GlobalConstants.CookieLoginPath]; // For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301864 public void ConfigureAuth(IAppBuilder app) { app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create); app.UseCustomJwtBearerToken(); // Enable the application to use a cookie to store information for the signed in user app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, AuthenticationMode = AuthenticationMode.Passive, LoginPath = new PathString(CookieLoginPath) }); // Use a cookie to temporarily store information about a user logging in with a third party login provider app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); LinkedInAuthenticationOptions inOptions = new LinkedInAuthenticationOptions(); inOptions.ClientId = LinkedInClientId; inOptions.ClientSecret = LinkedInClientSecrete; inOptions.CallbackPath = new PathString(LinkedInCallbackPath); app.UseLinkedInAuthentication(inOptions); } } }
There are few things we are not doing at this demo but we must be implementing them in enterprise applications- Caching of Jwt Token per UI Client and Validating Jwt Token against the trust provider (LinkedIn) at least once at the beginning to make sure token has not been altered. Alternatively, we could store the token at Azure Table before sending the token to client (Angular) in the first place. CustomJwtSecurityTokenHandler would validate the token against cached version on the subsequent calls to keep the performance of API at it’s best.
I would be sharing source code of api-aspnet4you in GitHub. You are welcome to drop comments on this post. If you have questions, you can connect me at LinkedIn.