TikTok V5 + Security
This commit is contained in:
@@ -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
62
src/lib/session-edge.ts
Normal 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
79
src/lib/session.ts
Normal 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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user