Skip to content
Merged
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ dotnet_naming_symbols.constant_fields.required_modifiers = const
[*.cs]

# var preferences
csharp_style_var_for_built_in_types = true:silent
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:silent
csharp_style_var_elsewhere = true:silent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,155 +4,212 @@
* for more information concerning the license and the contributors participating to this project.
*/

using System;

namespace AspNet.Security.OAuth.LinkedIn
{
/// <summary>
/// Contains constants specific to the <see cref="LinkedInAuthenticationHandler"/>.
/// </summary>
public static class LinkedInAuthenticationConstants
{
private const string ObsoleteMessage = "This constant is not used in the LinkedIn v2.0 API.";

public static class Claims
{
public const string PictureUrl = "urn:linkedin:pictureurl";

public const string PictureUrls = "urn:linkedin:pictureurls";

[Obsolete(ObsoleteMessage)]
public const string CurrentShare = "urn:linkedin:currentshare";

[Obsolete(ObsoleteMessage)]
public const string FormattedPhoneticName = "urn:linkedin:phoneticname";

[Obsolete(ObsoleteMessage)]
public const string Headline = "urn:linkedin:headline";

[Obsolete(ObsoleteMessage)]
public const string Industry = "urn:linkedin:industry";

[Obsolete(ObsoleteMessage)]
public const string Location = "urn:linkedin:location";

[Obsolete(ObsoleteMessage)]
public const string MaidenName = "urn:linkedin:maidenname";

[Obsolete(ObsoleteMessage)]
public const string NumConnections = "urn:linkedin:numconnections";

[Obsolete(ObsoleteMessage)]
public const string NumConnectionsCapped = "urn:linkedin:numconnectionscapped";

[Obsolete(ObsoleteMessage)]
public const string PhoneticFirstName = "urn:linkedin:phoneticfirstname";

[Obsolete(ObsoleteMessage)]
public const string PhoneticLastName = "urn:linkedin:phoneticlastname";
public const string PictureUrl = "urn:linkedin:pictureurl";
public const string PictureUrls = "urn:linkedin:pictureurls";

[Obsolete(ObsoleteMessage)]
public const string Positions = "urn:linkedin:positions";

[Obsolete(ObsoleteMessage)]
public const string ProfileUrl = "urn:linkedin:profile";

[Obsolete(ObsoleteMessage)]
public const string Specialties = "urn:linkedin:specialties";

[Obsolete(ObsoleteMessage)]
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
/// <summary>
/// Available profile fields after a LinkedIn authentication.
/// See <a>https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/lite-profile?context=linkedin/consumer/context</a>
/// </summary>
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 <c>personId</c> within a Person URN (<c>urn:li:person:{personId}</c>).
/// The <c>id</c> is unique to your specific developer application. Any attempts to use the <c>id</c> 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 <a>https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring</a>
/// </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 <a>https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring</a>
/// </summary>
public const string LastName = "last-name";
public const string LastName = "lastName";

/// <summary>
/// Metadata about the member's picture in the profile. See Profile Picture Fields for more information.
/// See <a>https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/profile-picture</a>
/// </summary>
public const string PictureUrl = "profilePicture(displayImage~:playableStreams)";

/// <summary>
/// The member's name, formatted based on language.
/// </summary>
[Obsolete(ObsoleteMessage)]
public const string FormattedName = "formatted-name";

/// <summary>
/// The member's maiden name.
/// </summary>
[Obsolete(ObsoleteMessage)]
public const string MaidenName = "maiden-name";

/// <summary>
/// The member's first name, spelled phonetically.
/// </summary>
[Obsolete(ObsoleteMessage)]
public const string PhoneticFirstName = "phonetic-first-name";

/// <summary>
/// The member's last name, spelled phonetically.
/// </summary>
[Obsolete(ObsoleteMessage)]
public const string PhoneticLastName = "phonetic-last-name";

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

/// <summary>
/// The member's headline.
/// </summary>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
public const string NumConnectionCapped = "num-connections-capped";

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

/// <summary>
/// A short-form text area describing the member's specialties.
/// </summary>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
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>
[Obsolete(ObsoleteMessage)]
public const string ApiStandardProfileRequest = "api-standard-profile-request";

/// <summary>
/// The URL to the member's public profile on LinkedIn.
/// </summary>
[Obsolete(ObsoleteMessage)]
public const string PublicProfileUrl = "public-profile-url";
}
}
Expand Down
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 <a>https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin</a> 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,9 @@
* for more information concerning the license and the contributors participating to this project.
*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
Expand All @@ -12,6 +15,7 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
Expand All @@ -32,13 +36,19 @@ public LinkedInAuthenticationHandler(
protected override async Task<AuthenticationTicket> CreateTicketAsync([NotNull] ClaimsIdentity identity,
[NotNull] AuthenticationProperties properties, [NotNull] OAuthTokenResponse tokens)
{
var address = Options.UserInformationEndpoint;
string address = Options.UserInformationEndpoint;
var fields = Options.Fields
.Where(f => !string.Equals(f, LinkedInAuthenticationConstants.EmailAddressField, StringComparison.OrdinalIgnoreCase))
.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 = QueryHelpers.AddQueryString(address, new Dictionary<string, string>
{
["projection"] = $"({string.Join(",", fields)})",
});
}

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

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

if (Options.Fields.Contains(LinkedInAuthenticationConstants.EmailAddressField))
{
payload.Last.AddAfterSelf(new JProperty("emailAddress", await GetEmailAsync(tokens)));
}

var principal = new ClaimsPrincipal(identity);
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload);
context.RunClaimActions(payload);

await Options.Events.CreatingTicket(context);
return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
}

protected virtual async Task<string> GetEmailAsync([NotNull] OAuthTokenResponse tokens)
{
var request = new HttpRequestMessage(HttpMethod.Get, Options.EmailAddressEndpoint);
request.Headers.Add("x-li-format", "json");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);

var response = await Backchannel.SendAsync(request, Context.RequestAborted);
if (!response.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: */ response.StatusCode,
/* Headers: */ response.Headers.ToString(),
/* Body: */ await response.Content.ReadAsStringAsync());

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

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

return (from address in payload.Value<JArray>("elements")
select address.Value<JObject>("handle~")?.Value<string>("emailAddress")).FirstOrDefault();
}
}
}
Loading