diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln
index b6d50e601..405dc7433 100644
--- a/AspNet.Security.OAuth.Providers.sln
+++ b/AspNet.Security.OAuth.Providers.sln
@@ -184,6 +184,7 @@ 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
docs\dropbox.md = docs\dropbox.md
docs\ebay.md = docs\ebay.md
docs\eveonline.md = docs\eveonline.md
@@ -219,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
@@ -316,6 +316,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.VkId"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.GitCode", "src\AspNet.Security.OAuth.GitCode\AspNet.Security.OAuth.GitCode.csproj", "{668833D5-DB6A-475F-B0FD-A03462B037B8}"
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
@@ -730,6 +732,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
{668833D5-DB6A-475F-B0FD-A03462B037B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{668833D5-DB6A-475F-B0FD-A03462B037B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{668833D5-DB6A-475F-B0FD-A03462B037B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -848,6 +854,7 @@ Global
{CD56ABE4-1CD2-4029-B556-E110A31A2CC4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{F3E62C24-5F82-4CF5-A994-0E10D04FB495} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{668833D5-DB6A-475F-B0FD-A03462B037B8} = {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 279718821..29f1eb0fb 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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.DigitalOcean/ "Download AspNet.Security.OAuth.DigitalOcean from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Discord/ "Download AspNet.Security.OAuth.Discord from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Docusign/ "Download AspNet.Security.OAuth.Docusign from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Douyin/ "Download AspNet.Security.OAuth.Douyin from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Dropbox/ "Download AspNet.Security.OAuth.Dropbox from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Ebay/ "Download AspNet.Security.OAuth.Ebay from NuGet.org") | [](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 | [](https://www.nuget.org/packages/AspNet.Security.OAuth.EVEOnline/ "Download AspNet.Security.OAuth.EVEOnline from NuGet.org") | [](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/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..85469237a
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Douyin/AspNet.Security.OAuth.Douyin.csproj
@@ -0,0 +1,23 @@
+
+
+
+ 9.1.0
+ $(DefaultNetCoreTargetFramework)
+
+
+
+
+ true
+
+
+
+ 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..ab2f77666
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Douyin/DouyinAuthenticationHandler.cs
@@ -0,0 +1,191 @@
+/*
+ * 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)
+ {
+ }
+
+ 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)
+ {
+ errorCode = 0;
+
+ if (element.TryGetProperty("error_code", out JsonElement errorCodeElement))
+ {
+ 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 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..b84d642f8
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Douyin/DouyinTests.cs
@@ -0,0 +1,60 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
+ * for more information concerning the license and the contributors participating to this project.
+ */
+
+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);
+
+ [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);
+ }
+}
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"
+ }
+ }
+ ]
+}