diff --git a/.editorconfig b/.editorconfig
index d61c1cf1e..487f9768d 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -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
diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs
index 19dec5534..cbfd3a175 100644
--- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs
+++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs
@@ -4,6 +4,8 @@
* for more information concerning the license and the contributors participating to this project.
*/
+using System;
+
namespace AspNet.Security.OAuth.LinkedIn
{
///
@@ -11,148 +13,203 @@ namespace AspNet.Security.OAuth.LinkedIn
///
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
+ ///
+ /// Available profile fields after a LinkedIn authentication.
+ /// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/lite-profile?context=linkedin/consumer/context
+ ///
public static class ProfileFields
{
///
- /// 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.
///
public const string Id = "id";
///
- /// 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
///
- public const string FirstName = "first-name";
+ public const string FirstName = "firstName";
///
- /// 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
///
- public const string LastName = "last-name";
+ public const string LastName = "lastName";
+
+ ///
+ /// 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
+ ///
+ public const string PictureUrl = "profilePicture(displayImage~:playableStreams)";
///
/// The member's name, formatted based on language.
///
+ [Obsolete(ObsoleteMessage)]
public const string FormattedName = "formatted-name";
///
/// The member's maiden name.
///
+ [Obsolete(ObsoleteMessage)]
public const string MaidenName = "maiden-name";
///
/// The member's first name, spelled phonetically.
///
+ [Obsolete(ObsoleteMessage)]
public const string PhoneticFirstName = "phonetic-first-name";
///
/// The member's last name, spelled phonetically.
///
+ [Obsolete(ObsoleteMessage)]
public const string PhoneticLastName = "phonetic-last-name";
///
/// The member's name, spelled phonetically and formatted based on language.
///
+ [Obsolete(ObsoleteMessage)]
public const string FormattedPhoneticName = "formatted-phonetic-name";
///
/// The member's headline.
///
+ [Obsolete(ObsoleteMessage)]
public const string Headline = "headline";
///
/// An object representing the user's physical location.
/// See Location Fields for a description of the fields available within this object.
///
+ [Obsolete(ObsoleteMessage)]
public const string Location = "location";
///
/// The industry the member belongs to.
/// See Industry Codes for a list of possible values.
///
+ [Obsolete(ObsoleteMessage)]
public const string Industry = "industry";
///
/// The most recent item the member has shared on LinkedIn.
/// If the member has not shared anything, their 'status' is returned instead.
///
+ [Obsolete(ObsoleteMessage)]
public const string CurrentShare = "current-share";
///
/// The number of LinkedIn connections the member has, capped at 500.
/// See 'num-connections-capped' to determine if the value returned has been capped.
///
+ [Obsolete(ObsoleteMessage)]
public const string NumConnections = "num-connections";
///
/// 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.
///
+ [Obsolete(ObsoleteMessage)]
public const string NumConnectionCapped = "num-connections-capped";
///
/// A long-form text area describing the member's professional profile.
///
+ [Obsolete(ObsoleteMessage)]
public const string Summary = "summary";
///
/// A short-form text area describing the member's specialties.
///
+ [Obsolete(ObsoleteMessage)]
public const string Specialties = "specialties";
///
/// An object representing the member's current position.
/// See Position Fields for a description of the fields available within this object.
///
+ [Obsolete(ObsoleteMessage)]
public const string Positions = "positions";
- ///
- /// A URL to the member's formatted profile picture, if one has been provided.
- ///
- public const string PictureUrl = "picture-url";
-
///
/// A URL to the member's original unformatted profile picture.
/// This image is usually larger than the picture-url value above.
///
+ [Obsolete(ObsoleteMessage)]
public const string PictureUrlsOriginal = "picture-urls::(original)";
///
/// The URL to the member's authenticated profile on LinkedIn.
/// You must be logged into LinkedIn to view this URL.
///
+ [Obsolete(ObsoleteMessage)]
public const string SiteStandardProfileRequest = "site-standard-profile-request";
///
/// A URL representing the resource you would request for programmatic access to the member's profile.
///
+ [Obsolete(ObsoleteMessage)]
public const string ApiStandardProfileRequest = "api-standard-profile-request";
///
/// The URL to the member's public profile on LinkedIn.
///
+ [Obsolete(ObsoleteMessage)]
public const string PublicProfileUrl = "public-profile-url";
}
}
diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs
index 9ade59aa9..835aebb6b 100644
--- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs
+++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs
@@ -46,9 +46,13 @@ public class LinkedInAuthenticationDefaults
///
/// Default value for .
- /// 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.
///
- public const string UserInformationEndpoint = "https://api.linkedin.com/v1/people/~";
+ public const string UserInformationEndpoint = "https://api.linkedin.com/v2/me";
+
+ ///
+ /// 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.
+ ///
+ public const string EmailAddressEndpoint = "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))";
}
}
diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs
index 197cd6369..36ab87204 100644
--- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs
+++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs
@@ -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;
@@ -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;
@@ -32,13 +36,19 @@ public LinkedInAuthenticationHandler(
protected override async Task 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
+ {
+ ["projection"] = $"({string.Join(",", fields)})",
+ });
}
var request = new HttpRequestMessage(HttpMethod.Get, address);
@@ -58,6 +68,12 @@ protected override async Task 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);
@@ -65,5 +81,29 @@ protected override async Task CreateTicketAsync([NotNull]
await Options.Events.CreatingTicket(context);
return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
}
+
+ protected virtual async Task 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("elements")
+ select address.Value("handle~")?.Value("emailAddress")).FirstOrDefault();
+ }
}
}
diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs
index 6cb69ce2d..17a3390cc 100644
--- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs
+++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs
@@ -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
@@ -27,41 +31,134 @@ public LinkedInAuthenticationOptions()
AuthorizationEndpoint = LinkedInAuthenticationDefaults.AuthorizationEndpoint;
TokenEndpoint = LinkedInAuthenticationDefaults.TokenEndpoint;
UserInformationEndpoint = LinkedInAuthenticationDefaults.UserInformationEndpoint;
+ EmailAddressEndpoint = LinkedInAuthenticationDefaults.EmailAddressEndpoint;
- 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());
+ Scope.Add("r_liteprofile");
+ Scope.Add("r_emailaddress");
+
+ ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, ProfileFields.Id);
+ ClaimActions.MapJsonKey(ClaimTypes.Email, LinkedInAuthenticationConstants.EmailAddressField);
+ 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);
+ });
}
+ ///
+ /// Gets or sets the email address endpoint.
+ ///
+ public string EmailAddressEndpoint { get; set; }
+
///
/// 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.
///
- public ISet Fields { get; } = new HashSet
+ public ISet Fields { get; } = new HashSet(StringComparer.OrdinalIgnoreCase)
{
ProfileFields.Id,
ProfileFields.FirstName,
ProfileFields.LastName,
- ProfileFields.FormattedName,
LinkedInAuthenticationConstants.EmailAddressField
};
+
+ ///
+ /// Gets or sets a MultiLocaleString resolver, a function which takes all localized values
+ /// and an eventual preferred locale from the member and returns the selected localized value.
+ /// The default implementation resolve it in this order:
+ /// 1. Returns the preferredLocale value if it is set and has a value.
+ /// 2. Returns the value corresponding to the if it exists.
+ /// 3. Returns the first value.
+ ///
+ ///
+ public Func, string, string> MultiLocaleStringResolver { get; set; } = DefaultMultiLocaleStringResolver;
+
+ ///
+ /// Gets the MultiLocaleString value using the configured resolver.
+ /// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring
+ ///
+ /// The payload returned by the user info endpoint.
+ /// The name of the MultiLocaleString property.
+ /// The property value.
+ private string GetMultiLocaleString(JObject user, string propertyName)
+ {
+ var property = user[propertyName];
+ if (property == null)
+ {
+ return null;
+ }
+
+ var propertyLocalized = property["localized"];
+ if (propertyLocalized == null)
+ {
+ return null;
+ }
+
+ var preferredLocale = property["preferredLocale"];
+ string preferredLocaleKey = preferredLocale == null ? null : $"{preferredLocale.Value("language")}_{preferredLocale.Value("country")}";
+ var values = propertyLocalized
+ .Children()
+ .ToDictionary(p => p.Name, p => p.Value.Value());
+
+ return MultiLocaleStringResolver(values, preferredLocaleKey);
+ }
+
+ private string GetFullName(JObject user)
+ {
+ string[] nameParts = new string[]
+ {
+ GetMultiLocaleString(user, ProfileFields.FirstName),
+ GetMultiLocaleString(user, ProfileFields.LastName)
+ };
+
+ return string.Join(" ", nameParts.Where(s => !string.IsNullOrWhiteSpace(s)));
+ }
+
+ private static IEnumerable GetPictureUrls(JObject user)
+ {
+ var profilePictureElements = user.Value("profilePicture")
+ ?.Value("displayImage~")
+ ?.Value("elements");
+
+ if (profilePictureElements == null)
+ {
+ return null;
+ }
+
+ return (from address in profilePictureElements
+ where address.Value("authorizationMethod") == "PUBLIC"
+ select address.Value("identifiers")?.First()?.Value("identifier"));
+ }
+
+ ///
+ /// The default MultiLocaleString resolver.
+ /// Resolve it in this order:
+ /// 1. Returns the preferredLocale value if it is set and has a value.
+ /// 2. Returns the value corresponding to the if it exists.
+ /// 3. Returns the first value.
+ ///
+ /// The localized values with culture keys.
+ /// The preferred locale, if provided by LinkedIn.
+ /// The localized value.
+ private static string DefaultMultiLocaleStringResolver(IReadOnlyDictionary localizedValues, string preferredLocale)
+ {
+ if (!string.IsNullOrEmpty(preferredLocale)
+ && localizedValues.TryGetValue(preferredLocale, out string preferredLocaleValue))
+ {
+ return preferredLocaleValue;
+ }
+
+ string currentUIKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_');
+ if (localizedValues.TryGetValue(currentUIKey, out string currentUIValue))
+ {
+ return currentUIValue;
+ }
+
+ return localizedValues.Values.FirstOrDefault();
+ }
}
}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/ApplicationFactory.cs b/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/ApplicationFactory.cs
index cfe7f1ba4..c015e006d 100644
--- a/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/ApplicationFactory.cs
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/ApplicationFactory.cs
@@ -62,7 +62,7 @@ private static void Configure(IWebHostBuilder builder, OAuthTests ConfigureApplication(app, tests))
.ConfigureServices(services =>
{
// Allow HTTP requests to external services to be intercepted
@@ -82,10 +82,12 @@ private static void Configure(IWebHostBuilder builder, OAuthTests(IApplicationBuilder app, OAuthTests tests)
+ where TOptions : OAuthOptions
{
// Configure a single HTTP resource that challenges the client if unauthenticated
// or returns the logged in user's claims as XML if the request is authenticated.
+ tests.ConfigureApplication(app);
app.UseAuthentication();
app.Map("/me", childApp => childApp.Run(
@@ -93,8 +95,8 @@ private static void ConfigureApplication(IApplicationBuilder app)
{
if (context.User.Identity.IsAuthenticated)
{
- var xml = IdentityToXmlString(context.User);
- var buffer = Encoding.UTF8.GetBytes(xml.ToString());
+ string xml = IdentityToXmlString(context.User);
+ byte[] buffer = Encoding.UTF8.GetBytes(xml.ToString());
context.Response.StatusCode = 200;
context.Response.ContentType = "text/xml";
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs
index 0242be519..3342ec3ca 100644
--- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs
+++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs
@@ -4,9 +4,12 @@
* for more information concerning the license and the contributors participating to this project.
*/
+using System;
+using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
@@ -15,6 +18,8 @@ namespace AspNet.Security.OAuth.LinkedIn
{
public class LinkedInTests : OAuthTests
{
+ private Action additionalConfiguration = null;
+
public LinkedInTests(ITestOutputHelper outputHelper)
{
OutputHelper = outputHelper;
@@ -24,7 +29,19 @@ public LinkedInTests(ITestOutputHelper outputHelper)
protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
{
- builder.AddLinkedIn(options => ConfigureDefaults(builder, options));
+ builder.AddLinkedIn(options =>
+ {
+ ConfigureDefaults(builder, options);
+ additionalConfiguration?.Invoke(options);
+ });
+ }
+
+ protected internal override void ConfigureApplication(IApplicationBuilder app)
+ {
+ app.UseRequestLocalization(new RequestLocalizationOptions
+ {
+ DefaultRequestCulture = new Microsoft.AspNetCore.Localization.RequestCulture("fr-FR"),
+ });
}
[Theory]
@@ -33,10 +50,57 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu
[InlineData(ClaimTypes.Email, "frodo@shire.middleearth")]
[InlineData(ClaimTypes.GivenName, "Frodo")]
[InlineData(ClaimTypes.Surname, "Baggins")]
- [InlineData("urn:linkedin:headline", "Jewelery Repossession in Middle Earth")]
+ [InlineData(LinkedInAuthenticationConstants.Claims.PictureUrl, "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);
+ using (var server = CreateTestServer())
+ {
+ // Act
+ var claims = await AuthenticateUserAsync(server);
+
+ // Assert
+ AssertClaim(claims, 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)
+ {
+ // Arrange
+ using (var server = CreateTestServer())
+ {
+ // Act
+ var claims = await AuthenticateUserAsync(server);
+
+ // Assert
+ AssertClaim(claims, 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 string value))
+ {
+ return value;
+ }
+
+ return values.Values.FirstOrDefault();
+ };
+
using (var server = CreateTestServer())
{
// Act
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json
index 2530fde75..d427b7281 100644
--- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json
+++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json
@@ -12,20 +12,122 @@
}
},
{
- "comment": "https://developer.linkedin.com/docs/signin-with-linkedin",
- "uri": "https://api.linkedin.com/v1/people/~:(id,first-name,last-name,formatted-name,email-address)",
+ "comment": "https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin",
+ "uri": "https://api.linkedin.com/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~%3AplayableStreams))",
"contentFormat": "json",
"contentJson": {
- "firstName": "Frodo",
- "headline": "Jewelery Repossession in Middle Earth",
"id": "1R2RtA",
- "lastName": "Baggins",
- "formattedName": "Frodo Baggins",
- "emailAddress": "frodo@shire.middleearth",
- "siteStandardProfileRequest": {
- "url": "https://www.linkedin.com/profile/view?id=frodo-baggins"
+ "firstName": {
+ "localized": {
+ "en_US": "Frodo",
+ "fr_FR": "Frodon"
+ },
+ "preferredLocale": {
+ "country": "US",
+ "language": "en"
+ }
+ },
+ "lastName": {
+ "localized": {
+ "en_US": "Baggins",
+ "fr_FR": "Sacquet"
+ },
+ "preferredLocale": {
+ "country": "US",
+ "language": "en"
+ }
+ },
+ "profilePicture": {
+ "displayImage": "urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ",
+ "displayImage~": {
+ "elements": [
+ {
+ "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100)",
+ "authorizationMethod": "PUBLIC",
+ "data": {
+ "com.linkedin.digitalmedia.mediaartifact.StillImage": {
+ "storageSize": {
+ "width": 100,
+ "height": 100
+ },
+ "storageAspectRatio": {
+ "widthAspect": 1,
+ "heightAspect": 1,
+ "formatted": "1.00:1.00"
+ },
+ "mediaType": "image/jpeg",
+ "rawCodecSpec": {
+ "name": "jpeg",
+ "type": "image"
+ },
+ "displaySize": {
+ "uom": "PX",
+ "width": 100,
+ "height": 100
+ },
+ "displayAspectRatio": {
+ "widthAspect": 1,
+ "heightAspect": 1,
+ "formatted": "1.00:1.00"
+ }
+ }
+ },
+ "identifiers": [
+ {
+ "identifier": "https://upload.wikimedia.org/wikipedia/en/4/4e/Elijah_Wood_as_Frodo_Baggins.png",
+ "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:C4D03AQGsitRwG8U8ZQ,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100,0)",
+ "index": 0,
+ "mediaType": "image/jpeg",
+ "identifierExpiresInSeconds": 1526940000
+ }
+ ]
+ }
+ ],
+ "paging": {
+ "count": 10,
+ "start": 0,
+ "links": [
+
+ ]
+ }
+ }
}
}
+ },
+ {
+ "comment": "https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin",
+ "uri": "https://api.linkedin.com/v2/me?projection=(id,firstName,lastName)",
+ "contentFormat": "json",
+ "contentJson": {
+ "id": "1R2RtA",
+ "firstName": {
+ "localized": {
+ "en_US": "Frodo",
+ "fr_FR": "Frodon"
+ }
+ },
+ "lastName": {
+ "localized": {
+ "en_US": "Baggins",
+ "fr_FR": "Sacquet"
+ }
+ }
+ }
+ },
+ {
+ "comment": "https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin",
+ "uri": "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))",
+ "contentFormat": "json",
+ "contentJson": {
+ "elements": [
+ {
+ "handle": "urn:li:emailAddress:3775708763",
+ "handle~": {
+ "emailAddress": "frodo@shire.middleearth"
+ }
+ }
+ ]
+ }
}
]
}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs b/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs
index 483ab652b..35b00f3e4 100644
--- a/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs
+++ b/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs
@@ -18,6 +18,7 @@
using MartinCostello.Logging.XUnit;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
+using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
@@ -65,6 +66,14 @@ protected OAuthTests()
/// The authentication builder to register authentication with.
protected internal abstract void RegisterAuthentication(AuthenticationBuilder builder);
+ ///
+ /// Configures the test server application.
+ /// Useful to add a middleware like a to test
+ /// localization scenario.
+ ///
+ /// The application.
+ protected internal virtual void ConfigureApplication(IApplicationBuilder app) { }
+
///
/// Configures the default authentication options.
///