Skip to content
137 changes: 14 additions & 123 deletions src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,147 +13,38 @@ public static class LinkedInAuthenticationConstants
{
public static class Claims
{
public const string CurrentShare = "urn:linkedin:currentshare";
public const string FormattedPhoneticName = "urn:linkedin:phoneticname";
public const string Headline = "urn:linkedin:headline";
public const string Industry = "urn:linkedin:industry";
public const string Location = "urn:linkedin:location";
public const string MaidenName = "urn:linkedin:maidenname";
public const string NumConnections = "urn:linkedin:numconnections";
public const string NumConnectionsCapped = "urn:linkedin:numconnectionscapped";
public const string PhoneticFirstName = "urn:linkedin:phoneticfirstname";
public const string PhoneticLastName = "urn:linkedin:phoneticlastname";
public const string PictureUrl = "urn:linkedin:pictureurl";
public const string PictureUrls = "urn:linkedin:pictureurls";
public const string Positions = "urn:linkedin:positions";
public const string ProfileUrl = "urn:linkedin:profile";
public const string Specialties = "urn:linkedin:specialties";
public const string Summary = "urn:linkedin:summary";
}

public const string EmailAddressField = "email-address";
public const string EmailAddressField = "emailAddress";

// https://developer.linkedin.com/docs/fields/basic-profile
// https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/lite-profile?context=linkedin/consumer/context
public static class ProfileFields
{
/// <summary>
/// A unique identifying value for the member.
///This value is linked to your specific application.Any attempts to use it with a different application will result in a "404 - Invalid member id" error.
/// The unique identifier for the given member. May also be referenced as the personId within a Person URN (urn:li:person:{personId}).
/// The id is unique to your specific developer application. Any attempts to use the id with other developer applications will not succeed.
/// </summary>
public const string Id = "id";

/// <summary>
/// The member's first name.
/// First name of the member. Represented as a MultiLocaleString object type.
/// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring
/// </summary>
public const string FirstName = "first-name";
public const string FirstName = "firstName";

/// <summary>
/// The member's last name.
/// Last name of the member. Represented as a MultiLocaleString object type.
/// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring
/// </summary>
public const string LastName = "last-name";
public const string LastName = "lastName";

/// <summary>
/// The member's name, formatted based on language.
/// Metadata about the member's picture in the profile. See Profile Picture Fields for more information.
/// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/profile-picture
/// </summary>
public const string FormattedName = "formatted-name";

/// <summary>
/// The member's maiden name.
/// </summary>
public const string MaidenName = "maiden-name";

/// <summary>
/// The member's first name, spelled phonetically.
/// </summary>
public const string PhoneticFirstName = "phonetic-first-name";

/// <summary>
/// The member's last name, spelled phonetically.
/// </summary>
public const string PhoneticLastName = "phonetic-last-name";

/// <summary>
/// The member's name, spelled phonetically and formatted based on language.
/// </summary>
public const string FormattedPhoneticName = "formatted-phonetic-name";

/// <summary>
/// The member's headline.
/// </summary>
public const string Headline = "headline";

/// <summary>
/// An object representing the user's physical location.
/// See Location Fields for a description of the fields available within this object.
/// </summary>
public const string Location = "location";

/// <summary>
/// The industry the member belongs to.
/// See <a href="https://developer.linkedin.com/docs/reference/industry-codes">Industry Codes</a> for a list of possible values.
/// </summary>
public const string Industry = "industry";

/// <summary>
/// The most recent item the member has shared on LinkedIn.
/// If the member has not shared anything, their 'status' is returned instead.
/// </summary>
public const string CurrentShare = "current-share";

/// <summary>
/// The number of LinkedIn connections the member has, capped at 500.
/// See 'num-connections-capped' to determine if the value returned has been capped.
/// </summary>
public const string NumConnections = "num-connections";

/// <summary>
/// Returns 'true' if the member's 'num-connections' value has been capped at 500',
/// or 'false' if 'num-connections' represents the user's true value.
/// </summary>
public const string NumConnectionCapped = "num-connections-capped";

/// <summary>
/// A long-form text area describing the member's professional profile.
/// </summary>
public const string Summary = "summary";

/// <summary>
/// A short-form text area describing the member's specialties.
/// </summary>
public const string Specialties = "specialties";

/// <summary>
/// An object representing the member's current position.
/// See <a href="https://developer.linkedin.com/docs/fields/positions">Position Fields</a> for a description of the fields available within this object.
/// </summary>
public const string Positions = "positions";

/// <summary>
/// A URL to the member's formatted profile picture, if one has been provided.
/// </summary>
public const string PictureUrl = "picture-url";

/// <summary>
/// A URL to the member's original unformatted profile picture.
/// This image is usually larger than the picture-url value above.
/// </summary>
public const string PictureUrlsOriginal = "picture-urls::(original)";

/// <summary>
/// The URL to the member's authenticated profile on LinkedIn.
/// You must be logged into LinkedIn to view this URL.
/// </summary>
public const string SiteStandardProfileRequest = "site-standard-profile-request";

/// <summary>
/// A URL representing the resource you would request for programmatic access to the member's profile.
/// </summary>
public const string ApiStandardProfileRequest = "api-standard-profile-request";

/// <summary>
/// The URL to the member's public profile on LinkedIn.
/// </summary>
public const string PublicProfileUrl = "public-profile-url";
public const string PictureUrl = "profilePicture(displayImage~:playableStreams)";
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ public class LinkedInAuthenticationDefaults

/// <summary>
/// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>.
/// Note: the endpoint must follow the LinkedIn convention and contain a '~' to append fields to, if they are specified.
/// See https://developer.linkedin.com/docs/signin-with-linkedin for more information.
/// </summary>
public const string UserInformationEndpoint = "https://api.linkedin.com/v1/people/~";
public const string UserInformationEndpoint = "https://api.linkedin.com/v2/me";

/// <summary>
/// Specific endpoint to retrieve the LinkedIn member's email address.
/// See https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin for more information.
/// </summary>
public const string EmailAddressEndpoint = "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* for more information concerning the license and the contributors participating to this project.
*/

using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
Expand Down Expand Up @@ -33,12 +34,13 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync([NotNull]
[NotNull] AuthenticationProperties properties, [NotNull] OAuthTokenResponse tokens)
{
var address = Options.UserInformationEndpoint;
var fields = Options.Fields.Where(f => f != LinkedInAuthenticationConstants.EmailAddressField).ToList();

// If at least one field is specified,
// append the fields to the endpoint URL.
if (Options.Fields.Count != 0)
if (fields.Count != 0)
{
address = address.Insert(address.LastIndexOf("~") + 1, $":({ string.Join(",", Options.Fields)})");
address = $"{address}?projection=({string.Join(",", fields)})";
}

var request = new HttpRequestMessage(HttpMethod.Get, address);
Expand All @@ -58,6 +60,32 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync([NotNull]
}

var payload = JObject.Parse(await response.Content.ReadAsStringAsync());

if (Options.Fields.Contains(LinkedInAuthenticationConstants.EmailAddressField))
{
var emailAddressRequest = new HttpRequestMessage(HttpMethod.Get, Options.EmailAddressEndpoint);
emailAddressRequest.Headers.Add("x-li-format", "json");
emailAddressRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);

var emailAddressResponse = await Backchannel.SendAsync(emailAddressRequest, Context.RequestAborted);
if (!emailAddressResponse.IsSuccessStatusCode)
{
Logger.LogError("An error occurred while retrieving the email address: the remote server " +
"returned a {Status} response with the following payload: {Headers} {Body}.",
/* Status: */ emailAddressResponse.StatusCode,
/* Headers: */ emailAddressResponse.Headers.ToString(),
/* Body: */ await emailAddressResponse.Content.ReadAsStringAsync());

throw new HttpRequestException("An error occurred while retrieving the email address.");
}

var emailAddressPayload = JObject.Parse(await emailAddressResponse.Content.ReadAsStringAsync());
var emailAddress = emailAddressPayload.SelectToken("elements[0].handle~.emailAddress").ToString();

var emailProperty = new JProperty("emailAddress", emailAddress);
payload.Last.AddAfterSelf(emailProperty);
}

var principal = new ClaimsPrincipal(identity);
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload);
context.RunClaimActions(payload);
Expand Down
82 changes: 61 additions & 21 deletions src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
* for more information concerning the license and the contributors participating to this project.
*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json.Linq;
using static AspNet.Security.OAuth.LinkedIn.LinkedInAuthenticationConstants;

namespace AspNet.Security.OAuth.LinkedIn
Expand All @@ -27,41 +31,77 @@ public LinkedInAuthenticationOptions()
AuthorizationEndpoint = LinkedInAuthenticationDefaults.AuthorizationEndpoint;
TokenEndpoint = LinkedInAuthenticationDefaults.TokenEndpoint;
UserInformationEndpoint = LinkedInAuthenticationDefaults.UserInformationEndpoint;
EmailAddressEndpoint = LinkedInAuthenticationDefaults.EmailAddressEndpoint;

Scope.Add("r_liteprofile");
Scope.Add("r_emailaddress");

ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
ClaimActions.MapJsonKey(ClaimTypes.Email, "emailAddress");
ClaimActions.MapJsonKey(ClaimTypes.Name, "formattedName");
ClaimActions.MapJsonKey(ClaimTypes.GivenName, "firstName");
ClaimActions.MapJsonKey(ClaimTypes.Surname, "lastName");
ClaimActions.MapJsonKey(Claims.MaidenName, "maidenName");
ClaimActions.MapJsonKey(Claims.ProfileUrl, "publicProfileUrl");
ClaimActions.MapJsonKey(Claims.PictureUrl, "pictureUrl");
ClaimActions.MapJsonKey(Claims.Industry, "industry");
ClaimActions.MapJsonKey(Claims.Summary, "summary");
ClaimActions.MapJsonKey(Claims.Headline, "headline");
ClaimActions.MapCustomJson(Claims.Positions, user => user["positions"]?.ToString());
ClaimActions.MapJsonKey(Claims.PhoneticFirstName, "phoneticFirstName");
ClaimActions.MapJsonKey(Claims.PhoneticLastName, "phoneticLastName");
ClaimActions.MapJsonKey(Claims.FormattedPhoneticName, "formattedPhoneticName");
ClaimActions.MapCustomJson(Claims.Location, user => user["location"]?.ToString());
ClaimActions.MapJsonKey(Claims.Specialties, "specialties");
ClaimActions.MapJsonKey(Claims.NumConnections, "numConnections");
ClaimActions.MapJsonKey(Claims.NumConnectionsCapped, "numConnectionsCapped");
ClaimActions.MapJsonKey(Claims.CurrentShare, "currentShare");
ClaimActions.MapCustomJson(Claims.PictureUrls, user => user["pictureUrls"]?.ToString());
ClaimActions.MapCustomJson(ClaimTypes.Name, user => $"{GetMultiLocaleString(user, "firstName")} {GetMultiLocaleString(user, "lastName")}");
ClaimActions.MapCustomJson(ClaimTypes.GivenName, user => GetMultiLocaleString(user, "firstName"));
ClaimActions.MapCustomJson(ClaimTypes.Surname, user => GetMultiLocaleString(user, "lastName"));
ClaimActions.MapCustomJson(Claims.PictureUrl, user
=> user.SelectTokens("$.profilePicture..elements[*].identifiers[0].identifier").Select(u => u.Value<string>()).LastOrDefault());
ClaimActions.MapCustomJson(Claims.PictureUrls, user
=> string.Join(",", user.SelectTokens("$.profilePicture..elements[*].identifiers[0].identifier").Select(u => u.Value<string>())));
}

/// <summary>
/// Gets or sets the email address endpoint.
/// </summary>
public string EmailAddressEndpoint { get; set; }

/// <summary>
/// Gets the list of fields to retrieve from the user information endpoint.
/// See https://developer.linkedin.com/docs/fields/basic-profile for more information.
/// See https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin for more information.
/// </summary>
public ISet<string> Fields { get; } = new HashSet<string>
{
ProfileFields.Id,
ProfileFields.FirstName,
ProfileFields.LastName,
ProfileFields.FormattedName,
LinkedInAuthenticationConstants.EmailAddressField
};

/// <summary>
/// Gets the MultiLocaleString value.
/// First checks if a preferredLocale is returned from the payload, then try to return the value from it.
/// Then checks if a value is available for the current UI culture.
/// Finally, returns the first localized value.
/// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring
/// </summary>
/// <param name="user">The payload returned by the user info endpoint.</param>
/// <param name="propertyName">The name of the MultiLocaleString property.</param>
/// <returns>The property value.</returns>
private string GetMultiLocaleString(JObject user, string propertyName)
{
if (user[propertyName] == null)
{
return null;
}

var preferredLocale = user[propertyName]["preferredLocale"];
const string localizedKey = "localized";

if (preferredLocale != null)
{
var preferredKey = $"{preferredLocale["language"]}_{preferredLocale["country"]}";
var preferredLocalizedValue = user[propertyName][localizedKey][preferredKey];
if (preferredLocalizedValue != null)
{
return preferredLocalizedValue.Value<string>();
}
}

var currentUiKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_');
var currentUiLocalizedValue = user[propertyName][localizedKey][currentUiKey];
if (currentUiLocalizedValue != null)
{
return currentUiLocalizedValue.Value<string>();
}

return user[propertyName][localizedKey].First.Value<string>();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu
[InlineData(ClaimTypes.Email, "[email protected]")]
[InlineData(ClaimTypes.GivenName, "Frodo")]
[InlineData(ClaimTypes.Surname, "Baggins")]
[InlineData("urn:linkedin:headline", "Jewelery Repossession in Middle Earth")]
public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue)
{
// Arrange
Expand Down
Loading