diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 36a25a3f..49068021 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1686,6 +1686,9 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId An HTTP/HTTPS redirect_uri or post_logout_redirect_uri cannot be used when using AS web authentication sessions. Make sure you're using a custom protocol scheme for all the callback URIs attached to the client registration. + + The Zoho integration requires sending the region of the server when using the client credentials or refresh token grants. For that, attach a ".location" authentication property containing the region to use. + The security token is missing. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs index 3f7cab62..c74166b8 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs @@ -17,7 +17,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create([ /* - * Token response extraction: + * Device authorization response extraction: */ MapNonStandardResponseParameters.Descriptor ]); diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs index 8efe5f8f..19844e26 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs @@ -192,8 +192,8 @@ public static partial class OpenIddictClientWebIntegrationHandlers // are manually added to the list of supported code challenge methods by this handler. if (context.Registration.ProviderType is - ProviderTypes.Adobe or ProviderTypes.Autodesk or - ProviderTypes.FaceIt or ProviderTypes.Microsoft) + ProviderTypes.Adobe or ProviderTypes.Autodesk or + ProviderTypes.FaceIt or ProviderTypes.Microsoft or ProviderTypes.Zoho) { context.Configuration.CodeChallengeMethodsSupported.Add(CodeChallengeMethods.Plain); context.Configuration.CodeChallengeMethodsSupported.Add(CodeChallengeMethods.Sha256); diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index 5c85d8fe..43d4a578 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -389,6 +389,37 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + // Zoho returns the region of the authenticated user as a non-standard "location" parameter + // that must be used to compute the address of the token and userinfo endpoints. + else if (context.Registration.ProviderType is ProviderTypes.Zoho) + { + var location = (string?) context.Request["location"]; + if (string.IsNullOrEmpty(location)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029("location"), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + // Ensure the specified location corresponds to well-known region. + if (location.ToUpperInvariant() is not ( "AU" or "CA" or "EU" or "IN" or "JP" or "SA" or "US")) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2052("location"), + uri: SR.FormatID8000(SR.ID2052)); + + return default; + } + + // Store the validated location as an authentication property + // so it can be resolved later to determine the user region. + context.Properties[Zoho.Properties.Location] = location; + } + return default; } } @@ -435,6 +466,37 @@ public static partial class OpenIddictClientWebIntegrationHandlers ProviderTypes.Trovo when context.GrantType is GrantTypes.RefreshToken => new Uri("https://open-api.trovo.live/openplatform/refreshtoken", UriKind.Absolute), + // Zoho requires using a region-specific token endpoint determined using + // the "location" parameter returned from the authorization endpoint. + // + // For more information, see + // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html. + ProviderTypes.Zoho when context.GrantType is GrantTypes.AuthorizationCode + => ((string?) context.Request?["location"])?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/v2/token", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/v2/token", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/v2/token", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/v2/token", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/v2/token", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/v2/token", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/v2/token", UriKind.Absolute) + }, + + ProviderTypes.Zoho when context.GrantType is GrantTypes.RefreshToken + => !context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) || + string.IsNullOrEmpty(location) ? throw new InvalidOperationException(SR.GetResourceString(SR.ID0451)) : + location?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/v2/token", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/v2/token", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/v2/token", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/v2/token", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/v2/token", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/v2/token", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/v2/token", UriKind.Absolute) + }, + _ => context.TokenEndpoint }; @@ -779,6 +841,37 @@ public static partial class OpenIddictClientWebIntegrationHandlers Uri.TryCreate(principal.GetClaim("http://schemes.superoffice.net/identity/webapi_url"), UriKind.Absolute, out Uri? uri) => OpenIddictHelpers.CreateAbsoluteUri(uri, new Uri("v1/user/currentPrincipal", UriKind.Relative)), + // Zoho requires using a region-specific userinfo endpoint determined using + // the "location" parameter returned from the authorization endpoint. + // + // For more information, see + // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html. + ProviderTypes.Zoho when context.GrantType is GrantTypes.AuthorizationCode + => ((string?) context.Request?["location"])?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute) + }, + + ProviderTypes.Zoho when context.GrantType is GrantTypes.RefreshToken + => !context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) || + string.IsNullOrEmpty(location) ? throw new InvalidOperationException(SR.GetResourceString(SR.ID0451)) : + location?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute) + }, + _ => context.UserinfoEndpoint }; @@ -1195,8 +1288,8 @@ public static partial class OpenIddictClientWebIntegrationHandlers // Patreon returns the email address as a custom "attributes/email" node: ProviderTypes.Patreon => (string?) context.UserinfoResponse?["attributes"]?["email"], - // ServiceChannel returns the email address as a custom "Email" node: - ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["Email"], + // ServiceChannel and Zoho return the email address as a custom "Email" node: + ProviderTypes.ServiceChannel or ProviderTypes.Zoho => (string?) context.UserinfoResponse?["Email"], // Shopify returns the email address as a custom "associated_user/email" node in token responses: ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["email"], @@ -1279,6 +1372,9 @@ public static partial class OpenIddictClientWebIntegrationHandlers // Typeform returns the username as a custom "alias" node: ProviderTypes.Typeform => (string?) context.UserinfoResponse?["alias"], + // Zoho returns the username as a custom "Display_Name" node: + ProviderTypes.Zoho => (string?) context.UserinfoResponse?["Display_Name"], + _ => context.MergedPrincipal.GetClaim(ClaimTypes.Name) }); @@ -1364,6 +1460,9 @@ public static partial class OpenIddictClientWebIntegrationHandlers // WordPress returns the user identifier as a custom "ID" node: ProviderTypes.WordPress => (string?) context.UserinfoResponse?["ID"], + // WordPress returns the user identifier as a custom "ZUID" node: + ProviderTypes.Zoho => (string?) context.UserinfoResponse?["ZUID"], + _ => context.MergedPrincipal.GetClaim(ClaimTypes.NameIdentifier) }); @@ -1460,6 +1559,22 @@ public static partial class OpenIddictClientWebIntegrationHandlers new Uri("https://connect.stripe.com/express/oauth/authorize", UriKind.Absolute) : new Uri("https://connect.stripe.com/oauth/authorize", UriKind.Absolute), + // Zoho requires using a region-specific authorization endpoint. + // + // For more information, see + // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html. + ProviderTypes.Zoho when context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) + => location?.ToUpperInvariant() switch + { + "AU" => new Uri("https://accounts.zoho.com.au/oauth/v2/auth", UriKind.Absolute), + "CA" => new Uri("https://accounts.zohocloud.ca/oauth/v2/auth", UriKind.Absolute), + "EU" => new Uri("https://accounts.zoho.eu/oauth/v2/auth", UriKind.Absolute), + "IN" => new Uri("https://accounts.zoho.in/oauth/v2/auth", UriKind.Absolute), + "JP" => new Uri("https://accounts.zoho.jp/oauth/v2/auth", UriKind.Absolute), + "SA" => new Uri("https://accounts.zoho.sa/oauth/v2/auth", UriKind.Absolute), + _ => new Uri("https://accounts.zoho.com/oauth/v2/auth", UriKind.Absolute) + }, + _ => context.AuthorizationEndpoint }; @@ -1735,6 +1850,16 @@ public static partial class OpenIddictClientWebIntegrationHandlers context.Request["language"] = settings.Language; } + // By default, Zoho doesn't return a refresh token but + // allows sending an "access_type" parameter to retrieve one. + else if (context.Registration.ProviderType is ProviderTypes.Zoho) + { + var settings = context.Registration.GetZohoSettings(); + + context.Request["access_type"] = settings.AccessType; + context.Request.Prompt = settings.Prompt; + } + return default; } } diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml index fd7cb4cd..d4fc43c1 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml @@ -269,11 +269,7 @@ Note: most Battle.net regions use the same issuer URI but a different domain is required for China. --> - + @@ -967,23 +963,10 @@ Note: Lark serves global users, but it is known as Feishu in China, which has a separate issuer and domain. --> - - - + + @@ -2150,6 +2133,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + +