Skip to content

Commit

Permalink
Retrieve user details after sign-in
Browse files Browse the repository at this point in the history
Get the user's name and email address, if available, as claims after signing in with an Apple ID. These details are only available the first time the user signs in; if they are not persisted they cannot currently be obtained again.
  • Loading branch information
martincostello committed Sep 8, 2019
1 parent eae3d43 commit 4a93eb6
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 17 deletions.
81 changes: 67 additions & 14 deletions src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);

Expand All @@ -124,6 +130,55 @@ protected override async Task<OAuthTokenResponse> ExchangeCodeAsync(string code,
return await base.ExchangeCodeAsync(code, redirectUri);
}

/// <summary>
/// Extracts the claims from the token received from the token endpoint.
/// </summary>
/// <param name="token">The token to extract the claims from.</param>
/// <returns>
/// An <see cref="IEnumerable{Claim}"/> containing the claims extracted from the token.
/// </returns>
protected virtual IEnumerable<Claim> ExtractClaimsFromToken([NotNull] string token)
{
try
{
var securityToken = _tokenHandler.ReadJwtToken(token);

return new List<Claim>(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);
}
}

/// <summary>
/// Extracts the claims from the user received from the authorization endpoint.
/// </summary>
/// <param name="user">The user object to extract the claims from.</param>
/// <returns>
/// An <see cref="IEnumerable{Claim}"/> containing the claims extracted from the user information.
/// </returns>
protected virtual IEnumerable<Claim> ExtractClaimsFromUser([NotNull] JObject user)
{
var claims = new List<Claim>();

if (user.TryGetValue("name", out var name))
{
claims.Add(new Claim(ClaimTypes.GivenName, name.Value<string>("firstName"), ClaimValueTypes.String, ClaimsIssuer));
claims.Add(new Claim(ClaimTypes.Surname, name.Value<string>("lastName"), ClaimValueTypes.String, ClaimsIssuer));
}

if (user.TryGetValue("email", out var email))
{
claims.Add(new Claim(ClaimTypes.Email, email.Value<string>(), ClaimValueTypes.String, ClaimsIssuer));
}

return claims;
}

/// <inheritdoc />
protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
{
Expand All @@ -149,19 +204,6 @@ protected override async Task<HandleRequestResult> 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<HandleRequestResult> HandleRemoteAuthenticateAsync(
[NotNull] Dictionary<string, StringValues> parameters)
{
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +29,13 @@ public AppleTests(ITestOutputHelper outputHelper)

public override string DefaultScheme => AppleAuthenticationDefaults.AuthenticationScheme;

protected override HttpMethod RedirectMethod => HttpMethod.Post;

protected override IDictionary<string, string> RedirectParameters => new Dictionary<string, string>()
{
["user"] = @"{""name"":{""firstName"":""Johnny"",""lastName"":""Appleseed""},""email"":""johnny.appleseed@apple.local""}",
};

protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
{
IdentityModelEventSource.ShowPII = true;
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -17,6 +18,10 @@ namespace AspNet.Security.OAuth.Infrastructure
/// </summary>
internal class LoopbackRedirectHandler : DelegatingHandler
{
public HttpMethod RedirectMethod { get; set; } = HttpMethod.Get;

public IDictionary<string, string> RedirectParameters { get; set; } = new Dictionary<string, string>();

public string RedirectUri { get; set; }

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
Expand All @@ -28,8 +33,40 @@ protected override async Task<HttpResponseMessage> 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<string, string>()
{
["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)
Expand Down
19 changes: 17 additions & 2 deletions test/AspNet.Security.OAuth.Providers.Tests/OAuthTests`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

/// <summary>
Expand All @@ -58,6 +63,16 @@ protected OAuthTests()
/// </summary>
public abstract string DefaultScheme { get; }

/// <summary>
/// Gets the optional redirect HTTP method to use for OAuth flows.
/// </summary>
protected virtual HttpMethod RedirectMethod => HttpMethod.Get;

/// <summary>
/// Gets the optional additional parameters for the redirect request with OAuth flows.
/// </summary>
protected virtual IDictionary<string, string> RedirectParameters => new Dictionary<string, string>();

/// <summary>
/// Gets the optional redirect URI to use for OAuth flows.
/// </summary>
Expand Down Expand Up @@ -112,7 +127,7 @@ protected HttpClient CreateBackchannel(AuthenticationBuilder builder)
public DelegatingHandler LoopbackRedirectHandler { get; set; }

/// <summary>
/// Run the ChannelAsync for authentication
/// Run the ChannelAsync for authentication
/// </summary>
/// <param name="context">The HTTP context</param>
protected internal virtual Task ChallengeAsync(HttpContext context) => context.ChallengeAsync();
Expand Down

0 comments on commit 4a93eb6

Please sign in to comment.