diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln
index b98754e46..161d60fa8 100644
--- a/AspNet.Security.OAuth.Providers.sln
+++ b/AspNet.Security.OAuth.Providers.sln
@@ -327,6 +327,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Miro"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Linear", "src\AspNet.Security.OAuth.Linear\AspNet.Security.OAuth.Linear.csproj", "{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Bilibili", "src\AspNet.Security.OAuth.Bilibili\AspNet.Security.OAuth.Bilibili.csproj", "{8350C405-9E17-4110-B9A8-0AB43A8816B7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -761,6 +763,10 @@ Global
{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8350C405-9E17-4110-B9A8-0AB43A8816B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8350C405-9E17-4110-B9A8-0AB43A8816B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8350C405-9E17-4110-B9A8-0AB43A8816B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8350C405-9E17-4110-B9A8-0AB43A8816B7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -879,6 +885,7 @@ Global
{F5DA8A08-5089-4076-B0FC-3F4A5CBB9664} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{7F22DE22-FDE8-4A14-AA65-D5B36098533E} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
+ {8350C405-9E17-4110-B9A8-0AB43A8816B7} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C}
diff --git a/README.md b/README.md
index 17c3d77b7..7d0748dcd 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
@@ -167,6 +168,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
| Baidu | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Baidu/ "Download AspNet.Security.OAuth.Baidu from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Baidu "Download AspNet.Security.OAuth.Baidu from MyGet.org") | [Documentation](https://developer.baidu.com/ "Baidu developer documentation") |
| Basecamp | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Basecamp/ "Download AspNet.Security.OAuth.Basecamp from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Basecamp "Download AspNet.Security.OAuth.Basecamp from MyGet.org") | [Documentation](https://github.com/basecamp/api/blob/master/sections/authentication.md "Basecamp developer documentation") |
| BattleNet | [](https://www.nuget.org/packages/AspNet.Security.OAuth.BattleNet/ "Download AspNet.Security.OAuth.BattleNet from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.BattleNet "Download AspNet.Security.OAuth.BattleNet from MyGet.org") | [Documentation](https://develop.battle.net/documentation/guides/using-oauth "BattleNet developer documentation") |
+| Bilibili | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Bilibili/ "Download AspNet.Security.OAuth.Bilibili from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Bilibili "Download AspNet.Security.OAuth.Bilibili from MyGet.org") | [Documentation](https://openhome.bilibili.com/doc/4/aac73b2e-4ff2-b75c-4c96-35ced865797b#h1-- "Bilibili developer documentation") |
| Bitbucket | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Bitbucket/ "Download AspNet.Security.OAuth.Bitbucket from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Bitbucket "Download AspNet.Security.OAuth.Bitbucket from MyGet.org") | [Documentation](https://developer.atlassian.com/bitbucket/api/2/reference/meta/authentication "Bitbucket developer documentation") |
| Buffer | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Buffer/ "Download AspNet.Security.OAuth.Buffer from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Buffer "Download AspNet.Security.OAuth.Buffer from MyGet.org") | [Documentation](https://buffer.com/developers/api/oauth "Buffer developer documentation") |
| Calendly | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Calendly/ "Download AspNet.Security.OAuth.Calendly from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Calendly "Download AspNet.Security.OAuth.Calendly from MyGet.org") | [Documentation](https://developer.calendly.com/api-docs/3cefb59b832eb-calendly-o-auth-2-0 "Calendly developer documentation") |
diff --git a/src/AspNet.Security.OAuth.Bilibili/AspNet.Security.OAuth.Bilibili.csproj b/src/AspNet.Security.OAuth.Bilibili/AspNet.Security.OAuth.Bilibili.csproj
new file mode 100644
index 000000000..c2b71d42d
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Bilibili/AspNet.Security.OAuth.Bilibili.csproj
@@ -0,0 +1,21 @@
+
+
+
+ 9.3.0
+
+ true
+ $(DefaultNetCoreTargetFramework)
+
+
+
+ ASP.NET Core security middleware enabling Bilibili authentication.
+ Loongle Tse
+ bilibili;aspnetcore;authentication;oauth;security
+
+
+
+
+
+
+
+
diff --git a/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationConstants.cs
new file mode 100644
index 000000000..9567fb132
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationConstants.cs
@@ -0,0 +1,18 @@
+/*
+ * 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.Bilibili;
+
+///
+/// Contains constants specific to the .
+///
+public static class BilibiliAuthenticationConstants
+{
+ public static class Claims
+ {
+ public const string Face = "urn:bilibili:face";
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationDefaults.cs
new file mode 100644
index 000000000..da6f6c17d
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationDefaults.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.Bilibili;
+
+///
+/// Default values for Bilibili authentication.
+///
+public static class BilibiliAuthenticationDefaults
+{
+ ///
+ /// Default value for .
+ ///
+ public const string AuthenticationScheme = "Bilibili";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string DisplayName = "Bilibili";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string CallbackPath = "/signin-bilibili";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string Issuer = "Bilibili";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string AuthorizationEndpoint = "https://account.bilibili.com/pc/account-pc/auth/oauth";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string TokenEndpoint = "https://api.bilibili.com/x/account-oauth2/v1/token";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string UserInformationEndpoint = "https://member.bilibili.com/arcopen/fn/user/account/info";
+}
diff --git a/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationExtensions.cs
new file mode 100644
index 000000000..74937f9ad
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationExtensions.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.Bilibili;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extension methods to add Bilibili authentication capabilities to an HTTP application pipeline.
+///
+public static class BilibiliAuthenticationExtensions
+{
+ ///
+ /// Adds to the specified
+ /// , which enables Bilibili authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// A reference to this instance after the operation has completed.
+ public static AuthenticationBuilder AddBilibili([NotNull] this AuthenticationBuilder builder)
+ {
+ return builder.AddBilibili(BilibiliAuthenticationDefaults.AuthenticationScheme, options => { });
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Bilibili 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 AddBilibili(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] Action configuration)
+ {
+ return builder.AddBilibili(BilibiliAuthenticationDefaults.AuthenticationScheme, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Bilibili authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The authentication scheme associated with this instance.
+ /// The delegate used to configure the Bilibili options.
+ /// The .
+ public static AuthenticationBuilder AddBilibili(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [NotNull] Action configuration)
+ {
+ return builder.AddBilibili(scheme, BilibiliAuthenticationDefaults.DisplayName, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Bilibili 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 Bilibili options.
+ /// The .
+ public static AuthenticationBuilder AddBilibili(
+ [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.Bilibili/BilibiliAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationHandler.cs
new file mode 100644
index 000000000..b1700e5bd
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationHandler.cs
@@ -0,0 +1,209 @@
+/*
+ * 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.Buffers.Text;
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace AspNet.Security.OAuth.Bilibili;
+
+public partial class BilibiliAuthenticationHandler : OAuthHandler
+{
+ public BilibiliAuthenticationHandler(
+ [NotNull] IOptionsMonitor options,
+ [NotNull] ILoggerFactory logger,
+ [NotNull] UrlEncoder encoder)
+ : base(options, logger, encoder)
+ {
+ }
+
+ protected override string BuildChallengeUrl([NotNull] AuthenticationProperties properties, [NotNull] string redirectUri)
+ {
+ var parameters = new Dictionary
+ {
+ ["client_id"] = Options.ClientId,
+ ["response_type"] = "code",
+ ["gourl"] = redirectUri // Used instead of "redirect_uri"
+ };
+
+ foreach (var additionalParameter in Options.AdditionalAuthorizationParameters)
+ {
+ parameters.Add(additionalParameter.Key, additionalParameter.Value);
+ }
+
+ parameters["state"] = Options.StateDataFormat.Protect(properties);
+
+ return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters);
+ }
+
+ protected override async Task ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context)
+ {
+ // See https://open.bilibili.com/doc/4/eaf0e2b5-bde9-b9a0-9be1-019bb455701c#h1-u7B80u4ECB for details.
+ var tokenRequestParameters = new Dictionary()
+ {
+ ["client_id"] = 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;
+ if (!ValidateReturnCode(mainElement, out var code))
+ {
+ return OAuthTokenResponse.Failed(new Exception($"An error (Code:{code}) occurred while retrieving an access token."));
+ }
+
+ var payload = JsonDocument.Parse(mainElement.GetProperty("data").GetRawText());
+ return OAuthTokenResponse.Success(payload);
+ }
+
+ private static string ComputeHmacSHA256(string key, string data)
+ {
+ var keyBytes = Encoding.UTF8.GetBytes(key);
+ var dataBytes = Encoding.UTF8.GetBytes(data);
+ var hash = HMACSHA256.HashData(keyBytes, dataBytes);
+ return Convert.ToHexStringLower(hash);
+ }
+
+ private static string BuildSignatureString(HttpRequestMessage request, string appSecret)
+ {
+ var headers = request.Headers
+ .Where(h => h.Key.StartsWith("x-bili-", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(h => h.Key)
+ .Select(h => $"{h.Key}:{string.Join(",", h.Value)}")
+ .ToList();
+
+ var signature = string.Join('\n', headers);
+
+ return ComputeHmacSHA256(appSecret, signature);
+ }
+
+ protected override async Task CreateTicketAsync(
+ [NotNull] ClaimsIdentity identity,
+ [NotNull] AuthenticationProperties properties,
+ [NotNull] OAuthTokenResponse tokens)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
+ request.Headers.Add("access-token", tokens.AccessToken);
+ request.Headers.Add("x-bili-accesskeyid", Options.ClientId);
+ request.Headers.Add("x-bili-content-md5", "d41d8cd98f00b204e9800998ecf8427e");
+ request.Headers.Add("x-bili-signature-method", "HMAC-SHA256");
+ request.Headers.Add("x-bili-signature-nonce", Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(256 / 8)));
+ request.Headers.Add("x-bili-signature-version", "2.0");
+ request.Headers.Add("x-bili-timestamp", TimeProvider.GetUtcNow().ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
+
+ var signature = BuildSignatureString(request, Options.ClientSecret);
+ request.Headers.Add("Authorization", signature);
+
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ using var response = await Backchannel.SendAsync(request, 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 mainElement = payload.RootElement;
+ if (!ValidateReturnCode(mainElement, out var code))
+ {
+ throw new AuthenticationFailureException($"An error (ErrorCode:{code}) occurred while retrieving user information.");
+ }
+
+ var principal = new ClaimsPrincipal(identity);
+ var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, mainElement.GetProperty("data"));
+ context.RunClaimActions();
+
+ await Events.CreatingTicket(context);
+ return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name);
+ }
+
+ ///
+ /// 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://open.bilibili.com/doc/4/8673959e-f7bb-56e6-6e68-d225f971b81b#h1-u63A5u53E3u7B7Eu540Du5B9Eu73B0u6807u51C6u548Cu72B6u6001u7801 for details.
+ /// True if succeed, otherwise false.
+ private static bool ValidateReturnCode(JsonElement element, out int code)
+ {
+ code = 0;
+ if (!element.TryGetProperty("code", out JsonElement errorCodeElement))
+ {
+ return true;
+ }
+
+ code = errorCodeElement.GetInt32();
+
+ return code == 0;
+ }
+
+ 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);
+
+ [LoggerMessage(2, LogLevel.Warning, "An error occurred while retrieving the email address associated with the logged in user: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")]
+ private static partial void EmailAddressError(
+ ILogger logger,
+ HttpStatusCode status,
+ string headers,
+ string body);
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationOptions.cs
new file mode 100644
index 000000000..bbbc44943
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Bilibili/BilibiliAuthenticationOptions.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 System.Security.Claims;
+using static AspNet.Security.OAuth.Bilibili.BilibiliAuthenticationConstants;
+
+namespace AspNet.Security.OAuth.Bilibili;
+
+///
+/// Defines a set of options used by .
+///
+public class BilibiliAuthenticationOptions : OAuthOptions
+{
+ public BilibiliAuthenticationOptions()
+ {
+ ClaimsIssuer = BilibiliAuthenticationDefaults.Issuer;
+ CallbackPath = BilibiliAuthenticationDefaults.CallbackPath;
+
+ AuthorizationEndpoint = BilibiliAuthenticationDefaults.AuthorizationEndpoint;
+ TokenEndpoint = BilibiliAuthenticationDefaults.TokenEndpoint;
+ UserInformationEndpoint = BilibiliAuthenticationDefaults.UserInformationEndpoint;
+
+ ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "openid");
+ ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
+ ClaimActions.MapJsonKey(Claims.Face, "face");
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Bilibili/BilibiliTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Bilibili/BilibiliTests.cs
new file mode 100644
index 000000000..333ef2c0b
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Bilibili/BilibiliTests.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 System.Web;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace AspNet.Security.OAuth.Bilibili;
+
+public class BilibiliTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper)
+{
+ public override string DefaultScheme => BilibiliAuthenticationDefaults.AuthenticationScheme;
+
+ protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
+ {
+ builder.AddBilibili(options =>
+ {
+ ConfigureDefaults(builder, options);
+ });
+ LoopbackRedirectHandler.RedirectUri = "http://localhost/signin-bilibili";
+ }
+
+ [Theory]
+ [InlineData(ClaimTypes.NameIdentifier, "9844422354fe42629cd126**********")]
+ [InlineData("urn:bilibili:face", "https://i0.hdslb.com/bfs/face/member/noface.jpg")]
+ [InlineData(ClaimTypes.Name, "TestAccount")]
+ public async Task Can_Sign_In_Using_Bilibili(string claimType, string claimValue)
+ => await AuthenticateUserAndAssertClaimValue(claimType, claimValue);
+
+ [Fact]
+ public async Task BuildChallengeUrl_Generates_Correct_Url()
+ {
+ // Arrange
+ var options = new BilibiliAuthenticationOptions();
+
+ var redirectUrl = "https://my-site.local/signin-bilibili";
+
+ // Act
+ Uri actual = await BuildChallengeUriAsync(
+ options,
+ redirectUrl,
+ (options, loggerFactory, encoder) => new BilibiliAuthenticationHandler(options, loggerFactory, encoder));
+
+ // Assert
+ actual.ShouldNotBeNull();
+ actual.ToString().ShouldStartWith("https://account.bilibili.com/pc/account-pc/auth/oauth");
+
+ var query = QueryHelpers.ParseQuery(actual.Query);
+
+ query.ShouldContainKey("state");
+ query.ShouldContainKeyAndValue("client_id", options.ClientId);
+ query.ShouldContainKeyAndValue("gourl", redirectUrl);
+ query.ShouldContainKeyAndValue("response_type", "code");
+
+ query.ShouldNotContainKey(OAuthConstants.CodeChallengeKey);
+ query.ShouldNotContainKey(OAuthConstants.CodeChallengeMethodKey);
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Bilibili/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Bilibili/bundle.json
new file mode 100644
index 000000000..6a451ac61
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Bilibili/bundle.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json",
+ "items": [
+ {
+ "uri": "https://account.bilibili.com/pc/account-pc/auth/oauth",
+ "contentFormat": "json",
+ "contentJson": {
+ "code": "code",
+ "access_token": "secret-access-token",
+ "client_token": "client_token",
+ "refresh_token": "secret-refresh-token"
+ }
+ },
+ {
+ "uri": "https://api.bilibili.com/x/account-oauth2/v1/token",
+ "contentFormat": "json",
+ "method": "POST",
+ "contentJson": {
+ "code": 0,
+ "message": "0",
+ "ttl": 1,
+ "data": {
+ "access_token": "1fbe4a8dc9624dfb84696b**********",
+ "expires_in": 1758100652,
+ "refresh_token": "e98d2aae693f019b6a4b2**********",
+ "scopes": [ "USER_INFO" ]
+ }
+ }
+ },
+ {
+ "uri": "https://member.bilibili.com/arcopen/fn/user/account/info",
+ "contentFormat": "json",
+ "contentJson": {
+ "code": 0,
+ "data": {
+ "face": "https://i0.hdslb.com/bfs/face/member/noface.jpg",
+ "name": "TestAccount",
+ "openid": "9844422354fe42629cd126**********"
+ },
+ "message": "0",
+ "request_id": "592a21bcf354ef1d525863**********",
+ "ttl": 1
+ }
+ }
+ ]
+}