TikTok V5 + Security

This commit is contained in:
2026-07-11 22:09:10 +02:00
parent 671c1a1559
commit d542f849aa
36 changed files with 740 additions and 463 deletions

View File

@@ -1,5 +1,6 @@
import { cookies } from 'next/headers';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import { getCsrfCookieOptions } from './cookieConfig';
const CSRF_TOKEN_COOKIE = 'csrf_token';
@@ -38,7 +39,12 @@ export function validateCsrfToken(headerToken: string | null): boolean {
}
// Constant-time comparison to prevent timing attacks
return cookieToken === headerToken;
const cookieBuf = Buffer.from(cookieToken);
const headerBuf = Buffer.from(headerToken);
if (cookieBuf.length !== headerBuf.length) {
return false;
}
return crypto.timingSafeEqual(cookieBuf, headerBuf);
}
/**

62
src/lib/session-edge.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Edge-runtime variant of the signed-session verification used by middleware.
*
* Next.js middleware runs on the Edge runtime, where Node's `crypto` module is
* unavailable, so we verify the HMAC signature with Web Crypto (SubtleCrypto).
* Keep this in sync with `src/lib/session.ts` (same secret, same algorithm).
*/
function base64url(bytes: ArrayBuffer): string {
const view = new Uint8Array(bytes);
let bin = '';
for (let i = 0; i < view.length; i++) {
bin += String.fromCharCode(view[i]);
}
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
/**
* Verify a signed cookie value in the Edge runtime.
* Returns the user id when the signature is valid, otherwise null.
*/
export async function verifySignedUserIdEdge(
value: string | undefined | null
): Promise<string | null> {
if (!value) return null;
const secret = process.env.NEXTAUTH_SECRET;
if (!secret) return null;
const separator = value.lastIndexOf('.');
if (separator <= 0 || separator === value.length - 1) {
return null;
}
const userId = value.slice(0, separator);
const providedSig = value.slice(separator + 1);
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(userId));
const expectedSig = base64url(signature);
if (!timingSafeEqual(providedSig, expectedSig)) {
return null;
}
return userId;
}

79
src/lib/session.ts Normal file
View File

@@ -0,0 +1,79 @@
import 'server-only';
import crypto from 'crypto';
import { cookies } from 'next/headers';
import { getAuthCookieOptions } from './cookieConfig';
/**
* Signed session cookie.
*
* The auth cookie holds the user id, but it MUST NOT be a bare, forgeable value.
* We attach an HMAC-SHA256 signature keyed with NEXTAUTH_SECRET so the server can
* detect a tampered/forged cookie and reject it. Format: `<userId>.<signature>`.
*/
export const AUTH_COOKIE_NAME = 'userId';
function getSecret(): string {
const secret = process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('NEXTAUTH_SECRET is not set — cannot sign or verify session cookies');
}
return secret;
}
function computeSignature(userId: string): string {
return crypto.createHmac('sha256', getSecret()).update(userId).digest('base64url');
}
/**
* Produce the signed cookie value for a user id.
*/
export function signUserId(userId: string): string {
return `${userId}.${computeSignature(userId)}`;
}
/**
* Verify a signed cookie value. Returns the user id if the signature is valid,
* otherwise null. Uses a constant-time comparison to avoid signature timing leaks.
*/
export function verifySignedUserId(value: string | undefined | null): string | null {
if (!value) return null;
const separator = value.lastIndexOf('.');
if (separator <= 0 || separator === value.length - 1) {
return null;
}
const userId = value.slice(0, separator);
const providedSig = value.slice(separator + 1);
const expectedSig = computeSignature(userId);
const providedBuf = Buffer.from(providedSig);
const expectedBuf = Buffer.from(expectedSig);
if (providedBuf.length !== expectedBuf.length) {
return null;
}
if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) {
return null;
}
return userId;
}
/**
* Read and verify the authenticated user id from the request cookies.
* Returns null when no valid, correctly-signed session cookie is present.
*
* Use this in route handlers instead of reading the `userId` cookie directly.
*/
export function getSessionUserId(): string | null {
return verifySignedUserId(cookies().get(AUTH_COOKIE_NAME)?.value);
}
/**
* Set the signed auth cookie for the given user id (server component / route handler context).
*/
export function setSessionCookie(userId: string): void {
cookies().set(AUTH_COOKIE_NAME, signUserId(userId), getAuthCookieOptions());
}

View File

@@ -1,7 +1,9 @@
import { db } from '@/lib/db';
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state';
export const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
export const TIKTOK_ACCOUNT_KEY = 'qrmaster';
const LEGACY_TIKTOK_ACCOUNT_KEY = 'hermes-agent';
export const TIKTOK_BRAND = 'qrmaster';
export class TiktokApiError extends Error {
status: number;
@@ -17,24 +19,57 @@ export class TiktokApiError extends Error {
// Refresh when the access token expires within this window, so Hermes never
// receives a token that dies mid-upload.
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
let refreshInFlight: Promise<Awaited<ReturnType<typeof findTiktokIntegration>>> | null = null;
export async function getValidTiktokTokens() {
const integration = await db.tiktokIntegration.findUnique({
const expectedOpenId = () => process.env.TIKTOK_EXPECTED_OPEN_ID?.trim();
export function assertExpectedTiktokAccount(openId: string | null | undefined) {
const expected = expectedOpenId();
if (!expected) {
throw new TiktokApiError('TIKTOK_EXPECTED_OPEN_ID is not configured for QRMaster.', 500);
}
if (!openId || openId !== expected) {
throw new TiktokApiError('Connected TikTok account does not match the QRMaster account.', 409);
}
}
async function findTiktokIntegration() {
const current = await db.tiktokIntegration.findUnique({
where: { accountKey: TIKTOK_ACCOUNT_KEY },
});
if (current) return current;
if (!integration) {
return null;
}
const legacy = await db.tiktokIntegration.findUnique({
where: { accountKey: LEGACY_TIKTOK_ACCOUNT_KEY },
});
if (!legacy) return null;
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
return integration;
// Preserve an existing installation while moving it to the product-specific key.
return db.tiktokIntegration.update({
where: { accountKey: LEGACY_TIKTOK_ACCOUNT_KEY },
data: { accountKey: TIKTOK_ACCOUNT_KEY },
});
}
async function refreshTiktokTokens() {
const integration = await findTiktokIntegration();
if (!integration) return null;
assertExpectedTiktokAccount(integration.openId);
if (
integration.refreshTokenExpiresAt &&
integration.refreshTokenExpiresAt.getTime() <= Date.now()
) {
throw new TiktokApiError(
'TikTok authorization has expired. Reconnect the QRMaster TikTok account.',
401
);
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
throw new Error('TikTok client credentials are not configured.');
throw new TiktokApiError('TikTok client credentials are not configured.', 500);
}
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
@@ -50,41 +85,63 @@ export async function getValidTiktokTokens() {
const tokens = await response.json();
if (!response.ok || tokens.error) {
throw new Error(tokens.error_description || tokens.error || 'TikTok token refresh failed');
const reconnectRequired =
response.status === 401 || tokens.error === 'invalid_grant';
throw new TiktokApiError(
reconnectRequired
? 'TikTok authorization is no longer valid. Reconnect the QRMaster TikTok account.'
: tokens.error_description || tokens.error || 'TikTok token refresh failed',
reconnectRequired ? 401 : 502,
tokens
);
}
const refreshedOpenId = tokens.open_id || integration.openId;
assertExpectedTiktokAccount(refreshedOpenId);
const now = Date.now();
return db.tiktokIntegration.update({
where: { accountKey: TIKTOK_ACCOUNT_KEY },
data: {
openId: tokens.open_id,
openId: refreshedOpenId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
scope: tokens.scope || null,
refreshToken: tokens.refresh_token || integration.refreshToken,
scope: tokens.scope || integration.scope,
accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000),
refreshTokenExpiresAt: tokens.refresh_expires_in
? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000)
: null,
: integration.refreshTokenExpiresAt,
},
});
}
export async function getLiveTiktokAccessToken() {
const integration = await db.tiktokIntegration.findUnique({
where: { accountKey: TIKTOK_ACCOUNT_KEY },
});
async function refreshTiktokTokensOnce() {
if (!refreshInFlight) {
refreshInFlight = refreshTiktokTokens().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
export async function getValidTiktokTokens(forceRefresh = false) {
const integration = await findTiktokIntegration();
if (!integration) {
throw new TiktokApiError('No TikTok account connected.', 404);
return null;
}
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
assertExpectedTiktokAccount(integration.openId);
if (!forceRefresh && integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
return integration;
}
return refreshTiktokTokensOnce();
}
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
throw new TiktokApiError('TikTok client credentials are not configured.', 500);
export async function getLiveTiktokAccessToken() {
const integration = await findTiktokIntegration();
if (!integration) {
throw new TiktokApiError('No TikTok account connected.', 404);
}
const refreshed = await getValidTiktokTokens();
@@ -95,20 +152,20 @@ export async function getLiveTiktokAccessToken() {
}
export async function tiktokApi(url: string, options: RequestInit = {}) {
const tokens = await getLiveTiktokAccessToken();
const accessToken = tokens.accessToken;
const fetchOptions: RequestInit = {
...options,
headers: {
...(options.headers as Record<string, string> | undefined),
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
const request = async (accessToken: string) => {
const res = await fetch(url, {
...options,
headers: {
...(options.headers as Record<string, string> | undefined),
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
});
return { res, text: await res.text() };
};
const res = await fetch(url, fetchOptions);
const text = await res.text();
let tokens = await getLiveTiktokAccessToken();
let { res, text } = await request(tokens.accessToken);
let data: Record<string, unknown>;
try {
data = JSON.parse(text) as Record<string, unknown>;
@@ -116,6 +173,17 @@ export async function tiktokApi(url: string, options: RequestInit = {}) {
data = { raw: text };
}
const errorCode = (data?.error as Record<string, string> | undefined)?.code;
if (errorCode === 'access_token_invalid') {
tokens = await getValidTiktokTokens(true) as NonNullable<typeof tokens>;
({ res, text } = await request(tokens.accessToken));
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
data = { raw: text };
}
}
if (!res.ok || (data?.error as Record<string, string> | undefined)?.code !== 'ok') {
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
throw new TiktokApiError(message, res.status, data);