From f2be82d7084513a205a79238c8d008bf912da328 Mon Sep 17 00:00:00 2001 From: Drew Killion Date: Sun, 16 Apr 2023 04:58:30 -0400 Subject: [PATCH] Add PingOne provider (#758) * Adding PingOne Provider * updating documentation * fixing url for tests * adding test with custom domain * adding new version to package validation Co-authored-by: Martin Costello * fixing casing the exception string Co-authored-by: Martin Costello * updating changes from PR review * updating docs to reflect code change * Update documentation Minor documentation updates. --------- Co-authored-by: Andrew Killion Co-authored-by: Martin Costello --- AspNet.Security.OAuth.Providers.sln | 7 ++ README.md | 2 + docs/README.md | 1 + docs/pingone.md | 26 +++++ .../AspNet.Security.OAuth.PingOne.csproj | 25 +++++ .../PingOneAuthenticationDefaults.cs | 53 +++++++++ .../PingOneAuthenticationExtensions.cs | 77 +++++++++++++ .../PingOneAuthenticationHandler.cs | 84 +++++++++++++++ .../PingOneAuthenticationOptions.cs | 76 +++++++++++++ .../PingOnePostConfigureOptions.cs | 51 +++++++++ .../PingOneAuthenticationOptionsTests.cs | 75 +++++++++++++ .../PingOnePostConfigureOptionsTests.cs | 102 ++++++++++++++++++ .../PingOne/PingOneTests.cs | 70 ++++++++++++ .../PingOne/bundle.json | 87 +++++++++++++++ 14 files changed, 736 insertions(+) create mode 100644 docs/pingone.md create mode 100644 src/AspNet.Security.OAuth.PingOne/AspNet.Security.OAuth.PingOne.csproj create mode 100644 src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationDefaults.cs create mode 100644 src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationExtensions.cs create mode 100644 src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationHandler.cs create mode 100644 src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationOptions.cs create mode 100644 src/AspNet.Security.OAuth.PingOne/PingOnePostConfigureOptions.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneAuthenticationOptionsTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOnePostConfigureOptionsTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/PingOne/bundle.json diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index 5ecce9b56..0dc6761d6 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -295,6 +295,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Smart EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Huawei", "src\AspNet.Security.OAuth.Huawei\AspNet.Security.OAuth.Huawei.csproj", "{E3CF7FFC-56A0-4033-87A9-BB3080CF030E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.PingOne", "src\AspNet.Security.OAuth.PingOne\AspNet.Security.OAuth.PingOne.csproj", "{CF8C4235-6AE6-404E-B572-4FF4E85AB5FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -677,6 +679,10 @@ Global {E3CF7FFC-56A0-4033-87A9-BB3080CF030E}.Debug|Any CPU.Build.0 = Debug|Any CPU {E3CF7FFC-56A0-4033-87A9-BB3080CF030E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E3CF7FFC-56A0-4033-87A9-BB3080CF030E}.Release|Any CPU.Build.0 = Release|Any CPU + {CF8C4235-6AE6-404E-B572-4FF4E85AB5FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF8C4235-6AE6-404E-B572-4FF4E85AB5FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF8C4235-6AE6-404E-B572-4FF4E85AB5FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF8C4235-6AE6-404E-B572-4FF4E85AB5FF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -782,6 +788,7 @@ Global {8E42EF81-A630-4BDB-B642-3F20C863F9BE} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {68862DC5-65B7-4517-B909-AB334F9FCF6E} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {E3CF7FFC-56A0-4033-87A9-BB3080CF030E} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {CF8C4235-6AE6-404E-B572-4FF4E85AB5FF} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C} diff --git a/README.md b/README.md index 19886a6e5..c3c2c4172 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ We would love it if you could help contributing to this repository. * [CoCo Lin](https://github.com/linmasaki) * [Dave Timmins](https://github.com/davetimmins) * [Dmitry Popov](https://github.com/justdmitry) +* [Drew Killion](https://github.com/drewkill32) * [Elan Hasson](https://github.com/ElanHasson) * [Eric Green](https://github.com/ericgreenmix) * [Ethan Celletti](https://github.com/Gekctek) @@ -180,6 +181,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | Onshape | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Onshape?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Onshape/ "Download AspNet.Security.OAuth.Onshape from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Onshape?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Onshape "Download AspNet.Security.OAuth.Onshape from MyGet.org") | N/A | | Patreon | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Patreon?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Patreon/ "Download AspNet.Security.OAuth.Patreon from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Patreon?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Patreon "Download AspNet.Security.OAuth.Patreon from MyGet.org") | [Documentation](https://docs.patreon.com/#oauth "Patreon developer documentation") | | Paypal | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Paypal?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Paypal/ "Download AspNet.Security.OAuth.Paypal from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Paypal?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Paypal "Download AspNet.Security.OAuth.Paypal from MyGet.org") | [Documentation](https://developer.paypal.com/docs/api-basics/#oauth-20-authorization-protocol "Paypal developer documentation") | +| PingOne | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.PingOne?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.PingOne/ "Download AspNet.Security.OAuth.PingOne from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.PingOne?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.PingOne "Download AspNet.Security.OAuth.PingOne from MyGet.org") | [Documentation](https://apidocs.pingidentity.com/pingone/platform/v1/api/#openid-connectoauth-2 "PingOne developer documentation") | | QQ | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.QQ?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.QQ/ "Download AspNet.Security.OAuth.QQ from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.QQ?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.QQ "Download AspNet.Security.OAuth.QQ from MyGet.org") | [Documentation](https://developers.e.qq.com/docs/apilist/auth/oauth2 "QQ developer documentation") | | QuickBooks | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.QuickBooks?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.QuickBooks/ "Download AspNet.Security.OAuth.QuickBooks from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.QuickBooks?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.QuickBooks "Download AspNet.Security.OAuth.QuickBooks from MyGet.org") | [Documentation](https://www.developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0-playground "QuickBooks developer documentation") | | Reddit | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Reddit?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Reddit/ "Download AspNet.Security.OAuth.Reddit from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Reddit?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Reddit "Download AspNet.Security.OAuth.Reddit from MyGet.org") | [Documentation](https://github.com/reddit-archive/reddit/wiki/oauth2 "Reddit developer documentation") | diff --git a/docs/README.md b/docs/README.md index 8f6fac04a..fabdb074e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,7 @@ covered by the section above. | Odnoklassniki | _Optional_ | [Documentation](odnoklassniki.md "Odnoklassniki provider documentation") | | Okta | **Required** | [Documentation](okta.md "Okta provider documentation") | | Patreon | _Optional_ | [Documentation](patreon.md "Patreon provider documentation") | +| PingOne | _Optional_ | [Documentation](pingone.md "PingOne provider documentation") | | QQ | _Optional_ | [Documentation](qq.md "QQ provider documentation") | | Reddit | _Optional_ | [Documentation](reddit.md "Reddit provider documentation") | | Salesforce | _Optional_ | [Documentation](salesforce.md "Salesforce provider documentation") | diff --git a/docs/pingone.md b/docs/pingone.md new file mode 100644 index 000000000..15546c0e2 --- /dev/null +++ b/docs/pingone.md @@ -0,0 +1,26 @@ +# Integrating the PingOne Provider + +## Example + +```csharp +services.AddAuthentication(options => /* Auth configuration */) + .AddPingOne(options => + { + options.ClientId = "my-client-id"; + options.ClientSecret = "my-client-secret"; + options.EnvironmentId = "63e9d5c3-5bb8-462d-8f71-8e6b2592e516"; + }); +``` + +## Required Additional Settings + +| Property Name | Property Type | Description | Default Value | +|:--|:--|:--|:--| +| `EnvironmentId` | `string` | The PingOne EnvironmentId to use for authentication. This can be found on the `environment.properties` page of the PingOne admin portal for your account. | `""` | + +## Optional Settings + +| Property Name | Property Type | Description | Default Value | +|:--|:--|:--|:--| +| `Domain` | `string?` | The PingOne domain to use for authentication. Can be a custom domain configured in PingOne or one of the following: `auth.pingone.com` for the United States region, `auth.pingone.ca` for the Canada region, `auth.pingone.eu` for the European Union region, or `auth.pingone.asia` for the Asia-Pacific region. | `"auth.pingone.com"` | + diff --git a/src/AspNet.Security.OAuth.PingOne/AspNet.Security.OAuth.PingOne.csproj b/src/AspNet.Security.OAuth.PingOne/AspNet.Security.OAuth.PingOne.csproj new file mode 100644 index 000000000..774a3e23d --- /dev/null +++ b/src/AspNet.Security.OAuth.PingOne/AspNet.Security.OAuth.PingOne.csproj @@ -0,0 +1,25 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + true + 7.0.1 + + + + ASP.NET Core security provider enabling PingOne authentication. + Drew Killion + pingone;aspnetcore;authentication;oauth;security + + + + + + + + diff --git a/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationDefaults.cs new file mode 100644 index 000000000..c3d680be7 --- /dev/null +++ b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationDefaults.cs @@ -0,0 +1,53 @@ +/* + * 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.PingOne; + +/// +/// Default values used by the PingOne authentication provider. +/// +public static class PingOneAuthenticationDefaults +{ + /// + /// Default value for . + /// + public const string AuthenticationScheme = "PingOne"; + + /// + /// Default value for . + /// + public static readonly string DisplayName = "PingOne"; + + /// + /// Default value for . + /// + public static readonly string Issuer = "PingOne"; + + /// + /// Default value for PingOne domain. + /// + public static readonly string Domain = "auth.pingone.com"; + + /// + /// Default value for . + /// + public static readonly string CallbackPath = "/signin-pingone"; + + /// + /// Default path format to use for . + /// + public static readonly string AuthorizationEndpointPathFormat = "/{0}/as/authorize"; + + /// + /// Default path format to use for . + /// + public static readonly string TokenEndpointPathFormat = "/{0}/as/token"; + + /// + /// Default path format to use for . + /// + public static readonly string UserInformationEndpointPathFormat = "/{0}/as/userinfo"; +} diff --git a/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationExtensions.cs new file mode 100644 index 000000000..00e1150a1 --- /dev/null +++ b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationExtensions.cs @@ -0,0 +1,77 @@ +/* + * 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 AspNet.Security.OAuth.PingOne; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods to add PingOne authentication capabilities to an HTTP application pipeline. +/// +public static class PingOneAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables PingOne authentication capabilities. + /// + /// The authentication builder. + /// The . + public static AuthenticationBuilder AddPingOne([NotNull] this AuthenticationBuilder builder) + { + return builder.AddPingOne(PingOneAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables PingOne authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the PingOne options. + /// The . + public static AuthenticationBuilder AddPingOne( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddPingOne(PingOneAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables PingOne authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the PingOne options. + /// The . + public static AuthenticationBuilder AddPingOne( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddPingOne(scheme, PingOneAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables PingOne 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 PingOne options. + /// The . + public static AuthenticationBuilder AddPingOne( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [CanBeNull] string caption, + [NotNull] Action configuration) + { + builder.Services.TryAddSingleton, PingOnePostConfigureOptions>(); + return builder.AddOAuth(scheme, caption, configuration); + } +} diff --git a/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationHandler.cs b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationHandler.cs new file mode 100644 index 000000000..5a0e40d77 --- /dev/null +++ b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationHandler.cs @@ -0,0 +1,84 @@ +/* + * 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.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AspNet.Security.OAuth.PingOne; + +/// +/// Defines a handler for authentication using PingOne. +/// +public partial class PingOneAuthenticationHandler : OAuthHandler +{ + /// + /// Initializes a new instance of the class. + /// + /// The authentication options. + /// The logger to use. + /// The URL encoder to use. + /// The system clock to use. + public PingOneAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder, + [NotNull] ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + /// + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + string endpoint = Options.UserInformationEndpoint; + + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + + using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving the user profile from PingOne."); + } + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted)); + + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement); + context.RunClaimActions(); + + await Events.CreatingTicket(context); + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + + private static partial class Log + { + internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + UserProfileError( + logger, + response.StatusCode, + response.Headers.ToString(), + await response.Content.ReadAsStringAsync(cancellationToken)); + } + + [LoggerMessage(1, LogLevel.Error, "An error occurred while retrieving the user profile: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")] + private static partial void UserProfileError( + ILogger logger, + System.Net.HttpStatusCode status, + string headers, + string body); + } +} diff --git a/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationOptions.cs b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationOptions.cs new file mode 100644 index 000000000..2ea094437 --- /dev/null +++ b/src/AspNet.Security.OAuth.PingOne/PingOneAuthenticationOptions.cs @@ -0,0 +1,76 @@ +/* + * 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.Security.Claims; + +namespace AspNet.Security.OAuth.PingOne; + +/// +/// Defines a set of options used by . +/// +public class PingOneAuthenticationOptions : OAuthOptions +{ + /// + /// Initializes a new instance of the class. + /// + public PingOneAuthenticationOptions() + { + ClaimsIssuer = PingOneAuthenticationDefaults.Issuer; + CallbackPath = PingOneAuthenticationDefaults.CallbackPath; + Domain = PingOneAuthenticationDefaults.Domain; + EnvironmentId = string.Empty; + + Scope.Add("openid"); + Scope.Add("profile"); + Scope.Add("email"); + + ClaimActions.MapJsonKey(ClaimTypes.Email, "email"); + ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name"); + ClaimActions.MapJsonKey(ClaimTypes.Name, "name"); + ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub"); + ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name"); + } + + /// + /// Gets or sets the PingOne domain to use for authentication. + /// + /// + /// The default value is . + /// + public string Domain { get; set; } + + /// + /// Gets or sets the PingOne environment Id to use for authentication. + /// + public string EnvironmentId { get; set; } + + /// + public override void Validate() + { + base.Validate(); + + if (!Uri.TryCreate(AuthorizationEndpoint, UriKind.Absolute, out _)) + { + throw new ArgumentException( + $"The '{nameof(AuthorizationEndpoint)}' option must be set to a valid URI.", + nameof(AuthorizationEndpoint)); + } + + if (!Uri.TryCreate(TokenEndpoint, UriKind.Absolute, out _)) + { + throw new ArgumentException( + $"The '{nameof(TokenEndpoint)}' option must be set to a valid URI.", + nameof(TokenEndpoint)); + } + + if (!Uri.TryCreate(UserInformationEndpoint, UriKind.Absolute, out _)) + { + throw new ArgumentException( + $"The '{nameof(UserInformationEndpoint)}' option must be set to a valid URI.", + nameof(UserInformationEndpoint)); + } + } +} diff --git a/src/AspNet.Security.OAuth.PingOne/PingOnePostConfigureOptions.cs b/src/AspNet.Security.OAuth.PingOne/PingOnePostConfigureOptions.cs new file mode 100644 index 000000000..492a14e0d --- /dev/null +++ b/src/AspNet.Security.OAuth.PingOne/PingOnePostConfigureOptions.cs @@ -0,0 +1,51 @@ +/* + * 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.Globalization; +using Microsoft.Extensions.Options; + +namespace AspNet.Security.OAuth.PingOne; + +/// +/// A class used to setup defaults for all . +/// +public class PingOnePostConfigureOptions : IPostConfigureOptions +{ + /// + public void PostConfigure( + string? name, + [NotNull] PingOneAuthenticationOptions options) + { + if (string.IsNullOrWhiteSpace(options.Domain)) + { + throw new ArgumentException("No PingOne domain configured.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.EnvironmentId)) + { + throw new ArgumentException("No PingOne environment Id configured.", nameof(options)); + } + + options.AuthorizationEndpoint = CreateUrl(options.Domain, PingOneAuthenticationDefaults.AuthorizationEndpointPathFormat, options.EnvironmentId); + options.TokenEndpoint = CreateUrl(options.Domain, PingOneAuthenticationDefaults.TokenEndpointPathFormat, options.EnvironmentId); + options.UserInformationEndpoint = CreateUrl(options.Domain, PingOneAuthenticationDefaults.UserInformationEndpointPathFormat, options.EnvironmentId); + } + + private static string CreateUrl(string domain, string pathFormat, params object[] args) + { + var path = string.Format(CultureInfo.InvariantCulture, pathFormat, args); + + // Enforce use of HTTPS + var builder = new UriBuilder(domain) + { + Path = path, + Port = -1, + Scheme = Uri.UriSchemeHttps, + }; + + return builder.Uri.ToString(); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneAuthenticationOptionsTests.cs new file mode 100644 index 000000000..e22ac42a5 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneAuthenticationOptionsTests.cs @@ -0,0 +1,75 @@ +/* + * 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.PingOne; + +public static class PingOneAuthenticationOptionsTests +{ + [Fact] + public static void Validate_Throws_If_AuthorizationEndpoint_Not_Set() + { + // Arrange + var options = new PingOneAuthenticationOptions() + { + ClientId = "ClientId", + ClientSecret = "ClientSecret", + TokenEndpoint = "https://auth.pingone.local", + UserInformationEndpoint = "https://auth.pingone.local", + }; + + // Act and Assert + Assert.Throws("AuthorizationEndpoint", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_TokenEndpoint_Not_Set() + { + // Arrange + var options = new PingOneAuthenticationOptions() + { + AuthorizationEndpoint = "https://auth.pingone.local", + ClientId = "ClientId", + ClientSecret = "ClientSecret", + UserInformationEndpoint = "https://auth.pingone.local", + }; + + // Act and Assert + Assert.Throws("TokenEndpoint", () => options.Validate()); + } + + [Fact] + public static void Validate_Throws_If_UserInformationEndpoint_Not_Set() + { + // Arrange + var options = new PingOneAuthenticationOptions() + { + AuthorizationEndpoint = "https://auth.pingone.local", + ClientId = "ClientId", + ClientSecret = "ClientSecret", + TokenEndpoint = "https://auth.pingone.local", + }; + + // Act and Assert + Assert.Throws("UserInformationEndpoint", () => options.Validate()); + } + + [Fact] + public static void Validate_Does_Not_Throw_If_Uris_Are_Valid() + { + // Arrange + var options = new PingOneAuthenticationOptions() + { + AuthorizationEndpoint = "https://auth.pingone.local", + ClientId = "ClientId", + ClientSecret = "ClientSecret", + TokenEndpoint = "https://auth.pingone.local", + UserInformationEndpoint = "https://auth.pingone.local", + }; + + // Act (no Assert) + options.Validate(); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOnePostConfigureOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOnePostConfigureOptionsTests.cs new file mode 100644 index 000000000..2bedc17c6 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOnePostConfigureOptionsTests.cs @@ -0,0 +1,102 @@ +/* + * 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.PingOne; + +public static class PingOnePostConfigureOptionsTests +{ + [Fact] + public static void PostConfigure_Configures_Valid_Endpoints() + { + // Arrange + string name = "PingOne"; + var target = new PingOnePostConfigureOptions(); + + var options = new PingOneAuthenticationOptions() + { + EnvironmentId = "b775aadd-a2f3-4d88-a768-b7c85dd1af47" + }; + + // Act + target.PostConfigure(name, options); + + // Assert + options.AuthorizationEndpoint.ShouldStartWith("https://auth.pingone.com/b775aadd-a2f3-4d88-a768-b7c85dd1af47/"); + Uri.TryCreate(options.AuthorizationEndpoint, UriKind.Absolute, out _).ShouldBeTrue(); + + options.TokenEndpoint.ShouldStartWith("https://auth.pingone.com/b775aadd-a2f3-4d88-a768-b7c85dd1af47/"); + Uri.TryCreate(options.TokenEndpoint, UriKind.Absolute, out _).ShouldBeTrue(); + + options.UserInformationEndpoint.ShouldStartWith("https://auth.pingone.com/b775aadd-a2f3-4d88-a768-b7c85dd1af47/"); + Uri.TryCreate(options.UserInformationEndpoint, UriKind.Absolute, out _).ShouldBeTrue(); + } + + [Fact] + public static void PostConfigure_Configures_Valid_Endpoints_With_Custom_Domain() + { + // Arrange + string name = "PingOne"; + var target = new PingOnePostConfigureOptions(); + + var options = new PingOneAuthenticationOptions() + { + EnvironmentId = "b775aadd-a2f3-4d88-a768-b7c85dd1af47", + Domain = "auth.pingone.local" + }; + + // Act + target.PostConfigure(name, options); + + // Assert + options.AuthorizationEndpoint.ShouldStartWith("https://auth.pingone.local/b775aadd-a2f3-4d88-a768-b7c85dd1af47/"); + Uri.TryCreate(options.AuthorizationEndpoint, UriKind.Absolute, out _).ShouldBeTrue(); + + options.TokenEndpoint.ShouldStartWith("https://auth.pingone.local/b775aadd-a2f3-4d88-a768-b7c85dd1af47/"); + Uri.TryCreate(options.TokenEndpoint, UriKind.Absolute, out _).ShouldBeTrue(); + + options.UserInformationEndpoint.ShouldStartWith("https://auth.pingone.local/b775aadd-a2f3-4d88-a768-b7c85dd1af47/"); + Uri.TryCreate(options.UserInformationEndpoint, UriKind.Absolute, out _).ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public static void PostConfigure_Throws_If_Domain_Is_Invalid(string value) + { + // Arrange + string name = "PingOne"; + var target = new PingOnePostConfigureOptions(); + + var options = new PingOneAuthenticationOptions() + { + Domain = value, + EnvironmentId = "b775aadd-a2f3-4d88-a768-b7c85dd1af47", + }; + + // Act and Assert + Assert.Throws("options", () => target.PostConfigure(name, options)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public static void PostConfigure_Throws_If_EnvironmentId_Is_Invalid(string value) + { + // Arrange + string name = "PingOne"; + var target = new PingOnePostConfigureOptions(); + + var options = new PingOneAuthenticationOptions() + { + EnvironmentId = value, + }; + + // Act and Assert + Assert.Throws("options", () => target.PostConfigure(name, options)); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneTests.cs new file mode 100644 index 000000000..7ad2bff69 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/PingOneTests.cs @@ -0,0 +1,70 @@ +/* + * 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.PingOne; + +public class PingOneTests : OAuthTests +{ + public PingOneTests(ITestOutputHelper outputHelper) + { + OutputHelper = outputHelper; + } + + public override string DefaultScheme => PingOneAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddPingOne(options => + { + ConfigureDefaults(builder, options); + options.EnvironmentId = "b775aadd-a2f3-4d88-a768-b7c85dd1af47"; + }); + } + + [Theory] + [InlineData(ClaimTypes.Email, "john.doe@example.com")] + [InlineData(ClaimTypes.GivenName, "John")] + [InlineData(ClaimTypes.Name, "John Doe")] + [InlineData(ClaimTypes.NameIdentifier, "00uid4BxXw6I6TV4m0g3")] + [InlineData(ClaimTypes.Surname, "Doe")] + public async Task Can_Sign_In_Using_PingOne(string claimType, string claimValue) + { + // Arrange + using var server = CreateTestServer(); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } + + [Theory] + [InlineData(ClaimTypes.Email, "jane.doe@example.com")] + [InlineData(ClaimTypes.GivenName, "Jane")] + [InlineData(ClaimTypes.Name, "Jane Doe")] + [InlineData(ClaimTypes.NameIdentifier, "00uid4BxXw6I6TV4m0g4")] + [InlineData(ClaimTypes.Surname, "Doe")] + public async Task Can_Sign_In_Using_PingOne_With_Custom_Domain(string claimType, string claimValue) + { + // Arrange + static void ConfigureServices(IServiceCollection services) + { + services.Configure("PingOne", (options) => + { + options.Domain = "auth.pingone.local"; + }); + } + + using var server = CreateTestServer(ConfigureServices); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/PingOne/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/bundle.json new file mode 100644 index 000000000..1049573da --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/PingOne/bundle.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "comment": "https://apidocs.pingidentity.com/pingone/platform/v1/api/#token", + "uri": "https://auth.pingone.com/b775aadd-a2f3-4d88-a768-b7c85dd1af47/as/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid email", + "refresh_token": "secret-refresh-token", + "id_token": "secret-id-token" + } + }, + { + "comment": "https://apidocs.pingidentity.com/pingone/platform/v1/api/#post-userinfo", + "uri": "https://auth.pingone.com/b775aadd-a2f3-4d88-a768-b7c85dd1af47/as/userinfo", + "contentFormat": "json", + "contentJson": { + "sub": "00uid4BxXw6I6TV4m0g3", + "name": "John Doe", + "nickname": "Jimmy", + "given_name": "John", + "middle_name": "James", + "family_name": "Doe", + "profile": "https://example.com/john.doe", + "zoneinfo": "America/Los_Angeles", + "locale": "en-US", + "updated_at": 1311280970, + "email": "john.doe@example.com", + "email_verified": true, + "address": { + "street_address": "123 Hollywood Blvd.", + "locality": "Los Angeles", + "region": "CA", + "postal_code": "90210", + "country": "US" + }, + "phone_number": "+1 (425) 555-1212" + } + }, + { + "comment": "https://apidocs.pingidentity.com/pingone/platform/v1/api/#token", + "uri": "https://auth.pingone.local/b775aadd-a2f3-4d88-a768-b7c85dd1af47/as/token", + "method": "POST", + "contentFormat": "json", + "contentJson": { + "access_token": "secret-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid email", + "refresh_token": "secret-refresh-token", + "id_token": "secret-id-token" + } + }, + { + "comment": "https://apidocs.pingidentity.com/pingone/platform/v1/api/#post-userinfo", + "uri": "https://auth.pingone.local/b775aadd-a2f3-4d88-a768-b7c85dd1af47/as/userinfo", + "contentFormat": "json", + "contentJson": { + "sub": "00uid4BxXw6I6TV4m0g4", + "name": "Jane Doe", + "nickname": "Jan", + "given_name": "Jane", + "middle_name": "Jenny", + "family_name": "Doe", + "profile": "https://example.com/jane.doe", + "zoneinfo": "America/Los_Angeles", + "locale": "en-US", + "updated_at": 1311280970, + "email": "jane.doe@example.com", + "email_verified": true, + "address": { + "street_address": "123 Hollywood Blvd.", + "locality": "Los Angeles", + "region": "CA", + "postal_code": "90210", + "country": "US" + }, + "phone_number": "+1 (425) 555-1212" + } + } + ] +}