diff --git a/.gitattributes b/.gitattributes index 8a92b1213..10b136d73 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,10 @@ ############################################################################### * text=auto +*.cer binary +*.p8 binary +*.pfx binary + ############################################################################### # Set default behavior for command prompt diff. # diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index ead4e67e5..f55209191 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -165,6 +165,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mvc.Client", "samples\Mvc.C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.GitLab", "src\AspNet.Security.OAuth.GitLab\AspNet.Security.OAuth.GitLab.csproj", "{FACB1C2F-3BF7-4DD3-958B-E471E69E214A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Apple", "src\AspNet.Security.OAuth.Apple\AspNet.Security.OAuth.Apple.csproj", "{DC80AFEC-EFB4-4B21-BF79-44DEA6E1FE26}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -395,6 +397,10 @@ Global {FACB1C2F-3BF7-4DD3-958B-E471E69E214A}.Debug|Any CPU.Build.0 = Debug|Any CPU {FACB1C2F-3BF7-4DD3-958B-E471E69E214A}.Release|Any CPU.ActiveCfg = Release|Any CPU {FACB1C2F-3BF7-4DD3-958B-E471E69E214A}.Release|Any CPU.Build.0 = Release|Any CPU + {DC80AFEC-EFB4-4B21-BF79-44DEA6E1FE26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC80AFEC-EFB4-4B21-BF79-44DEA6E1FE26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC80AFEC-EFB4-4B21-BF79-44DEA6E1FE26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC80AFEC-EFB4-4B21-BF79-44DEA6E1FE26}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -458,6 +464,7 @@ Global {112F6B50-0FD3-45AA-992E-72D7B873BF00} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {140A6CAD-FC84-4A09-A939-8A5692DF0C7C} = {BAC7067D-88FE-4385-8AC9-1A325FFBDE69} {FACB1C2F-3BF7-4DD3-958B-E471E69E214A} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {DC80AFEC-EFB4-4B21-BF79-44DEA6E1FE26} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C} diff --git a/README.md b/README.md index 02db921f9..d9ef7af4b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | Provider | Stable | Nightly | Documentation | |:-:|:-:|:-:|:-:| | Amazon | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Amazon?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Amazon/ "Download AspNet.Security.OAuth.Amazon from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Amazon?includePreReleases=false)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Amazon "Download AspNet.Security.OAuth.Amazon from MyGet.org") | [Documentation](https://developer.amazon.com/docs/login-with-amazon/documentation-overview.html "Amazon developer documentation") | +| Apple | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Apple?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Apple/ "Download AspNet.Security.OAuth.Apple from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Apple?includePreReleases=false)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Apple "Download AspNet.Security.OAuth.Apple from MyGet.org") | [Documentation](https://developer.apple.com/documentation/signinwithapplerestapi "Apple developer documentation") | | ArcGIS | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.ArcGIS?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.ArcGIS/ "Download AspNet.Security.OAuth.ArcGIS from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.ArcGIS?includePreReleases=false)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.ArcGIS "Download AspNet.Security.OAuth.ArcGIS from MyGet.org") | [Documentation](https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/what-is-oauth-2/ "ArcGIS developer documentation") | | Asana | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Asana?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Asana/ "Download AspNet.Security.OAuth.Asana from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Asana?includePreReleases=false)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Asana "Download AspNet.Security.OAuth.Asana from MyGet.org") | [Documentation](https://asana.com/developers/documentation/getting-started/auth "Asana developer documentation") | | Autodesk | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Autodesk?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Autodesk/ "Download AspNet.Security.OAuth.Autodesk from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Autodesk?includePreReleases=false)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Autodesk "Download AspNet.Security.OAuth.Autodesk from MyGet.org") | [Documentation](https://forge.autodesk.com/en/docs/oauth/v2/developers_guide/overview/ "Autodesk developer documentation") | diff --git a/build/common.props b/build/common.props index d6998bc5f..a43ca09ac 100644 --- a/build/common.props +++ b/build/common.props @@ -4,6 +4,7 @@ + latest $(NoWarn);CS1591 true true diff --git a/build/dependencies.props b/build/dependencies.props index 3b04c9187..d16329a0a 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -9,6 +9,8 @@ 0.1.0 1.0.0-beta2-19270-01 3.0.2 + 5.3.0 + 4.4.0 2.2.0 2.4.1 diff --git a/samples/Mvc.Client/Startup.cs b/samples/Mvc.Client/Startup.cs index a685479fe..5396b6530 100644 --- a/samples/Mvc.Client/Startup.cs +++ b/samples/Mvc.Client/Startup.cs @@ -7,12 +7,25 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Logging; namespace Mvc.Client { public class Startup { + public Startup(IConfiguration configuration, IHostingEnvironment hostingEnvironment) + { + Configuration = configuration; + HostingEnvironment = hostingEnvironment; + } + + private IConfiguration Configuration { get; } + + private IHostingEnvironment HostingEnvironment { get; } + public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(options => @@ -45,6 +58,17 @@ public void ConfigureServices(IServiceCollection services) options.Scope.Add("user:email"); }) + /* + .AddApple(options => + { + options.ClientId = Configuration["AppleClientId"]; + options.KeyId = Configuration["AppleKeyId"]; + options.TeamId = Configuration["AppleTeamId"]; + options.UsePrivateKey( + (keyId) => HostingEnvironment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); + }) + */ + .AddDropbox(options => { options.ClientId = "jpk24g2uxfxe939"; @@ -56,7 +80,18 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app) { - app.UseStaticFiles(); + if (HostingEnvironment.IsDevelopment()) + { + IdentityModelEventSource.ShowPII = true; + } + + // Required to serve files with no extension in the .well-known folder + var options = new StaticFileOptions() + { + ServeUnknownFileTypes = true, + }; + + app.UseStaticFiles(options); app.UseAuthentication(); diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationConstants.cs new file mode 100644 index 000000000..1aa1e16d5 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationConstants.cs @@ -0,0 +1,19 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Contains constants specific to the . + /// + public static class AppleAuthenticationConstants + { + /// + /// Default value for the token audience. + /// + public const string Audience = "https://appleid.apple.com"; + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationDefaults.cs new file mode 100644 index 000000000..be3ed5558 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationDefaults.cs @@ -0,0 +1,52 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OAuth; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Default values used by the Apple (Sign in with Apple) authentication provider. + /// + public static class AppleAuthenticationDefaults + { + /// + /// Default value for . + /// + public const string AuthenticationScheme = "Apple"; + + /// + /// Default value for . + /// + public const string DisplayName = "Apple"; + + /// + /// Default value for . + /// + public const string Issuer = "Apple"; + + /// + /// Default value for . + /// + public const string CallbackPath = "/signin-apple"; + + /// + /// Default value for . + /// + public const string AuthorizationEndpoint = "https://appleid.apple.com/auth/authorize"; + + /// + /// Default value for the endpoint to get the Apple public key to verify ID token signatures. + /// + public const string PublicKeyEndpoint = "https://appleid.apple.com/auth/keys"; + + /// + /// Default value for . + /// + public const string TokenEndpoint = "https://appleid.apple.com/auth/token"; + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationEvents.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationEvents.cs new file mode 100644 index 000000000..574680ee3 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationEvents.cs @@ -0,0 +1,56 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.Extensions.DependencyInjection; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Default implementation. + /// + public class AppleAuthenticationEvents : OAuthEvents + { + /// + /// Gets or sets the delegate that is invoked when the method is invoked. + /// + public Func OnGenerateClientSecret { get; set; } = async context => + { + var provider = context.HttpContext.RequestServices.GetService(); + context.Options.ClientSecret = await provider.GenerateAsync(context); + }; + + /// + /// Gets or sets the delegate that is invoked when the method is invoked. + /// + public Func OnValidateIdToken { get; set; } = async context => + { + var validator = context.HttpContext.RequestServices.GetRequiredService(); + await validator.ValidateAsync(context); + }; + + /// + /// Invoked whenever the client secret needs to be generated. + /// + /// Contains information about the current request. + /// + /// A representing the completed operation. + /// + public virtual async Task GenerateClientSecret([NotNull] AppleGenerateClientSecretContext context) => await OnGenerateClientSecret(context); + + /// + /// Invoked whenever the ID token needs to be validated. + /// + /// Contains information about the ID token to validate. + /// + /// A representing the completed operation. + /// + public virtual async Task ValidateIdToken([NotNull] AppleValidateIdTokenContext context) => await OnValidateIdToken(context); + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationExtensions.cs new file mode 100644 index 000000000..ba1978971 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationExtensions.cs @@ -0,0 +1,86 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.IdentityModel.Tokens.Jwt; +using AspNet.Security.OAuth.Apple; +using AspNet.Security.OAuth.Apple.Internal; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to add Sign in with Apple authentication capabilities to an HTTP application pipeline. + /// + public static class AppleAuthenticationExtensions + { + /// + /// Adds to the specified + /// , which enables Apple authentication capabilities. + /// + /// The authentication builder. + /// The . + public static AuthenticationBuilder AddApple([NotNull] this AuthenticationBuilder builder) + { + return builder.AddApple(AppleAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables Apple authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the Apple options. + /// The . + public static AuthenticationBuilder AddApple( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddApple(AppleAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables Apple authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the Apple options. + /// The . + public static AuthenticationBuilder AddApple( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddApple(scheme, AppleAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables Apple authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The optional display name associated with this instance. + /// The delegate used to configure the Apple options. + /// The . + public static AuthenticationBuilder AddApple( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [CanBeNull] string caption, + [NotNull] Action configuration) + { + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + return builder.AddOAuth(scheme, caption, configuration); + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs new file mode 100644 index 000000000..fca84e1bb --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationHandler.cs @@ -0,0 +1,347 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +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 Microsoft.Extensions.Primitives; +using Newtonsoft.Json.Linq; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Defines a handler for authentication using Apple. + /// + public class AppleAuthenticationHandler : OAuthHandler + { + private readonly JwtSecurityTokenHandler _tokenHandler; + + /// + /// Initializes a new instance of the class. + /// + /// The authentication options. + /// The logger to use. + /// The URL encoder to use. + /// The system clock to use. + /// The JWT security token handler to use. + public AppleAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder, + [NotNull] ISystemClock clock, + [NotNull] JwtSecurityTokenHandler tokenHandler) + : base(options, logger, encoder, clock) + { + _tokenHandler = tokenHandler; + } + + /// + /// The handler calls methods on the events which give the application control at certain points where processing is occurring. + /// If it is not provided a default instance is supplied which does nothing when the methods are called. + /// + protected new AppleAuthenticationEvents Events + { + get { return (AppleAuthenticationEvents)base.Events; } + set { base.Events = value; } + } + + /// + protected override string BuildChallengeUrl( + [NotNull] AuthenticationProperties properties, + [NotNull] string redirectUri) + { + string challengeUrl = base.BuildChallengeUrl(properties, redirectUri); + + // Apple requires the response mode to be form_post when the email or name scopes are requested + return QueryHelpers.AddQueryString(challengeUrl, "response_mode", "form_post"); + } + + /// + protected override Task CreateEventsAsync() => Task.FromResult(new AppleAuthenticationEvents()); + + /// + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + string idToken = tokens.Response.Value("id_token"); + + Logger.LogInformation("Creating ticket for Sign in with Apple."); + + if (Logger.IsEnabled(LogLevel.Trace)) + { + Logger.LogTrace("Access Token: {AccessToken}", tokens.AccessToken); + Logger.LogTrace("Refresh Token: {RefreshToken}", tokens.RefreshToken); + Logger.LogTrace("Token Type: {TokenType}", tokens.TokenType); + Logger.LogTrace("Expires In: {ExpiresIn}", tokens.ExpiresIn); + Logger.LogTrace("Response: {TokenResponse}", tokens.Response); + Logger.LogTrace("ID Token: {IdToken}", idToken); + } + + if (string.IsNullOrWhiteSpace(idToken)) + { + throw new InvalidOperationException("No Apple ID token was returned in the OAuth token response."); + } + + if (Options.ValidateTokens) + { + var validateIdContext = new AppleValidateIdTokenContext(Context, Scheme, Options, idToken); + await Options.Events.ValidateIdToken(validateIdContext); + } + + var tokenClaims = ExtractClaimsFromToken(idToken); + + foreach (var claim in tokenClaims) + { + identity.AddClaim(claim); + } + + var principal = new ClaimsPrincipal(identity); + + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens); + context.RunClaimActions(tokens.Response); + + await Options.Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name); + } + + /// + protected override async Task ExchangeCodeAsync(string code, string redirectUri) + { + if (Options.GenerateClientSecret) + { + var context = new AppleGenerateClientSecretContext(Context, Scheme, Options); + await Options.Events.GenerateClientSecret(context); + } + + 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() + { + IEnumerable> source; + + // If form_post was used, then read the parameters from the form rather than the query string + if (string.Equals(Request.Method, HttpMethod.Post.Method, StringComparison.OrdinalIgnoreCase)) + { + source = Request.Form; + } + else + { + source = Request.Query; + } + + var parameters = new Dictionary(); + + foreach (var param in source) + { + parameters.Add(param.Key, param.Value); + } + + return await HandleRemoteAuthenticateAsync(parameters); + } + + private async Task HandleRemoteAuthenticateAsync( + [NotNull] Dictionary parameters) + { + // Adapted from https://github.com/aspnet/AspNetCore/blob/e7f262e33108e92fc8805b925cc04b07d254118b/src/Security/Authentication/OAuth/src/OAuthHandler.cs#L45-L146 + if (!parameters.TryGetValue("state", out var state)) + { + state = default; + } + + var properties = Options.StateDataFormat.Unprotect(state); + + if (properties == null) + { + return HandleRequestResult.Fail("The oauth state was missing or invalid."); + } + + // OAuth2 10.12 CSRF + if (!ValidateCorrelationId(properties)) + { + return HandleRequestResult.Fail("Correlation failed."); + } + + if (!parameters.TryGetValue("error", out var error)) + { + error = default; + } + + if (!StringValues.IsNullOrEmpty(error)) + { + var failureMessage = new StringBuilder().Append(error); + + if (!parameters.TryGetValue("error_description", out var errorDescription)) + { + errorDescription = default; + } + + if (!StringValues.IsNullOrEmpty(errorDescription)) + { + failureMessage.Append(";Description=").Append(errorDescription); + } + + if (!parameters.TryGetValue("error_uri", out var errorUri)) + { + errorUri = default; + } + + if (!StringValues.IsNullOrEmpty(errorUri)) + { + failureMessage.Append(";Uri=").Append(errorUri); + } + + return HandleRequestResult.Fail(failureMessage.ToString()); + } + + if (!parameters.TryGetValue("code", out var code)) + { + code = default; + } + + if (StringValues.IsNullOrEmpty(code)) + { + return HandleRequestResult.Fail("Code was not found."); + } + + var tokens = await ExchangeCodeAsync(code, BuildRedirectUri(Options.CallbackPath)); + + if (tokens.Error != null) + { + return HandleRequestResult.Fail(tokens.Error); + } + + if (string.IsNullOrEmpty(tokens.AccessToken)) + { + return HandleRequestResult.Fail("Failed to retrieve access token."); + } + + var identity = new ClaimsIdentity(ClaimsIssuer); + + if (Options.SaveTokens) + { + var authTokens = new List() + { + new AuthenticationToken() { Name = "access_token", Value = tokens.AccessToken }, + }; + + if (!string.IsNullOrEmpty(tokens.RefreshToken)) + { + authTokens.Add(new AuthenticationToken() { Name = "refresh_token", Value = tokens.RefreshToken }); + } + + if (!string.IsNullOrEmpty(tokens.TokenType)) + { + authTokens.Add(new AuthenticationToken() { Name = "token_type", Value = tokens.TokenType }); + } + + if (!string.IsNullOrEmpty(tokens.ExpiresIn)) + { + if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) + { + // https://www.w3.org/TR/xmlschema-2/#dateTime + // https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx + var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value); + + authTokens.Add(new AuthenticationToken() + { + Name = "expires_at", + Value = expiresAt.ToString("o", CultureInfo.InvariantCulture), + }); + } + } + + 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) + { + return HandleRequestResult.Success(ticket); + } + else + { + return HandleRequestResult.Fail("Failed to retrieve user information from remote server."); + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationOptions.cs new file mode 100644 index 000000000..ea585e2a8 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationOptions.cs @@ -0,0 +1,169 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.OAuth; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Defines a set of options used by . + /// + public class AppleAuthenticationOptions : OAuthOptions + { + /// + /// Initializes a new instance of the class. + /// + public AppleAuthenticationOptions() + { + ClaimsIssuer = AppleAuthenticationDefaults.Issuer; + CallbackPath = AppleAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = AppleAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = AppleAuthenticationDefaults.TokenEndpoint; + + Events = new AppleAuthenticationEvents(); + + Scope.Add("name"); + Scope.Add("email"); + } + + /// + /// Gets or sets the period of time after which generated client secrets expire + /// if is set to . + /// + /// + /// The default client secret lifetime is six months. + /// + public TimeSpan ClientSecretExpiresAfter { get; set; } = TimeSpan.FromSeconds(15777000); // 6 months in seconds + + /// + /// Gets or sets the used to handle authentication events. + /// + public new AppleAuthenticationEvents Events + { + get => (AppleAuthenticationEvents)base.Events; + set => base.Events = value; + } + + /// + /// Gets or sets a value indicating whether to automatically generate a client secret. + /// + public bool GenerateClientSecret { get; set; } + + /// + /// Gets or sets the optional ID for your Sign in with Apple private key. + /// + public string KeyId { get; set; } + + /// + /// Gets or sets the URI the middleware will access to obtain the public key for + /// validating tokens if is . + /// + public string PublicKeyEndpoint { get; set; } = AppleAuthenticationDefaults.PublicKeyEndpoint; + + /// + /// Gets or sets an optional delegate to get the raw bytes of the client's private key + /// which is passed the value of the property. + /// + /// + /// On Windows, the private key should be in PKCS #8 (.p8) format. + /// On Linux and macOS, the private key should be PKCS #12 (.pfx) format. + /// + public Func> PrivateKeyBytes { get; set; } + + /// + /// Gets or sets the password/passphrase associated with the private key, if any. + /// + public string PrivateKeyPassword { get; set; } = string.Empty; + + /// + /// Gets or sets the Team ID for your Apple Developer account. + /// + public string TeamId { get; set; } + + /// + /// Gets or sets the audience used for tokens. + /// + public string TokenAudience { get; set; } = AppleAuthenticationConstants.Audience; + + /// + /// Gets or sets a value indicating whether to validate tokens using Apple's public key. + /// + public bool ValidateTokens { get; set; } = true; + + /// + public override void Validate() + { + try + { + // HACK We want all of the base validation except for ClientSecret, + // so rather than re-implement it all, catch the exception thrown + // for that being null and only throw if we aren't auto-generating + // the value. This does mean that three checks have to be re-implemented + // because the won't be validated if the ClientSecret validation fails. + base.Validate(); + } + catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret)) + { + if (!GenerateClientSecret) + { + throw; + } + } + + if (string.IsNullOrEmpty(AuthorizationEndpoint)) + { + throw new ArgumentException($"The '{nameof(AuthorizationEndpoint)}' option must be provided.", nameof(AuthorizationEndpoint)); + } + + if (string.IsNullOrEmpty(TokenEndpoint)) + { + throw new ArgumentException($"The '{nameof(TokenEndpoint)}' option must be provided.", nameof(TokenEndpoint)); + } + + if (!CallbackPath.HasValue) + { + throw new ArgumentException($"The '{nameof(CallbackPath)}' option must be provided.", nameof(CallbackPath)); + } + + if (GenerateClientSecret) + { + if (string.IsNullOrEmpty(KeyId)) + { + throw new ArgumentException($"The '{nameof(KeyId)}' option must be provided if the '{nameof(GenerateClientSecret)}' option is set to true.", nameof(KeyId)); + } + + if (string.IsNullOrEmpty(TeamId)) + { + throw new ArgumentException($"The '{nameof(TeamId)}' option must be provided if the '{nameof(GenerateClientSecret)}' option is set to true.", nameof(TeamId)); + } + + if (string.IsNullOrEmpty(TokenAudience)) + { + throw new ArgumentException($"The '{nameof(TokenAudience)}' option must be provided if the '{nameof(GenerateClientSecret)}' option is set to true.", nameof(TokenAudience)); + } + + if (ClientSecretExpiresAfter <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(ClientSecretExpiresAfter), + ClientSecretExpiresAfter, + $"The '{nameof(ClientSecretExpiresAfter)}' option must be a positive value if the '{nameof(GenerateClientSecret)}' option is set to true."); + } + } + + if (ValidateTokens) + { + if (string.IsNullOrEmpty(PublicKeyEndpoint)) + { + throw new ArgumentException($"The '{nameof(PublicKeyEndpoint)}' option must be provided if the '{nameof(ValidateTokens)}' option is set to true.", nameof(PublicKeyEndpoint)); + } + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleAuthenticationOptionsExtensions.cs b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationOptionsExtensions.cs new file mode 100644 index 000000000..b1a5f1fe1 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleAuthenticationOptionsExtensions.cs @@ -0,0 +1,63 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.IO; +using System.Linq; +using AspNet.Security.OAuth.Apple; +using JetBrains.Annotations; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to configure Sign in with Apple authentication capabilities for an HTTP application pipeline. + /// + public static class AppleAuthenticationOptionsExtensions + { + /// + /// Configures the application to use a specified private to generate a client secret for the provider. + /// + /// The Apple authentication options to configure. + /// + /// A delegate to a method to return the for the private + /// key which is passed the value of . + /// + /// The optional password for the private key. + /// + /// The value of the argument. + /// + public static AppleAuthenticationOptions UsePrivateKey( + [NotNull] this AppleAuthenticationOptions options, + [NotNull] Func privateKeyFile, + [CanBeNull] string privateKeyPassword = null) + { + options.GenerateClientSecret = true; + options.PrivateKeyPassword = privateKeyPassword ?? string.Empty; + options.PrivateKeyBytes = async (keyId) => + { + var fileInfo = privateKeyFile(keyId); + string privateKey; + + using (var stream = fileInfo.CreateReadStream()) + using (var reader = new StreamReader(stream)) + { + privateKey = await reader.ReadToEndAsync(); + } + + if (privateKey.StartsWith("-----BEGIN PRIVATE KEY-----", StringComparison.Ordinal)) + { + string[] lines = privateKey.Split('\n'); + privateKey = string.Join(string.Empty, lines.Skip(1).Take(lines.Length - 2)); + } + + return Convert.FromBase64String(privateKey); + }; + + return options; + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleClientSecretGenerator.cs b/src/AspNet.Security.OAuth.Apple/AppleClientSecretGenerator.cs new file mode 100644 index 000000000..202a09f57 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleClientSecretGenerator.cs @@ -0,0 +1,26 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Represents the base class for a client secret generator for Sign in with Apple. + /// + public abstract class AppleClientSecretGenerator + { + /// + /// Generates a client secret for Sign in with Apple as an asynchronous operation. + /// + /// The context. + /// + /// A representing the asynchronous operation + /// to generate a client secret for Sign in with Apple. + /// + public abstract Task GenerateAsync(AppleGenerateClientSecretContext context); + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleGenerateClientSecretContext.cs b/src/AspNet.Security.OAuth.Apple/AppleGenerateClientSecretContext.cs new file mode 100644 index 000000000..51737f94b --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleGenerateClientSecretContext.cs @@ -0,0 +1,28 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Contains information about the current request. + /// + public class AppleGenerateClientSecretContext : BaseContext + { + /// + /// Creates a new instance of the class. + /// + /// The HTTP context. + /// The authentication scheme. + /// The authentication options associated with the scheme. + public AppleGenerateClientSecretContext(HttpContext context, AuthenticationScheme scheme, AppleAuthenticationOptions options) + : base(context, scheme, options) + { + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleIdTokenValidator.cs b/src/AspNet.Security.OAuth.Apple/AppleIdTokenValidator.cs new file mode 100644 index 000000000..e8ee6027c --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleIdTokenValidator.cs @@ -0,0 +1,25 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Represents the base class for validating Apple ID tokens. + /// + public abstract class AppleIdTokenValidator + { + /// + /// Validates the Apple ID token associated with the specified context as an asynchronous operation. + /// + /// The context to validate the ID token for. + /// + /// A representing the asynchronous operation to validate the ID token. + /// + public abstract Task ValidateAsync(AppleValidateIdTokenContext context); + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleKeyStore.cs b/src/AspNet.Security.OAuth.Apple/AppleKeyStore.cs new file mode 100644 index 000000000..66c1280b3 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleKeyStore.cs @@ -0,0 +1,36 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Represents the base class for a store containing the keys for use with Sign in with Apple. + /// + public abstract class AppleKeyStore + { + /// + /// Loads the client private key as an asynchronous operation. + /// + /// The context. + /// + /// A representing the asynchronous operation + /// to get the raw bytes of the private key to use for Sign in with Apple. + /// + public abstract Task LoadPrivateKeyAsync(AppleGenerateClientSecretContext context); + + /// + /// Loads the Apple public key as an asynchronous operation. + /// + /// The context. + /// + /// A representing the asynchronous operation + /// to get the raw bytes of the public key to use for Sign in with Apple. + /// + public abstract Task LoadPublicKeysAsync(AppleValidateIdTokenContext context); + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AppleValidateIdTokenContext.cs b/src/AspNet.Security.OAuth.Apple/AppleValidateIdTokenContext.cs new file mode 100644 index 000000000..36f2adeef --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AppleValidateIdTokenContext.cs @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace AspNet.Security.OAuth.Apple +{ + /// + /// Contains information about the ID token to validate. + /// + public class AppleValidateIdTokenContext : BaseContext + { + /// + /// Creates a new instance of the class. + /// + /// The HTTP context. + /// The authentication scheme. + /// The authentication options associated with the scheme. + /// The Apple ID token for the user to validate. + public AppleValidateIdTokenContext(HttpContext context, AuthenticationScheme scheme, AppleAuthenticationOptions options, string idToken) + : base(context, scheme, options) + { + IdToken = idToken; + } + + /// + /// Gets the Apple ID token. + /// + public string IdToken { get; } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/AspNet.Security.OAuth.Apple.csproj b/src/AspNet.Security.OAuth.Apple/AspNet.Security.OAuth.Apple.csproj new file mode 100644 index 000000000..6b263e612 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/AspNet.Security.OAuth.Apple.csproj @@ -0,0 +1,22 @@ + + + + + + netstandard2.0 + + + + ASP.NET Core security provider enabling Apple authentication. + Martin Costello + apple;aspnetcore;authentication;oauth;security + + + + + + + + + + diff --git a/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleClientSecretGenerator.cs b/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleClientSecretGenerator.cs new file mode 100644 index 000000000..3486a3430 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleClientSecretGenerator.cs @@ -0,0 +1,129 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Runtime.InteropServices; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace AspNet.Security.OAuth.Apple.Internal +{ + internal sealed class DefaultAppleClientSecretGenerator : AppleClientSecretGenerator + { + private readonly ISystemClock _clock; + private readonly ILogger _logger; + private readonly AppleKeyStore _keyStore; + private readonly JwtSecurityTokenHandler _tokenHandler; + + private string _clientSecret; + private DateTimeOffset _expiresAt; + + public DefaultAppleClientSecretGenerator( + [NotNull] AppleKeyStore keyStore, + [NotNull] ISystemClock clock, + [NotNull] JwtSecurityTokenHandler tokenHandler, + [NotNull] ILogger logger) + { + _keyStore = keyStore; + _clock = clock; + _tokenHandler = tokenHandler; + _logger = logger; + } + + /// + public override async Task GenerateAsync([NotNull] AppleGenerateClientSecretContext context) + { + if (_clientSecret == null || _clock.UtcNow >= _expiresAt) + { + try + { + (_clientSecret, _expiresAt) = await GenerateNewSecretAsync(context); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to generate new client secret for the {context.Scheme.Name} authentication scheme."); + throw; + } + } + + return _clientSecret; + } + + private async Task<(string clientSecret, DateTimeOffset expiresAt)> GenerateNewSecretAsync( + [NotNull] AppleGenerateClientSecretContext context) + { + var expiresAt = _clock.UtcNow.Add(context.Options.ClientSecretExpiresAfter).UtcDateTime; + var subject = new Claim("sub", context.Options.ClientId); + + _logger.LogDebug( + "Generating new client secret for subject {Subject} that will expire at {ExpiresAt}.", + subject.Value, + expiresAt); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Audience = context.Options.TokenAudience, + Expires = expiresAt, + Issuer = context.Options.TeamId, + Subject = new ClaimsIdentity(new[] { subject }), + }; + + byte[] keyBlob = await _keyStore.LoadPrivateKeyAsync(context); + string clientSecret; + + using (var algorithm = CreateAlgorithm(keyBlob, context.Options.PrivateKeyPassword)) + { + tokenDescriptor.SigningCredentials = CreateSigningCredentials(context.Options.KeyId, algorithm); + + clientSecret = _tokenHandler.CreateEncodedJwt(tokenDescriptor); + } + + _logger.LogTrace("Generated new client secret with value {ClientSecret}.", clientSecret); + + return (clientSecret, expiresAt); + } + + private ECDsa CreateAlgorithm(byte[] keyBlob, string password) + { + // This becomes xplat in .NET Core 3.0: https://github.com/dotnet/corefx/pull/30271 + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + CreateAlgorithmWindows(keyBlob) : + CreateAlgorithmLinuxOrMac(keyBlob, password); + } + + private ECDsa CreateAlgorithmLinuxOrMac(byte[] keyBlob, string password) + { + // Does not support .p8 files in .NET Core 2.x as-per https://github.com/dotnet/corefx/issues/18733#issuecomment-296723615 + // Unlike Linux, macOS does not support empty passwords for .pfx files. + using (var cert = new X509Certificate2(keyBlob, password)) + { + return cert.GetECDsaPrivateKey(); + } + } + + private ECDsa CreateAlgorithmWindows(byte[] keyBlob) + { + // Only Windows supports .p8 files in .NET Core 2.0 as-per https://github.com/dotnet/corefx/issues/18733 + using (var privateKey = CngKey.Import(keyBlob, CngKeyBlobFormat.Pkcs8PrivateBlob)) + { + return new ECDsaCng(privateKey) { HashAlgorithm = CngAlgorithm.Sha256 }; + } + } + + private SigningCredentials CreateSigningCredentials(string keyId, ECDsa algorithm) + { + var key = new ECDsaSecurityKey(algorithm) { KeyId = keyId }; + return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256Signature); + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleIdTokenValidator.cs b/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleIdTokenValidator.cs new file mode 100644 index 000000000..0ccbd56f3 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleIdTokenValidator.cs @@ -0,0 +1,69 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace AspNet.Security.OAuth.Apple.Internal +{ + internal sealed class DefaultAppleIdTokenValidator : AppleIdTokenValidator + { + private readonly ILogger _logger; + private readonly AppleKeyStore _keyStore; + private readonly JwtSecurityTokenHandler _tokenHandler; + + public DefaultAppleIdTokenValidator( + [NotNull] AppleKeyStore keyStore, + [NotNull] JwtSecurityTokenHandler tokenHandler, + [NotNull] ILogger logger) + { + _keyStore = keyStore; + _tokenHandler = tokenHandler; + _logger = logger; + } + + public override async Task ValidateAsync([NotNull] AppleValidateIdTokenContext context) + { + if (!_tokenHandler.CanValidateToken) + { + throw new NotSupportedException($"The configured {nameof(JwtSecurityTokenHandler)} cannot validate tokens."); + } + + byte[] keysJson = await _keyStore.LoadPublicKeysAsync(context); + + string json = Encoding.UTF8.GetString(keysJson); + var keySet = JsonWebKeySet.Create(json); + + var parameters = new TokenValidationParameters() + { + ValidAudience = context.Options.ClientId, + ValidIssuer = context.Options.TokenAudience, + IssuerSigningKeys = keySet.Keys, + }; + + try + { + _tokenHandler.ValidateToken(context.IdToken, parameters, out var _); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Apple ID token validation failed for issuer {TokenIssuer} and audience {TokenAudience}. ID Token: {IdToken}", + parameters.ValidAudience, + parameters.ValidIssuer, + context.IdToken); + + throw; + } + } + } +} diff --git a/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleKeyStore.cs b/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleKeyStore.cs new file mode 100644 index 000000000..4189674d9 --- /dev/null +++ b/src/AspNet.Security.OAuth.Apple/Internal/DefaultAppleKeyStore.cs @@ -0,0 +1,71 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; + +namespace AspNet.Security.OAuth.Apple.Internal +{ + internal sealed class DefaultAppleKeyStore : AppleKeyStore + { + private readonly ILogger _logger; + + private byte[] _publicKey; + + public DefaultAppleKeyStore( + [NotNull] ILogger logger) + { + _logger = logger; + } + + /// + public override async Task LoadPrivateKeyAsync([NotNull] AppleGenerateClientSecretContext context) + { + if (context.Options.PrivateKeyBytes == null) + { + throw new ArgumentException( + $"The {nameof(AppleAuthenticationOptions.PrivateKeyBytes)} option must be set to be able to load the Sign in with Apple private key.", + nameof(AppleAuthenticationOptions.PrivateKeyBytes)); + } + + return await context.Options.PrivateKeyBytes(context.Options.KeyId); + } + + /// + public override async Task LoadPublicKeysAsync([NotNull] AppleValidateIdTokenContext context) + { + if (_publicKey == null) + { + _publicKey = await LoadApplePublicKeysAsync(context); + } + + return _publicKey; + } + + private async Task LoadApplePublicKeysAsync([NotNull] AppleValidateIdTokenContext context) + { + _logger.LogInformation("Loading Apple public keys from {PublicKeyEndpoint}.", context.Options.PublicKeyEndpoint); + + var response = await context.Options.Backchannel.GetAsync(context.Options.PublicKeyEndpoint, context.HttpContext.RequestAborted); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("An error occurred while retrieving the public keys from Apple: 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 public keys from Apple."); + } + + return await response.Content.ReadAsByteArrayAsync(); + } + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleAuthenticationOptionsTests.cs new file mode 100644 index 000000000..43c31c624 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleAuthenticationOptionsTests.cs @@ -0,0 +1,153 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using Xunit; + +namespace AspNet.Security.OAuth.Apple +{ + public static class AppleAuthenticationOptionsTests + { + [Fact] + public static void Validate_Throws_If_ClientSecret_Is_Null_With_No_Secret_Generation() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = null, + }; + + // Act and Assert + Assert.Throws("ClientSecret", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_AuthorizationEndpoint_Is_Null() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + AuthorizationEndpoint = null, + }; + + // Act and Assert + Assert.Throws("AuthorizationEndpoint", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_TokenEndpoint_Is_Null() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + TokenEndpoint = null, + }; + + // Act and Assert + Assert.Throws("TokenEndpoint", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_CallbackPath_Is_Null() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + CallbackPath = null, + }; + + // Act and Assert + Assert.Throws("CallbackPath", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_KeyId_Is_Null_With_Secret_Generation() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + KeyId = null, + }; + + // Act and Assert + Assert.Throws("KeyId", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_TeamId_Is_Null_With_Secret_Generation() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + KeyId = "my-key-id", + TeamId = null, + }; + + // Act and Assert + Assert.Throws("TeamId", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_TokenAudience_Is_Null_With_Secret_Generation() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + KeyId = "my-key-id", + TeamId = "my-team-id", + TokenAudience = null, + }; + + // Act and Assert + Assert.Throws("TokenAudience", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_ClientSecretExpiresAfter_Is_Zero_With_Secret_Generation() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + GenerateClientSecret = true, + KeyId = "my-key-id", + TeamId = "my-team-id", + ClientSecretExpiresAfter = TimeSpan.Zero, + }; + + // Act and Assert + Assert.Throws("ClientSecretExpiresAfter", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_PublicKeyEndpoint_Is_Null_With_Token_Validation() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + PublicKeyEndpoint = null, + }; + + // Act and Assert + Assert.Throws("PublicKeyEndpoint", () => options.Validate()); + } + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleClientSecretGeneratorTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleClientSecretGeneratorTests.cs new file mode 100644 index 000000000..073a163f6 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleClientSecretGeneratorTests.cs @@ -0,0 +1,129 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace AspNet.Security.OAuth.Apple +{ + public static class AppleClientSecretGeneratorTests + { + [Fact] + public static async Task GenerateAsync_Generates_Valid_Signed_Jwt() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecretExpiresAfter = TimeSpan.FromMinutes(1), + KeyId = "my-key-id", + TeamId = "my-team-id", + PrivateKeyBytes = (keyId) => TestKeys.GetPrivateKeyBytesAsync(), + PrivateKeyPassword = TestKeys.GetPrivateKeyPassword(), + }; + + await GenerateTokenAsync(options, async (generator, context) => + { + var utcNow = DateTimeOffset.UtcNow; + + // Act + string token = await generator.GenerateAsync(context); + + // Assert + token.ShouldNotBeNullOrWhiteSpace(); + token.Count((c) => c == '.').ShouldBe(2); // Format: "{header}.{body}.{signature}" + + // Act + var validator = new JwtSecurityTokenHandler(); + var securityToken = validator.ReadJwtToken(token); + + // Assert - See https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + securityToken.ShouldNotBeNull(); + + securityToken.Header.ShouldNotBeNull(); + securityToken.Header.ShouldContainKeyAndValue("alg", "ES256"); + securityToken.Header.ShouldContainKeyAndValue("kid", "my-key-id"); + + securityToken.Payload.ShouldNotBeNull(); + securityToken.Payload.ShouldContainKey("exp"); + securityToken.Payload.ShouldContainKey("iat"); + securityToken.Payload.ShouldContainKeyAndValue("aud", "https://appleid.apple.com"); + securityToken.Payload.ShouldContainKeyAndValue("iss", "my-team-id"); + securityToken.Payload.ShouldContainKeyAndValue("sub", "my-client-id"); + + ((long)securityToken.Payload.Iat.Value).ShouldBeGreaterThanOrEqualTo(utcNow.ToUnixTimeSeconds()); + ((long)securityToken.Payload.Exp.Value).ShouldBeGreaterThanOrEqualTo(utcNow.AddSeconds(60).ToUnixTimeSeconds()); + ((long)securityToken.Payload.Exp.Value).ShouldBeLessThanOrEqualTo(utcNow.AddSeconds(70).ToUnixTimeSeconds()); + }); + } + + [Fact] + public static async Task GenerateAsync_Caches_Jwt_Until_Expired() + { + // Arrange + var options = new AppleAuthenticationOptions() + { + ClientId = "my-client-id", + ClientSecretExpiresAfter = TimeSpan.FromSeconds(2), + KeyId = "my-key-id", + TeamId = "my-team-id", + PrivateKeyBytes = (keyId) => TestKeys.GetPrivateKeyBytesAsync(), + PrivateKeyPassword = TestKeys.GetPrivateKeyPassword(), + }; + + await GenerateTokenAsync(options, async (generator, context) => + { + // Act + string token1 = await generator.GenerateAsync(context); + string token2 = await generator.GenerateAsync(context); + + // Assert + token2.ShouldBe(token1); + + // Act + await Task.Delay(options.ClientSecretExpiresAfter.Add(options.ClientSecretExpiresAfter)); + string token3 = await generator.GenerateAsync(context); + + // Assert + token3.ShouldNotBe(token1); + }); + } + + private static async Task GenerateTokenAsync( + AppleAuthenticationOptions options, + Func actAndAssert) + { + // Arrange + var builder = new WebHostBuilder() + .Configure((app) => app.UseAuthentication()) + .ConfigureServices((services) => + { + services.AddAuthentication() + .AddApple(); + }); + + using (var host = builder.Build()) + { + var httpContext = new DefaultHttpContext(); + var scheme = new AuthenticationScheme("Apple", "Apple", typeof(AppleAuthenticationHandler)); + + var context = new AppleGenerateClientSecretContext(httpContext, scheme, options); + var generator = host.Services.GetRequiredService(); + + await actAndAssert(generator, context); + } + } + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs new file mode 100644 index 000000000..078eb6201 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/AppleTests.cs @@ -0,0 +1,339 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +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; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.Tokens; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace AspNet.Security.OAuth.Apple +{ + public class AppleTests : OAuthTests + { + public AppleTests(ITestOutputHelper outputHelper) + { + OutputHelper = 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; + + builder.AddApple(options => + { + ConfigureDefaults(builder, options); + options.ClientId = "com.martincostello.signinwithapple.test.client"; + }); + } + + [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 + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.PostConfigureAll((options) => + { + options.GenerateClientSecret = false; + options.ClientSecret = "my-client-secret"; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } + } + + [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 + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.PostConfigureAll((options) => + { + options.ClientSecret = string.Empty; + options.GenerateClientSecret = true; + options.KeyId = "my-key-id"; + options.TeamId = "my-team-id"; + options.ValidateTokens = true; + options.PrivateKeyPassword = TestKeys.GetPrivateKeyPassword(); + options.PrivateKeyBytes = async (keyId) => + { + Assert.Equal("my-key-id", keyId); + return await TestKeys.GetPrivateKeyBytesAsync(); + }; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } + } + + [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 + void ConfigureServices(IServiceCollection services) + { + services.PostConfigureAll((options) => + { + options.ValidateTokens = false; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Expired_Token() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.PostConfigureAll((options) => + { + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Invalid_Token_Audience() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.PostConfigureAll((options) => + { + options.ClientId = "my-team"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Invalid_Token_Issuer() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.PostConfigureAll((options) => + { + options.TokenAudience = "https://apple.local"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Invalid_Signing_Key() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.PostConfigureAll((options) => + { + options.PublicKeyEndpoint = "https://appleid.apple.local/auth/keys/invalid"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Unknown_Signing_Key() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.PostConfigureAll((options) => + { + options.PublicKeyEndpoint = "https://appleid.apple.local/auth/keys/none"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Null_Token() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.PostConfigureAll((options) => + { + options.TokenEndpoint = "https://appleid.apple.local/auth/token/null"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + exception.InnerException.Message.ShouldBe("No Apple ID token was returned in the OAuth token response."); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_Malformed_Token() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.PostConfigureAll((options) => + { + options.TokenEndpoint = "https://appleid.apple.local/auth/token/malformed"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + exception.InnerException.Message.ShouldStartWith("IDX"); + } + } + + [Fact] + public async Task Cannot_Sign_In_Using_Apple_With_No_Token() + { + // Arrange + void ConfigureServices(IServiceCollection services) + { + services.PostConfigureAll((options) => + { + options.TokenEndpoint = "https://appleid.apple.local/auth/token/none"; + options.ValidateTokens = true; + }); + } + + using (var server = CreateTestServer(ConfigureServices)) + { + // Act + var exception = await Assert.ThrowsAsync(() => AuthenticateUserAsync(server)); + + // Assert + exception.InnerException.ShouldBeOfType(); + exception.InnerException.Message.ShouldBe("No Apple ID token was returned in the OAuth token response."); + } + } + + private sealed class FrozenJwtSecurityTokenHandler : JwtSecurityTokenHandler + { + protected override void ValidateLifetime(DateTime? notBefore, DateTime? expires, JwtSecurityToken jwtToken, TokenValidationParameters validationParameters) + { + // Do not validate the lifetime as the test token has expired + } + } + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/TestKeys.cs b/test/AspNet.Security.OAuth.Providers.Tests/Apple/TestKeys.cs new file mode 100644 index 000000000..9f3c3a4b2 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/TestKeys.cs @@ -0,0 +1,45 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace AspNet.Security.OAuth.Apple +{ + internal static class TestKeys + { + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + internal static async Task GetPrivateKeyBytesAsync() + { + byte[] privateKey; + + if (IsWindows) + { + string content = await File.ReadAllTextAsync(Path.Combine("Apple", "test.p8")); + + if (content.StartsWith("-----BEGIN PRIVATE KEY-----", StringComparison.Ordinal)) + { + string[] keyLines = content.Split('\n'); + content = string.Join(string.Empty, keyLines.Skip(1).Take(keyLines.Length - 2)); + } + + privateKey = Convert.FromBase64String(content); + } + else + { + privateKey = await File.ReadAllBytesAsync(Path.Combine("Apple", "test.pfx")); + } + + return privateKey; + } + + internal static string GetPrivateKeyPassword() => IsWindows ? string.Empty : "passw0rd"; + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Apple/bundle.json new file mode 100644 index 000000000..66495dd11 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/bundle.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "comment": "https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature", + "uri": "https://appleid.apple.com/auth/keys", + "method": "GET", + "contentFormat": "json", + "contentJson": { + "keys": [ + { + "kty": "RSA", + "kid": "AIDOPK1", + "use": "sig", + "alg": "RS256", + "n": "lxrwmuYSAsTfn-lUu4goZSXBD9ackM9OJuwUVQHmbZo6GW4Fu_auUdN5zI7Y1dEDfgt7m7QXWbHuMD01HLnD4eRtY-RNwCWdjNfEaY_esUPY3OVMrNDI15Ns13xspWS3q-13kdGv9jHI28P87RvMpjz_JCpQ5IM44oSyRnYtVJO-320SB8E2Bw92pmrenbp67KRUzTEVfGU4-obP5RZ09OxvCr1io4KJvEOjDJuuoClF66AT72WymtoMdwzUmhINjR0XSqK6H0MdWsjw7ysyd_JhmqX5CAaT9Pgi0J8lU_pcl215oANqjy7Ob-VMhug9eGyxAWVfu_1u6QJKePlE-w", + "e": "AQAB" + } + ] + } + }, + { + "comment": "https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens", + "uri": "https://appleid.apple.com/auth/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "expires_in": "300", + "id_token": "eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1hcnRpbmNvc3RlbGxvLnNpZ25pbndpdGhhcHBsZS50ZXN0LmNsaWVudCIsImV4cCI6MTU2MDAwODkxMCwiaWF0IjoxNTYwMDA4MzEwLCJzdWIiOiIwMDE4ODMuZmNjNzdiYTk3NTAwNDAyMzg5ZGY5NjgyMWFkOWM3OTAuMTUxNyIsImF0X2hhc2giOiJjN0xnNk9mSk1WQVUyUHRJVGRaeW93In0.hwLfuE0dB3mNYnDFWCd08MyJThsiRbGQmF-KX6VpGQttXRzChNgy9QWTT3vfd4bftMvlWCUlUEwCG0Os7hQUbWPknKYYIdxZGAejtCSCWYQ4PMhS_eQ5goICdLdi3ITzOG2JUmU-Vry4bPn3dJiyZ8ODGpj7MIBsVaRlfL4AlAgOKi9rp5UjVqj05M4qm512G-u-tVX7nasx3Eg-pFvS-w0CQJtVp3xIR2Ez3DRRt2roL0S6f0jNA-zb-zhOt_sFwmeqElGnQAidakUvrPTN0tORMUk_rKuohtkcY1_6uaVIsQ8NnOMl5Xszg9NzkQh5Je2Gi-qRzMxskJ0fJDCAfA", + "refresh_token": "secret-refresh-token", + "token_type": "bearer" + } + }, + { + "uri": "https://appleid.apple.local/auth/keys/none", + "method": "GET", + "contentFormat": "json", + "contentJson": { + "keys": [] + } + }, + { + "uri": "https://appleid.apple.local/auth/keys/invalid", + "method": "GET", + "contentFormat": "json", + "contentJson": { + "keys": [ + { + "kty": "RSA", + "kid": "AIDOPK1", + "use": "sig", + "alg": "RS256", + "n": "lxrwmuYSAsTfn-lUu4goZSXBD9ackM9OJmwUVQHmbZo6GW4Fu_auUdN5zI7Y1dEDfgt7m7QXWbHuMD01HLnD4eRtY-RNwCWdjNfEaZ_esUPY3OVMrNDI15Ns13xspWS3q-13kdGv9jHI28P87RvMpjz_JCpQ5IM44oSyRnYtVJO-320SB8E2Bw92pmrenbp67KRUzTEVfGU4-obP5RZ09OxvCr1io4KJvEOjDJuuoClF66AT72WymtoMdwzUmhINjR0XSqK6H0MdWsjw7ysyd_JhmqX5CAaT9Pgi0J8lU_pcl215oANqjy7Ob-VMhug9eGyxAWVfu_1u6QJKePlE-w", + "e": "AQAB" + } + ] + } + }, + { + "uri": "https://appleid.apple.local/auth/token/null", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "expires_in": "0", + "id_token": null, + "refresh_token": "secret-refresh-token", + "token_type": "bearer" + } + }, + { + "uri": "https://appleid.apple.local/auth/token/none", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "expires_in": "0", + "id_token": "", + "refresh_token": "secret-refresh-token", + "token_type": "bearer" + } + }, + { + "uri": "https://appleid.apple.local/auth/token/malformed", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "expires_in": "0", + "id_token": "1", + "refresh_token": "secret-refresh-token", + "token_type": "bearer" + } + } + ] +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.cer b/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.cer new file mode 100644 index 000000000..9f67664d6 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.cer @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICYzCCAgmgAwIBAgIJAI8YasGI5F6oMAoGCCqGSM49BAMCMIGNMQswCQYDVQQG +EwJHQjEQMA4GA1UECAwHRW5nbGFuZDEPMA0GA1UEBwwGTG9uZG9uMRcwFQYDVQQK +DA5hc3BuZXQtY29udHJpYjEYMBYGA1UEAwwPTWFydGluIENvc3RlbGxvMSgwJgYJ +KoZIhvcNAQkBFhltYXJ0aW5AbWFydGluY29zdGVsbG8uY29tMB4XDTE5MDYwOTEy +NTgyNVoXDTE5MDcwOTEyNTgyNVowgY0xCzAJBgNVBAYTAkdCMRAwDgYDVQQIDAdF +bmdsYW5kMQ8wDQYDVQQHDAZMb25kb24xFzAVBgNVBAoMDmFzcG5ldC1jb250cmli +MRgwFgYDVQQDDA9NYXJ0aW4gQ29zdGVsbG8xKDAmBgkqhkiG9w0BCQEWGW1hcnRp +bkBtYXJ0aW5jb3N0ZWxsby5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQf +rvDWizEnWAzB2Hx2r/NyvIBO6KGBDL7wkZoKnz4Sm4+1P1dhD9fVEhbsdoq9RKEf +8dvzTOZMaC/iLqZFKSN6o1AwTjAdBgNVHQ4EFgQUZT6ij94pUE1x0rrWWCwswodX +FRMwHwYDVR0jBBgwFoAUZT6ij94pUE1x0rrWWCwswodXFRMwDAYDVR0TBAUwAwEB +/zAKBggqhkjOPQQDAgNIADBFAiAOR5YjLHk5QFPdq9BflblpV4/gwwYcxLM+OuNf +a4PsUQIhAJhh1DNlUH0kvG6/L1gXtd+pi41tjM96RVi4kvrf0xzN +-----END CERTIFICATE----- diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.p8 b/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.p8 new file mode 100644 index 000000000..2e9721225 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.p8 @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgU208KCg/doqiSzsVF5sknVtYSgt8/3oiYGbvryIRrzSgCgYIKoZIzj0DAQehRANCAAQfrvDWizEnWAzB2Hx2r/NyvIBO6KGBDL7wkZoKnz4Sm4+1P1dhD9fVEhbsdoq9RKEf8dvzTOZMaC/iLqZFKSN6 +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.pfx b/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.pfx new file mode 100644 index 000000000..51ed10eb8 Binary files /dev/null and b/test/AspNet.Security.OAuth.Providers.Tests/Apple/test.pfx differ diff --git a/test/AspNet.Security.OAuth.Providers.Tests/AspNet.Security.OAuth.Providers.Tests.csproj b/test/AspNet.Security.OAuth.Providers.Tests/AspNet.Security.OAuth.Providers.Tests.csproj index 6a3f0dcd6..11b34686b 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/AspNet.Security.OAuth.Providers.Tests.csproj +++ b/test/AspNet.Security.OAuth.Providers.Tests/AspNet.Security.OAuth.Providers.Tests.csproj @@ -9,6 +9,7 @@ + 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();