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

@@ -30,7 +30,8 @@ CRON_SECRET=
# Production example: https://qrmaster.net/api/tiktok/callback # Production example: https://qrmaster.net/api/tiktok/callback
# Local dev example: http://localhost:3000/api/tiktok/callback # Local dev example: http://localhost:3000/api/tiktok/callback
# Tokens are saved in the DB after the OAuth callback; do not store access tokens here. # Tokens are saved in the DB after the OAuth callback; do not store access tokens here.
TIKTOK_CLIENT_KEY=aw2l7czcin3uk426 TIKTOK_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=LapujWkqpZfj1jgWBeCzFhw2nuhMPMBA TIKTOK_CLIENT_SECRET=
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
TIKTOK_ADMIN_KEY=yV0CzlXyw00kFc7hnnJZv_eoH5WaqGsD5l8_GH7Aj2s TIKTOK_ADMIN_KEY=
TIKTOK_EXPECTED_OPEN_ID=

View File

@@ -58,6 +58,7 @@ services:
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-} TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-}
IP_SALT: ${IP_SALT:-your-salt-change-in-production} IP_SALT: ${IP_SALT:-your-salt-change-in-production}
ENABLE_DEMO: ${ENABLE_DEMO:-false} ENABLE_DEMO: ${ENABLE_DEMO:-false}
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}

View File

@@ -39,6 +39,8 @@
- Optional helper: `TIKTOK_ADMIN_KEY` for guarding the `/api/tiktok/connect` start route. - Optional helper: `TIKTOK_ADMIN_KEY` for guarding the `/api/tiktok/connect` start route.
- OAuth result: access/refresh tokens are stored in the database after `/api/tiktok/connect` and `/api/tiktok/callback`. - OAuth result: access/refresh tokens are stored in the database after `/api/tiktok/connect` and `/api/tiktok/callback`.
- Do not put TikTok access tokens in `.env`; the cron job reads them from the DB through the app flow. - Do not put TikTok access tokens in `.env`; the cron job reads them from the DB through the app flow.
- Configure `TIKTOK_EXPECTED_OPEN_ID` for QRMaster before deploying the hardened routes. The callback and every API call fail closed when it is missing or belongs to another account.
- `GET /api/tiktok/token` is status-only and never returns access or refresh tokens. Automations must call the server upload route instead of caching credentials locally.
- For cron posting, use the same QRMaster server environment that already contains `CRON_SECRET` / `INTERNAL_API_SECRET` for internal APIs. - For cron posting, use the same QRMaster server environment that already contains `CRON_SECRET` / `INTERNAL_API_SECRET` for internal APIs.
## TikTok Connection Status + Credential Locations ## TikTok Connection Status + Credential Locations

View File

@@ -55,3 +55,4 @@ TIKTOK_CLIENT_SECRET=
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
# Optional: protects /api/tiktok/connect from being triggered by strangers # Optional: protects /api/tiktok/connect from being triggered by strangers
TIKTOK_ADMIN_KEY= TIKTOK_ADMIN_KEY=
TIKTOK_EXPECTED_OPEN_ID=

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics'; import { TrendData } from '@/types/analytics';
@@ -41,7 +41,7 @@ function calculateTrend(current: number, previous: number): TrendData {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { sendPasswordResetEmail } from '@/lib/email'; import { sendPasswordResetEmail } from '@/lib/email';
import crypto from 'crypto'; import crypto from 'crypto';
@@ -15,6 +16,26 @@ export async function POST(req: NextRequest) {
); );
} }
// Rate Limiting
const clientId = getClientIdentifier(req);
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_RESET);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many password reset requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
},
}
);
}
const body = await req.json(); const body = await req.json();
const { email } = body; const { email } = body;

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import { import {
appendRedirectParam, appendRedirectParam,
GOOGLE_OAUTH_STATE_COOKIE_NAME, GOOGLE_OAUTH_STATE_COOKIE_NAME,
@@ -227,7 +228,7 @@ export async function GET(request: NextRequest) {
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`); const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
const response = NextResponse.redirect(redirectUrl.toString()); const response = NextResponse.redirect(redirectUrl.toString());
response.cookies.set('userId', user.id, getAuthCookieOptions()); response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
response.cookies.delete(ATTRIBUTION_COOKIE_NAME); response.cookies.delete(ATTRIBUTION_COOKIE_NAME);

View File

@@ -62,7 +62,7 @@ export async function POST(req: NextRequest) {
} }
// Hash the new password // Hash the new password
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 12);
// Update user's password and clear reset token // Update user's password and clear reset token
await db.user.update({ await db.user.update({

View File

@@ -5,6 +5,7 @@ import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import { signupSchema, validateRequest } from '@/lib/validationSchemas'; import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email'; import { sendWelcomeEmail } from '@/lib/email';
import { sendConversionEvent } from '@/lib/metaConversions'; import { sendConversionEvent } from '@/lib/metaConversions';
@@ -129,7 +130,7 @@ export async function POST(request: NextRequest) {
}); });
// Set cookie for auto-login after signup // Set cookie for auto-login after signup
response.cookies.set('userId', user.id, getAuthCookieOptions()); response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
response.cookies.delete(ATTRIBUTION_COOKIE_NAME); response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response; return response;

View File

@@ -1,13 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { setSessionCookie } from '@/lib/session';
import { loginSchema, validateRequest } from '@/lib/validationSchemas'; import { loginSchema, validateRequest } from '@/lib/validationSchemas';
import { shouldResumeOnboarding } from '@/lib/revops'; import { shouldResumeOnboarding } from '@/lib/revops';
// A fixed bcrypt hash used to equalize timing when no user is found, so the
// response time does not reveal whether an email is registered.
const DUMMY_BCRYPT_HASH = '$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// CSRF Protection // CSRF Protection
@@ -65,14 +68,16 @@ export async function POST(request: NextRequest) {
}); });
if (!user) { if (!user) {
// Equalize response time to prevent user enumeration via timing.
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid email or password' }, { error: 'Invalid email or password' },
{ status: 401 } { status: 401 }
); );
} }
// Verify password // Verify password (dummy-compare when no hash exists, e.g. OAuth-only accounts)
const isValid = await bcrypt.compare(password, user.password || ''); const isValid = await bcrypt.compare(password, user.password || DUMMY_BCRYPT_HASH);
if (!isValid) { if (!isValid) {
return NextResponse.json( return NextResponse.json(
@@ -81,8 +86,8 @@ export async function POST(request: NextRequest) {
); );
} }
// Set cookie // Set signed session cookie
cookies().set('userId', user.id, getAuthCookieOptions()); setSessionCookie(user.id);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit'; import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit';
@@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic';
export async function GET() { export async function GET() {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -36,7 +36,7 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE); const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -16,9 +16,8 @@ export async function GET(
if (session?.user?.id) { if (session?.user?.id) {
userId = session.user.id; userId = session.user.id;
} else { } else {
// Fallback: Check raw userId cookie (like /api/user does) // Fallback: verified signed userId cookie (like /api/user does)
const cookieStore = await cookies(); userId = getSessionUserId() ?? undefined;
userId = cookieStore.get('userId')?.value;
} }
if (!userId) { if (!userId) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { z } from 'zod'; import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
@@ -18,7 +18,7 @@ export async function GET(
{ params }: { params: { id: string } } { params }: { params: { id: string } }
) { ) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
@@ -62,7 +62,7 @@ export async function PATCH(
return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);
@@ -151,7 +151,7 @@ export async function DELETE(
return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
@@ -15,7 +15,7 @@ export async function DELETE(request: NextRequest) {
); );
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash'; import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
@@ -11,7 +11,7 @@ import { triggerLifecycleScoring } from '@/lib/revops-server';
// GET /api/qrs - List user's QR codes // GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash'; import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL // POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
@@ -7,7 +7,7 @@ import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,14 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { stripe, STRIPE_PLANS } from '@/lib/stripe'; import { stripe, STRIPE_PLANS } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Get user from cookie (using userId like other routes) // Get user from verified signed session cookie
const cookieStore = await cookies(); const userId = getSessionUserId();
const userId = cookieStore.get('userId')?.value;
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server'; import { scoreUserLifecycle } from '@/lib/revops-server';
@@ -11,7 +11,7 @@ import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Use cookie-based auth // Use cookie-based auth
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server'; import { scoreUserLifecycle } from '@/lib/revops-server';
@@ -7,7 +7,7 @@ import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Use cookie-based auth instead of NextAuth // Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { TIKTOK_ACCOUNT_KEY, TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok'; import {
assertExpectedTiktokAccount,
TIKTOK_ACCOUNT_KEY,
TIKTOK_OAUTH_STATE_COOKIE_NAME,
} from '@/lib/tiktok';
const textResponse = (body: string, status: number) => { const textResponse = (body: string, status: number) => {
const response = new NextResponse(body, { const response = new NextResponse(body, {
@@ -56,6 +60,9 @@ export async function GET(request: NextRequest) {
throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed'); throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed');
} }
// Reject the wrong browser account before it can replace the valid QRMaster connection.
assertExpectedTiktokAccount(tokens.open_id);
const now = Date.now(); const now = Date.now();
const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000); const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
const refreshTokenExpiresAt = tokens.refresh_expires_in const refreshTokenExpiresAt = tokens.refresh_expires_in
@@ -87,6 +94,8 @@ export async function GET(request: NextRequest) {
} catch (err) { } catch (err) {
console.error('TikTok callback error:', err); console.error('TikTok callback error:', err);
const message = err instanceof Error ? err.message : 'Unknown error'; const message = err instanceof Error ? err.message : 'Unknown error';
return textResponse(`Failed to connect TikTok account: ${message}`, 502); const status =
err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
return textResponse(`Failed to connect TikTok account: ${message}`, status || 502);
} }
} }

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getValidTiktokTokens } from '@/lib/tiktok'; import { createHash } from 'crypto';
import { TIKTOK_BRAND, getValidTiktokTokens } from '@/lib/tiktok';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const adminKey = process.env.TIKTOK_ADMIN_KEY; const adminKey = process.env.TIKTOK_ADMIN_KEY;
@@ -7,7 +8,7 @@ export async function GET(request: NextRequest) {
// Unlike /connect, this endpoint hands out live credentials — never expose // Unlike /connect, this endpoint hands out live credentials — never expose
// it without a configured key. // it without a configured key.
return NextResponse.json( return NextResponse.json(
{ error: 'TIKTOK_ADMIN_KEY must be configured to expose tokens' }, { error: 'TIKTOK_ADMIN_KEY must be configured to expose TikTok status' },
{ status: 500 } { status: 500 }
); );
} }
@@ -27,15 +28,28 @@ export async function GET(request: NextRequest) {
); );
} }
const now = Date.now();
const refreshExpired = Boolean(
tokens.refreshTokenExpiresAt && tokens.refreshTokenExpiresAt.getTime() <= now
);
// This endpoint is status-only. Access and refresh tokens remain server-side.
return NextResponse.json({ return NextResponse.json({
access_token: tokens.accessToken, connected: true,
open_id: tokens.openId, accountMatch: true,
brand: TIKTOK_BRAND,
openIdFingerprint: createHash('sha256').update(tokens.openId).digest('hex').slice(0, 12),
scope: tokens.scope, scope: tokens.scope,
expires_at: tokens.accessTokenExpiresAt.toISOString(), accessTokenExpiresAt: tokens.accessTokenExpiresAt.toISOString(),
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt?.toISOString() || null,
requiresReconnect: refreshExpired,
uploadMode: 'draft',
}); });
} catch (err) { } catch (err) {
console.error('TikTok token endpoint error:', err); console.error('TikTok token endpoint error:', err);
const message = err instanceof Error ? err.message : 'Unknown error'; const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 502 }); const status =
err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
return NextResponse.json({ error: message }, { status: status || 502 });
} }
} }

View File

@@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { uploadFileToR2 } from '@/lib/r2'; import { uploadFileToR2 } from '@/lib/r2';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSessionUserId } from '@/lib/session';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
// Fallback: Check for simple-login cookie if no NextAuth session // Fallback: Check for simple-login cookie if no NextAuth session
if (!userId) { if (!userId) {
const cookieUserId = request.cookies.get('userId')?.value; const cookieUserId = getSessionUserId();
if (cookieUserId) { if (cookieUserId) {
// Verify user exists // Verify user exists
const user = await db.user.findUnique({ const user = await db.user.findUnique({

View File

@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; // Debug endpoint must never be reachable in production
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
@@ -16,7 +17,7 @@ export async function DELETE(request: NextRequest) {
); );
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
@@ -17,7 +17,7 @@ export async function PATCH(request: NextRequest) {
); );
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { STRIPE_PLANS } from '@/lib/stripe'; import { STRIPE_PLANS } from '@/lib/stripe';
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Use cookie-based auth instead of NextAuth // Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas'; import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
@@ -13,7 +13,7 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
} }
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
// Force dynamic rendering (required for cookies) // Force dynamic rendering (required for cookies)
@@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const userId = cookies().get('userId')?.value; const userId = getSessionUserId();
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }

View File

@@ -1,5 +1,6 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import { getCsrfCookieOptions } from './cookieConfig'; import { getCsrfCookieOptions } from './cookieConfig';
const CSRF_TOKEN_COOKIE = 'csrf_token'; const CSRF_TOKEN_COOKIE = 'csrf_token';
@@ -38,7 +39,12 @@ export function validateCsrfToken(headerToken: string | null): boolean {
} }
// Constant-time comparison to prevent timing attacks // 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'; import { db } from '@/lib/db';
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state'; 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 { export class TiktokApiError extends Error {
status: number; status: number;
@@ -17,24 +19,57 @@ export class TiktokApiError extends Error {
// Refresh when the access token expires within this window, so Hermes never // Refresh when the access token expires within this window, so Hermes never
// receives a token that dies mid-upload. // receives a token that dies mid-upload.
const REFRESH_BUFFER_MS = 5 * 60 * 1000; const REFRESH_BUFFER_MS = 5 * 60 * 1000;
let refreshInFlight: Promise<Awaited<ReturnType<typeof findTiktokIntegration>>> | null = null;
export async function getValidTiktokTokens() { const expectedOpenId = () => process.env.TIKTOK_EXPECTED_OPEN_ID?.trim();
const integration = await db.tiktokIntegration.findUnique({
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 }, where: { accountKey: TIKTOK_ACCOUNT_KEY },
}); });
if (current) return current;
if (!integration) { const legacy = await db.tiktokIntegration.findUnique({
return null; where: { accountKey: LEGACY_TIKTOK_ACCOUNT_KEY },
} });
if (!legacy) return null;
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) { // Preserve an existing installation while moving it to the product-specific key.
return integration; 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 clientKey = process.env.TIKTOK_CLIENT_KEY;
const clientSecret = process.env.TIKTOK_CLIENT_SECRET; const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) { 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/', { const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
@@ -50,41 +85,63 @@ export async function getValidTiktokTokens() {
const tokens = await response.json(); const tokens = await response.json();
if (!response.ok || tokens.error) { 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(); const now = Date.now();
return db.tiktokIntegration.update({ return db.tiktokIntegration.update({
where: { accountKey: TIKTOK_ACCOUNT_KEY }, where: { accountKey: TIKTOK_ACCOUNT_KEY },
data: { data: {
openId: tokens.open_id, openId: refreshedOpenId,
accessToken: tokens.access_token, accessToken: tokens.access_token,
refreshToken: tokens.refresh_token, refreshToken: tokens.refresh_token || integration.refreshToken,
scope: tokens.scope || null, scope: tokens.scope || integration.scope,
accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000), accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000),
refreshTokenExpiresAt: tokens.refresh_expires_in refreshTokenExpiresAt: tokens.refresh_expires_in
? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000) ? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000)
: null, : integration.refreshTokenExpiresAt,
}, },
}); });
} }
export async function getLiveTiktokAccessToken() { async function refreshTiktokTokensOnce() {
const integration = await db.tiktokIntegration.findUnique({ if (!refreshInFlight) {
where: { accountKey: TIKTOK_ACCOUNT_KEY }, refreshInFlight = refreshTiktokTokens().finally(() => {
}); refreshInFlight = null;
});
}
return refreshInFlight;
}
export async function getValidTiktokTokens(forceRefresh = false) {
const integration = await findTiktokIntegration();
if (!integration) { 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 integration;
} }
return refreshTiktokTokensOnce();
}
const clientKey = process.env.TIKTOK_CLIENT_KEY; export async function getLiveTiktokAccessToken() {
const clientSecret = process.env.TIKTOK_CLIENT_SECRET; const integration = await findTiktokIntegration();
if (!clientKey || !clientSecret) { if (!integration) {
throw new TiktokApiError('TikTok client credentials are not configured.', 500); throw new TiktokApiError('No TikTok account connected.', 404);
} }
const refreshed = await getValidTiktokTokens(); const refreshed = await getValidTiktokTokens();
@@ -95,20 +152,20 @@ export async function getLiveTiktokAccessToken() {
} }
export async function tiktokApi(url: string, options: RequestInit = {}) { export async function tiktokApi(url: string, options: RequestInit = {}) {
const tokens = await getLiveTiktokAccessToken(); const request = async (accessToken: string) => {
const accessToken = tokens.accessToken; const res = await fetch(url, {
...options,
const fetchOptions: RequestInit = { headers: {
...options, ...(options.headers as Record<string, string> | undefined),
headers: { Authorization: `Bearer ${accessToken}`,
...(options.headers as Record<string, string> | undefined), 'Content-Type': 'application/json; charset=UTF-8',
Authorization: `Bearer ${accessToken}`, },
'Content-Type': 'application/json; charset=UTF-8', });
}, return { res, text: await res.text() };
}; };
const res = await fetch(url, fetchOptions); let tokens = await getLiveTiktokAccessToken();
const text = await res.text(); let { res, text } = await request(tokens.accessToken);
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { try {
data = JSON.parse(text) as Record<string, unknown>; data = JSON.parse(text) as Record<string, unknown>;
@@ -116,6 +173,17 @@ export async function tiktokApi(url: string, options: RequestInit = {}) {
data = { raw: text }; 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') { 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}`; 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); throw new TiktokApiError(message, res.status, data);

View File

@@ -5,6 +5,7 @@ import {
buildAttributionSnapshot, buildAttributionSnapshot,
serializeAttributionCookie, serializeAttributionCookie,
} from '@/lib/revops'; } from '@/lib/revops';
import { verifySignedUserIdEdge } from '@/lib/session-edge';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@@ -41,7 +42,7 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
return response; return response;
} }
export function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname; const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
@@ -157,8 +158,8 @@ export function middleware(req: NextRequest) {
return attachAttributionCookie(req, NextResponse.next()); return attachAttributionCookie(req, NextResponse.next());
} }
// For protected routes, check for userId cookie // For protected routes, require a validly signed userId cookie
const userId = req.cookies.get('userId')?.value; const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value);
if (!userId) { if (!userId) {
// Not authenticated - redirect to signup // Not authenticated - redirect to signup