From f36c8295ab83a1af0ae0a17f42c57400f6e95cc0 Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 00:12:00 +0200 Subject: [PATCH 1/8] Fix LinkedIn authentication with v2 endpoints to get name, email and profile pictures --- .../LinkedInAuthenticationConstants.cs | 137 ++---------------- .../LinkedInAuthenticationDefaults.cs | 10 +- .../LinkedInAuthenticationHandler.cs | 32 +++- .../LinkedInAuthenticationOptions.cs | 82 ++++++++--- 4 files changed, 112 insertions(+), 149 deletions(-) diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs index 19dec5534..ab1062969 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs @@ -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 { /// - /// 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"; /// - /// 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 /// - public const string FormattedName = "formatted-name"; - - /// - /// The member's maiden name. - /// - public const string MaidenName = "maiden-name"; - - /// - /// The member's first name, spelled phonetically. - /// - public const string PhoneticFirstName = "phonetic-first-name"; - - /// - /// The member's last name, spelled phonetically. - /// - public const string PhoneticLastName = "phonetic-last-name"; - - /// - /// The member's name, spelled phonetically and formatted based on language. - /// - public const string FormattedPhoneticName = "formatted-phonetic-name"; - - /// - /// The member's headline. - /// - 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. - /// - public const string Location = "location"; - - /// - /// The industry the member belongs to. - /// See Industry Codes for a list of possible values. - /// - 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. - /// - 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. - /// - 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. - /// - public const string NumConnectionCapped = "num-connections-capped"; - - /// - /// A long-form text area describing the member's professional profile. - /// - public const string Summary = "summary"; - - /// - /// A short-form text area describing the member's specialties. - /// - 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. - /// - 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. - /// - 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. - /// - public const string SiteStandardProfileRequest = "site-standard-profile-request"; - - /// - /// A URL representing the resource you would request for programmatic access to the member's profile. - /// - public const string ApiStandardProfileRequest = "api-standard-profile-request"; - - /// - /// The URL to the member's public profile on LinkedIn. - /// - public const string PublicProfileUrl = "public-profile-url"; + public const string PictureUrl = "profilePicture(displayImage~:playableStreams)"; } } -} +} \ No newline at end of file diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs index 9ade59aa9..38d0d18c4 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..fa043b2cf 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs @@ -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; @@ -33,12 +34,13 @@ protected override async Task 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); @@ -58,6 +60,32 @@ protected override async Task 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); diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs index 6cb69ce2d..a4aa7cfbe 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,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()).LastOrDefault()); + ClaimActions.MapCustomJson(Claims.PictureUrls, user + => string.Join(",", user.SelectTokens("$.profilePicture..elements[*].identifiers[0].identifier").Select(u => u.Value()))); } + /// + /// 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 { ProfileFields.Id, ProfileFields.FirstName, ProfileFields.LastName, - ProfileFields.FormattedName, LinkedInAuthenticationConstants.EmailAddressField }; + + /// + /// 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 + /// + /// The payload returned by the user info endpoint. + /// The name of the MultiLocaleString property. + /// The property value. + 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(); + } + } + + var currentUiKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_'); + var currentUiLocalizedValue = user[propertyName][localizedKey][currentUiKey]; + if (currentUiLocalizedValue != null) + { + return currentUiLocalizedValue.Value(); + } + + return user[propertyName][localizedKey].First.Value(); + } } } From be7becb955058e7f03d2d46569155955d884c80a Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 00:40:14 +0200 Subject: [PATCH 2/8] Update unit tests with new JSON payload --- .../LinkedIn/LinkedInTests.cs | 1 - .../LinkedIn/bundle.json | 46 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs index 0242be519..064de3cb5 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs @@ -33,7 +33,6 @@ 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")] public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue) { // Arrange diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json index 2530fde75..350a4b196 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json @@ -12,20 +12,48 @@ } }, { - "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)", "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" + }, + "preferredLocale": { + "country": "US", + "language": "en" + } + }, + "lastName": { + "localized": { + "en_US": "Baggins" + }, + "preferredLocale": { + "country": "US", + "language": "en" + } + }, + "profilePicture": { + "displayImage": "urn:li:digitalmediaAsset:B54328XZFfe2134zTyq" } } + }, + { + "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" + } + } + ] + } } ] } From 097f050b89509785cb7ee9ccd4446d065e8289bc Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 14:52:06 +0200 Subject: [PATCH 3/8] Resolve PR comments --- .../LinkedInAuthenticationConstants.cs | 15 +++-- .../LinkedInAuthenticationDefaults.cs | 2 +- .../LinkedInAuthenticationHandler.cs | 58 +++++++++++-------- .../LinkedInAuthenticationOptions.cs | 55 +++++++++++++----- .../LinkedIn/LinkedInTests.cs | 7 ++- .../LinkedIn/bundle.json | 56 +++++++++++++++++- 6 files changed, 145 insertions(+), 48 deletions(-) diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs index ab1062969..c5bca8f3b 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs @@ -19,30 +19,33 @@ public static class Claims public const string EmailAddressField = "emailAddress"; - // https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/lite-profile?context=linkedin/consumer/context + /// + /// 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 { /// - /// 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. + /// 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"; /// /// 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 + /// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring /// public const string FirstName = "firstName"; /// /// 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 + /// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring /// 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 + /// See https://docs.microsoft.com/en-us/linkedin/shared/references/v2/profile/profile-picture /// public const string PictureUrl = "profilePicture(displayImage~:playableStreams)"; } diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs index 38d0d18c4..835aebb6b 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationDefaults.cs @@ -51,7 +51,7 @@ public class LinkedInAuthenticationDefaults /// /// 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. + /// 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 fa043b2cf..032368ca4 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs @@ -4,6 +4,8 @@ * 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; @@ -13,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; @@ -34,13 +37,18 @@ protected override async Task CreateTicketAsync([NotNull] [NotNull] AuthenticationProperties properties, [NotNull] OAuthTokenResponse tokens) { var address = Options.UserInformationEndpoint; - var fields = Options.Fields.Where(f => f != LinkedInAuthenticationConstants.EmailAddressField).ToList(); + 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) { - address = $"{address}?projection=({string.Join(",", fields)})"; + address = QueryHelpers.AddQueryString(address, new Dictionary + { + ["projection"] = $"({string.Join(",", fields)})", + }); } var request = new HttpRequestMessage(HttpMethod.Get, address); @@ -63,27 +71,7 @@ protected override async Task CreateTicketAsync([NotNull] 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); + payload.Last.AddAfterSelf(new JProperty("emailAddress", await GetEmailAsync(tokens))); } var principal = new ClaimsPrincipal(identity); @@ -93,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 a4aa7cfbe..dba1bdf9e 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs @@ -36,15 +36,13 @@ public LinkedInAuthenticationOptions() Scope.Add("r_liteprofile"); Scope.Add("r_emailaddress"); - ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); - ClaimActions.MapJsonKey(ClaimTypes.Email, "emailAddress"); - 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()).LastOrDefault()); - ClaimActions.MapCustomJson(Claims.PictureUrls, user - => string.Join(",", user.SelectTokens("$.profilePicture..elements[*].identifiers[0].identifier").Select(u => u.Value()))); + 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 => string.Join(",", GetPictureUrls(user))); } /// @@ -54,9 +52,9 @@ public LinkedInAuthenticationOptions() /// /// Gets the list of fields to retrieve from the user information endpoint. - /// See https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin 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, @@ -65,16 +63,16 @@ public LinkedInAuthenticationOptions() }; /// - /// Gets the MultiLocaleString value. + /// 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 + /// 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 name of the MultiLocaleString property. /// The property value. - private string GetMultiLocaleString(JObject user, string propertyName) + private static string GetMultiLocaleString(JObject user, string propertyName) { if (user[propertyName] == null) { @@ -103,5 +101,32 @@ private string GetMultiLocaleString(JObject user, string propertyName) return user[propertyName][localizedKey].First.Value(); } + + private static string GetFullName(JObject 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 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")); + } } } diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs index 064de3cb5..a85ac1d8d 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs @@ -24,7 +24,11 @@ public LinkedInTests(ITestOutputHelper outputHelper) protected internal override void RegisterAuthentication(AuthenticationBuilder builder) { - builder.AddLinkedIn(options => ConfigureDefaults(builder, options)); + builder.AddLinkedIn(options => + { + ConfigureDefaults(builder, options); + options.Fields.Add(LinkedInAuthenticationConstants.ProfileFields.PictureUrl); + }); } [Theory] @@ -33,6 +37,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu [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")] public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue) { // Arrange diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json index 350a4b196..f81443865 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json @@ -13,7 +13,7 @@ }, { "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)", + "uri": "https://api.linkedin.com/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~%3AplayableStreams))", "contentFormat": "json", "contentJson": { "id": "1R2RtA", @@ -36,7 +36,59 @@ } }, "profilePicture": { - "displayImage": "urn:li:digitalmediaAsset:B54328XZFfe2134zTyq" + "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": [ + + ] + } + } } } }, From 1ca0ddeac58a06e50f7749896645c3e52defa96f Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 16:55:14 +0200 Subject: [PATCH 4/8] Add MultiLocaleStringResolver to let the integrator decide which localized value should be taken --- .../LinkedInAuthenticationOptions.cs | 71 ++++++++++++------- .../LinkedIn/LinkedInTests.cs | 33 +++++++++ .../LinkedIn/bundle.json | 6 +- 3 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs index dba1bdf9e..415db2b6b 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs @@ -63,46 +63,39 @@ public LinkedInAuthenticationOptions() }; /// - /// 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. + /// 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 static string GetMultiLocaleString(JObject user, string propertyName) + private string GetMultiLocaleString(JObject user, string propertyName) { - if (user[propertyName] == null) + const string localizedKey = "localized"; + if (user[propertyName] == null || user[propertyName][localizedKey] == 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(); - } - } - - var currentUiKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_'); - var currentUiLocalizedValue = user[propertyName][localizedKey][currentUiKey]; - if (currentUiLocalizedValue != null) - { - return currentUiLocalizedValue.Value(); - } + var preferredLocaleKey = preferredLocale == null ? null : $"{preferredLocale["language"]}_{preferredLocale["country"]}"; + var values = user[propertyName][localizedKey].ToObject>(); - return user[propertyName][localizedKey].First.Value(); + return MultiLocaleStringResolver(values, preferredLocaleKey); } - private static string GetFullName(JObject user) + private string GetFullName(JObject user) { var nameParts = new string[] { @@ -128,5 +121,31 @@ private static IEnumerable GetPictureUrls(JObject user) 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.ContainsKey(preferredLocale)) + { + return localizedValues[preferredLocale]; + } + + var currentUiKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_'); + if (localizedValues.ContainsKey(currentUiKey)) + { + return localizedValues[currentUiKey]; + } + + return localizedValues.Values.FirstOrDefault(); + } } } diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs index a85ac1d8d..c6df14e2e 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs @@ -4,6 +4,8 @@ * 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; @@ -15,6 +17,8 @@ namespace AspNet.Security.OAuth.LinkedIn { public class LinkedInTests : OAuthTests { + private Action additionnalConfiguration = null; + public LinkedInTests(ITestOutputHelper outputHelper) { OutputHelper = outputHelper; @@ -28,6 +32,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu { ConfigureDefaults(builder, options); options.Fields.Add(LinkedInAuthenticationConstants.ProfileFields.PictureUrl); + additionnalConfiguration?.Invoke(options); }); } @@ -50,5 +55,33 @@ public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue 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 + additionnalConfiguration = options => options.MultiLocaleStringResolver = (values, preferredLocale) => + { + if (values.ContainsKey("fr_FR")) + { + return values["fr_FR"]; + } + + return values.Values.FirstOrDefault(); + }; + + using (var server = CreateTestServer()) + { + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } + } } } diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json index f81443865..706b7ee84 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json @@ -19,7 +19,8 @@ "id": "1R2RtA", "firstName": { "localized": { - "en_US": "Frodo" + "en_US": "Frodo", + "fr_FR": "Frodon" }, "preferredLocale": { "country": "US", @@ -28,7 +29,8 @@ }, "lastName": { "localized": { - "en_US": "Baggins" + "en_US": "Baggins", + "fr_FR": "Sacquet" }, "preferredLocale": { "country": "US", From 47f0d60ce2a900511eccc3f48147dc5c9d364d57 Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 17:01:48 +0200 Subject: [PATCH 5/8] Rename private member --- .../LinkedIn/LinkedInTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs index c6df14e2e..d7a7cafcb 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs @@ -17,7 +17,7 @@ namespace AspNet.Security.OAuth.LinkedIn { public class LinkedInTests : OAuthTests { - private Action additionnalConfiguration = null; + private Action additionalConfiguration = null; public LinkedInTests(ITestOutputHelper outputHelper) { @@ -32,7 +32,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu { ConfigureDefaults(builder, options); options.Fields.Add(LinkedInAuthenticationConstants.ProfileFields.PictureUrl); - additionnalConfiguration?.Invoke(options); + additionalConfiguration?.Invoke(options); }); } @@ -64,7 +64,7 @@ public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue public async Task Can_Sign_In_Using_LinkedIn_Localized(string claimType, string claimValue) { // Arrange - additionnalConfiguration = options => options.MultiLocaleStringResolver = (values, preferredLocale) => + additionalConfiguration = options => options.MultiLocaleStringResolver = (values, preferredLocale) => { if (values.ContainsKey("fr_FR")) { From 3bf8c112c633e64c17b0640be1d7a0ef4f81f243 Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 18:52:30 +0200 Subject: [PATCH 6/8] Fix PR comments, add tests with RequestLocalization --- .editorconfig | 2 +- .../LinkedInAuthenticationHandler.cs | 2 +- .../LinkedInAuthenticationOptions.cs | 32 +++++++++++------- .../Infrastructure/ApplicationFactory.cs | 10 +++--- .../LinkedIn/LinkedInTests.cs | 33 +++++++++++++++++-- .../LinkedIn/bundle.json | 20 +++++++++++ .../OAuthTests`1.cs | 9 +++++ 7 files changed, 87 insertions(+), 21 deletions(-) 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/LinkedInAuthenticationHandler.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs index 032368ca4..36ab87204 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationHandler.cs @@ -36,7 +36,7 @@ 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(); diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs index 415db2b6b..6bc06ce79 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs @@ -42,7 +42,11 @@ public LinkedInAuthenticationOptions() 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 => string.Join(",", GetPictureUrls(user))); + ClaimActions.MapCustomJson(Claims.PictureUrls, user => + { + var urls = GetPictureUrls(user); + return urls == null ? null : string.Join(",", urls); + }); } /// @@ -82,22 +86,25 @@ public LinkedInAuthenticationOptions() /// The property value. private string GetMultiLocaleString(JObject user, string propertyName) { - const string localizedKey = "localized"; - if (user[propertyName] == null || user[propertyName][localizedKey] == null) + var property = user[propertyName]; + var propertyLocalized = property["localized"]; + if (property == null || propertyLocalized == null) { return null; } - var preferredLocale = user[propertyName]["preferredLocale"]; - var preferredLocaleKey = preferredLocale == null ? null : $"{preferredLocale["language"]}_{preferredLocale["country"]}"; - var values = user[propertyName][localizedKey].ToObject>(); + 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) { - var nameParts = new string[] + string[] nameParts = new string[] { GetMultiLocaleString(user, ProfileFields.FirstName), GetMultiLocaleString(user, ProfileFields.LastName) @@ -134,15 +141,16 @@ where address.Value("authorizationMethod") == "PUBLIC" /// The localized value. private static string DefaultMultiLocaleStringResolver(IReadOnlyDictionary localizedValues, string preferredLocale) { - if (!string.IsNullOrEmpty(preferredLocale) && localizedValues.ContainsKey(preferredLocale)) + if (!string.IsNullOrEmpty(preferredLocale) + && localizedValues.TryGetValue(preferredLocale, out string preferredLocaleValue)) { - return localizedValues[preferredLocale]; + return preferredLocaleValue; } - var currentUiKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_'); - if (localizedValues.ContainsKey(currentUiKey)) + string currentUIKey = Thread.CurrentThread.CurrentUICulture.ToString().Replace('-', '_'); + if (localizedValues.TryGetValue(currentUIKey, out string currentUIValue)) { - return localizedValues[currentUiKey]; + 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 d7a7cafcb..3342ec3ca 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/LinkedInTests.cs @@ -9,6 +9,7 @@ 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; @@ -31,10 +32,17 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu builder.AddLinkedIn(options => { ConfigureDefaults(builder, options); - options.Fields.Add(LinkedInAuthenticationConstants.ProfileFields.PictureUrl); additionalConfiguration?.Invoke(options); }); } + + protected internal override void ConfigureApplication(IApplicationBuilder app) + { + app.UseRequestLocalization(new RequestLocalizationOptions + { + DefaultRequestCulture = new Microsoft.AspNetCore.Localization.RequestCulture("fr-FR"), + }); + } [Theory] [InlineData(ClaimTypes.NameIdentifier, "1R2RtA")] @@ -46,6 +54,7 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu 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 @@ -62,13 +71,31 @@ public async Task Can_Sign_In_Using_LinkedIn(string claimType, string claimValue [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.ContainsKey("fr_FR")) + if (values.TryGetValue("fr_FR", out string value)) { - return values["fr_FR"]; + return value; } return values.Values.FirstOrDefault(); diff --git a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json index 706b7ee84..d427b7281 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json +++ b/test/AspNet.Security.OAuth.Providers.Tests/LinkedIn/bundle.json @@ -94,6 +94,26 @@ } } }, + { + "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~))", 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. /// From e9628e68595ef7a14586f11d7b1d9fac890cfbc4 Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Wed, 22 May 2019 19:35:51 +0200 Subject: [PATCH 7/8] I can't describe this commit without insulting myself :face_with_rolling_eyes: --- .../LinkedInAuthenticationOptions.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs index 6bc06ce79..17a3390cc 100644 --- a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationOptions.cs @@ -87,8 +87,13 @@ public LinkedInAuthenticationOptions() private string GetMultiLocaleString(JObject user, string propertyName) { var property = user[propertyName]; + if (property == null) + { + return null; + } + var propertyLocalized = property["localized"]; - if (property == null || propertyLocalized == null) + if (propertyLocalized == null) { return null; } From 2faddb4fc14c677d0bdcaa359bdd229e97b68db0 Mon Sep 17 00:00:00 2001 From: Adrien Siffermann Date: Thu, 23 May 2019 08:52:55 +0200 Subject: [PATCH 8/8] Restore constants as obsolete --- .../LinkedInAuthenticationConstants.cs | 165 +++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs b/src/AspNet.Security.OAuth.LinkedIn/LinkedInAuthenticationConstants.cs index c5bca8f3b..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,10 +13,55 @@ 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"; + + [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 = "emailAddress"; @@ -48,6 +95,122 @@ public static class ProfileFields /// 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 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"; } } -} \ No newline at end of file +}