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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.JumpCloud?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.JumpCloud/ "Download AspNet.Security.OAuth.JumpCloud from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.JumpCloud?logo=nuget&label=MyGet&color=blue)](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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.KakaoTalk?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.KakaoTalk/ "Download AspNet.Security.OAuth.KakaoTalk from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.KakaoTalk?logo=nuget&label=MyGet&color=blue)](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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Keycloak?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Keycloak/ "Download AspNet.Security.OAuth.Keycloak from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Keycloak?logo=nuget&label=MyGet&color=blue)](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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Kick?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Kick/ "Download AspNet.Security.OAuth.Kick from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Kick?logo=nuget&label=MyGet&color=blue)](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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Kook?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Kook/ "Download AspNet.Security.OAuth.Kook from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Kook?logo=nuget&label=MyGet&color=blue)](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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Kroger?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Kroger/ "Download AspNet.Security.OAuth.Kroger from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Kroger?logo=nuget&label=MyGet&color=blue)](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 | [![NuGet](https://img.shields.io/nuget/v/AspNet.Security.OAuth.Lichess?logo=nuget&label=NuGet&color=blue)](https://www.nuget.org/packages/AspNet.Security.OAuth.Lichess/ "Download AspNet.Security.OAuth.Lichess from NuGet.org") | [![MyGet](https://img.shields.io/myget/aspnet-contrib/vpre/AspNet.Security.OAuth.Lichess?logo=nuget&label=MyGet&color=blue)](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" + } + ] + } + } + ] +}