Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for the new LinkedIn API version format #834

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,25 @@ public static class LinkedInAuthenticationConstants
{
public static class Claims
{
[Obsolete($"This constant is obsolete and is no longer used. It will be removed in a future version. Use {nameof(Picture)} instead.", false)]
public const string PictureUrl = "urn:linkedin:pictureurl";

[Obsolete($"This constant is obsolete and is no longer used. It will be removed in a future version. Use {nameof(Picture)} instead.", false)]
public const string PictureUrls = "urn:linkedin:pictureurls";

public const string Picture = "picture";
martincostello marked this conversation as resolved.
Show resolved Hide resolved

public const string Email = "email";

public const string Sub = "sub";

public const string EmailVerified = "email_verified";

public const string Name = "name";

public const string GivenName = "given_name";

public const string FamilyName = "family_name";
}

public const string EmailAddressField = "emailAddress";
Expand All @@ -27,27 +43,54 @@ public static class Claims
public static class ProfileFields
{
/// <summary>
/// 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.
/// The unique identifier for the given member.
/// </summary>
public const string Id = "id";
public const string Id = "sub";

/// <summary>
/// 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>
[Obsolete($"This constant is obsolete and is no longer used. It will be removed in a future version. Use {nameof(GivenName)} instead.", false)]
public const string FirstName = "firstName";

/// <summary>
/// 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>
[Obsolete($"This constant is obsolete and is no longer used. It will be removed in a future version. Use {nameof(FamilyName)} instead.", false)]
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>
[Obsolete($"This constant is obsolete and is no longer used. It will be removed in a future version. Use {nameof(Picture)} instead.", false)]
public const string PictureUrl = "profilePicture(displayImage~:playableStreams)";

/// <summary>
/// Full name of the member.
/// </summary>
public const string Name = "name";

/// <summary>
/// Picture URL of the member.
/// </summary>
public const string Picture = "picture";

/// <summary>
/// Given/First name of the member.
/// </summary>
public const string GivenName = "given_name";

/// <summary>
/// Last name of the member.
/// </summary>
public const string FamilyName = "family_name";

/// <summary>
/// Email address of the member.
/// </summary>
public const string Email = "email";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public class LinkedInAuthenticationDefaults
/// <summary>
/// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>.
/// </summary>
public static readonly string UserInformationEndpoint = "https://api.linkedin.com/v2/me";
public static readonly string UserInformationEndpoint = "https://api.linkedin.com/v2/userinfo";

/// <summary>
/// Specific endpoint to retrieve the LinkedIn member's email address.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,8 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync(
[NotNull] OAuthTokenResponse tokens)
{
var requestUri = 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 (fields.Count > 0)
{
requestUri = QueryHelpers.AddQueryString(requestUri, "projection", $"({string.Join(',', fields)})");
}

using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("x-li-format", "json");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);

using var response = await Backchannel.SendAsync(request, Context.RequestAborted);
Expand All @@ -58,20 +48,11 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync(
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
context.RunClaimActions();

if (!string.IsNullOrEmpty(Options.EmailAddressEndpoint) &&
Options.Fields.Contains(LinkedInAuthenticationConstants.EmailAddressField))
{
var email = await GetEmailAsync(tokens);
if (!string.IsNullOrEmpty(email))
{
identity.AddClaim(new Claim(ClaimTypes.Email, email, ClaimValueTypes.String, Options.ClaimsIssuer));
}
}

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

[Obsolete("This method is no longer used and will be removed in a future version.", false)]
protected virtual async Task<string?> GetEmailAsync([NotNull] OAuthTokenResponse tokens)
martincostello marked this conversation as resolved.
Show resolved Hide resolved
{
using var request = new HttpRequestMessage(HttpMethod.Get, Options.EmailAddressEndpoint);
Expand Down
107 changes: 14 additions & 93 deletions src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.Options;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't look like this is needed for anything?

using static AspNet.Security.OAuth.LinkedIn.LinkedInAuthenticationConstants;

namespace AspNet.Security.OAuth.LinkedIn;
Expand All @@ -25,19 +26,17 @@ public LinkedInAuthenticationOptions()
UserInformationEndpoint = LinkedInAuthenticationDefaults.UserInformationEndpoint;
EmailAddressEndpoint = LinkedInAuthenticationDefaults.EmailAddressEndpoint;

Scope.Add("r_liteprofile");
Scope.Add("r_emailaddress");
Scope.Add("openid");
Scope.Add("profile");
Scope.Add("email");

ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, ProfileFields.Id);
ClaimActions.MapCustomJson(ClaimTypes.Name, user => GetFullName(user));
ClaimActions.MapCustomJson(ClaimTypes.GivenName, user => GetMultiLocaleString(user, ProfileFields.FirstName));
ClaimActions.MapCustomJson(ClaimTypes.Surname, user => GetMultiLocaleString(user, ProfileFields.LastName));
ClaimActions.MapCustomJson(Claims.PictureUrl, user => GetPictureUrls(user)?.LastOrDefault());
ClaimActions.MapCustomJson(Claims.PictureUrls, user =>
{
var urls = GetPictureUrls(user);
return urls == null ? null : string.Join(',', urls);
});
ClaimActions.MapJsonKey(ClaimTypes.Name, ProfileFields.Name);
ClaimActions.MapJsonKey(ClaimTypes.Email, ProfileFields.Email);
ClaimActions.MapJsonKey(ClaimTypes.GivenName, ProfileFields.GivenName);
ClaimActions.MapJsonKey(ClaimTypes.Surname, ProfileFields.FamilyName);
martincostello marked this conversation as resolved.
Show resolved Hide resolved
ClaimActions.MapJsonKey(Claims.Picture, Claims.Picture);
ClaimActions.MapJsonKey(Claims.EmailVerified, Claims.EmailVerified);
}

/// <summary>
Expand All @@ -49,11 +48,12 @@ public LinkedInAuthenticationOptions()
/// Gets the list of fields to retrieve from the user information endpoint.
/// See <a>https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin</a> for more information.
/// </summary>
[Obsolete("This property is no longer used and will be removed in a future version.", false)]
public ISet<string> Fields { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
softwareolatomiwa marked this conversation as resolved.
Show resolved Hide resolved
{
ProfileFields.Id,
ProfileFields.FirstName,
ProfileFields.LastName,
ProfileFields.GivenName,
ProfileFields.FamilyName,
EmailAddressField
};

Expand All @@ -66,88 +66,9 @@ public LinkedInAuthenticationOptions()
/// 3. Returns the first value.
/// </summary>
/// <see cref="DefaultMultiLocaleStringResolver(IReadOnlyDictionary{string, string}, string?)"/>
[Obsolete("This method is no longer used and will be removed in a future version.", false)]
public Func<IReadOnlyDictionary<string, string?>, string?, string?> MultiLocaleStringResolver { get; set; } = DefaultMultiLocaleStringResolver;

/// <summary>
/// Gets the <c>MultiLocaleString</c> value using the configured resolver.
/// See <a>https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring</a>
/// </summary>
/// <param name="user">The payload returned by the user info endpoint.</param>
/// <param name="propertyName">The name of the <c>MultiLocaleString</c> property.</param>
/// <returns>The property value.</returns>
private string? GetMultiLocaleString(JsonElement user, string propertyName)
{
if (!user.TryGetProperty(propertyName, out var property))
{
return null;
}

if (!property.TryGetProperty("localized", out var propertyLocalized))
{
return null;
}

string? preferredLocaleKey = null;

if (property.TryGetProperty("preferredLocale", out var preferredLocale))
{
preferredLocaleKey = $"{preferredLocale.GetString("language")}_{preferredLocale.GetString("country")}";
}

var preferredLocales = new Dictionary<string, string?>();

foreach (var element in propertyLocalized.EnumerateObject())
{
preferredLocales[element.Name] = element.Value.GetString();
}

return MultiLocaleStringResolver(preferredLocales, preferredLocaleKey);
}

private string GetFullName(JsonElement user)
{
var nameParts = new string?[]
{
GetMultiLocaleString(user, ProfileFields.FirstName),
GetMultiLocaleString(user, ProfileFields.LastName),
};

return string.Join(' ', nameParts.Where(s => !string.IsNullOrWhiteSpace(s)));
}

private static IEnumerable<string> GetPictureUrls(JsonElement user)
{
if (!user.TryGetProperty("profilePicture", out var profilePicture) ||
!profilePicture.TryGetProperty("displayImage~", out var displayImage) ||
!displayImage.TryGetProperty("elements", out var displayImageElements))
{
return Array.Empty<string>();
}

var pictureUrls = new List<string>();

foreach (var element in displayImageElements.EnumerateArray())
{
if (!string.Equals(element.GetString("authorizationMethod"), "PUBLIC", StringComparison.Ordinal) ||
!element.TryGetProperty("identifiers", out var imageIdentifier))
{
continue;
}

var pictureUrl = imageIdentifier
.EnumerateArray()
.FirstOrDefault()
.GetString("identifier");

if (!string.IsNullOrEmpty(pictureUrl))
{
pictureUrls.Add(pictureUrl);
}
}

return pictureUrls;
}

/// <summary>
/// The default <c>MultiLocaleString</c> resolver.
/// Resolve it in this order:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@ namespace AspNet.Security.OAuth.LinkedIn;

public class LinkedInTests(ITestOutputHelper outputHelper) : OAuthTests<LinkedInAuthenticationOptions>(outputHelper)
{
private Action<LinkedInAuthenticationOptions>? additionalConfiguration;

public override string DefaultScheme => LinkedInAuthenticationDefaults.AuthenticationScheme;

protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
{
builder.AddLinkedIn(options =>
{
ConfigureDefaults(builder, options);
additionalConfiguration?.Invoke(options);
});
}

Expand All @@ -37,41 +34,9 @@ protected internal override void ConfigureApplication(IApplicationBuilder app)
[InlineData(ClaimTypes.Email, "frodo@shire.middleearth")]
[InlineData(ClaimTypes.GivenName, "Frodo")]
[InlineData(ClaimTypes.Surname, "Baggins")]
[InlineData(LinkedInAuthenticationConstants.Claims.PictureUrl, "https://upload.wikimedia.org/wikipedia/en/4/4e/Elijah_Wood_as_Frodo_Baggins.png")]
[InlineData(LinkedInAuthenticationConstants.Claims.Picture, "https://upload.wikimedia.org/wikipedia/en/4/4e/Elijah_Wood_as_Frodo_Baggins.png")]
public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue)
{
// Arrange
additionalConfiguration = options => options.Fields.Add(LinkedInAuthenticationConstants.ProfileFields.PictureUrl);

await AuthenticateUserAndAssertClaimValue(claimType, claimValue);
}

[Theory]
[InlineData(ClaimTypes.NameIdentifier, "1R2RtA")]
[InlineData(ClaimTypes.Name, "Frodon Sacquet")]
[InlineData(ClaimTypes.GivenName, "Frodon")]
[InlineData(ClaimTypes.Surname, "Sacquet")]
public async Task Can_Sign_In_Using_LinkedIn_Localized(string claimType, string claimValue)
=> await AuthenticateUserAndAssertClaimValue(claimType, claimValue);

[Theory]
[InlineData(ClaimTypes.NameIdentifier, "1R2RtA")]
[InlineData(ClaimTypes.Name, "Frodon Sacquet")]
[InlineData(ClaimTypes.GivenName, "Frodon")]
[InlineData(ClaimTypes.Surname, "Sacquet")]
public async Task Can_Sign_In_Using_LinkedIn_Localized_With_Custom_Resolver(string claimType, string claimValue)
{
// Arrange
additionalConfiguration = options => options.MultiLocaleStringResolver = (values, preferredLocale) =>
{
if (values.TryGetValue("fr_FR", out var value))
{
return value;
}

return values.Values.FirstOrDefault();
};

await AuthenticateUserAndAssertClaimValue(claimType, claimValue);
}
}
Loading