Skip to content

Commit

Permalink
Support for the new LinkedIn API version format (#834)
Browse files Browse the repository at this point in the history
- Added OpenID support.
- Updated Userdetails and scope according to this linkhttps://www.linkedin.com/developers/news/featured-updates/openid-connect-authentication.
  • Loading branch information
softwareolatomiwa authored Jun 14, 2024
1 parent 4685e27 commit 22fe33a
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 265 deletions.
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";

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)
{
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;
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);
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)
{
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

0 comments on commit 22fe33a

Please sign in to comment.