From 594cfacf99899b501f01a1d26b71ed87b4080ce0 Mon Sep 17 00:00:00 2001 From: Loongle Date: Wed, 19 Feb 2025 12:25:03 +0800 Subject: [PATCH 01/10] Add Douyin provider --- AspNet.Security.OAuth.Providers.sln | 10 +- README.md | 2 + docs/README.md | 1 + docs/douyin.md | 20 ++ .../AspNet.Security.OAuth.Douyin.csproj | 17 ++ .../DouyinAuthenticationConstants.cs | 20 ++ .../DouyinAuthenticationDefaults.cs | 48 ++++ .../DouyinAuthenticationExtensions.cs | 74 ++++++ .../DouyinAuthenticationHandler.cs | 235 ++++++++++++++++++ .../DouyinAuthenticationOptions.cs | 30 +++ .../Douyin/DouyinTests.cs | 30 +++ .../Douyin/bundle.json | 55 ++++ 12 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 docs/douyin.md create mode 100644 src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj create mode 100644 src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationConstants.cs create mode 100644 src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationDefaults.cs create mode 100644 src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationExtensions.cs create mode 100644 src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs create mode 100644 src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationOptions.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs create mode 100644 test/AspNet.Security.OAuth.Providers.Tests/Douyin/bundle.json diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index e5b64b67c..870c8b3c7 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -184,6 +184,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\bitbucket.md = docs\bitbucket.md docs\digitalocean.md = docs\digitalocean.md docs\discord.md = docs\discord.md + docs\docusign.md = docs\docusign.md + douyin.md = douyin.md docs\dropbox.md = docs\dropbox.md docs\ebay.md = docs\ebay.md docs\eveonline.md = docs\eveonline.md @@ -219,7 +221,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\workweixin.md = docs\workweixin.md docs\xumm.md = docs\xumm.md docs\zendesk.md = docs\zendesk.md - docs\docusign.md = docs\docusign.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Basecamp", "src\AspNet.Security.OAuth.Basecamp\AspNet.Security.OAuth.Basecamp.csproj", "{42306484-B2BF-4B52-B950-E0CDFA58B02A}" @@ -313,6 +314,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Zoho" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.VkId", "src\AspNet.Security.OAuth.VkId\AspNet.Security.OAuth.VkId.csproj", "{F3E62C24-5F82-4CF5-A994-0E10D04FB495}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Douyin", "src\AspNet.Security.OAuth.Douyin\AspNet.Security.OAuth.Douyin.csproj", "{1F02BB27-45BF-4FEF-9D07-B9C5C91988A4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -727,6 +730,10 @@ Global {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Debug|Any CPU.Build.0 = Debug|Any CPU {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Release|Any CPU.ActiveCfg = Release|Any CPU {F3E62C24-5F82-4CF5-A994-0E10D04FB495}.Release|Any CPU.Build.0 = Release|Any CPU + {1F02BB27-45BF-4FEF-9D07-B9C5C91988A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F02BB27-45BF-4FEF-9D07-B9C5C91988A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F02BB27-45BF-4FEF-9D07-B9C5C91988A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F02BB27-45BF-4FEF-9D07-B9C5C91988A4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -840,6 +847,7 @@ Global {4E96BD06-04CD-4014-BA42-10D2CDB820D6} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {CD56ABE4-1CD2-4029-B556-E110A31A2CC4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} {F3E62C24-5F82-4CF5-A994-0E10D04FB495} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} + {1F02BB27-45BF-4FEF-9D07-B9C5C91988A4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C} diff --git a/README.md b/README.md index 943168778..3d2e42720 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ We would love it if you could help contributing to this repository. * [Vicente Yu](https://github.com/vicenteyu) * [Volodymyr Baydalka](https://github.com/zVolodymyr) * [Logan Dam](https://github.com/biltongza) +* [Loongle Tse](https://github.com/loongle) ## Security policy @@ -176,6 +177,7 @@ If a provider you're looking for does not exist, consider making a PR to add one | DigitalOcean | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.DigitalOcean?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.DigitalOcean/ "Download AspNet.Security.OAuth.DigitalOcean from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.DigitalOcean?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.DigitalOcean "Download AspNet.Security.OAuth.DigitalOcean from MyGet.org") | [Documentation](https://docs.digitalocean.com/reference/api/oauth-api/ "DigitalOcean developer documentation") | | Discord | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Discord?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Discord/ "Download AspNet.Security.OAuth.Discord from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Discord?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Discord "Download AspNet.Security.OAuth.Discord from MyGet.org") | [Documentation](https://discord.com/developers/docs/topics/oauth2 "Discord developer documentation") | | Docusign | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Docusign?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Docusign/ "Download AspNet.Security.OAuth.Docusign from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Docusign?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Docusign "Download AspNet.Security.OAuth.Docusign from MyGet.org") | [Documentation](https://developers.docusign.com/platform/auth/ "Docusign developer documentation") | +| Douyin | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Douyin?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Douyin/ "Download AspNet.Security.OAuth.Douyin from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Douyin?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Douyin "Download AspNet.Security.OAuth.Douyin from MyGet.org") | [Documentation](https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/mobile-app/permission/overall-permission "Douyin developer documentation") | | Dropbox | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Dropbox?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Dropbox/ "Download AspNet.Security.OAuth.Dropbox from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Dropbox?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Dropbox "Download AspNet.Security.OAuth.Dropbox from MyGet.org") | [Documentation](https://www.dropbox.com/developers/reference/oauth-guide?_tk=guides_lp&_ad=deepdive2&_camp=oauth "Dropbox developer documentation") | | eBay | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Ebay?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Ebay/ "Download AspNet.Security.OAuth.Ebay from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Ebay?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Ebay "Download AspNet.Security.OAuth.Ebay from MyGet.org") | [Documentation](https://developer.ebay.com/api-docs/static/oauth-tokens.html "eBay developer documentation") | | EVEOnline | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.EVEOnline?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.EVEOnline/ "Download AspNet.Security.OAuth.EVEOnline from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.EVEOnline?logo=nuget&label=MyGet&color=blue)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.EVEOnline "Download AspNet.Security.OAuth.EVEOnline from MyGet.org") | [Documentation](https://github.com/esi/esi-docs/blob/master/docs/sso/web_based_sso_flow.md "EVEOnline developer documentation") | diff --git a/docs/README.md b/docs/README.md index a05f40a7b..1176bb12b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ covered by the section above. | DigitalOcean | _Optional_ | [Documentation](digitalocean.md "DigitalOcean provider documentation") | | Discord | _Optional_ | [Documentation](discord.md "Discord provider documentation") | | Docusign | **Required** | [Documentation](docusign.md "Docusign provider documentation") | +| Douyin | _Optional_ | [Documentation](douyin.md "Douyin provider documentation") | | eBay | **Required** | [Documentation](ebay.md "eBay provider documentation") | | EVEOnline | _Optional_ | [Documentation](eveonline.md "EVEOnline provider documentation") | | Foursquare | _Optional_ | [Documentation](foursquare.md "Foursquare provider documentation") | diff --git a/docs/douyin.md b/docs/douyin.md new file mode 100644 index 000000000..7a0460e59 --- /dev/null +++ b/docs/douyin.md @@ -0,0 +1,20 @@ +# Integrating the Douyin Provider + +## Example + +```csharp +services.AddAuthentication(options => /* Auth configuration */) + .AddDouyin(options => + { + options.ClientId = "my-client-id"; + options.ClientSecret = "my-client-secret"; + }); +``` + +## Required Additional Settings + +_None._ + +## Optional Settings + +_None._ diff --git a/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj b/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj new file mode 100644 index 000000000..2e3605a94 --- /dev/null +++ b/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + ASP.NET Core security middleware enabling Douyin authentication. + Loongle Tse + douyin;bytedance;aspnetcore;authentication;oauth;security + + + + + + + \ No newline at end of file diff --git a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationConstants.cs new file mode 100644 index 000000000..4cd837713 --- /dev/null +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationConstants.cs @@ -0,0 +1,20 @@ +/* + * 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.Douyin; + +/// +/// Contains constants specific to the . +/// +public static class DouyinAuthenticationConstants +{ + public static class Claims + { + public const string Avatar = "urn:douyin:avatar"; + + public const string Nickname = "urn:douyin:nickname"; + } +} diff --git a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationDefaults.cs new file mode 100644 index 000000000..31e3f7ab3 --- /dev/null +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationDefaults.cs @@ -0,0 +1,48 @@ +/* + * 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.Douyin; + +/// +/// Default values for Douyin authentication. +/// +public static class DouyinAuthenticationDefaults +{ + /// + /// Default value for . + /// + public const string AuthenticationScheme = "Douyin"; + + /// + /// Default value for . + /// + public static readonly string DisplayName = "Douyin"; + + /// + /// Default value for . + /// + public static readonly string Issuer = "Douyin"; + + /// + /// Default value for . + /// + public static readonly string CallbackPath = "/signin-douyin"; + + /// + /// Default value for . + /// + public static readonly string AuthorizationEndpoint = "https://open.douyin.com/platform/oauth/connect/"; + + /// + /// Default value for . + /// + public static readonly string TokenEndpoint = "https://open.douyin.com/oauth/access_token/"; + + /// + /// Default value for . + /// + public static readonly string UserInformationEndpoint = "https://open.douyin.com/oauth/userinfo/"; +} diff --git a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationExtensions.cs new file mode 100644 index 000000000..0751ef01d --- /dev/null +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationExtensions.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 AspNet.Security.OAuth.Douyin; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods to add Douyin authentication capabilities to an HTTP application pipeline. +/// +public static class DouyinAuthenticationExtensions +{ + /// + /// Adds to the specified + /// , which enables Douyin authentication capabilities. + /// + /// The authentication builder. + /// The . + public static AuthenticationBuilder AddDouyin([NotNull] this AuthenticationBuilder builder) + { + return builder.AddDouyin(DouyinAuthenticationDefaults.AuthenticationScheme, options => { }); + } + + /// + /// Adds to the specified + /// , which enables Douyin authentication capabilities. + /// + /// The authentication builder. + /// The delegate used to configure the OpenID 2.0 options. + /// The . + public static AuthenticationBuilder AddDouyin( + [NotNull] this AuthenticationBuilder builder, + [NotNull] Action configuration) + { + return builder.AddDouyin(DouyinAuthenticationDefaults.AuthenticationScheme, configuration); + } + + /// + /// Adds to the specified + /// , which enables Douyin authentication capabilities. + /// + /// The authentication builder. + /// The authentication scheme associated with this instance. + /// The delegate used to configure the Douyin options. + /// The . + public static AuthenticationBuilder AddDouyin( + [NotNull] this AuthenticationBuilder builder, + [NotNull] string scheme, + [NotNull] Action configuration) + { + return builder.AddDouyin(scheme, DouyinAuthenticationDefaults.DisplayName, configuration); + } + + /// + /// Adds to the specified + /// , which enables Douyin 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 Douyin options. + /// The . + public static AuthenticationBuilder AddDouyin( + [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.Douyin/DouyinAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs new file mode 100644 index 000000000..9bac292cf --- /dev/null +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs @@ -0,0 +1,235 @@ +/* + * 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 System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace AspNet.Security.OAuth.Douyin; + +/// +/// Defines a handler for authentication using Douyin. +/// +public partial class DouyinAuthenticationHandler : OAuthHandler +{ + public DouyinAuthenticationHandler( + [NotNull] IOptionsMonitor options, + [NotNull] ILoggerFactory logger, + [NotNull] UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + private const string AuthCode = "auth_code"; + + protected override Task HandleRemoteAuthenticateAsync() + { + if (TryStandardizeRemoteAuthenticateQuery(Request.Query, out var queryString)) + { + Request.QueryString = queryString; + } + + return base.HandleRemoteAuthenticateAsync(); + } + + protected override async Task ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context) + { + // See https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-access-token for details. + var tokenRequestParameters = new Dictionary() + { + ["client_key"] = Options.ClientId, + ["code"] = context.Code, + ["client_secret"] = Options.ClientSecret, + ["grant_type"] = "authorization_code", + }; + + using var tokenRequestContent = new FormUrlEncodedContent(tokenRequestParameters); + + using var response = await Backchannel.PostAsync(Options.TokenEndpoint, tokenRequestContent, Context.RequestAborted); + + if (!response.IsSuccessStatusCode) + { + await Log.AccessTokenError(Logger, response, Context.RequestAborted); + return OAuthTokenResponse.Failed(new Exception("An error occurred while retrieving an access token.")); + } + + using var stream = await response.Content.ReadAsStreamAsync(Context.RequestAborted); + using var document = await JsonDocument.ParseAsync(stream); + + var mainElement = document.RootElement.GetProperty("data"); + if (!ValidateReturnCode(mainElement, out var errorCode)) + { + return OAuthTokenResponse.Failed(new Exception($"An error (ErrorCode:{errorCode}) occurred while retrieving an access token.")); + } + + var payload = JsonDocument.Parse(mainElement.GetRawText()); + return OAuthTokenResponse.Success(payload); + } + + protected override async Task CreateTicketAsync( + [NotNull] ClaimsIdentity identity, + [NotNull] AuthenticationProperties properties, + [NotNull] OAuthTokenResponse tokens) + { + // See https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-account-open-info for details. + var parameters = new SortedDictionary() + { + ["open_id"] = tokens.Response!.RootElement.GetProperty("open_id").GetString()!, + ["access_token"] = tokens.AccessToken, + }; + + using var userInfoRequestContent = new FormUrlEncodedContent(parameters); + + using var response = await Backchannel.PostAsync(Options.UserInformationEndpoint, userInfoRequestContent, Context.RequestAborted); + + if (!response.IsSuccessStatusCode) + { + await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted); + throw new HttpRequestException("An error occurred while retrieving user information."); + } + + using var stream = await response.Content.ReadAsStreamAsync(Context.RequestAborted); + using var document = await JsonDocument.ParseAsync(stream); + var mainElement = document.RootElement.GetProperty("data"); + + if (!ValidateReturnCode(mainElement, out var errorCode)) + { + throw new AuthenticationFailureException($"An error (ErrorCode:{errorCode}) occurred while retrieving user information."); + } + + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, mainElement.GetString("open_id")!, ClaimValueTypes.String, Options.ClaimsIssuer)); + + var principal = new ClaimsPrincipal(identity); + var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, mainElement); + + context.RunClaimActions(); + + await Events.CreatingTicket(context); + + return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name); + } + + protected override string FormatScope([NotNull] IEnumerable scopes) => string.Join(',', Options.Scope); + + /// + /// Check the code sent back by server for potential server errors. + /// + /// Main part of json document from response + /// Returned error_code from server + /// See https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/status-code for details. + /// True if succeed, otherwise false. + private static bool ValidateReturnCode(JsonElement element, out int errorCode) + { + if (!element.TryGetProperty("error_code", out JsonElement errorCodeElement)) + { + errorCode = 0; + return true; + } + + errorCode = errorCodeElement.GetInt32()!; + return errorCode == 0; + } + + /// + protected override string BuildChallengeUrl([NotNull] AuthenticationProperties properties, [NotNull] string redirectUri) + { + var scopeParameter = properties.GetParameter>(OAuthChallengeProperties.ScopeKey); + var scope = scopeParameter != null ? FormatScope(scopeParameter) : FormatScope(); + + var parameters = new Dictionary + { + ["client_key"] = Options.ClientId, // Used instead of "client_id" + ["scope"] = scope, + ["response_type"] = "code", + ["redirect_uri"] = redirectUri, + }; + + foreach (var additionalParameter in Options.AdditionalAuthorizationParameters) + { + parameters.Add(additionalParameter.Key, additionalParameter.Value); + } + + parameters["state"] = Options.StateDataFormat.Protect(properties); + + return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters); + } + + private static bool TryStandardizeRemoteAuthenticateQuery(IQueryCollection query, out QueryString queryString) + { + if (!query.TryGetValue(AuthCode, out var authCode)) + { + queryString = default; + return false; + } + + // Before: mydomain/signin-douyin?auth_code=xxx&state=xxx&... + // After: mydomain/signin-douyin?code=xxx&state=xxx&... + var queryParams = new List>(query.Count) + { + new("code", authCode) + }; + foreach (var item in query) + { + switch (item.Key) + { + case "code": + case AuthCode: // No need in fact, skip it + break; + + default: + queryParams.Add(item); + break; + } + } + + queryString = QueryString.Create(queryParams); + return true; + } + + 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 AccessTokenError(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) + { + AccessTokenError( + 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, + HttpStatusCode status, + string headers, + string body); + + [LoggerMessage(2, 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 AccessTokenError( + ILogger logger, + HttpStatusCode status, + string headers, + string body); + } +} diff --git a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationOptions.cs new file mode 100644 index 000000000..4bd08d47f --- /dev/null +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationOptions.cs @@ -0,0 +1,30 @@ +/* + * 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 static AspNet.Security.OAuth.Douyin.DouyinAuthenticationConstants; + +namespace AspNet.Security.OAuth.Douyin; + +/// +/// Defines a set of options used by . +/// +public class DouyinAuthenticationOptions : OAuthOptions +{ + public DouyinAuthenticationOptions() + { + ClaimsIssuer = DouyinAuthenticationDefaults.Issuer; + CallbackPath = DouyinAuthenticationDefaults.CallbackPath; + + AuthorizationEndpoint = DouyinAuthenticationDefaults.AuthorizationEndpoint; + TokenEndpoint = DouyinAuthenticationDefaults.TokenEndpoint; + UserInformationEndpoint = DouyinAuthenticationDefaults.UserInformationEndpoint; + + Scope.Add("user_info"); + + ClaimActions.MapJsonKey(Claims.Avatar, "avatar"); + ClaimActions.MapJsonKey(Claims.Nickname, "nickname"); + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs new file mode 100644 index 000000000..2a6537c19 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs @@ -0,0 +1,30 @@ +/* + * 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.AspNetCore.WebUtilities; + +namespace AspNet.Security.OAuth.Douyin; + +public class DouyinTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper) +{ + public override string DefaultScheme => DouyinAuthenticationDefaults.AuthenticationScheme; + + protected internal override void RegisterAuthentication(AuthenticationBuilder builder) + { + builder.AddDouyin(options => + { + ConfigureDefaults(builder, options); + options.ClientSecret = "ee9ee51ee0ceabdeeeb9459168eeeef7"; + }); + } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "0da22181-d833-447f-995f-1beefe******")] + [InlineData("urn:douyin:avatar", "https://example.com/x.jpeg")] + [InlineData("urn:douyin:nickname", "TestAccount")] + public async Task Can_Sign_In_Using_Douyin(string claimType, string claimValue) + => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Douyin/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/bundle.json new file mode 100644 index 000000000..5791538c8 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/bundle.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json", + "items": [ + { + "uri": "https://open.douyin.com/platform/oauth/connect/", + "contentFormat": "json", + "contentJson": { + "code": "code", + "access_token": "secret-access-token", + "client_token": "client_token", + "refresh_token": "secret-refresh-token" + } + }, + { + "uri": "https://open.douyin.com/oauth/access_token/", + "contentFormat": "json", + "method": "POST", + "contentJson": { + "data": { + "access_token": "act.f7094fbffab2ecbfc45e9af9c32bc241oYdckvBKe82BPx8T******", + "captcha": "", + "desc_url": "", + "description": "", + "error_code": 0, + "expires_in": 1296000, + "log_id": "20230525105733ED3ED7AC56A******", + "open_id": "b9b71865-7fea-44cc-******", + "refresh_expires_in": 2592000, + "refresh_token": "rft.713900b74edde9f30ec4e246b706da30t******", + "scope": "user_info" + }, + "message": "success" + } + }, + { + "uri": "https://open.douyin.com/oauth/userinfo/", + "contentFormat": "json", + "method": "POST", + "contentJson": { + "data": { + "avatar": "https://example.com/x.jpeg", + "avatar_larger": "https://example.com/x.jpeg", + "client_key": "ExampleClientKey", + "e_account_role": "", + "error_code": 0, + "log_id": "202212011600080101351682282501F9E7", + "nickname": "TestAccount", + "open_id": "0da22181-d833-447f-995f-1beefe******", + "union_id": "1ad4e099-4a0c-47d1-a410-bffb4f******" + }, + "message": "success" + } + } + ] +} From ee0ebb6ef5c7ccaa7d9a3bd33b781e31962ccdb8 Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 15:19:09 +0800 Subject: [PATCH 02/10] Remove doyin.md --- AspNet.Security.OAuth.Providers.sln | 1 - docs/douyin.md | 20 -------------------- 2 files changed, 21 deletions(-) delete mode 100644 docs/douyin.md diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index 870c8b3c7..52b438ba0 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -185,7 +185,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\digitalocean.md = docs\digitalocean.md docs\discord.md = docs\discord.md docs\docusign.md = docs\docusign.md - douyin.md = douyin.md docs\dropbox.md = docs\dropbox.md docs\ebay.md = docs\ebay.md docs\eveonline.md = docs\eveonline.md diff --git a/docs/douyin.md b/docs/douyin.md deleted file mode 100644 index 7a0460e59..000000000 --- a/docs/douyin.md +++ /dev/null @@ -1,20 +0,0 @@ -# Integrating the Douyin Provider - -## Example - -```csharp -services.AddAuthentication(options => /* Auth configuration */) - .AddDouyin(options => - { - options.ClientId = "my-client-id"; - options.ClientSecret = "my-client-secret"; - }); -``` - -## Required Additional Settings - -_None._ - -## Optional Settings - -_None._ From 0528c42e03f1ee0cef1bddf1dc7e3b229861fb4d Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 15:25:38 +0800 Subject: [PATCH 03/10] Update Douyin csproj --- .../AspNet.Security.OAuth.Douyin.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj b/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj index 2e3605a94..85469237a 100644 --- a/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj +++ b/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj @@ -1,9 +1,15 @@  + 9.1.0 $(DefaultNetCoreTargetFramework) + + + true + + ASP.NET Core security middleware enabling Douyin authentication. Loongle Tse From 7adfdcc1d93e62abf46009e5d81cce8e2a8de29f Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 15:32:03 +0800 Subject: [PATCH 04/10] Fix logic error and typo in comments of ValidateReturnCode method --- .../DouyinAuthenticationHandler.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs index 9bac292cf..4942e98dd 100644 --- a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs @@ -126,19 +126,19 @@ protected override async Task CreateTicketAsync( /// /// Check the code sent back by server for potential server errors. /// - /// Main part of json document from response + /// Main part of JSON document from response /// Returned error_code from server /// See https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/status-code for details. /// True if succeed, otherwise false. private static bool ValidateReturnCode(JsonElement element, out int errorCode) { - if (!element.TryGetProperty("error_code", out JsonElement errorCodeElement)) + errorCode = 0; + + if (element.TryGetProperty("error_code", out JsonElement errorCodeElement)) { - errorCode = 0; - return true; + errorCode = errorCodeElement.GetInt32()!; } - errorCode = errorCodeElement.GetInt32()!; return errorCode == 0; } From 30a95b2706afdcb169b2842e683b9ee3f8df3413 Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 15:38:35 +0800 Subject: [PATCH 05/10] Remove HandleRemoteAuthenticateAsync --- .../DouyinAuthenticationHandler.cs | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs index 4942e98dd..ab2f77666 100644 --- a/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs @@ -32,18 +32,6 @@ public DouyinAuthenticationHandler( { } - private const string AuthCode = "auth_code"; - - protected override Task HandleRemoteAuthenticateAsync() - { - if (TryStandardizeRemoteAuthenticateQuery(Request.Query, out var queryString)) - { - Request.QueryString = queryString; - } - - return base.HandleRemoteAuthenticateAsync(); - } - protected override async Task ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context) { // See https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-access-token for details. @@ -166,38 +154,6 @@ protected override string BuildChallengeUrl([NotNull] AuthenticationProperties p return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters); } - private static bool TryStandardizeRemoteAuthenticateQuery(IQueryCollection query, out QueryString queryString) - { - if (!query.TryGetValue(AuthCode, out var authCode)) - { - queryString = default; - return false; - } - - // Before: mydomain/signin-douyin?auth_code=xxx&state=xxx&... - // After: mydomain/signin-douyin?code=xxx&state=xxx&... - var queryParams = new List>(query.Count) - { - new("code", authCode) - }; - foreach (var item in query) - { - switch (item.Key) - { - case "code": - case AuthCode: // No need in fact, skip it - break; - - default: - queryParams.Add(item); - break; - } - } - - queryString = QueryString.Create(queryParams); - return true; - } - private static partial class Log { internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken) From 139fa75b7df39c28f5b7dc3457d8de2959929db4 Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 15:52:27 +0800 Subject: [PATCH 06/10] Add BuildChallengeUrl test --- .../Douyin/DouyinTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs index 2a6537c19..cf2a9bb36 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs @@ -4,6 +4,7 @@ * for more information concerning the license and the contributors participating to this project. */ +using AspNet.Security.OAuth.Xero; using Microsoft.AspNetCore.WebUtilities; namespace AspNet.Security.OAuth.Douyin; @@ -27,4 +28,34 @@ protected internal override void RegisterAuthentication(AuthenticationBuilder bu [InlineData("urn:douyin:nickname", "TestAccount")] public async Task Can_Sign_In_Using_Douyin(string claimType, string claimValue) => await AuthenticateUserAndAssertClaimValue(claimType, claimValue); + + [Fact] + public async Task BuildChallengeUrl_Generates_Correct_Url() + { + // Arrange + var options = new DouyinAuthenticationOptions(); + + var redirectUrl = "https://my-site.local/signin-douyin"; + + // Act + Uri actual = await BuildChallengeUriAsync( + options, + redirectUrl, + (options, loggerFactory, encoder) => new DouyinAuthenticationHandler(options, loggerFactory, encoder)); + + // Assert + actual.ShouldNotBeNull(); + actual.ToString().ShouldStartWith("https://open.douyin.com/platform/oauth/connect/"); + + var query = QueryHelpers.ParseQuery(actual.Query); + + query.ShouldContainKey("state"); + query.ShouldContainKeyAndValue("client_key", options.ClientId); + query.ShouldContainKeyAndValue("redirect_uri", redirectUrl); + query.ShouldContainKeyAndValue("response_type", "code"); + query.ShouldContainKeyAndValue("scope", "user_info"); + + query.ShouldNotContainKey(OAuthConstants.CodeChallengeKey); + query.ShouldNotContainKey(OAuthConstants.CodeChallengeMethodKey); + } } From 7cc7a5011af628f5517e0264e583e3c606db40fd Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 16:00:02 +0800 Subject: [PATCH 07/10] Update README.md: Remove Douyin link --- docs/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 1176bb12b..4a5647e5a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,8 +48,7 @@ covered by the section above. | Bitbucket | _Optional_ | [Documentation](bitbucket.md "Bitbucket provider documentation") | | DigitalOcean | _Optional_ | [Documentation](digitalocean.md "DigitalOcean provider documentation") | | Discord | _Optional_ | [Documentation](discord.md "Discord provider documentation") | -| Docusign | **Required** | [Documentation](docusign.md "Docusign provider documentation") | -| Douyin | _Optional_ | [Documentation](douyin.md "Douyin provider documentation") | +| Docusign | **Required** | [Documentation](docusign.md "Docusign provider documentation") | | eBay | **Required** | [Documentation](ebay.md "eBay provider documentation") | | EVEOnline | _Optional_ | [Documentation](eveonline.md "EVEOnline provider documentation") | | Foursquare | _Optional_ | [Documentation](foursquare.md "Foursquare provider documentation") | From 1110877e76989c1b064a1581cffc1e6391f90c6f Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 16:59:03 +0800 Subject: [PATCH 08/10] Remove invalid references --- test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs index cf2a9bb36..b84d642f8 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs @@ -4,7 +4,6 @@ * for more information concerning the license and the contributors participating to this project. */ -using AspNet.Security.OAuth.Xero; using Microsoft.AspNetCore.WebUtilities; namespace AspNet.Security.OAuth.Douyin; From aee5de2e054c78f25fd305851331bafde87c6c02 Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 17:02:33 +0800 Subject: [PATCH 09/10] Remove extra white space --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 4a5647e5a..a05f40a7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,7 +48,7 @@ covered by the section above. | Bitbucket | _Optional_ | [Documentation](bitbucket.md "Bitbucket provider documentation") | | DigitalOcean | _Optional_ | [Documentation](digitalocean.md "DigitalOcean provider documentation") | | Discord | _Optional_ | [Documentation](discord.md "Discord provider documentation") | -| Docusign | **Required** | [Documentation](docusign.md "Docusign provider documentation") | +| Docusign | **Required** | [Documentation](docusign.md "Docusign provider documentation") | | eBay | **Required** | [Documentation](ebay.md "eBay provider documentation") | | EVEOnline | _Optional_ | [Documentation](eveonline.md "EVEOnline provider documentation") | | Foursquare | _Optional_ | [Documentation](foursquare.md "Foursquare provider documentation") | From a8c7d32fdd5f2942064072a7ed991d6d1ef8f2e5 Mon Sep 17 00:00:00 2001 From: Loongle Date: Thu, 20 Feb 2025 21:34:51 +0800 Subject: [PATCH 10/10] Remove duplicate references to docusign.md file --- AspNet.Security.OAuth.Providers.sln | 1 - 1 file changed, 1 deletion(-) diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln index c97a0b6a5..405dc7433 100644 --- a/AspNet.Security.OAuth.Providers.sln +++ b/AspNet.Security.OAuth.Providers.sln @@ -220,7 +220,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A docs\workweixin.md = docs\workweixin.md docs\xumm.md = docs\xumm.md docs\zendesk.md = docs\zendesk.md - docs\docusign.md = docs\docusign.md docs\gitcode.md = docs\gitcode.md EndProjectSection EndProject