diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs index 102c31468..217e14545 100644 --- a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs @@ -20,6 +20,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Newtonsoft.Json.Linq; namespace AspNet.Security.OAuth.Apple { @@ -101,7 +102,12 @@ protected new AppleAuthenticationEvents Events await Events.ValidateIdToken(validateIdContext); } - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, GetNameIdentifier(idToken))); + var tokenClaims = ExtractClaimsFromToken(idToken); + + foreach (var claim in tokenClaims) + { + identity.AddClaim(claim); + } var principal = new ClaimsPrincipal(identity); @@ -124,6 +130,55 @@ protected override async Task ExchangeCodeAsync(string code, return await base.ExchangeCodeAsync(code, redirectUri); } + /// + /// Extracts the claims from the token received from the token endpoint. + /// + /// The token to extract the claims from. + /// + /// An containing the claims extracted from the token. + /// + protected virtual IEnumerable ExtractClaimsFromToken([NotNull] string token) + { + try + { + var securityToken = _tokenHandler.ReadJwtToken(token); + + return new List(securityToken.Claims) + { + new Claim(ClaimTypes.NameIdentifier, securityToken.Subject, ClaimValueTypes.String, ClaimsIssuer), + }; + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to parse JWT for claims from Apple ID token.", ex); + } + } + + /// + /// Extracts the claims from the user received from the authorization endpoint. + /// + /// The user object to extract the claims from. + /// + /// An containing the claims extracted from the user information. + /// + protected virtual IEnumerable ExtractClaimsFromUser([NotNull] JObject user) + { + var claims = new List(); + + if (user.TryGetValue("name", out var name)) + { + claims.Add(new Claim(ClaimTypes.GivenName, name.Value("firstName"), ClaimValueTypes.String, ClaimsIssuer)); + claims.Add(new Claim(ClaimTypes.Surname, name.Value("lastName"), ClaimValueTypes.String, ClaimsIssuer)); + } + + if (user.TryGetValue("email", out var email)) + { + claims.Add(new Claim(ClaimTypes.Email, email.Value(), ClaimValueTypes.String, ClaimsIssuer)); + } + + return claims; + } + /// protected override async Task HandleRemoteAuthenticateAsync() { @@ -149,19 +204,6 @@ protected override async Task HandleRemoteAuthenticateAsync return await HandleRemoteAuthenticateAsync(parameters); } - private string GetNameIdentifier(string token) - { - try - { - var userToken = _tokenHandler.ReadJwtToken(token); - return userToken.Subject; - } - catch (Exception ex) - { - throw new InvalidOperationException("Failed to parse JWT from Apple ID token.", ex); - } - } - private async Task HandleRemoteAuthenticateAsync( [NotNull] Dictionary parameters) { @@ -276,6 +318,17 @@ private string GetNameIdentifier(string token) properties.StoreTokens(authTokens); } + if (parameters.TryGetValue("user", out var userJson)) + { + var user = JObject.Parse(userJson); + var userClaims = ExtractClaimsFromUser(user); + + foreach (var claim in userClaims) + { + identity.AddClaim(claim); + } + } + var ticket = await CreateTicketAsync(identity, properties, tokens); if (ticket != null) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs index 60d827a22..078eb6201 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs @@ -5,7 +5,9 @@ */ using System; +using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; @@ -27,6 +29,13 @@ public AppleTests(ITestOutputHelper outputHelper) public override string DefaultScheme => AppleAuthenticationDefaults.AuthenticationScheme; + protected override HttpMethod RedirectMethod => HttpMethod.Post; + + protected override IDictionary RedirectParameters => new Dictionary() + { + ["user"] = @"{""name"":{""firstName"":""Johnny"",""lastName"":""Appleseed""},""email"":""johnny.appleseed@apple.local""}", + }; + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) { IdentityModelEventSource.ShowPII = true; @@ -39,7 +48,10 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu } [Theory] + [InlineData(ClaimTypes.Email, "johnny.appleseed@apple.local")] + [InlineData(ClaimTypes.GivenName, "Johnny")] [InlineData(ClaimTypes.NameIdentifier, "001883.fcc77ba97500402389df96821ad9c790.1517")] + [InlineData(ClaimTypes.Surname, "Appleseed")] public async Task Can_Sign_In_Using_Apple_With_Client_Secret(string claimType, string claimValue) { // Arrange @@ -64,7 +76,10 @@ void ConfigureServices(IServiceCollection services) } [Theory] + [InlineData(ClaimTypes.Email, "johnny.appleseed@apple.local")] + [InlineData(ClaimTypes.GivenName, "Johnny")] [InlineData(ClaimTypes.NameIdentifier, "001883.fcc77ba97500402389df96821ad9c790.1517")] + [InlineData(ClaimTypes.Surname, "Appleseed")] public async Task Can_Sign_In_Using_Apple_With_Private_Key(string claimType, string claimValue) { // Arrange @@ -98,7 +113,10 @@ void ConfigureServices(IServiceCollection services) } [Theory] + [InlineData(ClaimTypes.Email, "johnny.appleseed@apple.local")] + [InlineData(ClaimTypes.GivenName, "Johnny")] [InlineData(ClaimTypes.NameIdentifier, "001883.fcc77ba97500402389df96821ad9c790.1517")] + [InlineData(ClaimTypes.Surname, "Appleseed")] public async Task Can_Sign_In_Using_Apple_With_No_Token_Validation(string claimType, string claimValue) { // Arrange diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/LoopbackRedirectHandler.cs b/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/LoopbackRedirectHandler.cs index 4e50c5528..4562a9501 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/LoopbackRedirectHandler.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Infrastructure/LoopbackRedirectHandler.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -17,6 +18,10 @@ namespace AspNet.Security.OAuth.Infrastructure /// internal class LoopbackRedirectHandler : DelegatingHandler { + public HttpMethod RedirectMethod { get; set; } = HttpMethod.Get; + + public IDictionary RedirectParameters { get; set; } = new Dictionary(); + public string RedirectUri { get; set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -28,8 +33,40 @@ protected override async Task SendAsync(HttpRequestMessage !string.Equals(result.Headers.Location?.Host, "localhost", StringComparison.OrdinalIgnoreCase)) { var uri = BuildLoopbackUri(result); + HttpContent content = null; + + if (RedirectMethod == HttpMethod.Post) + { + var queryString = HttpUtility.ParseQueryString(result.Headers.Location.Query); + string state = queryString["state"]; + + var parameters = new Dictionary() + { + ["code"] = "a6ed8e7f-471f-44f1-903b-65946475f351", + ["state"] = state, + }; + + if (RedirectParameters?.Count > 0) + { + foreach (var parameter in RedirectParameters) + { + parameters[parameter.Key] = parameter.Value; + } + } + + content = new FormUrlEncodedContent(parameters); + } + else + { + uri = BuildLoopbackUri(result); + } + + var redirectRequest = new HttpRequestMessage(RedirectMethod, uri); - var redirectRequest = new HttpRequestMessage(request.Method, uri); + if (content != null) + { + redirectRequest.Content = content; + } // Forward on the headers and cookies foreach (var header in result.Headers) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs b/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs index 5d41ece6f..6c49265f9 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs @@ -40,7 +40,12 @@ protected OAuthTests() .ThrowsOnMissingRegistration() .RegisterBundle(Path.Combine(GetType().Name.Replace("Tests", string.Empty), "bundle.json")); - LoopbackRedirectHandler = new LoopbackRedirectHandler { RedirectUri = RedirectUri }; + LoopbackRedirectHandler = new LoopbackRedirectHandler + { + RedirectMethod = RedirectMethod, + RedirectParameters = RedirectParameters, + RedirectUri = RedirectUri, + }; } /// @@ -58,6 +63,16 @@ protected OAuthTests() /// public abstract string DefaultScheme { get; } + /// + /// Gets the optional redirect HTTP method to use for OAuth flows. + /// + protected virtual HttpMethod RedirectMethod => HttpMethod.Get; + + /// + /// Gets the optional additional parameters for the redirect request with OAuth flows. + /// + protected virtual IDictionary RedirectParameters => new Dictionary(); + /// /// Gets the optional redirect URI to use for OAuth flows. /// @@ -112,7 +127,7 @@ protected HttpClient CreateBackchannel(AuthenticationBuilder builder) public DelegatingHandler LoopbackRedirectHandler { get; set; } /// - /// Run the ChannelAsync for authentication + /// Run the ChannelAsync for authentication /// /// The HTTP context protected internal virtual Task ChallengeAsync(HttpContext context) => context.ChallengeAsync();