diff --git a/AspNet.Security.OAuth.Providers.slnx b/AspNet.Security.OAuth.Providers.slnx
index 6e45900e7..e6855aa06 100644
--- a/AspNet.Security.OAuth.Providers.slnx
+++ b/AspNet.Security.OAuth.Providers.slnx
@@ -137,6 +137,7 @@
+
diff --git a/README.md b/README.md
index 064ebc419..dd9ed31eb 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
| JumpCloud | [](https://www.nuget.org/packages/AspNet.Security.OAuth.JumpCloud/ "Download AspNet.Security.OAuth.JumpCloud from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.JumpCloud "Download AspNet.Security.OAuth.JumpCloud from MyGet.org") | [Documentation](https://jumpcloud.com/support/sso-with-oidc "JumpCloud developer documentation") |
| KakaoTalk | [](https://www.nuget.org/packages/AspNet.Security.OAuth.KakaoTalk/ "Download AspNet.Security.OAuth.KakaoTalk from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.KakaoTalk "Download AspNet.Security.OAuth.KakaoTalk from MyGet.org") | [Documentation](https://developers.kakao.com/docs/latest/en/kakaologin/common "KakaoTalk developer documentation") |
| Keycloak | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Keycloak/ "Download AspNet.Security.OAuth.Keycloak from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Keycloak "Download AspNet.Security.OAuth.Keycloak from MyGet.org") | [Documentation](https://www.keycloak.org/docs/latest/authorization_services/#_service_overview "Keycloak developer documentation") |
+| Kick | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Kick/ "Download AspNet.Security.OAuth.Kick from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Kick "Download AspNet.Security.OAuth.Kick from MyGet.org") | [Documentation](https://docs.kick.com/getting-started/generating-tokens-oauth2-flow "Kick developer documentation") |
| KOOK | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Kook/ "Download AspNet.Security.OAuth.Kook from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Kook "Download AspNet.Security.OAuth.Kook from MyGet.org") | [Documentation](https://developer.kookapp.cn/doc/oauth2 "KOOK developer documentation") |
| Kroger | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Kroger/ "Download AspNet.Security.OAuth.Kroger from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Kroger "Download AspNet.Security.OAuth.Kroger from MyGet.org") | [Documentation](https://developer.kroger.com/reference/#section/Authentication "Kroger developer documentation") |
| Lichess | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Lichess/ "Download AspNet.Security.OAuth.Lichess from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Lichess "Download AspNet.Security.OAuth.Lichess from MyGet.org") | [Documentation](https://lichess.org/api#section/Authentication "Lichess developer documentation") |
diff --git a/src/AspNet.Security.OAuth.Kick/AspNet.Security.OAuth.Kick.csproj b/src/AspNet.Security.OAuth.Kick/AspNet.Security.OAuth.Kick.csproj
new file mode 100644
index 000000000..395c77a19
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Kick/AspNet.Security.OAuth.Kick.csproj
@@ -0,0 +1,21 @@
+
+
+
+ 10.1.0
+
+ true
+ $(DefaultNetCoreTargetFramework)
+
+
+
+ ASP.NET Core security middleware enabling Kick authentication.
+ Daniel Beaupre
+ aspnetcore;authentication;kick;oauth;security
+
+
+
+
+
+
+
+
diff --git a/src/AspNet.Security.OAuth.Kick/KickAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Kick/KickAuthenticationConstants.cs
new file mode 100644
index 000000000..8df393556
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Kick/KickAuthenticationConstants.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.Kick;
+
+///
+/// Contains constants specific to the .
+///
+public static class KickAuthenticationConstants
+{
+ public static class Claims
+ {
+ public static readonly string ProfilePicture = "urn:kick:profilepicture";
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Kick/KickAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Kick/KickAuthenticationDefaults.cs
new file mode 100644
index 000000000..cc8b4809e
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Kick/KickAuthenticationDefaults.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.Kick;
+
+///
+/// Default values used by the Kick authentication middleware.
+///
+public static class KickAuthenticationDefaults
+{
+ ///
+ /// Default value for .
+ ///
+ public const string AuthenticationScheme = "Kick";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string DisplayName = "Kick";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string Issuer = "Kick";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string CallbackPath = "/signin-kick";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string AuthorizationEndpoint = "https://id.kick.com/oauth/authorize";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string TokenEndpoint = "https://id.kick.com/oauth/token";
+
+ ///
+ /// Default value for .
+ ///
+ public static readonly string UserInformationEndpoint = "https://api.kick.com/public/v1/users";
+}
diff --git a/src/AspNet.Security.OAuth.Kick/KickAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Kick/KickAuthenticationExtensions.cs
new file mode 100644
index 000000000..ae86fdb53
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Kick/KickAuthenticationExtensions.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.Kick;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extension methods to add Kick authentication capabilities to an HTTP application pipeline.
+///
+public static class KickAuthenticationExtensions
+{
+ ///
+ /// Adds to the specified
+ /// , which enables Kick authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The .
+ public static AuthenticationBuilder AddKick([NotNull] this AuthenticationBuilder builder)
+ {
+ return builder.AddKick(KickAuthenticationDefaults.AuthenticationScheme, options => { });
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Kick authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The delegate used to configure the Kick options.
+ /// The .
+ public static AuthenticationBuilder AddKick(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] Action configuration)
+ {
+ return builder.AddKick(KickAuthenticationDefaults.AuthenticationScheme, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Kick authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The authentication scheme associated with this instance.
+ /// The delegate used to configure the Kick options.
+ /// The .
+ public static AuthenticationBuilder AddKick(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [NotNull] Action configuration)
+ {
+ return builder.AddKick(scheme, KickAuthenticationDefaults.DisplayName, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Kick 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 Kick options.
+ /// The .
+ public static AuthenticationBuilder AddKick(
+ [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.Kick/KickAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Kick/KickAuthenticationHandler.cs
new file mode 100644
index 000000000..5620ddd0a
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Kick/KickAuthenticationHandler.cs
@@ -0,0 +1,73 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
+ * for more information concerning the license and the contributors participating to this project.
+ */
+
+using System.Net.Http.Headers;
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace AspNet.Security.OAuth.Kick;
+
+///
+/// Authentication handler for Kick OAuth.
+///
+public partial class KickAuthenticationHandler : OAuthHandler
+{
+ public KickAuthenticationHandler(
+ [NotNull] IOptionsMonitor options,
+ [NotNull] ILoggerFactory logger,
+ [NotNull] UrlEncoder encoder)
+ : base(options, logger, encoder)
+ {
+ }
+
+ protected override async Task CreateTicketAsync(
+ [NotNull] ClaimsIdentity identity,
+ [NotNull] AuthenticationProperties properties,
+ [NotNull] OAuthTokenResponse tokens)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
+
+ 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 principal = new ClaimsPrincipal(identity);
+ var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
+ context.RunClaimActions();
+
+ await Events.CreatingTicket(context);
+ return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name);
+ }
+
+ 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));
+ }
+
+ [LoggerMessage(1, LogLevel.Error, "An error occurred while retrieving the user profile: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")]
+ private static partial void UserProfileError(
+ ILogger logger,
+ System.Net.HttpStatusCode status,
+ string headers,
+ string body);
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Kick/KickAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Kick/KickAuthenticationOptions.cs
new file mode 100644
index 000000000..77822f6cd
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Kick/KickAuthenticationOptions.cs
@@ -0,0 +1,49 @@
+/*
+ * 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 System.Text.Json;
+using static AspNet.Security.OAuth.Kick.KickAuthenticationConstants;
+
+namespace AspNet.Security.OAuth.Kick;
+
+///
+/// Defines a set of options used by .
+///
+public class KickAuthenticationOptions : OAuthOptions
+{
+ public KickAuthenticationOptions()
+ {
+ ClaimsIssuer = KickAuthenticationDefaults.Issuer;
+ CallbackPath = KickAuthenticationDefaults.CallbackPath;
+
+ AuthorizationEndpoint = KickAuthenticationDefaults.AuthorizationEndpoint;
+ TokenEndpoint = KickAuthenticationDefaults.TokenEndpoint;
+ UserInformationEndpoint = KickAuthenticationDefaults.UserInformationEndpoint;
+
+ Scope.Add("user:read");
+
+ ClaimActions.MapCustomJson(ClaimTypes.NameIdentifier, user => GetData(user, "user_id"));
+ ClaimActions.MapCustomJson(ClaimTypes.Name, user => GetData(user, "name"));
+ ClaimActions.MapCustomJson(ClaimTypes.Email, user => GetData(user, "email"));
+ ClaimActions.MapCustomJson(Claims.ProfilePicture, user => GetData(user, "profile_picture"));
+
+ // Kick requires PKCE (OAuth 2.1)
+ UsePkce = true;
+ }
+
+ private static string? GetData(JsonElement user, string key)
+ {
+ if (!user.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+
+ return data.EnumerateArray()
+ .Select(p => p.GetString(key))
+ .FirstOrDefault();
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Kick/KickTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Kick/KickTests.cs
new file mode 100644
index 000000000..821a5011a
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Kick/KickTests.cs
@@ -0,0 +1,59 @@
+/*
+ * 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;
+using static AspNet.Security.OAuth.Kick.KickAuthenticationConstants;
+
+namespace AspNet.Security.OAuth.Kick;
+
+public class KickTests(ITestOutputHelper outputHelper) : OAuthTests(outputHelper)
+{
+ public override string DefaultScheme => KickAuthenticationDefaults.AuthenticationScheme;
+
+ protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
+ {
+ builder.AddKick(options => ConfigureDefaults(builder, options));
+ }
+
+ [Theory]
+ [InlineData(ClaimTypes.NameIdentifier, "123456")]
+ [InlineData(ClaimTypes.Name, "testuser")]
+ [InlineData(ClaimTypes.Email, "test@example.com")]
+ [InlineData("urn:kick:profilepicture", "https://files.kick.com/images/user/123456/profile_image.png")]
+ public async Task Can_Sign_In_Using_Kick(string claimType, string claimValue)
+ => await AuthenticateUserAndAssertClaimValue(claimType, claimValue);
+
+ [Fact]
+ public async Task BuildChallengeUrl_Generates_Correct_Url_With_Pkce()
+ {
+ // Arrange
+ var options = new KickAuthenticationOptions();
+
+ var redirectUrl = "https://my-site.local/signin-kick";
+
+ // Act
+ Uri actual = await BuildChallengeUriAsync(
+ options,
+ redirectUrl,
+ (options, loggerFactory, encoder) => new KickAuthenticationHandler(options, loggerFactory, encoder));
+
+ // Assert
+ actual.ShouldNotBeNull();
+ actual.ToString().ShouldStartWith("https://id.kick.com/oauth/authorize?");
+
+ var query = QueryHelpers.ParseQuery(actual.Query);
+
+ query.ShouldContainKey("state");
+ query.ShouldContainKeyAndValue("client_id", options.ClientId);
+ query.ShouldContainKeyAndValue("redirect_uri", redirectUrl);
+ query.ShouldContainKeyAndValue("response_type", "code");
+ query.ShouldContainKeyAndValue("scope", "user:read");
+
+ // Kick requires PKCE
+ query.ShouldContainKey(OAuthConstants.CodeChallengeKey);
+ query.ShouldContainKey(OAuthConstants.CodeChallengeMethodKey);
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Kick/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Kick/bundle.json
new file mode 100644
index 000000000..92ad8863e
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Kick/bundle.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json",
+ "items": [
+ {
+ "uri": "https://id.kick.com/oauth/token",
+ "method": "POST",
+ "contentFormat": "json",
+ "contentJson": {
+ "access_token": "secret-access-token",
+ "token_type": "Bearer",
+ "refresh_token": "secret-refresh-token",
+ "expires_in": "300"
+ }
+ },
+ {
+ "uri": "https://api.kick.com/public/v1/users",
+ "contentFormat": "json",
+ "contentJson": {
+ "data": [
+ {
+ "user_id": 123456,
+ "name": "testuser",
+ "email": "test@example.com",
+ "profile_picture": "https://files.kick.com/images/user/123456/profile_image.png"
+ }
+ ]
+ }
+ }
+ ]
+}