Merge pull request #253 from todorm85/master

Migration to LinkedIn v2.0 API (deadline March 2019)
This commit is contained in:
Tommy Parnell
2019-02-11 08:31:45 -05:00
committed by GitHub
3 changed files with 201 additions and 122 deletions

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
@@ -17,7 +18,9 @@ namespace Owin.Security.Providers.LinkedIn
{
private const string XmlSchemaString = "http://www.w3.org/2001/XMLSchema#string";
private const string TokenEndpoint = "https://www.linkedin.com/oauth/v2/accessToken";
private const string UserInfoEndpoint = "https://api.linkedin.com/v1/people/";
private const string UserInfoEndpoint = "https://api.linkedin.com/v2/me";
private const string AuthorizationEndpoint = "https://www.linkedin.com/oauth/v2/authorization";
private const string EmailEndpoint = "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))";
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
@@ -34,21 +37,7 @@ namespace Owin.Security.Providers.LinkedIn
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];
}
var state = GetQueryValue("state");
properties = Options.StateDataFormat.Unprotect(state);
if (properties == null)
{
@@ -61,94 +50,31 @@ namespace Owin.Security.Providers.LinkedIn
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 code = GetQueryValue("code");
var accessTokenResponse = await GetAccessToken(code);
dynamic response = JsonConvert.DeserializeObject<dynamic>(accessTokenResponse);
var accessToken = (string)response.access_token;
var expires = (string) response.expires_in;
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 userInfoResponse = await GetUserInfo(accessToken);
var user = JObject.Parse(userInfoResponse);
string email = null;
if (Options.Scope.Contains(LinkedInAuthenticationOptions.EmailAddressScopeName))
{
email = await GetUserEmail(accessToken);
}
var context = new LinkedInAuthenticatedContext(Context, user, accessToken, expires)
var context = new LinkedInAuthenticatedContext(Context, user, accessToken, expires, email)
{
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));
}
context.Properties = properties;
AddClaimsToContextIdentity(context);
context.Properties = properties;
await Options.Provider.Authenticated(context);
return new AuthenticationTicket(context.Identity, context.Properties);
@@ -206,7 +132,7 @@ namespace Owin.Security.Providers.LinkedIn
var state = Options.StateDataFormat.Protect(properties);
var authorizationEndpoint =
"https://www.linkedin.com/oauth/v2/authorization" +
AuthorizationEndpoint +
"?response_type=code" +
"&client_id=" + Uri.EscapeDataString(Options.ClientId) +
"&redirect_uri=" + Uri.EscapeDataString(redirectUri) +
@@ -281,5 +207,123 @@ namespace Owin.Security.Providers.LinkedIn
{
return string.IsNullOrWhiteSpace(Options.ProxyHost) ? Request.Host.ToString() : Options.ProxyHost;
}
private static void SetAuthorizedRequestHeaders(string accessToken, HttpRequestMessage userRequest)
{
userRequest.Headers.Add("x-li-format", "json");
userRequest.Headers.Add("Authorization", "Bearer " + accessToken);
}
private async Task<string> GetUserEmail(string accessToken)
{
var emailRequest = new HttpRequestMessage(HttpMethod.Get, EmailEndpoint);
SetAuthorizedRequestHeaders(accessToken, emailRequest);
var graphResponse = await _httpClient.SendAsync(emailRequest, Request.CallCancelled);
try
{
graphResponse.EnsureSuccessStatusCode();
}
catch (Exception e)
{
_logger.WriteError(string.Format("Retrieving the user email using authorization from provider {0} failed. Message: {1}", Options.AuthenticationType, e.Message));
throw;
}
var text = await graphResponse.Content.ReadAsStringAsync();
var emailResponse = JObject.Parse(text);
string email = null;
var emailValue = emailResponse.SelectToken("elements[0].handle~.emailAddress");
if (emailValue != null)
{
email = emailValue.Value<string>();
}
else
{
var errorMessageValue = emailResponse.SelectToken("elements[0].handle!.message");
var errorMessage = string.Empty;
if (errorMessageValue != null)
{
errorMessage = errorMessageValue.Value<string>();
}
_logger.WriteWarning("Could not retrieve the user email from LinkedIn. Message: " + errorMessage);
}
return await Task.FromResult<string>(email);
}
private async Task<string> GetAccessToken(string code)
{
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();
return await tokenResponse.Content.ReadAsStringAsync();
}
private async Task<string> GetUserInfo(string accessToken)
{
var userInfoEndpoint = UserInfoEndpoint
+ "?projection=(" + string.Join(",", Options.ProfileFields.Distinct().ToArray()) + ")";
var userRequest = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint);
SetAuthorizedRequestHeaders(accessToken, userRequest);
var graphResponse = await _httpClient.SendAsync(userRequest, Request.CallCancelled);
graphResponse.EnsureSuccessStatusCode();
return await graphResponse.Content.ReadAsStringAsync();
}
private void AddClaimsToContextIdentity(LinkedInAuthenticatedContext context)
{
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.AccessToken))
{
context.Identity.AddClaim(new Claim("urn:linkedin:accesstoken", context.AccessToken, XmlSchemaString, Options.AuthenticationType));
}
}
private string GetQueryValue(string name)
{
string result = null;
var query = Request.Query;
var values = query.GetValues(name);
if (values != null && values.Count == 1)
{
result = values[0];
}
return result;
}
}
}

View File

@@ -8,6 +8,11 @@ namespace Owin.Security.Providers.LinkedIn
{
public class LinkedInAuthenticationOptions : AuthenticationOptions
{
/// <summary>
/// The calim name that is used to request permission to retrieve the user address during the authorization call
/// </summary>
public const string EmailAddressScopeName = "r_emailaddress";
/// <summary>
/// Gets or sets the a pinned certificate validator to use to validate the endpoints used
/// in back channel communications belong to LinkedIn.
@@ -118,22 +123,15 @@ namespace Owin.Security.Providers.LinkedIn
AuthenticationMode = AuthenticationMode.Passive;
Scope = new List<string>
{
"r_basicprofile",
"r_emailaddress"
"r_liteprofile",
EmailAddressScopeName
};
ProfileFields = new List<string>
{
"id",
"first-name",
"last-name",
"formatted-name",
"email-address",
"public-profile-url",
"picture-url",
"industry",
"headline",
"summary",
"positions"
"firstName",
"lastName",
"profilePicture(displayImage~:playableStreams)"
};
BackchannelTimeout = TimeSpan.FromSeconds(60);
}

View File

@@ -6,8 +6,8 @@ using System.Security.Claims;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Provider;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Owin.Security.Providers.LinkedIn
{
@@ -26,26 +26,35 @@ namespace Owin.Security.Providers.LinkedIn
public LinkedInAuthenticatedContext(IOwinContext context, JObject user, string accessToken, string expires)
: base(context)
{
User = user;
AccessToken = accessToken;
this.User = user;
this.AccessToken = accessToken;
int expiresValue;
if (int.TryParse(expires, NumberStyles.Integer, CultureInfo.InvariantCulture, out expiresValue))
{
ExpiresIn = TimeSpan.FromSeconds(expiresValue);
this.ExpiresIn = TimeSpan.FromSeconds(expiresValue);
}
Id = TryGetValue(user, "id");
Name = TryGetValue(user, "formattedName");
FamilyName = TryGetValue(user, "lastName");
GivenName = TryGetValue(user, "firstName");
Link = TryGetValue(user, "publicProfileUrl");
UserName = TryGetValue(user, "formattedName").Replace(" ", "");
Email = TryGetValue(user, "emailAddress");
Industry = TryGetValue(user, "industry");
Summary = TryGetValue(user, "summary");
Headline = TryGetValue(user, "headline");
Positions = TryGetValueAndSerialize(user, "positions");
this.Id = TryGetValue(user, "id");
this.FamilyName = TryGetLocalizedValue(user, "lastName");
this.GivenName = TryGetLocalizedValue(user, "firstName");
if (this.FamilyName != null || this.GivenName != null)
{
this.Name = string.Join(" ", this.GivenName, this.FamilyName);
}
}
/// <summary>
/// Initializes a <see cref="LinkedInAuthenticatedContext"/>
/// </summary>
/// <param name="context">The OWIN environment</param>
/// <param name="user">The JSON-serialized user</param>
/// <param name="accessToken">LinkedIn Access token</param>
/// <param name="expires">Seconds until expiration</param>
/// <param name="expires">User email returned from dedicated endpoint</param>
public LinkedInAuthenticatedContext(IOwinContext context, JObject user, string accessToken, string expires, string email) : this(context, user, accessToken, expires)
{
this.Email = email;
}
/// <summary>
@@ -95,25 +104,30 @@ namespace Owin.Security.Providers.LinkedIn
/// <summary>
/// Describes the users membership profile
/// </summary>
[Obsolete("LinkedIn doesn't return this claim anymore.")]
public string Summary { get; private set; }
/// <summary>
/// Industry the member belongs to
/// https://developer.linkedin.com/docs/reference/industry-codes
/// </summary>
[Obsolete("LinkedIn doesn't return this claim anymore.")]
public string Industry { get; set; }
/// <summary>
/// The members headline
/// </summary>
[Obsolete("LinkedIn doesn't return this claim anymore.")]
public string Headline { get; set; }
/// <summary>
/// Member's current positions
/// https://developer.linkedin.com/docs/fields/positions
/// </summary>
[Obsolete("LinkedIn doesn't return this claim anymore.")]
public string Positions { get; set; }
[Obsolete("LinkedIn doesn't return this claim anymore.")]
public string Link { get; private set; }
/// <summary>
@@ -142,5 +156,28 @@ namespace Owin.Security.Providers.LinkedIn
JToken value;
return user.TryGetValue(propertyName, out value) ? JsonConvert.SerializeObject(value) : null;
}
private static string TryGetLocalizedValue(JObject container, string propertyName)
{
var localizedValues = container.SelectToken(propertyName + ".localized") as JObject;
if (localizedValues == null)
{
return null;
}
var defaultValue = TryGetValue(localizedValues, "en-US");
if (defaultValue != null)
{
return defaultValue;
}
if (localizedValues.HasValues)
{
return localizedValues.First.First.Value<string>();
}
return null;
}
}
}