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. ///