diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln
index f90e5fa60..f0fce9985 100644
--- a/AspNet.Security.OAuth.Providers.sln
+++ b/AspNet.Security.OAuth.Providers.sln
@@ -296,6 +296,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Kook"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.PingOne", "src\AspNet.Security.OAuth.PingOne\AspNet.Security.OAuth.PingOne.csproj", "{CF8C4235-6AE6-404E-B572-4FF4E85AB5FF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.JumpCloud", "src\AspNet.Security.OAuth.JumpCloud\AspNet.Security.OAuth.JumpCloud.csproj", "{8AF5DDBE-2631-4E71-9045-73A6356CE86B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -678,6 +680,10 @@ Global
{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
+ {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8AF5DDBE-2631-4E71-9045-73A6356CE86B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -783,6 +789,7 @@ Global
{E3CF7FFC-56A0-4033-87A9-BB3080CF030E} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{101681FB-569F-4941-B943-2AD380039BE0} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{CF8C4235-6AE6-404E-B572-4FF4E85AB5FF} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
+ {8AF5DDBE-2631-4E71-9045-73A6356CE86B} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C}
diff --git a/README.md b/README.md
index 6061e5e25..4bba74f60 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@ We would love it if you could help contributing to this repository.
**Special thanks to our contributors:**
+* [Aaron Sadler](https://github.com/aaronsadleruk)
* [Abhinav Nigam](https://github.com/abhinavnigam)
* [Adam Reisinger](https://github.com/Res42)
* [Albert Zakiev](https://github.com/serber)
@@ -159,6 +160,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
| HubSpot | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.HubSpot?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.HubSpot/ "Download AspNet.Security.OAuth.HubSpot from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.HubSpot?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.HubSpot "Download AspNet.Security.OAuth.HubSpot from MyGet.org") | [Documentation](https://developers.hubspot.com/docs "HubSpot developer documentation") |
| Imgur | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Imgur?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Imgur/ "Download AspNet.Security.OAuth.Imgur from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Imgur?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Imgur "Download AspNet.Security.OAuth.Imgur from MyGet.org") | [Documentation](https://apidocs.imgur.com/?version=latest#authorization-and-oauth "Imgur developer documentation") |
| Instagram | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Instagram?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Instagram/ "Download AspNet.Security.OAuth.Instagram from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Instagram?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Instagram "Download AspNet.Security.OAuth.Instagram from MyGet.org") | [Documentation](https://www.instagram.com/developer/authentication/ "Instagram developer documentation") |
+| JumpCloud | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.JumpCloud?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.JumpCloud/ "Download AspNet.Security.OAuth.JumpCloud from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.JumpCloud?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.JumpCloud "Download AspNet.Security.OAuth.JumpCloud from MyGet.org") | [Documentation](https://jumpcloud.com/support/sso-with-oidc "JumpCloud developer documentation") |
| KakaoTalk | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.KakaoTalk?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.KakaoTalk/ "Download AspNet.Security.OAuth.KakaoTalk from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.KakaoTalk?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.KakaoTalk "Download AspNet.Security.OAuth.KakaoTalk from MyGet.org") | [Documentation](https://developers.kakao.com/docs/latest/en/kakaologin/common "KakaoTalk developer documentation") |
| Keycloak | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Keycloak?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Keycloak/ "Download AspNet.Security.OAuth.Keycloak from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Keycloak?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Keycloak "Download AspNet.Security.OAuth.Keycloak from MyGet.org") | [Documentation](https://www.keycloak.org/docs/latest/authorization_services/#_service_overview "Keycloak developer documentation") |
| Kloudless | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Kloudless?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Kloudless/ "Download AspNet.Security.OAuth.Kloudless from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Kloudless?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Kloudless "Download AspNet.Security.OAuth.Kloudless from MyGet.org") | [Documentation](https://developers.kloudless.com/docs/v1/authentication "Kloudless developer documentation") |
diff --git a/docs/jumpcloud.md b/docs/jumpcloud.md
new file mode 100644
index 000000000..70cff69bc
--- /dev/null
+++ b/docs/jumpcloud.md
@@ -0,0 +1,23 @@
+# Integrating the JumpCloud Provider
+
+## Example
+
+```csharp
+services.AddAuthentication(options => /* Auth configuration */)
+ .AddJumpCloud(options =>
+ {
+ options.ClientId = "my-client-id";
+ options.ClientSecret = "my-client-secret";
+ options.Domain = "https://oauth.id.jumpcloud.com";
+ });
+```
+
+## Required Additional Settings
+
+| Property Name | Property Type | Description | Default Value |
+|:--|:--|:--|:--|
+| `Domain` | `string?` | The JumpCloud domain to use for authentication. | `null` |
+
+## Optional Settings
+
+_None._
diff --git a/src/AspNet.Security.OAuth.JumpCloud/AspNet.Security.OAuth.JumpCloud.csproj b/src/AspNet.Security.OAuth.JumpCloud/AspNet.Security.OAuth.JumpCloud.csproj
new file mode 100644
index 000000000..e9893651c
--- /dev/null
+++ b/src/AspNet.Security.OAuth.JumpCloud/AspNet.Security.OAuth.JumpCloud.csproj
@@ -0,0 +1,24 @@
+
+
+
+ 7.0.4
+ $(DefaultNetCoreTargetFramework)
+
+
+
+
+ true
+
+
+
+ ASP.NET Core security middleware enabling JumpCloud authentication.
+ AaronSadlerUK
+ jumpcloud;aspnetcore;authentication;oauth;security
+
+
+
+
+
+
+
+
diff --git a/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationDefaults.cs
new file mode 100644
index 000000000..d9256fd6f
--- /dev/null
+++ b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationDefaults.cs
@@ -0,0 +1,65 @@
+/*
+ * 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;
+
+namespace AspNet.Security.OAuth.JumpCloud;
+
+///
+/// Default values used by the JumpCloud authentication provider.
+///
+public static class JumpCloudAuthenticationDefaults
+{
+ ///
+ /// Default value for .
+ ///
+ public const string AuthenticationScheme = "JumpCloud";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string DisplayName = "JumpCloud";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string Issuer = "JumpCloud";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string CallbackPath = "/signin-jumpcloud";
+
+ ///
+ /// Default path format to use for .
+ ///
+ public static readonly string AuthorizationEndpointPathFormat = "/oauth2/auth";
+
+ ///
+ /// Default path format to use for .
+ ///
+ public static readonly string TokenEndpointPathFormat = "/oauth2/token";
+
+ ///
+ /// Default path format to use for .
+ ///
+ public static readonly string UserInformationEndpointPathFormat = "/userinfo";
+
+ ///
+ /// Default path to use for .
+ ///
+ public static readonly string AuthorizationEndpointPath = string.Format(CultureInfo.InvariantCulture, AuthorizationEndpointPathFormat);
+
+ ///
+ /// Default path to use for .
+ ///
+ public static readonly string TokenEndpointPath = string.Format(CultureInfo.InvariantCulture, TokenEndpointPathFormat);
+
+ ///
+ /// Default path to use for .
+ ///
+ public static readonly string UserInformationEndpointPath = string.Format(CultureInfo.InvariantCulture, UserInformationEndpointPathFormat);
+}
diff --git a/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationExtensions.cs
new file mode 100644
index 000000000..29a56de77
--- /dev/null
+++ b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationExtensions.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 Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace AspNet.Security.OAuth.JumpCloud;
+
+///
+/// Extension methods to add JumpCloud authentication capabilities to an HTTP application pipeline.
+///
+public static class JumpCloudAuthenticationExtensions
+{
+ ///
+ /// Adds to the specified
+ /// , which enables JumpCloud authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The .
+ public static AuthenticationBuilder AddJumpCloud([NotNull] this AuthenticationBuilder builder)
+ {
+ return builder.AddJumpCloud(JumpCloudAuthenticationDefaults.AuthenticationScheme, options => { });
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables JumpCloud authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The delegate used to configure the JumpCloud options.
+ /// The .
+ public static AuthenticationBuilder AddJumpCloud(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] Action configuration)
+ {
+ return builder.AddJumpCloud(JumpCloudAuthenticationDefaults.AuthenticationScheme, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables JumpCloud authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The authentication scheme associated with this instance.
+ /// The delegate used to configure the JumpCloud options.
+ /// The .
+ public static AuthenticationBuilder AddJumpCloud(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [NotNull] Action configuration)
+ {
+ return builder.AddJumpCloud(scheme, JumpCloudAuthenticationDefaults.DisplayName, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables JumpCloud 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 JumpCloud options.
+ /// The .
+ public static AuthenticationBuilder AddJumpCloud(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [CanBeNull] string caption,
+ [NotNull] Action configuration)
+ {
+ builder.Services.TryAddSingleton, JumpCloudPostConfigureOptions>();
+ return builder.AddOAuth(scheme, caption, configuration);
+ }
+}
diff --git a/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationHandler.cs b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationHandler.cs
new file mode 100644
index 000000000..e936eebf7
--- /dev/null
+++ b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationHandler.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.JumpCloud;
+
+///
+/// Defines a handler for authentication using JumpCloud.
+///
+public partial class JumpCloudAuthenticationHandler : 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 JumpCloudAuthenticationHandler(
+ [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 JumpCloud.");
+ }
+
+ 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.JumpCloud/JumpCloudAuthenticationOptions.cs b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationOptions.cs
new file mode 100644
index 000000000..73fe44341
--- /dev/null
+++ b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudAuthenticationOptions.cs
@@ -0,0 +1,66 @@
+/*
+ * 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.JumpCloud;
+
+///
+/// Defines a set of options used by .
+///
+public class JumpCloudAuthenticationOptions : OAuthOptions
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public JumpCloudAuthenticationOptions()
+ {
+ ClaimsIssuer = JumpCloudAuthenticationDefaults.Issuer;
+ CallbackPath = JumpCloudAuthenticationDefaults.CallbackPath;
+
+ 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 JumpCloud domain (Org URL) to use for authentication.
+ ///
+ public string? Domain { 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.JumpCloud/JumpCloudPostConfigureOptions.cs b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudPostConfigureOptions.cs
new file mode 100644
index 000000000..5d926bf25
--- /dev/null
+++ b/src/AspNet.Security.OAuth.JumpCloud/JumpCloudPostConfigureOptions.cs
@@ -0,0 +1,46 @@
+/*
+ * 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.JumpCloud;
+
+///
+/// A class used to setup defaults for all .
+///
+public class JumpCloudPostConfigureOptions : IPostConfigureOptions
+{
+ ///
+ public void PostConfigure(
+ string? name,
+ [NotNull] JumpCloudAuthenticationOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(options.Domain))
+ {
+ throw new ArgumentException("No JumpCloud domain configured.", nameof(options));
+ }
+
+ options.AuthorizationEndpoint = CreateUrl(options.Domain, JumpCloudAuthenticationDefaults.AuthorizationEndpointPathFormat);
+ options.TokenEndpoint = CreateUrl(options.Domain, JumpCloudAuthenticationDefaults.TokenEndpointPathFormat);
+ options.UserInformationEndpoint = CreateUrl(options.Domain, JumpCloudAuthenticationDefaults.UserInformationEndpointPathFormat);
+ }
+
+ 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/JumpCloud/JumpCloudAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudAuthenticationOptionsTests.cs
new file mode 100644
index 000000000..0ca3b1693
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudAuthenticationOptionsTests.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.JumpCloud;
+
+public static class JumpCloudAuthenticationOptionsTests
+{
+ [Fact]
+ public static void Validate_Throws_If_AuthorizationEndpoint_Not_Set()
+ {
+ // Arrange
+ var options = new JumpCloudAuthenticationOptions()
+ {
+ ClientId = "ClientId",
+ ClientSecret = "ClientSecret",
+ TokenEndpoint = "https://jumpcloud.local",
+ UserInformationEndpoint = "https://jumpcloud.local",
+ };
+
+ // Act and Assert
+ Assert.Throws("AuthorizationEndpoint", () => options.Validate());
+ }
+
+ [Fact]
+ public static void Validate_Throws_If_TokenEndpoint_Not_Set()
+ {
+ // Arrange
+ var options = new JumpCloudAuthenticationOptions()
+ {
+ AuthorizationEndpoint = "https://jumpcloud.local",
+ ClientId = "ClientId",
+ ClientSecret = "ClientSecret",
+ UserInformationEndpoint = "https://jumpcloud.local",
+ };
+
+ // Act and Assert
+ Assert.Throws("TokenEndpoint", () => options.Validate());
+ }
+
+ [Fact]
+ public static void Validate_Throws_If_UserInformationEndpoint_Not_Set()
+ {
+ // Arrange
+ var options = new JumpCloudAuthenticationOptions()
+ {
+ AuthorizationEndpoint = "https://jumpcloud.local",
+ ClientId = "ClientId",
+ ClientSecret = "ClientSecret",
+ TokenEndpoint = "https://jumpcloud.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 JumpCloudAuthenticationOptions()
+ {
+ AuthorizationEndpoint = "https://jumpcloud.local",
+ ClientId = "ClientId",
+ ClientSecret = "ClientSecret",
+ TokenEndpoint = "https://jumpcloud.local",
+ UserInformationEndpoint = "https://jumpcloud.local",
+ };
+
+ // Act (no Assert)
+ options.Validate();
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudPostConfigureOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudPostConfigureOptionsTests.cs
new file mode 100644
index 000000000..0c7fbd1f1
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudPostConfigureOptionsTests.cs
@@ -0,0 +1,60 @@
+/*
+ * 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.JumpCloud;
+
+public static class JumpCloudPostConfigureOptionsTests
+{
+ [Theory]
+ [InlineData("jumpcloud.local")]
+ [InlineData("http://jumpcloud.local")]
+ [InlineData("http://jumpcloud.local/")]
+ [InlineData("https://jumpcloud.local")]
+ [InlineData("https://jumpcloud.local/")]
+ public static void PostConfigure_Configures_Valid_Endpoints(string domain)
+ {
+ // Arrange
+ string name = "JumpCloud";
+ var target = new JumpCloudPostConfigureOptions();
+
+ var options = new JumpCloudAuthenticationOptions()
+ {
+ Domain = domain,
+ };
+
+ // Act
+ target.PostConfigure(name, options);
+
+ // Assert
+ options.AuthorizationEndpoint.ShouldStartWith("https://jumpcloud.local/oauth2/auth");
+ Uri.TryCreate(options.AuthorizationEndpoint, UriKind.Absolute, out _).ShouldBeTrue();
+
+ options.TokenEndpoint.ShouldStartWith("https://jumpcloud.local/oauth2/token");
+ Uri.TryCreate(options.TokenEndpoint, UriKind.Absolute, out _).ShouldBeTrue();
+
+ options.UserInformationEndpoint.ShouldStartWith("https://jumpcloud.local/userinfo");
+ 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 = "JumpCloud";
+ var target = new JumpCloudPostConfigureOptions();
+
+ var options = new JumpCloudAuthenticationOptions()
+ {
+ Domain = value,
+ };
+
+ // Act and Assert
+ Assert.Throws("options", () => target.PostConfigure(name, options));
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudTests.cs
new file mode 100644
index 000000000..b36028ea9
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/JumpCloudTests.cs
@@ -0,0 +1,44 @@
+/*
+ * 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.JumpCloud;
+
+public class JumpCloudTests : OAuthTests
+{
+ public JumpCloudTests(ITestOutputHelper outputHelper)
+ {
+ OutputHelper = outputHelper;
+ }
+
+ public override string DefaultScheme => JumpCloudAuthenticationDefaults.AuthenticationScheme;
+
+ protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
+ {
+ builder.AddJumpCloud(options =>
+ {
+ ConfigureDefaults(builder, options);
+ options.Domain = "jumpcloud.local";
+ });
+ }
+
+ [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_JumpCloud(string claimType, string claimValue)
+ {
+ // Arrange
+ using var server = CreateTestServer();
+
+ // Act
+ var claims = await AuthenticateUserAsync(server);
+
+ // Assert
+ AssertClaim(claims, claimType, claimValue);
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/bundle.json
new file mode 100644
index 000000000..b6aa0e9ef
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/JumpCloud/bundle.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json",
+ "items": [
+ {
+ "comment": "https://jumpcloud.com/support/sso-with-oidc",
+ "uri": "https://jumpcloud.local/oauth2/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://jumpcloud.com/support/sso-with-oidc",
+ "uri": "https://jumpcloud.local/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"
+ }
+ }
+ ]
+}