src/Api/ApiEndpoints.cs
+1
-0
diff --git a/src/Api/ApiEndpoints.cs b/src/Api/ApiEndpoints.cs
index e7b0511..8564f37 100644
@@ -33,6 +33,7 @@ public static class ApiEndpoints
.WithSummary("Triggers a job generating a new clip.");
api.MapYouTubeApi();
api.MapTikTokApi();
return api;
}
src/Api/Program.cs
+8
-2
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 8eaa94e..9cc38ee 100644
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using Quartz;
using Quartz.AspNetCore;
using Slopper.Api;
using Slopper.Api.TikTokAuth;
using Slopper.Api.YouTubeAuth;
using Slopper.Domain;
using Slopper.Infrastructure.Ai;
@@ -47,9 +48,13 @@ builder
)
.AddQuartzServer();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie().AddYouTube();
builder
.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
.AddYouTube()
.AddTikTok();
builder.Services.AddAuthorizationBuilder().AddYouTubePolicy();
builder.Services.AddAuthorizationBuilder().AddTikTokPolicy().AddYouTubePolicy();
if (builder.Environment.IsDevelopment())
{
@@ -58,6 +63,7 @@ if (builder.Environment.IsDevelopment())
using var app = builder.Build();
app.UseCookiePolicy();
app.UseAuthentication();
app.UseAuthorization();
src/Api/Properties/launchSettings.json
+9
-0
diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json
index 90e03e3..a57ee64 100644
@@ -10,6 +10,15 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://slopper.dev.localhost:5055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"proxied": {
"commandName": "Project",
"dotnetRunMessages": true,
src/Api/TikTokApiEndpoints.cs
+28
-0
diff --git a/src/Api/TikTokApiEndpoints.cs b/src/Api/TikTokApiEndpoints.cs
new file mode 100644
index 0000000..51f1183
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
using Slopper.Api.TikTokAuth;
namespace Slopper.Api;
public static class TikTokApiEndpoints
{
extension(IEndpointRouteBuilder endpoints)
{
public IEndpointConventionBuilder MapTikTokApi()
{
var tiktok = endpoints.MapGroup("/tiktok").RequireTikTokAuthorization();
tiktok.MapGet("/login", Login).WithDisplayName("TikTok Login");
tiktok.MapGet("/name", Name).WithDisplayName("Returns signed in TikTok user's name");
return tiktok;
}
}
private static RedirectHttpResult Login() => TypedResults.Redirect("/admin/tiktok");
private static Results<Ok<string>, UnauthorizedHttpResult> Name(HttpContext httpContext) =>
httpContext.User.Identity?.Name is string name ? TypedResults.Ok(name) : TypedResults.Unauthorized();
}
src/Api/TikTokAuth/TikTokAuthExtensions.cs
+119
-0
diff --git a/src/Api/TikTokAuth/TikTokAuthExtensions.cs b/src/Api/TikTokAuth/TikTokAuthExtensions.cs
new file mode 100644
index 0000000..13e6674
@@ -0,0 +1,119 @@
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;
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");
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);
}
}
src/Api/TikTokAuth/TikTokBackchannelHandler.cs
+31
-0
diff --git a/src/Api/TikTokAuth/TikTokBackchannelHandler.cs b/src/Api/TikTokAuth/TikTokBackchannelHandler.cs
new file mode 100644
index 0000000..4bc91fe
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
namespace Slopper.Api.TikTokAuth;
internal sealed class TikTokBackchannelHandler() : DelegatingHandler(new HttpClientHandler())
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
if (request.Content?.Headers.ContentType?.MediaType is "application/x-www-form-urlencoded")
{
var body = await request.Content.ReadAsStringAsync(cancellationToken);
var form = QueryHelpers.ParseQuery(body);
if (form.Remove("client_id", out var clientId))
{
form["client_key"] = clientId;
request.Content = new FormUrlEncodedContent(
form.SelectMany(kvp => kvp.Value.Select(v => new KeyValuePair<string, string>(kvp.Key, v ?? "")))
);
}
}
return await base.SendAsync(request, cancellationToken);
}
}
src/Api/YouTubeAuth/YouTubeAuthExtensions.cs
+2
-1
diff --git a/src/Api/YouTubeAuth/YouTubeAuthExtensions.cs b/src/Api/YouTubeAuth/YouTubeAuthExtensions.cs
index 726272e..e8eec0f 100644
@@ -1,3 +1,4 @@
using System.Security.Claims;
using System.Threading.Tasks;
using Google.Apis.Auth.AspNetCore3;
using Microsoft.AspNetCore.Authentication;
@@ -23,7 +24,7 @@ public static class YouTubeAuthExtensions
{
options.CallbackPath = "/admin/redirect/youtube";
options.ResponseMode = OpenIdConnectResponseMode.Query;
options.TokenValidationParameters.NameClaimType = "name";
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
options.Events = new()
{
OnRedirectToIdentityProvider = context =>
src/Frontend/src/layouts/AdminLayout.vue
+1
-0
diff --git a/src/Frontend/src/layouts/AdminLayout.vue b/src/Frontend/src/layouts/AdminLayout.vue
index a5ecec9..5fd6196 100644
@@ -5,6 +5,7 @@
</main>
<nav class="admin-nav">
<RouterLink to="/admin/youtube" class="nav-item">YouTube</RouterLink>
<RouterLink to="/admin/tiktok" class="nav-item">TikTok</RouterLink>
</nav>
</div>
</template>
src/Frontend/src/pages/AdminTikTokPage.vue
+51
-0
diff --git a/src/Frontend/src/pages/AdminTikTokPage.vue b/src/Frontend/src/pages/AdminTikTokPage.vue
new file mode 100644
index 0000000..6af4fb9
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getTikTokUsername, } from "../services/api";
const unauthenticated = ref(true);
const username = ref<string | null>(null);
onMounted(async () => {
const result = await getTikTokUsername();
if (result == null) return;
unauthenticated.value = false;
username.value = result;
});
</script>
<template>
<div v-if="unauthenticated" class="login-prompt">
<a href="/api/tiktok/login" class="button">Login to TikTok</a>
</div>
<template v-else-if="username">
<h1>TikTok</h1>
<p>Welcome {{ username }}!</p>
</template>
</template>
<style scoped>
h1 {
margin-bottom: 1.25rem;
}
button {
display: block;
width: 100%;
margin-top: 1rem;
}
.login-prompt {
display: flex;
min-height: 50dvh;
align-items: center;
justify-content: center;
}
@media (min-width: 640px) {
button {
width: auto;
min-width: 10rem;
}
}
</style>
src/Frontend/src/router/index.ts
+5
-1
diff --git a/src/Frontend/src/router/index.ts b/src/Frontend/src/router/index.ts
index 28804c4..106b0ed 100644
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
import ClipFeed from "../pages/ClipFeed.vue";
import AdminLayout from "../layouts/AdminLayout.vue";
import AdminYouTubePage from "../pages/AdminYouTubePage.vue";
import AdminTikTokPage from "../pages/AdminTikTokPage.vue";
export default createRouter({
history: createWebHistory(),
@@ -10,7 +11,10 @@ export default createRouter({
{
path: "/admin",
component: AdminLayout,
children: [{ path: "youtube", component: AdminYouTubePage }],
children: [
{ path: "youtube", component: AdminYouTubePage },
{ path: "tiktok", component: AdminTikTokPage },
],
},
],
});
src/Frontend/src/services/api.ts
+7
-0
diff --git a/src/Frontend/src/services/api.ts b/src/Frontend/src/services/api.ts
index 25dea19..b2d4b13 100644
@@ -17,6 +17,13 @@ export async function startYouTubeUpload(): Promise<void> {
if (!res.ok) throw new Error(`Unexpected response: ${res.status}`);
}
export async function getTikTokUsername(): Promise<string | null> {
const res = await fetch("/api/tiktok/name");
if (res.status === 403) return null;
if (!res.ok) throw new Error(`Unexpected response: ${res.status}`);
return res.text();
}
export async function fetchClips(after?: string): Promise<Array<Clip>> {
const params = new URLSearchParams({ limit: "10" });
if (after) params.set("after", after);