📄 src/Api/TikTokAuth/TikTokAuthExtensions.cs
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Slopper.Infrastructure.TikTok;

namespace Slopper.Api.TikTokAuth;

public static class TikTokAuthExtensions
{
    private const string SchemeName = "TikTok";
    private const string TikTokPolicyName = "TikTok";

    extension(AuthenticationBuilder auth)
    {
        public AuthenticationBuilder AddTikTok()
        {
            auth.AddOAuth(
                SchemeName,
                options =>
                {
                    options.AuthorizationEndpoint = "https://www.tiktok.com/v2/auth/authorize/";
                    options.TokenEndpoint = "https://open.tiktokapis.com/v2/oauth/token/";
                    options.UserInformationEndpoint =
                        "https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name";
                    options.CallbackPath = "/admin/redirect/tiktok";
                    options.SaveTokens = true;
                    options.Scope.Add("user.info.basic,video.publish");

                    options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "open_id");
                    options.ClaimActions.MapJsonKey(ClaimTypes.Name, "display_name");

                    options.BackchannelHttpHandler = new TikTokBackchannelHandler();

                    options.Events = new OAuthEvents
                    {
                        OnRedirectToAuthorizationEndpoint = context =>
                        {
                            if (context.Request.Headers.GetCommaSeparatedValues("Sec-Fetch-Mode") is not ["navigate"])
                            {
                                context.Response.OnStarting(
                                    static response =>
                                    {
                                        ((HttpResponse)response).StatusCode = StatusCodes.Status403Forbidden;
                                        return Task.CompletedTask;
                                    },
                                    context.Response
                                );
                                return Task.CompletedTask;
                            }

                            var uri = new Uri(context.RedirectUri);
                            var query = QueryHelpers
                                .ParseQuery(uri.Query)
                                .ToDictionary(
                                    kvp => kvp.Key == "client_id" ? "client_key" : kvp.Key,
                                    kvp => (string?)kvp.Value.ToString()
                                );
                            context.Response.Redirect(
                                QueryHelpers.AddQueryString(uri.GetLeftPart(UriPartial.Path), query)
                            );
                            return Task.CompletedTask;
                        },
                        OnCreatingTicket = async context =>
                        {
                            using var request = new HttpRequestMessage(
                                HttpMethod.Get,
                                context.Options.UserInformationEndpoint
                            );
                            request.Headers.Authorization = new AuthenticationHeaderValue(
                                "Bearer",
                                context.AccessToken
                            );

                            using var response = await context.Backchannel.SendAsync(
                                request,
                                context.HttpContext.RequestAborted
                            );
                            response.EnsureSuccessStatusCode();

                            using var doc = JsonDocument.Parse(
                                await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted)
                            );
                            context.RunClaimActions(doc.RootElement.GetProperty("data").GetProperty("user"));
                        },
                    };
                }
            );

            auth.Services.AddOptions<OAuthOptions>(SchemeName).BindConfiguration("TikTok");

            auth.Services.AddSingleton<TikTokAccessTokenProvider>();
            auth.Services.AddSingleton<ITikTokAccessTokenProvider>(sp =>
                sp.GetRequiredService<TikTokAccessTokenProvider>()
            );

            return auth;
        }
    }

    extension(AuthorizationBuilder auth)
    {
        public AuthorizationBuilder AddTikTokPolicy() =>
            auth.AddPolicy(
                TikTokPolicyName,
                policy => policy.AddAuthenticationSchemes(SchemeName).RequireAuthenticatedUser()
            );
    }

    extension<TBuilder>(TBuilder builder)
        where TBuilder : IEndpointConventionBuilder
    {
        public TBuilder RequireTikTokAuthorization() => builder.RequireAuthorization(TikTokPolicyName);
    }
}